sevDesk: automatische Rechnung (Entwurf) bei Konfigurator-Bestellung
- Neue Hook-Extension skrift-sevdesk: legt bei orders.items.create (source= configurator) eine sevDesk-Rechnung als ENTWURF an – Kontakt wird angelegt/ wiederverwendet (sevdesk_contact_id am Konto), Positionen aus price_lines (Produkt mit Menge; Versand/Motiv/Zusatz je eigene Position), 19% USt. Nicht-blockierend, per SEVDESK_API_TOKEN aktiviert; Versand NICHT über sevDesk. - orders.price_lines (JSON) speichert die Positionsaufstellung bei Bestellung (Konfigurator + manueller Auftrag). - bootstrap: orders.price_lines/sevdesk_invoice_id/sevdesk_invoice_number, directus_users.sevdesk_contact_id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -137,6 +137,7 @@ const USER_FIELDS = [
|
|||||||
// Veraltet (frühere Mehrfach-Adressliste) – bleibt für Alt-Daten erhalten.
|
// Veraltet (frühere Mehrfach-Adressliste) – bleibt für Alt-Daten erhalten.
|
||||||
f('addresses', 'json', { interface: 'list', hidden: true, note: 'Veraltet – ersetzt durch billing_address/shipping_address.', options: { fields: ADDRESS_SUBFIELDS } }),
|
f('addresses', 'json', { interface: 'list', hidden: true, note: 'Veraltet – ersetzt durch billing_address/shipping_address.', options: { fields: ADDRESS_SUBFIELDS } }),
|
||||||
f('notes', 'text', { interface: 'input-multiline', note: 'Interne Notizen (für den Kunden nicht sichtbar).' }),
|
f('notes', 'text', { interface: 'input-multiline', note: 'Interne Notizen (für den Kunden nicht sichtbar).' }),
|
||||||
|
f('sevdesk_contact_id', 'string', { interface: 'input', hidden: true, note: 'Verknüpfter sevDesk-Kontakt (für Rechnungen).' }),
|
||||||
// Zusätzliche Login-Mailadressen: Wer sich mit einer dieser Mails per Code
|
// Zusätzliche Login-Mailadressen: Wer sich mit einer dieser Mails per Code
|
||||||
// anmeldet, landet im SELBEN Konto (voller Zugriff). Liste von E-Mails.
|
// anmeldet, landet im SELBEN Konto (voller Zugriff). Liste von E-Mails.
|
||||||
f('alt_emails', 'json', { interface: 'tags', note: 'Weitere Login-Mailadressen (gleiches Konto).' }),
|
f('alt_emails', 'json', { interface: 'tags', note: 'Weitere Login-Mailadressen (gleiches Konto).' }),
|
||||||
@@ -312,6 +313,11 @@ const collections = [
|
|||||||
f('artifact_path', 'string', { interface: 'input', note: 'Verweis auf Backend-Output.' }),
|
f('artifact_path', 'string', { interface: 'input', note: 'Verweis auf Backend-Output.' }),
|
||||||
f('internal_note', 'text', { interface: 'input-multiline', note: 'Interne Notiz zum Auftrag (Produktion).' }),
|
f('internal_note', 'text', { interface: 'input-multiline', note: 'Interne Notiz zum Auftrag (Produktion).' }),
|
||||||
m2o('linked_order', 'orders', { onDelete: 'SET NULL', note: 'Verknüpfter Auftrag (z. B. Kuverts zum Unterschriftenservice). Nachträglich änderbar.' }),
|
m2o('linked_order', 'orders', { onDelete: 'SET NULL', note: 'Verknüpfter Auftrag (z. B. Kuverts zum Unterschriftenservice). Nachträglich änderbar.' }),
|
||||||
|
// Rechnungs-Positionsaufstellung (aus der Preisberechnung) – Basis für die sevDesk-Rechnung.
|
||||||
|
f('price_lines', 'json', { interface: 'input-code', note: 'Positionsaufstellung [{label, amount, per_unit}] zum Auftragszeitpunkt.' }),
|
||||||
|
// sevDesk-Rechnung (automatisch erzeugt).
|
||||||
|
f('sevdesk_invoice_id', 'string', { interface: 'input', note: 'sevDesk-Rechnungs-ID (Entwurf/gebucht).' }),
|
||||||
|
f('sevdesk_invoice_number', 'string', { interface: 'input', note: 'sevDesk-Rechnungsnummer.' }),
|
||||||
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
||||||
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
|
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
|
||||||
// Produktionsansicht: durchsuchbare Empfängertabelle (Briefnummer + volle
|
// Produktionsansicht: durchsuchbare Empfängertabelle (Briefnummer + volle
|
||||||
|
|||||||
@@ -1807,6 +1807,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
entries_count: entries.length,
|
entries_count: entries.length,
|
||||||
count_inland: entries.length - countAusland, count_ausland: countAusland,
|
count_inland: entries.length - countAusland, count_ausland: countAusland,
|
||||||
net_total: quote?.net_total ?? null, vat_amount: quote?.vat_amount ?? null, gross_total: quote?.gross_total ?? null,
|
net_total: quote?.net_total ?? null, vat_amount: quote?.vat_amount ?? null, gross_total: quote?.gross_total ?? null,
|
||||||
|
price_lines: quote?.lines ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
let orderId, orderNumber;
|
let orderId, orderNumber;
|
||||||
@@ -1990,6 +1991,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
net_total: quote.net_total,
|
net_total: quote.net_total,
|
||||||
vat_amount: quote.vat_amount,
|
vat_amount: quote.vat_amount,
|
||||||
gross_total: quote.gross_total,
|
gross_total: quote.gross_total,
|
||||||
|
price_lines: quote.lines ?? null,
|
||||||
voucher: quote.voucher?.id ?? null,
|
voucher: quote.voucher?.id ?? null,
|
||||||
payment_method: paymentMethod,
|
payment_method: paymentMethod,
|
||||||
payment_status: body.source === 'extern' ? 'bezahlt' : 'offen',
|
payment_status: body.source === 'extern' ? 'bezahlt' : 'offen',
|
||||||
|
|||||||
213
extensions/directus-extension-skrift-sevdesk/dist/index.js
vendored
Normal file
213
extensions/directus-extension-skrift-sevdesk/dist/index.js
vendored
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* Skrift – sevDesk-Rechnungen (Hook)
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Legt bei jeder Konfigurator-Bestellung automatisch eine sevDesk-RECHNUNG als
|
||||||
|
* ENTWURF an (Status 100) – inkl. Kontakt (wird angelegt/wiederverwendet).
|
||||||
|
* Positionen kommen aus der gespeicherten Preisaufstellung (orders.price_lines):
|
||||||
|
* • Produkt = 1 Position mit Menge (z. B. 50 Stk.)
|
||||||
|
* • Versand/Sammelversand, Motiv-Upload, jede Zusatzleistung = eigene Position
|
||||||
|
* 19 % USt (taxType „default"). Der VERSAND der Rechnung erfolgt NICHT über
|
||||||
|
* sevDesk (kommt separat über den eigenen Mailer, wird später nachgerüstet).
|
||||||
|
*
|
||||||
|
* Auslöser: orders.items.create mit source = 'configurator' und vorhandener
|
||||||
|
* Preisaufstellung. Läuft NIE blockierend – Fehler werden nur geloggt, die
|
||||||
|
* Bestellung wird davon nicht beeinträchtigt.
|
||||||
|
*
|
||||||
|
* Env:
|
||||||
|
* SEVDESK_API_TOKEN Pflicht – ohne Token passiert nichts.
|
||||||
|
* SEVDESK_BASE Default https://my.sevdesk.de/api/v1
|
||||||
|
* SEVDESK_TAX_RATE Default 19
|
||||||
|
* SEVDESK_INVOICE_SOURCES Komma-Liste der order.source, für die gebucht wird
|
||||||
|
* (Default „configurator").
|
||||||
|
*
|
||||||
|
* Von Hand als ESM (kein Build-Schritt), analog zu skrift-mailer/skrift-contacts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TOKEN = (process.env.SEVDESK_API_TOKEN || '').trim();
|
||||||
|
const BASE = (process.env.SEVDESK_BASE || 'https://my.sevdesk.de/api/v1').replace(/\/$/, '');
|
||||||
|
const TAX_RATE = Number(process.env.SEVDESK_TAX_RATE || 19);
|
||||||
|
const SOURCES = (process.env.SEVDESK_INVOICE_SOURCES || 'configurator')
|
||||||
|
.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
|
||||||
|
|
||||||
|
export default (register, { services, getSchema, logger }) => {
|
||||||
|
const { ItemsService } = services;
|
||||||
|
const svc = (coll, schema) => new ItemsService(coll, { schema, accountability: null });
|
||||||
|
|
||||||
|
// ── sevDesk-API-Helfer ────────────────────────────────────────────────────
|
||||||
|
async function sev(method, path, body) {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: TOKEN, 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let json = null;
|
||||||
|
try { json = text ? JSON.parse(text) : null; } catch { /* kein JSON */ }
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = (json && (json.message || JSON.stringify(json.error || json))) || text || '';
|
||||||
|
throw new Error(`sevDesk ${method} ${path} → ${res.status}: ${String(detail).slice(0, 400)}`);
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der „contactPerson" einer Rechnung ist ein sevUser des Kontos. Einmal holen.
|
||||||
|
let sevUserId = null;
|
||||||
|
async function getSevUserId() {
|
||||||
|
if (sevUserId) return sevUserId;
|
||||||
|
const r = await sev('GET', '/SevUser?limit=1');
|
||||||
|
sevUserId = r?.objects?.[0]?.id || null;
|
||||||
|
if (!sevUserId) throw new Error('Kein sevUser gefunden (contactPerson).');
|
||||||
|
return sevUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kontakt anlegen (Person oder Organisation) + E-Mail. Gibt die Kontakt-ID zurück.
|
||||||
|
async function createContact({ company, first, last, email, phone }) {
|
||||||
|
const catStr = (process.env.SEVDESK_CONTACT_CATEGORY || '3').trim(); // 3 = Kunde (Standard)
|
||||||
|
const isOrg = !!(company && company.trim());
|
||||||
|
const body = {
|
||||||
|
objectName: 'Contact',
|
||||||
|
category: { id: Number(catStr) || 3, objectName: 'Category' },
|
||||||
|
};
|
||||||
|
if (isOrg) body.name = company.trim();
|
||||||
|
else { body.surename = (first || '').trim(); body.familyname = (last || '').trim(); }
|
||||||
|
const c = await sev('POST', '/Contact', body);
|
||||||
|
const id = c?.objects?.id || c?.objects?.[0]?.id;
|
||||||
|
if (!id) throw new Error('Kontakt konnte nicht angelegt werden (keine ID).');
|
||||||
|
// E-Mail hinterlegen (best effort).
|
||||||
|
if (email) {
|
||||||
|
try {
|
||||||
|
await sev('POST', '/CommunicationWay', {
|
||||||
|
objectName: 'CommunicationWay', contact: { id, objectName: 'Contact' },
|
||||||
|
type: 'EMAIL', value: email, key: { id: 1, objectName: 'CommunicationWayKey' }, main: 1,
|
||||||
|
});
|
||||||
|
} catch (e) { logger.warn(`[skrift-sevdesk] E-Mail am Kontakt fehlgeschlagen: ${e.message}`); }
|
||||||
|
}
|
||||||
|
if (phone) {
|
||||||
|
try {
|
||||||
|
await sev('POST', '/CommunicationWay', {
|
||||||
|
objectName: 'CommunicationWay', contact: { id, objectName: 'Contact' },
|
||||||
|
type: 'PHONE', value: phone, key: { id: 1, objectName: 'CommunicationWayKey' }, main: 1,
|
||||||
|
});
|
||||||
|
} catch { /* egal */ }
|
||||||
|
}
|
||||||
|
return String(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positionen aus der Preisaufstellung. Produkt (erste Zeile) mit Menge, Rest je 1.
|
||||||
|
function buildPositions(order) {
|
||||||
|
const lines = Array.isArray(order.price_lines) ? order.price_lines : [];
|
||||||
|
const menge = Number(order.entries_count) || 1;
|
||||||
|
const suffix = order.product_name_suffix ? ` ${order.product_name_suffix}` : '';
|
||||||
|
return lines.map((l, i) => {
|
||||||
|
const label = String(l.label || 'Position');
|
||||||
|
if (i === 0 && menge > 1) {
|
||||||
|
// Produkt-Grundpreis → eine Position mit Menge; „(N Stk.)" aus dem Namen nehmen.
|
||||||
|
const name = label.replace(/\s*\(\s*\d+\s*Stk\.?\s*\)\s*$/i, '') + suffix;
|
||||||
|
return { objectName: 'InvoicePos', mapAll: 'true', name, quantity: menge,
|
||||||
|
price: round2(Number(l.amount) / menge), taxRate: TAX_RATE,
|
||||||
|
unity: { id: 1, objectName: 'Unity' } };
|
||||||
|
}
|
||||||
|
return { objectName: 'InvoicePos', mapAll: 'true', name: label, quantity: 1,
|
||||||
|
price: round2(l.amount), taxRate: TAX_RATE, unity: { id: 1, objectName: 'Unity' } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function adressBlock(order) {
|
||||||
|
const b = order.billing && typeof order.billing === 'object' ? order.billing : {};
|
||||||
|
const person = [order.first_name, order.last_name].filter(Boolean).join(' ');
|
||||||
|
const name = person || order.company || order.customer_email || '';
|
||||||
|
const text = [
|
||||||
|
order.company || null,
|
||||||
|
person || (order.company ? null : name),
|
||||||
|
b.street || null,
|
||||||
|
[b.zip, b.city].filter(Boolean).join(' ') || null,
|
||||||
|
b.country || null,
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
return { name, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kernablauf je Auftrag ───────────────────────────────────────────────────
|
||||||
|
async function rechnungFuerAuftrag(orderId) {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const order = await svc('orders', schema).readOne(orderId, {
|
||||||
|
fields: ['id', 'order_number', 'source', 'price_lines', 'entries_count', 'net_total',
|
||||||
|
'gross_total', 'product_name_suffix', 'customer_email', 'billing', 'company',
|
||||||
|
'sevdesk_invoice_id', 'customer.id', 'customer.first_name', 'customer.last_name',
|
||||||
|
'customer.company', 'customer.phone', 'customer.sevdesk_contact_id'],
|
||||||
|
});
|
||||||
|
if (!order) return;
|
||||||
|
if (order.sevdesk_invoice_id) return; // schon erstellt
|
||||||
|
if (!SOURCES.includes(String(order.source || ''))) return; // nur Konfigurator
|
||||||
|
const lines = Array.isArray(order.price_lines) ? order.price_lines : [];
|
||||||
|
if (!lines.length || !(Number(order.net_total) > 0)) {
|
||||||
|
logger.info(`[skrift-sevdesk] ${order.order_number}: keine Positionen/Betrag → keine Rechnung.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cust = order.customer || {};
|
||||||
|
const first = cust.first_name || '';
|
||||||
|
const last = cust.last_name || '';
|
||||||
|
const company = cust.company || order.company || '';
|
||||||
|
|
||||||
|
// Kontakt: am Konto vorhandene sevDesk-ID wiederverwenden, sonst anlegen + merken.
|
||||||
|
let contactId = cust.sevdesk_contact_id || null;
|
||||||
|
if (!contactId) {
|
||||||
|
contactId = await createContact({ company, first, last, email: order.customer_email, phone: cust.phone });
|
||||||
|
if (cust.id) {
|
||||||
|
try { await svc('directus_users', schema).updateOne(cust.id, { sevdesk_contact_id: contactId }); }
|
||||||
|
catch (e) { logger.warn(`[skrift-sevdesk] sevDesk-Kontakt-ID nicht am Konto gespeichert: ${e.message}`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactPersonId = await getSevUserId();
|
||||||
|
const adr = adressBlock({ ...order, first_name: first, last_name: last, company });
|
||||||
|
const heute = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
invoice: {
|
||||||
|
objectName: 'Invoice',
|
||||||
|
invoiceType: 'RE',
|
||||||
|
status: '100', // Entwurf
|
||||||
|
invoiceDate: heute,
|
||||||
|
header: `Rechnung ${order.order_number}`,
|
||||||
|
contact: { id: Number(contactId), objectName: 'Contact' },
|
||||||
|
contactPerson: { id: Number(contactPersonId), objectName: 'SevUser' },
|
||||||
|
address: adr.text,
|
||||||
|
addressName: adr.name,
|
||||||
|
currency: 'EUR',
|
||||||
|
taxRate: TAX_RATE,
|
||||||
|
taxText: `Umsatzsteuer ${TAX_RATE}%`,
|
||||||
|
taxType: 'default',
|
||||||
|
discount: 0,
|
||||||
|
mapAll: 'true',
|
||||||
|
},
|
||||||
|
invoicePosSave: buildPositions(order),
|
||||||
|
invoicePosDelete: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await sev('POST', '/Invoice/Factory/saveInvoice', payload);
|
||||||
|
const inv = res?.objects?.invoice || res?.objects;
|
||||||
|
const invId = inv?.id;
|
||||||
|
const invNo = inv?.invoiceNumber || null;
|
||||||
|
if (invId) {
|
||||||
|
await svc('orders', schema).updateOne(orderId, {
|
||||||
|
sevdesk_invoice_id: String(invId),
|
||||||
|
sevdesk_invoice_number: invNo ? String(invNo) : null,
|
||||||
|
});
|
||||||
|
logger.info(`[skrift-sevdesk] ${order.order_number}: Rechnungs-Entwurf angelegt (sevDesk #${invId}${invNo ? ` / ${invNo}` : ''}).`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`[skrift-sevdesk] ${order.order_number}: Rechnung ohne ID zurückgekommen.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register.action('orders.items.create', async ({ key, keys }) => {
|
||||||
|
if (!TOKEN) return; // ohne Token nichts tun
|
||||||
|
const ids = keys || (key != null ? [key] : []);
|
||||||
|
for (const id of ids) {
|
||||||
|
try { await rechnungFuerAuftrag(id); }
|
||||||
|
catch (e) { logger.error(`[skrift-sevdesk] Rechnung für Auftrag ${id} fehlgeschlagen: ${e.message}`); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
13
extensions/directus-extension-skrift-sevdesk/package.json
Normal file
13
extensions/directus-extension-skrift-sevdesk/package.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "directus-extension-skrift-sevdesk",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Erstellt bei Konfigurator-Bestellungen automatisch eine sevDesk-Rechnung (Entwurf) inkl. Kontakt. Positionen aus der Preisaufstellung. Nicht-blockierend, per SEVDESK_API_TOKEN aktiviert.",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "hook",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "dist/index.js",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user