From 48053a6205ce9232ab2afbb061908ff240117c15 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 14 Aug 2026 18:25:05 +0200 Subject: [PATCH] feat: add support for stock purchase attachments - update backend service to fetch stock purchase attachments - refactor dedupe logic to avoid filename collisions between customer and stock attachments - adjust PoCard to generate correct file URLs for stock attachments - update test expectations for the modified service query --- .../(app)/collection-confirm/view-model.ts | 67 +++++++++++++++++-- src/components/collection-confirm/po-card.tsx | 25 ++++--- .../server/collection-confirm-service.test.ts | 4 +- src/lib/server/collection-confirm-service.ts | 31 ++++++++- 4 files changed, 111 insertions(+), 16 deletions(-) diff --git a/src/app/(app)/collection-confirm/view-model.ts b/src/app/(app)/collection-confirm/view-model.ts index f8d0fec..93617fc 100644 --- a/src/app/(app)/collection-confirm/view-model.ts +++ b/src/app/(app)/collection-confirm/view-model.ts @@ -315,23 +315,78 @@ export function appendCostNote( return JSON.stringify([...existing, entry]); } -export type DedupedAttachment = { filename: string; itemIds: string[] }; +export type DedupedAttachment = { + filename: string; + /** + * IDs of the source rows that this attachment appears on. For + * customer rows these are `order_items.id`s; the chip's preview URL + * uses `/api/v1/files/order_items/{id}/{filename}`. + */ + itemIds: string[]; + /** + * For stock-source rows the actual file lives on the PARENT + * `stock_purchases.additional_attachments` field — not on the + * line itself. `stockPurchaseId` is the id to use in the chip + * preview URL: `/api/v1/files/stock_purchases/{id}/{filename}`. + * Undefined for customer-only attachments. + */ + stockPurchaseId?: string; +}; export function dedupeAttachments( - rows: Array<{ id?: string; order_item_attachements?: string[] | null }>, + rows: Array<{ + id?: string; + order_item_attachements?: string[] | null; + source?: "stock" | "customer"; + stock_purchase_id?: string | null; + stock_purchase_attachments?: string[] | null; + }>, ): DedupedAttachment[] { - const byFilename = new Map(); + // Order rows write to `order_items.order_item_attachements`; stock + // rows write to the parent `stock_purchases.additional_attachments`. + // Two separate maps so a filename that appears on both (rare but + // possible — the same file might be uploaded to both) doesn't get + // collapsed into the wrong preview URL. + const customerByFilename = new Map(); + const stockByFilename = new Map< + string, + { itemIds: string[]; stockPurchaseId?: string } + >(); for (const row of rows) { const seen = new Set(); for (const filename of row.order_item_attachements ?? []) { if (seen.has(filename)) continue; seen.add(filename); - const bucket = byFilename.get(filename) ?? []; + const bucket = customerByFilename.get(filename) ?? []; if (row.id) bucket.push(row.id); - byFilename.set(filename, bucket); + customerByFilename.set(filename, bucket); + } + if (row.stock_purchase_attachments && row.stock_purchase_attachments.length > 0) { + const stockSeen = new Set(); + const stockPurchaseId = row.stock_purchase_id ?? undefined; + for (const filename of row.stock_purchase_attachments) { + if (stockSeen.has(filename)) continue; + stockSeen.add(filename); + const existing = stockByFilename.get(filename) ?? { + itemIds: [], + stockPurchaseId, + }; + if (row.id) existing.itemIds.push(row.id); + // Use the row's stock_purchase_id even if existing already has + // one — the parent PO is the same across the card. + if (stockPurchaseId) existing.stockPurchaseId = stockPurchaseId; + stockByFilename.set(filename, existing); + } } } - return Array.from(byFilename.entries()).map(([filename, itemIds]) => ({ filename, itemIds })); + const out: DedupedAttachment[] = []; + for (const [filename, itemIds] of customerByFilename.entries()) { + out.push({ filename, itemIds }); + } + for (const [filename, { itemIds, stockPurchaseId }] of stockByFilename.entries()) { + out.push({ filename, itemIds, stockPurchaseId }); + } + return out; } /** diff --git a/src/components/collection-confirm/po-card.tsx b/src/components/collection-confirm/po-card.tsx index 591d2c2..a18cd48 100644 --- a/src/components/collection-confirm/po-card.tsx +++ b/src/components/collection-confirm/po-card.tsx @@ -238,15 +238,24 @@ export function PoCard({ ); // Distinct documents on this PO. Empty array → no preview block. + // Customer rows use `/api/v1/files/order_items/...`; stock rows + // use `/api/v1/files/stock_purchases/...` (the file lives on the + // parent stock_purchase, not on the line). The dedupe helper tags + // each entry with `stockPurchaseId` when it came from a stock + // upload so we can build the right URL. const attachments = useMemo( - () => dedupeAttachments(rows).map(({ filename, itemIds }) => { - const itemId = itemIds[0]; - return { - filename, - itemId, - url: `/api/v1/files/order_items/${encodeURIComponent(itemId)}/${encodeURIComponent(filename)}`, - } satisfies Attachment; - }), + () => + dedupeAttachments(rows).map(({ filename, itemIds, stockPurchaseId }) => { + const itemId = itemIds[0]; + const collectionPath = stockPurchaseId + ? `stock_purchases/${encodeURIComponent(stockPurchaseId)}` + : `order_items/${encodeURIComponent(itemId)}`; + return { + filename, + itemId, + url: `/api/v1/files/${collectionPath}/${encodeURIComponent(filename)}`, + } satisfies Attachment; + }), [rows], ); diff --git a/src/lib/server/collection-confirm-service.test.ts b/src/lib/server/collection-confirm-service.test.ts index 4aa5d3e..1fea614 100644 --- a/src/lib/server/collection-confirm-service.test.ts +++ b/src/lib/server/collection-confirm-service.test.ts @@ -189,7 +189,9 @@ describe("listCollectedItems", () => { const stockOpts = stockItemsGetFullList.mock.calls[0]?.[0] as { filter?: string; expand?: string } | undefined; expect(stockOpts?.filter).toBe('status = "collected"'); - expect(stockOpts?.expand).toBe("purchase.supplier,purchase,stock_item"); + expect(stockOpts?.expand).toBe( + "purchase.supplier,purchase.stock_item,purchase.additional_attachments", + ); // Order-items leg must NOT be called when we only returned stock rows. // (In the real code it IS called — but it returned empty.) expect(orderItemsGetFullList).toHaveBeenCalledTimes(1); diff --git a/src/lib/server/collection-confirm-service.ts b/src/lib/server/collection-confirm-service.ts index 7238816..41ba839 100644 --- a/src/lib/server/collection-confirm-service.ts +++ b/src/lib/server/collection-confirm-service.ts @@ -74,6 +74,16 @@ export type ConfirmRow = { // written under the spelled-correct variant. order_item_attachments?: string[]; order_item_attachements?: string[]; + /** + * Stock-only: filenames on the PARENT stock_purchase's + * `additional_attachments` file field. The stock-side invoice + * upload (handled by `handleStockInvoiceUpload` on the page) + * writes here, not on the line itself. The page's + * `dedupeAttachments` reads BOTH this field (for stock rows) and + * `order_item_attachements` (for customer rows) so the chip + * strip surfaces the agent's just-uploaded invoice. + */ + stock_purchase_attachments?: string[]; order_item_details?: string | null; // Surplus split fields (added by 2026-07-14-create-stock migration). // The chip + confirm button only render when the row has positive @@ -259,7 +269,7 @@ export async function listCollectedItems(): Promise { >({ filter: 'status = "collected"', sort: "-created", - expand: "purchase.supplier,purchase,stock_item", + expand: "purchase.supplier,purchase.stock_item,purchase.additional_attachments", }) // Fail-soft: matches the page's `load()` at page.tsx:294 — a // stock-side outage must not wipe the customer-deliveries view. @@ -307,6 +317,25 @@ export async function listCollectedItems(): Promise { assigned_at: s.assigned_at ?? null, stock_purchase_id: s.expand?.purchase?.id ?? null, poNumber: s.expand?.purchase?.purchase_number ?? undefined, + // Filenames on the parent stock_purchase.additional_attachments + // (the agent / operator writes the supplier invoice here via + // `/api/v1/stock-purchases/[id]/attachments/upload`). The page + // exposes these through the same chip strip as customer + // attachments so the operator doesn't have to navigate to the + // detail page just to see the latest upload. + // + // PB file fields normally return string[]. A few legacy rows + // were written under a text-typed variant of this column; coerce + // string-to-array here so the chip strip doesn't iterate the + // string character-by-character (the v1 dev bug that surfaced + // 2026-08-14). `null`/`undefined` collapses to []. + stock_purchase_attachments: (() => { + const raw = ( + s.expand?.purchase as { additional_attachments?: unknown } | undefined + )?.additional_attachments; + if (raw == null) return []; + return Array.isArray(raw) ? raw : [raw]; + })(), expand: { // Reuse the same shape so the supplier link works (the page // looks at `expand.purchase_order.expand.supplier.suppliername`).