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:
s4luorth
2026-09-18 07:44:28 +02:00
parent 10776764d7
commit 0f73a653b3
4 changed files with 234 additions and 0 deletions

View File

@@ -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

View File

@@ -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',

View 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}`); }
}
});
};

View 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"
}
}