Kontakt-CRM, Chargen-Sammeldruck, API-Status & Auto-Generierung
Kontakte/CRM (Directus): - Neue Collections `contacts` (Leads) + `contact_actions` (Rückruf/Angebot/Mail). - Hook `skrift-contacts`: Kontakte werden nur auf Anforderung angelegt (Auftrag "In Kontakte speichern"); Rückruf-Erinnerung (Zeitplan) und Angebots-/Mail-an-mich-Mails; Auto-Löschung nur bei Lead-Status "kein_interesse" nach 30 Tagen; optionaler DSGVO-Strip (per Env, Dry-Run). - Modul `skrift-kontakte`: handytaugliches Board (Anruf via groundwire:, Notizen mit Autosave, Durchblättern, CSV-Export, Aktionen als Buttons). API-Chargen & PDF-Sammeldruck: - Produktions-Board gruppiert API-Aufträge unter "Neu" zu Chargen (Kunde + Typ + Versandtag + Eingangstag), aufklappbar, mit "fällig bis" (Versandtag der übernächsten Woche). - Mehrfachauswahl -> Sammel-PDF (Backend `pdf-lib`, /api/charge/merge-pdf, Directus-Proxy /skrift-orders/charge/pdf), Kuverts an Plotter und Sammel-Status; PDF- und Kuvert-Reihenfolge nach Auftragsnummer (Kollation). - Mailer: keine Einzelmail je API-Auftrag mehr, stattdessen Tagesübersicht (18:00) pro Kunde, nach Versandtag aufgeschlüsselt. Status-API & Generierung: - Public-API-Status liest jetzt production_status (Board) statt nur kaufmännischem Status; neues Feld `status_since` in der Antwort. - Auftrag stempelt `production_status_changed_at` bei jedem Statuswechsel. - Generierung wird IMMER beim Eingang angestoßen (auch Datei-Produkte mit handgeschriebenem Kuvert), nicht nur bei letter/postcard. - Versandtag im Produktionsdashboard sichtbar. Rechte: Service-Rolle darf directus_files lesen (Backend lädt PDFs für den Sammeldruck über /assets). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -33,12 +33,17 @@ const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex'
|
||||
const pad3 = (n) => String(n).padStart(3, '0');
|
||||
const WOCHENTAGE = ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'];
|
||||
|
||||
/** Internal order status → public status (pending | processing | shipped | cancelled). */
|
||||
function kundenStatus(status) {
|
||||
const s = String(status || '');
|
||||
if (s === 'versendet' || s === 'abgeschlossen') return 'shipped';
|
||||
if (s === 'in_produktion' || s === 'gedruckt') return 'processing';
|
||||
if (s === 'storniert') return 'cancelled';
|
||||
/**
|
||||
* Internal order → public status (pending | processing | shipped | cancelled).
|
||||
* Der Betreiber steuert im Produktions-Board `production_status`; der kaufmännische
|
||||
* `status` dient als Rückfall. Beide werden berücksichtigt.
|
||||
*/
|
||||
function kundenStatus(order) {
|
||||
const ps = String(order?.production_status || '');
|
||||
const s = String(order?.status || '');
|
||||
if (ps === 'storniert' || s === 'storniert') return 'cancelled';
|
||||
if (ps === 'versendet' || s === 'versendet' || s === 'abgeschlossen') return 'shipped';
|
||||
if (['im_druck', 'kuvertieren', 'versandfertig'].includes(ps) || ['in_produktion', 'gedruckt'].includes(s)) return 'processing';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
@@ -155,10 +160,10 @@ const DOCS_HTML = `<!doctype html>
|
||||
<h2>Retrieve order status</h2>
|
||||
<div class="card">
|
||||
<div class="ep"><span class="m get">GET</span> /v1/orders/{order_number}</div>
|
||||
<p>Returns the current status of one of your orders.</p>
|
||||
<p>Returns the current status of one of your orders. <code>status_since</code> is the ISO 8601 timestamp (UTC) since which the order has been in its current status.</p>
|
||||
<pre>curl https://dev.skrift.de/v1/orders/18-08-26-001 -H "X-Api-Key: <KEY>"
|
||||
|
||||
{ "order_number":"18-08-26-001", "status":"processing", "recipients":1 }</pre>
|
||||
{ "order_number":"18-08-26-001", "status":"shipped", "status_since":"2026-08-20T09:14:00.000Z", "recipients":1 }</pre>
|
||||
</div>
|
||||
|
||||
<h2>Upload a PDF</h2>
|
||||
@@ -395,13 +400,14 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
|
||||
await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Über die Public-API angelegt.' }).catch(() => {});
|
||||
|
||||
// Generierung anstoßen (fire-and-forget) – identisch zum manuellen Auftrag.
|
||||
if (['letter', 'postcard', 'envelope'].includes(product.type)) {
|
||||
fetch(`${BACKEND_URL}/api/order/from-directus`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
|
||||
body: JSON.stringify({ orderId }),
|
||||
}).catch((e) => logger.warn(`[skrift-api] Generierung nicht gestartet: ${e.message}`));
|
||||
}
|
||||
// Generierung IMMER anstoßen (fire-and-forget) – für jedes Produkt. Das
|
||||
// Backend entscheidet, was zu erzeugen ist: Schriftstücke bei Handschrift-
|
||||
// Produkten, Kuverts immer wenn gewünscht; bei Datei-Produkten (z. B.
|
||||
// Unterschriftenservice) NUR die Kuverts. Ohne zu Erzeugendes = No-Op.
|
||||
fetch(`${BACKEND_URL}/api/order/from-directus`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
|
||||
body: JSON.stringify({ orderId }),
|
||||
}).catch((e) => logger.warn(`[skrift-api] Generierung nicht gestartet: ${e.message}`));
|
||||
|
||||
return res.status(201).json({ order_number: orderNumber, status: 'pending' });
|
||||
} catch (err) {
|
||||
@@ -419,13 +425,21 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
const rows = await new ItemsService('orders', { schema, accountability: null }).readByQuery({
|
||||
// Nur eigene Aufträge des Zugangs.
|
||||
filter: { order_number: { _eq: String(req.params.nummer) }, api_client: { _eq: client.id } },
|
||||
limit: 1, fields: ['order_number', 'status', 'entries_count', 'date_created'],
|
||||
limit: 1, fields: ['order_number', 'status', 'production_status', 'production_status_changed_at',
|
||||
'versandt_am', 'entries_count', 'date_created'],
|
||||
});
|
||||
const order = rows?.[0];
|
||||
if (!order) return fehler(res, 404, 'Order not found.');
|
||||
const status = kundenStatus(order);
|
||||
// Seit wann der Status so ist: Zeitpunkt der letzten Produktionsstatus-
|
||||
// Änderung; bei „shipped" ersatzweise der Versandzeitpunkt, sonst Anlage.
|
||||
const statusSince = order.production_status_changed_at
|
||||
|| (status === 'shipped' ? order.versandt_am : null)
|
||||
|| order.date_created || null;
|
||||
return res.json({
|
||||
order_number: order.order_number,
|
||||
status: kundenStatus(order.status),
|
||||
status,
|
||||
status_since: statusSince,
|
||||
recipients: order.entries_count ?? null,
|
||||
created_at: order.date_created ?? null,
|
||||
});
|
||||
@@ -514,7 +528,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
'/v1/orders/{order_number}': {
|
||||
get: {
|
||||
summary: 'Retrieve order status', parameters: [{ name: 'order_number', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
responses: { 200: { description: 'Order status', content: { 'application/json': { schema: { type: 'object', properties: { order_number: { type: 'string' }, status: { type: 'string', enum: ['pending', 'processing', 'shipped', 'cancelled'] } } } } } } },
|
||||
responses: { 200: { description: 'Order status', content: { 'application/json': { schema: { type: 'object', properties: { order_number: { type: 'string' }, status: { type: 'string', enum: ['pending', 'processing', 'shipped', 'cancelled'] }, status_since: { type: 'string', format: 'date-time', description: 'ISO 8601 timestamp since which the order has held its current status.' }, recipients: { type: 'integer' } } } } } } },
|
||||
},
|
||||
},
|
||||
'/v1/files': {
|
||||
|
||||
Reference in New Issue
Block a user