Bestandspflege: Material je Standort, Auto-Abbuchung beim Druck

Datenmodell (bootstrap):
- Collections standorte (seed Skrift/Signascript), inventory (Papier/Kuvert/
  Briefmarke je Standort, initiale Zeilen) und inventory_log.
- directus_users.produktionsstandort (m2o standorte; USER_FIELDS-Relation wird
  jetzt angelegt); orders.frankiert + porto_klasse; jobs.inventory_done.

Neue Hook-Extension skrift-inventory:
- Bucht beim Job-Status 'printed' automatisch ab (am Kunden-Standort, +10 %):
  Brief->Papier (phys. Format), Kuvert->Kuvert + Briefmarke (nur wenn frankiert;
  Inland Standard / Ausland International, bzw. porto_klasse). Idempotent.

produktion: neuer Tab 'Bestand' - je Standort, Unterschreitung rot
('nachbestellen'), manuelles Zubuchen + Mindestbestand/Bestand editierbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
s4luorthandClaude Opus 4.8 committed 2026-10-01 08:34:27 +02:00
1 parent 79d6b4f64b
commit f8f219a3fa
4 files changed
+271 -4

No files matched your search

@@ -0,0 +1,121 @@
/**
* Skrift – Bestands-Hook (Auto-Abbuchung)
* ---------------------------------------------------------------------------
* Bucht Material automatisch ab, sobald ein Druck-Job als „printed" gemeldet
* wird (Abgang BEIM DRUCK), am Produktionsstandort des Kunden (leer = Skrift):
*
* Brief gedruckt → Papier (physisches Format) × Menge × 1,10
* Kuvert gedruckt → Kuvert (envelope_format) × Menge × 1,10
* + Briefmarke, NUR wenn von uns frankiert:
* Inland → Standard, Ausland → International
* (bzw. porto_klasse-Override) × Menge × 1,10
*
* „Von uns frankiert" = order.frankiert, sonst automatisch: Einzelversand ja,
* Sammelversand nein. +10 % Puffer (Zusatzversorgung), aufgerundet.
*
* Idempotent über jobs.inventory_done. Fehlende Bestandszeilen werden
* übersprungen (geloggt), damit nie ein Druck blockiert.
*/
// Auftrags-Format-Key (formats.key) → physischer Papier-Key (inventory.format).
const PAPIER = {
a4: 'a4',
a5p: 'a5', a5l: 'a5', a5_hoch: 'a5', a5_quer: 'a5',
a6p: 'a6', a6l: 'a6', a6_hoch: 'a6', a6_quer: 'a6',
dinlang_karte: 'dinlang',
};
// +10 % Zusatzversorgung, aufgerundet.
const puffer = (n) => Math.ceil((Number(n) || 0) * 1.1);
// Anzahl aus einem numbers-String (z. B. „1-50", „1,3,5-7"). Leer = 0.
function anzahlAusNumbers(s) {
const txt = String(s || '').trim();
if (!txt) return 0;
let n = 0;
for (const teil of txt.split(',')) {
const t = teil.trim();
const m = t.match(/^(\d+)\s*-\s*(\d+)$/);
if (m) n += Math.max(0, Number(m[2]) - Number(m[1]) + 1);
else if (/^\d+$/.test(t)) n += 1;
}
return n;
}
export default (register, { services, getSchema, logger }) => {
const { ItemsService } = services;
let skriftId = null; // Cache: Standort-ID „Skrift" (Default).
async function defaultStandort(svc) {
if (skriftId) return skriftId;
const rows = await svc('standorte').readByQuery({ filter: { name: { _eq: 'Skrift' } }, limit: 1, fields: ['id'] });
skriftId = (rows && rows[0]) ? rows[0].id : null;
return skriftId;
}
async function abbuchen(svc, standortId, kategorie, formatKey, menge, orderId) {
if (!standortId || menge <= 0 || !formatKey) return;
const rows = await svc('inventory').readByQuery({
filter: { _and: [{ standort: { _eq: standortId } }, { kategorie: { _eq: kategorie } }, { format: { _eq: String(formatKey) } }] },
limit: 1, fields: ['id', 'bestand', 'mindestbestand', 'bezeichnung'],
});
const row = rows && rows[0];
if (!row) { logger.info(`[skrift-inventory] keine Bestandszeile ${kategorie}/${formatKey} @Standort ${standortId} – übersprungen`); return; }
const neu = (Number(row.bestand) || 0) - menge;
await svc('inventory').updateOne(row.id, { bestand: neu });
await svc('inventory_log').createOne({ inventory: row.id, delta: -menge, grund: 'druck', order: orderId || null });
if (neu < (Number(row.mindestbestand) || 0)) {
logger.warn(`[skrift-inventory] Mindestbestand unterschritten: ${row.bezeichnung || formatKey} (${neu})`);
}
}
async function verbucheJob(svc, jobId) {
const job = await svc('jobs').readOne(jobId, {
fields: ['id', 'type', 'status', 'numbers', 'inventory_done',
'order.id', 'order.format.key', 'order.envelope_format', 'order.entries_count',
'order.count_inland', 'order.count_ausland', 'order.shipping_type',
'order.frankiert', 'order.porto_klasse', 'order.customer.produktionsstandort'],
});
if (!job || job.status !== 'printed' || job.inventory_done || !job.order) return;
const o = job.order;
const standortId = o.customer?.produktionsstandort || await defaultStandort(svc);
if (!standortId) { logger.info('[skrift-inventory] kein Standort (auch kein „Skrift") – übersprungen'); return; }
const menge = anzahlAusNumbers(job.numbers) || Number(o.entries_count) || 0;
if (menge <= 0) { await svc('jobs').updateOne(jobId, { inventory_done: true }); return; }
if (job.type === 'umschlag') {
await abbuchen(svc, standortId, 'kuvert', o.envelope_format || 'dinlang', puffer(menge), o.id);
// Briefmarke nur, wenn von uns frankiert (Default: Einzel ja / Sammel nein).
const frankiert = (typeof o.frankiert === 'boolean') ? o.frankiert : (o.shipping_type !== 'sammel');
if (frankiert) {
const inland = Number(o.count_inland) || 0;
const ausland = Number(o.count_ausland) || 0;
const klasse = (o.porto_klasse && o.porto_klasse !== 'auto') ? o.porto_klasse : null;
if (klasse) {
await abbuchen(svc, standortId, 'briefmarke', klasse, puffer((inland || ausland) ? inland + ausland : menge), o.id);
} else {
const nInland = (inland || ausland) ? inland : menge; // keine Länderzählung → alles Inland
if (nInland > 0) await abbuchen(svc, standortId, 'briefmarke', 'standard', puffer(nInland), o.id);
if (ausland > 0) await abbuchen(svc, standortId, 'briefmarke', 'international', puffer(ausland), o.id);
}
}
} else {
// Brief → Papier (physisches Format). 1 Blatt je Brief (mehrseitig: späteres TODO).
const papierKey = PAPIER[String(o.format?.key || 'a4').toLowerCase()] || 'a4';
await abbuchen(svc, standortId, 'papier', papierKey, puffer(menge), o.id);
}
await svc('jobs').updateOne(jobId, { inventory_done: true });
}
register.action('jobs.items.update', async ({ keys, payload }) => {
if (!payload || payload.status !== 'printed') return;
try {
const schema = await getSchema();
const svc = (c) => new ItemsService(c, { schema, accountability: null });
for (const key of (keys || [])) {
try { await verbucheJob(svc, key); }
catch (e) { logger.warn(`[skrift-inventory] Job ${key}: ${e.message}`); }
}
} catch (e) { logger.warn(`[skrift-inventory] update-Hook: ${e.message}`); }
});
};