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
This commit is contained in:
Dave 2026-08-14 18:25:05 +02:00
parent e27184245b
commit 48053a6205
4 changed files with 111 additions and 16 deletions

View file

@ -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<string, string[]>();
// 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<string, string[]>();
const stockByFilename = new Map<
string,
{ itemIds: string[]; stockPurchaseId?: string }
>();
for (const row of rows) {
const seen = new Set<string>();
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<string>();
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;
}
/**

View file

@ -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],
);

View file

@ -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);

View file

@ -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<ConfirmRow[]> {
>({
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<ConfirmRow[]> {
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`).