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:
parent
22305645e7
commit
23725f10c3
2 changed files with 91 additions and 6 deletions
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue