diff --git a/src/lib/server/stock-quantities-bulk-service.test.ts b/src/lib/server/stock-quantities-bulk-service.test.ts index 9cac01f..c640e1c 100644 --- a/src/lib/server/stock-quantities-bulk-service.test.ts +++ b/src/lib/server/stock-quantities-bulk-service.test.ts @@ -21,6 +21,16 @@ const STOCK_ITEMS = [ current_qty: 5, is_active: true, }, + // A second item with the same wording, so a text-only line has two + // equally good candidates and comes back ambiguous. + { + id: "id-amb", + sku: "BOLT-M6-40-ALT", + name: "Bolt M6 40mm", + description: "Hex bolt M6 x 40mm", + current_qty: 7, + is_active: true, + }, ]; function stubPb( @@ -212,10 +222,17 @@ describe("setStockQuantitiesBulk — applying", () => { expect(res.summary.applied).toBe(1); expect(res.results[0].status).toBe("applied"); expect(res.results[0].new_qty).toBe(50); - // The failed row is reported on its own, with the reason. - expect(res.summary.unmatched).toBe(1); - expect(res.results[1].status).toBe("unmatched"); + // The failed row is reported on its own, with the reason. It is + // NOT "unmatched" — the SKU was fine, the write was not. + expect(res.summary.failed).toBe(1); + expect(res.summary.unmatched).toBe(0); + expect(res.results[1].status).toBe("failed"); expect(res.results[1].message).toContain("Failed to update"); + // Every line is counted once, in exactly one bucket. + const { applied, unchanged, unmatched, ambiguous, failed } = res.summary; + expect(res.summary.total).toBe( + applied + unchanged + unmatched + ambiguous + failed, + ); }); it("applies the good lines and reports the unmatched one", async () => { @@ -237,6 +254,45 @@ describe("setStockQuantitiesBulk — applying", () => { }); }); +describe("setStockQuantitiesBulk — summary", () => { + it("counts every line once, in exactly one bucket", async () => { + const { pb } = stubPb([], { failUpdateIds: ["id-def"] }); + const res = await setStockQuantitiesBulk({ + pb, + lines: [ + { sku: "M16-FLAT-WASHER", qty: 50 }, // applied + { sku: "BOLT-M6-40-ALT", qty: 7 }, // unchanged — it holds 7 + { sku: "ZZZ-9", qty: 30 }, // unmatched + { description: "Bolt M6 40mm", qty: 5 }, // ambiguous — two items + { sku: "BOLT-M6-40", qty: 9 }, // failed — the write blows up + ], + mode: "set", + dryRun: false, + actor: { id: "u1", name: "Dave" }, + }); + + expect(res.summary).toEqual({ + total: 5, + applied: 1, + unchanged: 1, + unmatched: 1, + ambiguous: 1, + failed: 1, + }); + expect(res.results.map((r) => r.status)).toEqual([ + "applied", + "unchanged", + "unmatched", + "ambiguous", + "failed", + ]); + const { applied, unchanged, unmatched, ambiguous, failed } = res.summary; + expect(res.summary.total).toBe( + applied + unchanged + unmatched + ambiguous + failed, + ); + }); +}); + describe("setStockQuantitiesBulk — price_list mirror", () => { it("mirrors the new quantity onto every catalogue row for that SKU", async () => { const { pb, priceListUpdates } = stubPb([ diff --git a/src/lib/server/stock-quantities-bulk-service.ts b/src/lib/server/stock-quantities-bulk-service.ts index 9554741..51186c2 100644 --- a/src/lib/server/stock-quantities-bulk-service.ts +++ b/src/lib/server/stock-quantities-bulk-service.ts @@ -73,11 +73,27 @@ export type StockQuantitiesBulkInput = { actor: { id: string; name: string }; }; +/** + * What happened to one pasted line. + * + * - "applied" — the quantity was written (or, on a dry run, would be). + * - "unchanged" — the item already held that quantity; nothing written. + * - "unmatched" — the line never resolved to an active stock item: + * nothing matched the SKU or the text, or the best + * match was an inactive item. + * - "ambiguous" — several items matched equally well; a person picks. + * - "failed" — the line matched, but the write did not complete. + * + * "unmatched" and "failed" are deliberately apart: the first means + * "check the SKU or activate the item", the second means "the write + * blew up — retry it". The reason for each is in the row's message. + */ export type StockQuantitiesBulkRowStatus = | "applied" | "unchanged" | "unmatched" - | "ambiguous"; + | "ambiguous" + | "failed"; export type StockQuantitiesBulkResultRow = { /** The SKU as the operator typed it. */ @@ -113,6 +129,8 @@ export type StockQuantitiesBulkResult = { unchanged: number; unmatched: number; ambiguous: number; + /** The line matched, but the write did not complete. */ + failed: number; }; results: StockQuantitiesBulkResultRow[]; }; @@ -175,6 +193,8 @@ async function mirrorPriceListQty( const norm = normaliseSku(rawSku); if (!norm) return 0; + // One page is enough at the catalogue's size: a SKU has one + // row per supplier, so a handful at most. const page = await pb .collection("price_list") .getList(1, 200, { @@ -284,12 +304,15 @@ export async function setStockQuantitiesBulk( } const results: StockQuantitiesBulkResultRow[] = []; + // One line lands in exactly one of these counts, so they add up + // to `total`. const summary = { total: lines.length, applied: 0, unchanged: 0, unmatched: 0, ambiguous: 0, + failed: 0, }; for (let i = 0; i < planned.length; i++) { @@ -312,6 +335,9 @@ export async function setStockQuantitiesBulk( continue; } + // The line never resolved to an active stock item — nothing + // matched, or the best match was inactive. The resolver's + // message says which. if (line.status === "unmatched" || !line.item || p.target === undefined) { summary.unmatched += 1; results.push({ ...base, status: "unmatched", message: line.message }); @@ -356,11 +382,14 @@ export async function setStockQuantitiesBulk( const writeRow = writeByPlanIndex.get(i); + // The line resolved, but the write did not complete. That is + // its own outcome: the operator should retry, not go hunting + // for a typo in the SKU. if (!writeRow || writeRow.ok === false) { - summary.unmatched += 1; + summary.failed += 1; results.push({ ...base, - status: "unmatched", + status: "failed", match_method: line.match_method, id: line.item.id, name: line.item.name,