feat(stock): tell a failed write apart from an unmatched SKU

A write that did not complete now has its own status, "failed", and
its own summary count. Reporting it as "unmatched" sent the operator
looking for a typo in the SKU when the real cause was a PocketBase
error — two different problems with two different responses, so they
no longer share a status. "unmatched" keeps its meaning: the line
never resolved to an active stock item.

The five counts still add up to "total".

The price-list mirror reads one 200-row page. The catalogue holds
about 2,900 rows over about 2,700 SKUs, so a SKU has one row per
supplier and a handful at most: the page is unbounded by design at
this data volume, not an oversight.
This commit is contained in:
Dave 2026-11-05 21:59:06 +02:00
parent 22305645e7
commit 23725f10c3
2 changed files with 91 additions and 6 deletions

View file

@ -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([

View file

@ -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<PriceListRow>(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,