Batch-Uebermittlung (Phase 1): Collection + POST /v1/batch
Alternativer Uebermittlungsweg neben /v1/orders (unveraendert). Neue Collection batch_submissions (Empfaengerschema Firma/Vorname/Nachname/ Zeile1-3/PLZ/Stadt/Land + Text ODER Druck-PDF, je Kunde ueber api_client). Neuer Endpunkt POST /v1/batch: ein Empfaenger je Aufruf, PDF via pdf_file_id (aus /v1/files) oder pdf_base64, oder text; Zeichen-Warnung wie /v1/orders. Service-Policy darf batch_submissions lesen/als gebuendelt markieren. Buendelung (xlsx+PDF+FTP) folgt in Phase 2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -691,6 +691,30 @@ const collections = [
|
|||||||
f('notified', 'boolean', { interface: 'boolean', default: false, note: 'Erinnerungs-Mail bereits verschickt (verhindert Wiederholung).' }),
|
f('notified', 'boolean', { interface: 'boolean', default: false, note: 'Erinnerungs-Mail bereits verschickt (verhindert Wiederholung).' }),
|
||||||
{ 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: {} },
|
||||||
] },
|
] },
|
||||||
|
|
||||||
|
// Alternativer Übermittlungsweg (Lettershop/FTP): je Empfänger ein Datensatz
|
||||||
|
// im festen Schema + fertige Druck-PDF ODER Schriftstücktext. Wird je Kunde
|
||||||
|
// täglich (03:00 Folgetag; Sa+So gemeinsam) zu einer xlsx + Sammel-Druck-PDF
|
||||||
|
// gebündelt und per FTP hochgeladen.
|
||||||
|
{ collection: 'batch_submissions', meta: { icon: 'inventory', note: 'Alternativer Übermittlungsweg: Empfängerzeile + Druck-PDF/Text; täglich je Kunde zu xlsx+PDF gebündelt (FTP).' }, fields: [
|
||||||
|
m2o('api_client', 'api_clients', { onDelete: 'CASCADE', note: 'Zugang, über den übermittelt wurde.' }),
|
||||||
|
m2o('customer', 'directus_users', { note: 'Kunde (Login-Konto) des Zugangs.' }),
|
||||||
|
f('firma', 'string', { interface: 'input' }),
|
||||||
|
f('vorname', 'string', { interface: 'input' }),
|
||||||
|
f('nachname', 'string', { interface: 'input' }),
|
||||||
|
f('zeile1', 'string', { interface: 'input' }),
|
||||||
|
f('zeile2', 'string', { interface: 'input' }),
|
||||||
|
f('zeile3', 'string', { interface: 'input' }),
|
||||||
|
f('plz', 'string', { interface: 'input' }),
|
||||||
|
f('stadt', 'string', { interface: 'input' }),
|
||||||
|
f('land', 'string', { interface: 'input' }),
|
||||||
|
f('text', 'text', { interface: 'input-multiline', note: 'Schriftstücktext (Handschrift), falls keine Druck-PDF.' }),
|
||||||
|
m2o('pdf_file', 'directus_files', { note: 'Fertige Druck-PDF (wird 1:1 übernommen), falls kein Text.' }),
|
||||||
|
dropdown('status', ['pending', 'bundled'], { default: 'pending', note: 'pending = noch nicht gebündelt.' }),
|
||||||
|
f('bundle_name', 'string', { interface: 'input', readonly: true, note: 'Name der Bündel-Datei, in die dieser Datensatz ging.' }),
|
||||||
|
f('bundled_at', 'timestamp', { interface: 'datetime', readonly: true }),
|
||||||
|
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
||||||
|
] },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Schema anlegen ──────────────────────────────────────────────────────────
|
// ── Schema anlegen ──────────────────────────────────────────────────────────
|
||||||
@@ -1161,6 +1185,10 @@ async function applyPermissions() {
|
|||||||
// Hochgeladene Druckdateien lesen (Backend führt PDFs für den Charge-Sammeldruck
|
// Hochgeladene Druckdateien lesen (Backend führt PDFs für den Charge-Sammeldruck
|
||||||
// zusammen und lädt sie dafür über /assets).
|
// zusammen und lädt sie dafür über /assets).
|
||||||
await ensurePermission(svcPol.id, 'directus_files', 'read');
|
await ensurePermission(svcPol.id, 'directus_files', 'read');
|
||||||
|
// Alternativer Übermittlungsweg: Backend liest die Datensätze zum Bündeln und
|
||||||
|
// markiert sie danach als gebündelt.
|
||||||
|
await ensurePermission(svcPol.id, 'batch_submissions', 'read');
|
||||||
|
await ensurePermission(svcPol.id, 'batch_submissions', 'update', ['status', 'bundle_name', 'bundled_at']);
|
||||||
|
|
||||||
console.log(' Hinweis: Für Service einen Nutzer in Rolle "Service" anlegen und dessen',
|
console.log(' Hinweis: Für Service einen Nutzer in Rolle "Service" anlegen und dessen',
|
||||||
'statischen Token in .env von Backend/Produktion eintragen.');
|
'statischen Token in .env von Backend/Produktion eintragen.');
|
||||||
|
|||||||
@@ -265,6 +265,73 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── POST /v1/batch ──────────────────────────────────────────────────────────
|
||||||
|
// Alternativer Übermittlungsweg: EIN Empfänger je Aufruf im festen Schema plus
|
||||||
|
// ENTWEDER eine fertige Druck-PDF ODER einen Schriftstücktext. Wird gespeichert
|
||||||
|
// und später je Kunde täglich zu xlsx + Sammel-Druck-PDF gebündelt (FTP).
|
||||||
|
// Ändert NICHTS an /v1/orders – eigenständiger Endpunkt.
|
||||||
|
router.post('/v1/batch', async (req, res) => {
|
||||||
|
const client = await authClient(req);
|
||||||
|
if (!client) return fehler(res, 401, 'Missing or invalid API key.');
|
||||||
|
const b = req.body || {};
|
||||||
|
const feld = (v) => (v == null ? '' : String(v));
|
||||||
|
const adr = {
|
||||||
|
firma: feld(b.firma), vorname: feld(b.vorname), nachname: feld(b.nachname),
|
||||||
|
zeile1: feld(b.zeile1), zeile2: feld(b.zeile2), zeile3: feld(b.zeile3),
|
||||||
|
plz: feld(b.plz), stadt: feld(b.stadt), land: feld(b.land),
|
||||||
|
};
|
||||||
|
if (!Object.values(adr).some((v) => v.trim())) return fehler(res, 400, 'At least one address field is required.');
|
||||||
|
const text = feld(b.text).trim();
|
||||||
|
const hatPdf = !!(b.pdf_file_id || b.pdf_base64);
|
||||||
|
if (!text && !hatPdf) return fehler(res, 400, 'Either a print PDF (pdf_file_id or pdf_base64) or letter text is required.');
|
||||||
|
if (text && hatPdf) return fehler(res, 400, 'Provide either a PDF or text, not both.');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const svc = svcFactory(schema);
|
||||||
|
const clean = await ladeClean(schema);
|
||||||
|
|
||||||
|
// Zeichen-Warnungen (wie /v1/orders): nicht erlaubte Zeichen werden entfernt.
|
||||||
|
const unerlaubt = new Set();
|
||||||
|
const pruefe = (s) => { for (const ch of new Set(String(s || ''))) if (clean(ch) === '') unerlaubt.add(ch); };
|
||||||
|
Object.values(adr).forEach(pruefe);
|
||||||
|
if (text) pruefe(text);
|
||||||
|
|
||||||
|
// PDF: bestehende file_id (aus /v1/files) ODER base64 hier hochladen.
|
||||||
|
let pdfFileId = null;
|
||||||
|
if (b.pdf_file_id) {
|
||||||
|
pdfFileId = String(b.pdf_file_id);
|
||||||
|
} else if (b.pdf_base64) {
|
||||||
|
let buf;
|
||||||
|
try { buf = Buffer.from(String(b.pdf_base64), 'base64'); } catch { return fehler(res, 400, 'pdf_base64 is not valid base64.'); }
|
||||||
|
if (!buf.length || buf.slice(0, 5).toString('latin1') !== '%PDF-') return fehler(res, 415, 'pdf_base64 is not a valid PDF.');
|
||||||
|
if (buf.length > 80 * 1024 * 1024) return fehler(res, 413, 'PDF too large (max. 80 MB).');
|
||||||
|
const filesSvc = new FilesService({ schema, accountability: null });
|
||||||
|
pdfFileId = await filesSvc.uploadOne(Readable.from(buf), {
|
||||||
|
storage: STORAGE, filename_download: `batch_${Date.now()}.pdf`, title: 'Batch print PDF', type: 'application/pdf',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await svc('batch_submissions').createOne({
|
||||||
|
api_client: client.id, customer: client.customer || null,
|
||||||
|
firma: clean(adr.firma) || null, vorname: clean(adr.vorname) || null, nachname: clean(adr.nachname) || null,
|
||||||
|
zeile1: clean(adr.zeile1) || null, zeile2: clean(adr.zeile2) || null, zeile3: clean(adr.zeile3) || null,
|
||||||
|
plz: clean(adr.plz) || null, stadt: clean(adr.stadt) || null, land: clean(adr.land) || null,
|
||||||
|
text: text ? clean(text) : null,
|
||||||
|
pdf_file: pdfFileId,
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
const warnings = [];
|
||||||
|
if (unerlaubt.size) warnings.push({ code: 'unsupported_characters', characters: [...unerlaubt],
|
||||||
|
message: 'Some characters are not supported and were removed. See the allowed character set in the documentation.' });
|
||||||
|
return res.status(201).json({ id: String(id), status: 'accepted', ...(warnings.length ? { warnings } : {}) });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[skrift-api] batch: ${err.stack || err.message}`);
|
||||||
|
return fehler(res, 500, 'Submission could not be stored.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── POST /v1/orders ─────────────────────────────────────────────────────────
|
// ── POST /v1/orders ─────────────────────────────────────────────────────────
|
||||||
router.post('/v1/orders', async (req, res) => {
|
router.post('/v1/orders', async (req, res) => {
|
||||||
const client = await authClient(req);
|
const client = await authClient(req);
|
||||||
|
|||||||
Reference in New Issue
Block a user