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:
parent
e27184245b
commit
48053a6205
4 changed files with 111 additions and 16 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
Loading…
Reference in a new issue