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.
|
||||
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('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
|
||||
// anmeldet, landet im SELBEN Konto (voller Zugriff). Liste von E-Mails.
|
||||
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('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.' }),
|
||||
// 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_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
|
||||
// Produktionsansicht: durchsuchbare Empfängertabelle (Briefnummer + volle
|
||||
|
||||
@@ -1807,6 +1807,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
entries_count: entries.length,
|
||||
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,
|
||||
price_lines: quote?.lines ?? null,
|
||||
};
|
||||
|
||||
let orderId, orderNumber;
|
||||
@@ -1990,6 +1991,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
net_total: quote.net_total,
|
||||
vat_amount: quote.vat_amount,
|
||||
gross_total: quote.gross_total,
|
||||
price_lines: quote.lines ?? null,
|
||||
voucher: quote.voucher?.id ?? null,
|
||||
payment_method: paymentMethod,
|
||||
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