diff --git a/.agents/skills/backend/SKILL.md b/.agents/skills/backend/SKILL.md index 22cbea6..17efa0a 100644 --- a/.agents/skills/backend/SKILL.md +++ b/.agents/skills/backend/SKILL.md @@ -4,6 +4,130 @@ description: >- Reference documentation for backend service. Use this skill whenever working with backend APIs. --- +# we should cache in client (at least) : company,language,tax,unit,unittext,customergroup,warehouse,account,brand,category,categorytext,attributename,attributevalue,propertyname,companysetting,printer,labeltemplate,posterminal,posterminalcategory + +todos: + +1. Replace 13 List GETs with 1 /bootstrap Endpoint +Because these reference tables are cached in memory on login/company-switch and invalidated via GET /events SSE, all individual list endpoints can be merged into a single GET /bootstrap (or folded into GET /auth/me / PUT /auth/selected-company): + +GET /account + +GET /attributename (already returns nested values: T28[] in T16) + +GET /brand (unless you have thousands of brands requiring server pagination) + +GET /carrier + +GET /category/tree + +GET /company & GET /company/setting + +GET /customergroup + +GET /labeltemplate (metadata list without the heavy content.objects array) + +GET /posterminal + +GET /printer + +GET /propertyname + +GET /supplier + +GET /unit + +GET /warehouse + + +Drop 6 Redundant Single-Item & Child GET Endpoints +Once the parent lists above are in the client cache, looking up a single item by ID or key over the network is dead weight: + +Endpoint to Remove,Why It's Redundant with Client Cache +GET /attributename/{attributeId}/value,"GET /attributename returns T16[], which already embeds values: T28[]." +GET /carrier/{id},Already in cached carriers: T31[]. +GET /company/setting/{key},Already in cached settings map. +GET /printer/{id},Already in cached printers: T22[]. +GET /warehouse/{id},Already in cached warehouses list. +GET /order/{id}/paymentGET /order/{id}/shipmentGET /order/{id}/shipping,"GET /order/{id} returns T1, which already embeds payments: T30[], shipments: T3[], and shippings: T19[]." + + + + + +Consolidate Micro-Mutation Endpoints (~20 Routes Saved) +Several resources expose separate endpoints for single-column updates or duplicate HTTP verbs. Folding these into their parent PATCH or PUT routes cuts massive routing boilerplate: + +Article single-field setters (Save 4 routes): +PUT /article/{sku}/brand, PUT /article/{sku}/track_stock, PUT /article/{sku}/weight, and POST /article/{sku}/parent all return T2/T32. Fold brandId, track_stock, weight, isParent, and attributeNameId directly into PATCH /article/{sku}. + +Article sub-resource getters (Save 4 routes): +If you embed attributeValueIds: int[], properties: { propertyId: int, value: str }[], articlelist, and optionslist directly inside T2 (GET /article/{sku}), you can drop GET /article/{sku}/attributevalue, GET /article/{sku}/property, GET /article/{sku}/articlelist, and GET /article/{sku}/optionslist. + +POS Terminal category toggles (Save 4 routes): +DELETE /posterminal/{id}/all, PUT /posterminal/{id}/all, DELETE /posterminal/{id}/category/{categoryId}, and PUT /posterminal/{id}/category/{categoryId} can all be replaced by passing categoryIds?: int[] to PATCH /posterminal/{id}. + + + + +Duplicate PUT + PATCH & Export verbs (Save 4 routes): + +labeltemplate/{id}, printer/{id}, and warehouse/{id} each define both PATCH and PUT with identical request bodies and 200 responses. Drop the PUT routes. + +company/export defines both GET and POST for the exact same SSE stream. + +Ledger & Supplier query duplicates (Save 4 routes): + +GET /ledger/manifestarticle/{id} and GET /ledger/sku/{sku} are 100% covered by GET /ledger?manifestarticle_id=...&sku=.... + +GET /supplier/{id}/articles?skus=... and POST /supplier/{id}/articles do the exact same thing. + +DELETE /article/{sku}/property/{propertyId} and DELETE /category/{id}/property/{propertyId} are redundant when you already use bulk PUT .../property to replace the property array. + + + + +Unify Document File Attachments (Save 8 Routes) +delivery, order, quote, and shipment all repeat identical attachment routes because they are all backed by manifest and manifestattachment in the database: + +GET /{delivery|order|quote|shipment}/{id}/file/{fileId} (4 routes) + +DELETE /{delivery|order|quote|shipment}/{id}/file/{fileId} (4 routes) + +POST /{delivery|order|quote|shipment}/{id}/pdf (4 routes) + +Since file.id is a primary key (or scoped via manifestattachment), you can replace all 8 file routes with GET /file/{fileId} and DELETE /file/{fileId} (saving 6 routes). + + + + +5. Slim Down API Payloads (Stop Joining Cached Data on the Server) +Even for endpoints you keep, the client cache lets you remove server-side SQL JOINs and strip redundant strings from your response types: + +T2 (GET /article/{sku}) & GET /category/{id}/articles: + +Currently returns full unit: T27 ({ si, basesi, basesifactor, symbol, name }) and brand: T26 ({ id, name, fullHash, thumbW, thumbH, thumbHash }). + +Reduction: Return only unitId: int (or unitSi: str) and brandId: int|null. The client resolves the unit symbols, conversion factors, brand name, and brand logo hash instantly from the cache. + +GET /article/{sku}/attributevalue & GET /category/{id}/attributevalue: + +Currently joins and returns attributeId, attributeName, value, sort, exclusive. + +Reduction: Return only attributeValueIds: int[]. The client already has the entire attributename -> attributevalue tree cached. + +GET /article/{sku}/property & GET /category/{id}/property: + +Currently joins propertyname to return name: str and exclusive: bool on every property row. + +Reduction: Return { propertyId: int, value: str, sort: int }[] and resolve name and exclusive from the cached propertyname table. + +T11 (articlesupplier) & T19 (shipping): + +Strip supplierName?: str from T11 (resolve via entityId in cached supplier list) and strip carrier?: str from T19 (resolve via carrierId in cached carrier list). + + + # Dash API Routes Overview (All) diff --git a/src/components/manifest/BookDeliveryDialog.jsx b/src/components/manifest/BookDeliveryDialog.jsx index 296bea7..51c90c9 100644 --- a/src/components/manifest/BookDeliveryDialog.jsx +++ b/src/components/manifest/BookDeliveryDialog.jsx @@ -30,7 +30,7 @@ import WarehouseIcon from '@mui/icons-material/Warehouse'; import AddIcon from '@mui/icons-material/Add'; import { enqueueSnackbar } from 'notistack'; import { notifyError } from '../../utils/notify'; -import { formatDate } from './manifestConfig'; +import { formatDate, resolveLineSkus } from './manifestConfig'; import { fetchDeliveryLedgerSummary, bookDeliveryLines, @@ -45,66 +45,6 @@ import { SURFACE_RAISED, } from '../../colors'; -function resolveLineSkus(line, delivery, supplierArticlesMap = {}) { - const rawSku = line?.sku ? String(line.sku).trim() : ''; - const supplierId = delivery?.supplierId ?? delivery?.partyId; - - // 1. Direct line properties if available - let supplierSku = - line?.supplierSku || - line?.supplier_sku || - line?.supplierArticle?.sku || - ''; - let ownSku = - line?.articleSku || - line?.article_sku || - line?.article?.sku || - line?.supplierArticle?.article?.sku || - ''; - - // 2. Check supplierArticlesMap - const sa = rawSku - ? (supplierArticlesMap[rawSku] || supplierArticlesMap[rawSku.toLowerCase()]) - : null; - - if (sa) { - if (!supplierSku && sa.sku) supplierSku = sa.sku; - if (!ownSku && sa.article?.sku) ownSku = sa.article.sku; - } - - // 3. Check loaded article details - const cachedArticle = - line?.article || - (ownSku ? getArticle(ownSku) : null) || - (rawSku ? getArticle(rawSku) : null); - - if (cachedArticle) { - if (!ownSku && cachedArticle.sku) { - ownSku = cachedArticle.sku; - } - const sup = - (supplierId && cachedArticle.suppliers?.find((s) => s.entityId === supplierId)) || - cachedArticle.suppliers?.[0]; - if (!supplierSku && sup?.sku) { - supplierSku = sup.sku; - } - } - - // 4. Fallback heuristics if one is still missing - if (!supplierSku && !ownSku) { - ownSku = rawSku; - } else if (!supplierSku && rawSku && rawSku !== ownSku) { - supplierSku = rawSku; - } else if (!ownSku && rawSku && rawSku !== supplierSku) { - ownSku = rawSku; - } - - return { - supplierSku: supplierSku || '–', - ownSku: ownSku || '–', - }; -} - export default class BookDeliveryDialog extends PureComponent { constructor(props) { super(props); diff --git a/src/components/manifest/manifestConfig.js b/src/components/manifest/manifestConfig.js index 623ee35..cc825d1 100644 --- a/src/components/manifest/manifestConfig.js +++ b/src/components/manifest/manifestConfig.js @@ -707,3 +707,67 @@ export function manifestConfig(cfgOrKind) { } throw new Error(`Unknown manifest kind: ${kind}`); } + +/** + * Resolves supplier SKU and own SKU for a manifest line. + */ +export function resolveLineSkus(line, delivery, supplierArticlesMap = {}) { + const rawSku = line?.sku ? String(line.sku).trim() : ''; + const supplierId = delivery?.supplierId ?? delivery?.partyId; + + // 1. Direct line properties if available + let supplierSku = + line?.supplierSku || + line?.supplier_sku || + line?.supplierArticle?.sku || + ''; + let ownSku = + line?.articleSku || + line?.article_sku || + line?.article?.sku || + line?.supplierArticle?.article?.sku || + ''; + + // 2. Check supplierArticlesMap + const sa = rawSku + ? (supplierArticlesMap[rawSku] || supplierArticlesMap[rawSku.toLowerCase()]) + : null; + + if (sa) { + if (!supplierSku && sa.sku) supplierSku = sa.sku; + if (!ownSku && sa.article?.sku) ownSku = sa.article.sku; + } + + // 3. Check loaded article details + const cachedArticle = + line?.article || + (ownSku ? getArticle(ownSku) : null) || + (rawSku ? getArticle(rawSku) : null); + + if (cachedArticle) { + if (!ownSku && cachedArticle.sku) { + ownSku = cachedArticle.sku; + } + const sup = + (supplierId && cachedArticle.suppliers?.find((s) => s.entityId === supplierId)) || + cachedArticle.suppliers?.[0]; + if (!supplierSku && sup?.sku) { + supplierSku = sup.sku; + } + } + + // 4. Fallback heuristics if one is still missing + if (!supplierSku && !ownSku) { + ownSku = rawSku; + } else if (!supplierSku && rawSku && rawSku !== ownSku) { + supplierSku = rawSku; + } else if (!ownSku && rawSku && rawSku !== supplierSku) { + ownSku = rawSku; + } + + return { + supplierSku: supplierSku || '–', + ownSku: ownSku || '–', + }; +} + diff --git a/src/routes/lieferung/neu.jsx b/src/routes/lieferung/neu.jsx index 859161e..56f4927 100644 --- a/src/routes/lieferung/neu.jsx +++ b/src/routes/lieferung/neu.jsx @@ -36,7 +36,8 @@ import { ArticleSearchDialog, BookDeliveryDialog, } from '../../components/manifest'; -import { loadParties, apiFor } from '../../components/manifest/manifestConfig'; +import { loadParties, apiFor, resolveLineSkus } from '../../components/manifest/manifestConfig'; +import warehouseAutomat from '../../automats/warehouseAutomat'; import { article as articleApi, delivery as deliveryApi } from '../../api'; import { getCategoryState } from '../../automats/categoryAutomat'; import { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; @@ -79,6 +80,7 @@ class LieferungNeuPage extends PureComponent { ledgerSummary: null, bookDeliveryDialogOpen: false, bookDeliveryPrefillLineId: null, + warehouses: warehouseAutomat.state.warehouses || [], }; this._cancelled = false; this.handleRetry = this.handleRetry.bind(this); @@ -123,6 +125,10 @@ class LieferungNeuPage extends PureComponent { this._settingsUnsub = userSettingsAutomat.subscribe(this, (state) => ({ viewerPosition: state.viewerPosition, })); + this._warehouseUnsub = warehouseAutomat.subscribe(this, (state) => ({ + warehouses: state.warehouses, + })); + void warehouseAutomat.actions.load().catch(() => {}); this._cancelled = false; if (this.props.docId != null) { setLastDeliveryPath(`/lieferung/${this.props.docId}`); @@ -161,6 +167,7 @@ class LieferungNeuPage extends PureComponent { } this.unsubscribe?.(); this._settingsUnsub?.(); + this._warehouseUnsub?.(); } async loadPartiesOnly() { @@ -1265,27 +1272,42 @@ class LieferungNeuPage extends PureComponent { } renderLedgerPanel() { - const { ledgerSummary } = this.state; + const { ledgerSummary, warehouses = [] } = this.state; if (!ledgerSummary) return null; - const { - status, - totalPositions, - bookedPositions, - totalQuantity, - bookedQuantity, - lines, - } = ledgerSummary; + const { lines } = ledgerSummary; + const delivery = this.state.delivery || this.automat?.state?.delivery; + const supplierArticlesMap = this.state.supplierArticlesMap || {}; const allEntries = []; if (Array.isArray(lines)) { for (const line of lines) { if (Array.isArray(line.ledgerEntries)) { + const { supplierSku, ownSku } = resolveLineSkus(line, delivery, supplierArticlesMap); + const lineName = line.name || line.article?.name || line.description || '–'; + const deliveredAmount = line.amount ?? line.count ?? line.targetAmount ?? '–'; + for (const entry of line.ledgerEntries) { + const hasWarehouse = entry.warehouse_id != null; + const wh = hasWarehouse + ? (warehouses.find((w) => Number(w.id) === Number(entry.warehouse_id)) || + warehouseAutomat.state.warehouses?.find((w) => Number(w.id) === Number(entry.warehouse_id))) + : null; + const warehouseName = wh?.name || (hasWarehouse ? `Lager #${entry.warehouse_id}` : '–'); + + const rawBookedAmount = entry.warehouse_amount ?? (hasWarehouse ? entry.amount : null); + const mengeEingebucht = rawBookedAmount != null + ? (Number(rawBookedAmount) > 0 ? `+${rawBookedAmount}` : String(rawBookedAmount)) + : '–'; + allEntries.push({ ...entry, - lineSku: line.sku, - lineName: line.name, + supplierSku, + ownSku, + name: lineName, + deliveredAmount, + warehouseName, + mengeEingebucht, }); } } @@ -1293,190 +1315,85 @@ class LieferungNeuPage extends PureComponent { } allEntries.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''))); - if (!ledgerSummary.hasTrackStock && allEntries.length === 0) return null; + if (allEntries.length === 0) return null; return ( - - - - - - Status - - {status === 'booked' && ( - } - label="Vollständig gebucht" - color="success" - variant="outlined" - sx={{ fontWeight: 600, height: 26 }} - /> - )} - {status === 'partial' && ( - } - label={`Teilweise gebucht (${bookedPositions}/${totalPositions})`} - color="warning" - variant="outlined" - sx={{ fontWeight: 600, height: 26 }} - /> - )} - {status === 'unbooked' && ( - - )} - {status === 'empty' && ( - - )} - - - - - Positionen - - - {bookedPositions} / {totalPositions} gebucht - - - - - - Menge - - - {bookedQuantity} / {totalQuantity} Stk. gebucht - - - - {totalQuantity > bookedQuantity && ( - - - Noch offen - - - {totalQuantity - bookedQuantity} Stk. - - - )} - - - {ledgerSummary.hasTrackStock && ( - - )} - - - {allEntries.length > 0 && ( - - - Bisherige Ledger-Buchungen ({allEntries.length}): - - - - - - Zeitpunkt - Typ - SKU / Artikel - Menge - Buchungstext - Prüfsumme (prev_hash) - - - - {allEntries.map((entry) => ( - - - - {entry.created_at ? new Date(entry.created_at).toLocaleString('de-DE') : '–'} - - - - - - - - {entry.lineSku || entry.sku || '–'} - - {entry.lineName && ( - - {entry.lineName} - - )} - - - +{entry.amount} - - - - {entry.transaction_text || '–'} - - - - - {entry.prev_hash ? `${entry.prev_hash.slice(0, 16)}…` : '–'} - - - - ))} - -
-
-
- )} -
+ + + + + Zeitpunkt + Lieferanten SKU + Eigene SKU + Name + Menge geliefert + Lager + Menge eingebucht + Referenz + + + + {allEntries.map((entry) => ( + + + + {entry.created_at ? new Date(entry.created_at).toLocaleString('de-DE') : '–'} + + + + + {entry.supplierSku} + + + + + {entry.ownSku} + + + + + {entry.name} + + + + + {entry.deliveredAmount} + + + + + {entry.warehouseName} + + + + {entry.mengeEingebucht} + + + + {entry.transaction_text || '–'} + + + + ))} + +
+
); } diff --git a/src/utils/deliveryLedger.js b/src/utils/deliveryLedger.js index 5fbe909..a698ef0 100644 --- a/src/utils/deliveryLedger.js +++ b/src/utils/deliveryLedger.js @@ -118,10 +118,41 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def const trackStock = isLineStockTracked(line, article); - const deliveryEntries = ledgerEntries.filter((e) => { + const acceptanceEntries = ledgerEntries.filter((e) => { const t = String(e.transaction_type).toUpperCase(); return t === 'ACCEPTANCE' || t === 'RETURN' || t === 'DELIVERY'; }); + const warehouseEntries = ledgerEntries.filter((e) => { + const t = String(e.transaction_type).toUpperCase(); + return t === 'WAREHOUSE'; + }); + + let deliveryEntries; + if (acceptanceEntries.length > 0) { + deliveryEntries = acceptanceEntries.map((acc, idx) => { + const matchingWh = + warehouseEntries.find( + (w) => + w.warehouse_id != null && + (w.transaction_text === acc.transaction_text || + Math.abs(new Date(w.created_at) - new Date(acc.created_at)) < 15000) + ) || warehouseEntries[idx] || null; + + return { + ...acc, + warehouse_id: matchingWh?.warehouse_id ?? acc.warehouse_id ?? null, + warehouse_amount: matchingWh?.amount ?? null, + }; + }); + } else if (warehouseEntries.length > 0) { + deliveryEntries = warehouseEntries.map((w) => ({ + ...w, + warehouse_id: w.warehouse_id ?? null, + warehouse_amount: w.amount ?? null, + })); + } else { + deliveryEntries = []; + } const bookedAmount = deliveryEntries.reduce((sum, e) => sum + Number(e.amount || 0), 0); const targetAmount = Number(line.amount || 0); const remainingAmount = Math.max(0, targetAmount - bookedAmount);