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:
121
extensions/directus-extension-skrift-inventory/dist/index.js
vendored
Normal file
121
extensions/directus-extension-skrift-inventory/dist/index.js
vendored
Normal file
@@ -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}`); }
|
||||
});
|
||||
};
|
||||
13
extensions/directus-extension-skrift-inventory/package.json
Normal file
13
extensions/directus-extension-skrift-inventory/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "directus-extension-skrift-inventory",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Bucht Materialbestand (Papier/Kuvert/Briefmarke) automatisch ab, sobald ein Druck-Job als gedruckt gemeldet wird – am Produktionsstandort des Kunden, mit +10 % Puffer.",
|
||||
"directus:extension": {
|
||||
"type": "hook",
|
||||
"path": "dist/index.js",
|
||||
"source": "dist/index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ const TABS = [
|
||||
{ key: 'versandfertig', label: '📦 Versandfertig' },
|
||||
{ key: 'versendet', label: '🚚 Versendet' },
|
||||
{ key: 'batch', label: '📦 Übermittlungen' },
|
||||
{ key: 'bestand', label: '🧾 Bestand' },
|
||||
{ key: 'archiv', label: '🗄 Archiv' },
|
||||
];
|
||||
const STATUS_LABEL = {
|
||||
@@ -187,6 +188,11 @@ const Board = defineComponent({
|
||||
let sucheTimer = null;
|
||||
const sucheSetzen = (v) => { clearTimeout(sucheTimer); sucheTimer = setTimeout(() => { sucheText.value = v; }, 250); };
|
||||
const loading = ref(false);
|
||||
// Bestand (Material je Standort) + Standorte für den Bestand-Tab.
|
||||
const bestand = ref([]);
|
||||
const standorte = ref([]);
|
||||
const bestandMsg = ref('');
|
||||
const bestandEingabe = reactive({}); // je Bestandszeile: Zugangsmenge
|
||||
const fehler = ref('');
|
||||
const meldung = ref('');
|
||||
|
||||
@@ -399,7 +405,7 @@ const Board = defineComponent({
|
||||
// leise = Hintergrund-Aktualisierung: kein Spinner, kein Fehlerbanner.
|
||||
if (!leise) { loading.value = true; fehler.value = ''; }
|
||||
try {
|
||||
const [o, r, j, m, t, ms, mtx, bs] = await Promise.all([
|
||||
const [o, r, j, m, t, ms, mtx, bs, inv, st] = await Promise.all([
|
||||
ladeOrders(),
|
||||
api.get('/items/offer_requests', { params: {
|
||||
limit: -1, sort: '-date_created',
|
||||
@@ -416,6 +422,8 @@ const Board = defineComponent({
|
||||
filter: { status: { _eq: 'pending' } },
|
||||
fields: 'id,api_client.id,api_client.name,firma,vorname,nachname,zeile1,zeile2,zeile3,plz,stadt,land,text,pdf_file,status,versendet,date_created',
|
||||
} }).catch(() => ({ data: { data: [] } })),
|
||||
api.get('/items/inventory', { params: { limit: -1, fields: 'id,standort,kategorie,format,bezeichnung,bestand,mindestbestand,einheit', sort: 'kategorie,bezeichnung' } }).catch(() => ({ data: { data: [] } })),
|
||||
api.get('/items/standorte', { params: { limit: -1, fields: 'id,name,sort', sort: 'sort' } }).catch(() => ({ data: { data: [] } })),
|
||||
]);
|
||||
orders.value = o.data?.data || [];
|
||||
requests.value = r.data?.data || [];
|
||||
@@ -425,6 +433,8 @@ const Board = defineComponent({
|
||||
muster.value = ms.data?.data || { text_du: '', text_sie: '' };
|
||||
musterListe.value = mtx.data?.data || [];
|
||||
batch.value = bs.data?.data || [];
|
||||
bestand.value = inv.data?.data || [];
|
||||
standorte.value = st.data?.data || [];
|
||||
// Empfänger-Index nur beim vollen Laden neu bauen (nicht bei der 15-s-
|
||||
// Hintergrund-Aktualisierung) – im Hintergrund, blockiert das Board nicht.
|
||||
if (!leise) { ladeEmpfIndex(); ladeSendIndex(); ladeChargeNotizen(); }
|
||||
@@ -1628,8 +1638,69 @@ const Board = defineComponent({
|
||||
return [leiste, ...bloecke];
|
||||
}
|
||||
|
||||
// ── Bestand (Material je Standort) ────────────────────────────────────────
|
||||
async function bestandPatch(row, felder) {
|
||||
try { await api.patch('/items/inventory/' + row.id, felder); Object.assign(row, felder); }
|
||||
catch (e) { bestandMsg.value = 'Speichern fehlgeschlagen: ' + (e?.response?.data?.errors?.[0]?.message || e?.message || ''); }
|
||||
}
|
||||
async function bestandBuchen(row, delta, grund) {
|
||||
const neu = (Number(row.bestand) || 0) + delta;
|
||||
await bestandPatch(row, { bestand: neu });
|
||||
try { await api.post('/items/inventory_log', { inventory: row.id, delta, grund: grund || (delta >= 0 ? 'auffuellung' : 'korrektur') }); } catch { /* Log optional */ }
|
||||
bestandMsg.value = `${row.bezeichnung}: ${delta >= 0 ? '+' : ''}${delta} → ${neu} ${row.einheit || ''}`;
|
||||
setTimeout(() => { if (bestandMsg.value.startsWith(row.bezeichnung)) bestandMsg.value = ''; }, 3000);
|
||||
}
|
||||
const klLabel = (t) => h('span', { style: 'font-size:11px;color:var(--foreground-subdued);' }, t);
|
||||
function bestandZeile(b) {
|
||||
const unter = (Number(b.bestand) || 0) < (Number(b.mindestbestand) || 0);
|
||||
if (!(b.id in bestandEingabe)) bestandEingabe[b.id] = '';
|
||||
return h('div', { key: 'inv' + b.id, style: `display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:6px 8px;border-radius:8px;margin-bottom:4px;${unter ? 'background:var(--danger-25,rgba(198,40,40,.08));' : ''}` }, [
|
||||
h('div', { style: 'flex:1 1 180px;min-width:150px;font-size:14px;' }, [
|
||||
h('span', { style: 'font-weight:600;' }, b.bezeichnung || b.format),
|
||||
unter ? h('span', { style: 'margin-left:8px;color:var(--danger,#c62828);font-weight:700;font-size:12px;' }, '• nachbestellen') : null,
|
||||
]),
|
||||
h('div', { style: 'display:flex;align-items:center;gap:4px;' }, [klLabel('Bestand'),
|
||||
h('input', { type: 'number', value: b.bestand, style: 'width:84px;' + S_INPUT, onChange: (e) => bestandPatch(b, { bestand: Math.round(Number(e.target.value) || 0) }) }),
|
||||
h('span', { style: 'font-size:12px;color:var(--foreground-subdued);' }, b.einheit || '')]),
|
||||
h('div', { style: 'display:flex;align-items:center;gap:4px;' }, [klLabel('Mindest'),
|
||||
h('input', { type: 'number', value: b.mindestbestand, style: 'width:70px;' + S_INPUT, onChange: (e) => bestandPatch(b, { mindestbestand: Math.round(Number(e.target.value) || 0) }) })]),
|
||||
h('div', { style: 'display:flex;align-items:center;gap:4px;' }, [
|
||||
h('input', { type: 'number', placeholder: 'Menge', value: bestandEingabe[b.id], style: 'width:84px;' + S_INPUT, onInput: (e) => { bestandEingabe[b.id] = e.target.value; } }),
|
||||
btn('+ Zubuchen', () => { const n = Math.round(Number(bestandEingabe[b.id]) || 0); if (n) { bestandBuchen(b, n, 'auffuellung'); bestandEingabe[b.id] = ''; } }, 'primary'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
function bestandInhalt() {
|
||||
if (!standorte.value.length) return subdued('Noch keine Standorte/Bestand angelegt. Bitte den Directus-Bootstrap ausführen.');
|
||||
const KAT = [['papier', 'Papier'], ['kuvert', 'Kuvert'], ['briefmarke', 'Briefmarke']];
|
||||
const bloecke = standorte.value.map((s) => {
|
||||
const rows = bestand.value.filter((b) => String(b.standort) === String(s.id));
|
||||
const unter = rows.filter((b) => (Number(b.bestand) || 0) < (Number(b.mindestbestand) || 0)).length;
|
||||
const katBloecke = KAT.map(([k, kl]) => {
|
||||
const items = rows.filter((b) => b.kategorie === k);
|
||||
if (!items.length) return null;
|
||||
return h('div', { style: 'margin-bottom:8px;' }, [
|
||||
h('div', { style: 'font-size:12px;font-weight:700;color:var(--foreground-subdued);text-transform:uppercase;letter-spacing:.03em;margin:6px 0 2px;' }, kl),
|
||||
...items.map(bestandZeile),
|
||||
]);
|
||||
});
|
||||
return h('div', { key: 'st' + s.id, style: CARD + 'margin-bottom:16px;' }, [
|
||||
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;flex-wrap:wrap;gap:8px;' }, [
|
||||
h('div', { style: 'font-weight:700;font-size:16px;' }, '🏭 ' + s.name),
|
||||
unter ? chip(`⚠ ${unter}× nachbestellen`, 'var(--danger-25,rgba(198,40,40,.15))') : chip('✓ Bestand ok', 'var(--success-25,rgba(46,125,50,.15))'),
|
||||
]),
|
||||
rows.length ? h('div', {}, katBloecke) : subdued('Keine Materialzeilen an diesem Standort.'),
|
||||
]);
|
||||
});
|
||||
return [
|
||||
bestandMsg.value ? h('div', { style: 'margin-bottom:10px;color:var(--primary,#1e50a5);font-size:13px;font-weight:600;' }, bestandMsg.value) : null,
|
||||
subdued('Zugänge/Auffüllungen hier eintragen. Abgänge werden beim Druck automatisch gebucht (+10 % Puffer).', 'margin-bottom:10px;'),
|
||||
...bloecke,
|
||||
];
|
||||
}
|
||||
function tabInhalt() {
|
||||
if (tab.value === 'batch') return batchInhalt();
|
||||
if (tab.value === 'bestand') return bestandInhalt();
|
||||
if (tab.value === 'suche') return sucheInhalt();
|
||||
if (tab.value === 'neu') {
|
||||
const items = [
|
||||
|
||||
Reference in New Issue
Block a user