feat(stock): bulk quantity writes with a price-list mirror

Resolves pasted lines, computes the signed delta, and hands the write
to updateStockItemsQuantities so the ledger row is written first and
stamped with the acting user. A negative result is a normal outcome,
not an error — the till oversells on purpose.

Mirrors the new quantity onto price_list.stock_qty, which is a
denormalised snapshot of our own quantity. The catalogue holds one row
per SKU per supplier, so one item can touch several rows; the count is
reported per line.
This commit is contained in:
Dave 2026-11-05 21:54:20 +02:00
parent 36894a2908
commit 22305645e7
2 changed files with 677 additions and 0 deletions

View file

@ -0,0 +1,271 @@
import { describe, it, expect, vi } from "vitest";
import {
setStockQuantitiesBulk,
type StockQuantitiesBulkPb,
} from "./stock-quantities-bulk-service";
const STOCK_ITEMS = [
{
id: "id-abc",
sku: "M16-FLAT-WASHER",
name: "M16 Flat Washer",
description: "Flat washer M16",
current_qty: 12,
is_active: true,
},
{
id: "id-def",
sku: "BOLT-M6-40",
name: "Bolt M6 40mm",
description: "Hex bolt M6 x 40mm",
current_qty: 5,
is_active: true,
},
];
function stubPb(
priceListRows: Array<Record<string, unknown>> = [],
opts: { failUpdateIds?: string[] } = {},
) {
const calls: string[] = [];
const ledgerCreates: Array<Record<string, unknown>> = [];
const stockUpdates: Array<{ id: string; patch: Record<string, unknown> }> = [];
const priceListUpdates: Array<{ id: string; patch: Record<string, unknown> }> = [];
// The quantity each row now holds, so a row that is written twice
// reads back the value it now has — the way PB behaves.
const written: Record<string, number> = {};
const pb = {
collection: (name: string) => {
if (name === "stock_items") {
return {
getFullList: vi.fn().mockResolvedValue(STOCK_ITEMS),
getOne: vi.fn().mockImplementation(async (id: string) => {
const item = STOCK_ITEMS.find((i) => i.id === id);
return {
...item,
current_qty: written[id] ?? item?.current_qty,
};
}),
update: vi.fn().mockImplementation(async (id: string, patch: Record<string, unknown>) => {
calls.push("stock_items.update");
stockUpdates.push({ id, patch });
if (opts.failUpdateIds?.includes(id)) {
throw new Error("PB write failed");
}
if (typeof patch.current_qty === "number") {
written[id] = patch.current_qty;
}
return { id, ...patch };
}),
create: vi.fn(),
};
}
if (name === "stock_ledger") {
return {
create: vi.fn().mockImplementation(async (row: Record<string, unknown>) => {
calls.push("stock_ledger.create");
ledgerCreates.push(row);
return { id: "ledger1", ...row };
}),
};
}
if (name === "price_list") {
return {
getList: vi.fn().mockResolvedValue({
items: priceListRows,
totalItems: priceListRows.length,
}),
update: vi.fn().mockImplementation(async (id: string, patch: Record<string, unknown>) => {
priceListUpdates.push({ id, patch });
return { id, ...patch };
}),
};
}
throw new Error(`unexpected collection ${name}`);
},
} as unknown as StockQuantitiesBulkPb;
return { pb, calls, ledgerCreates, stockUpdates, priceListUpdates };
}
describe("setStockQuantitiesBulk — dry run", () => {
it("plans without writing anything", async () => {
const { pb, calls, ledgerCreates, stockUpdates, priceListUpdates } = stubPb();
const res = await setStockQuantitiesBulk({
pb,
lines: [{ sku: "M16-FLAT-WASHER", qty: 50 }],
mode: "set",
dryRun: true,
actor: { id: "u1", name: "Dave" },
});
expect(res.summary.applied).toBe(1);
expect(res.results[0].previous_qty).toBe(12);
expect(res.results[0].new_qty).toBe(50);
expect(res.results[0].delta).toBe(38);
expect(ledgerCreates).toHaveLength(0);
expect(stockUpdates).toHaveLength(0);
expect(priceListUpdates).toHaveLength(0);
expect(calls).toHaveLength(0);
});
});
describe("setStockQuantitiesBulk — applying", () => {
it("writes the ledger row before the quantity, stamped with the actor", async () => {
const { pb, calls, ledgerCreates, stockUpdates } = stubPb();
const res = await setStockQuantitiesBulk({
pb,
lines: [{ sku: "M16-FLAT-WASHER", qty: 50 }],
mode: "set",
dryRun: false,
actor: { id: "u1", name: "Dave" },
});
expect(res.summary.applied).toBe(1);
expect(calls[0]).toBe("stock_ledger.create");
expect(calls[1]).toBe("stock_items.update");
expect(ledgerCreates[0].change_qty).toBe(38);
expect(ledgerCreates[0].created_by).toBe("u1");
expect(ledgerCreates[0].notes).toContain("via Giellie");
expect(stockUpdates[0].patch.current_qty).toBe(50);
});
it("adds to the current quantity in add mode", async () => {
const { pb, ledgerCreates } = stubPb();
const res = await setStockQuantitiesBulk({
pb,
lines: [{ sku: "M16-FLAT-WASHER", qty: 8 }],
mode: "add",
dryRun: false,
actor: { id: "u1", name: "Dave" },
});
expect(res.results[0].new_qty).toBe(20);
expect(ledgerCreates[0].change_qty).toBe(8);
});
it("keeps the plan and the audit trail honest when a SKU is pasted twice", async () => {
const { pb, ledgerCreates, stockUpdates } = stubPb();
const res = await setStockQuantitiesBulk({
pb,
lines: [
{ sku: "M16-FLAT-WASHER", qty: 8 },
{ sku: "M16-FLAT-WASHER", qty: 3 },
],
mode: "add",
dryRun: false,
actor: { id: "u1", name: "Dave" },
});
// The second line is planned from the first line's target, not
// from the quantity we read before the batch started.
expect(res.results[0].previous_qty).toBe(12);
expect(res.results[0].new_qty).toBe(20);
expect(res.results[1].previous_qty).toBe(20);
expect(res.results[1].new_qty).toBe(23);
// One ledger row per pasted line, each with its own signed delta.
expect(ledgerCreates).toHaveLength(2);
expect(ledgerCreates[0].change_qty).toBe(8);
expect(ledgerCreates[1].change_qty).toBe(3);
expect(stockUpdates.map((u) => u.patch.current_qty)).toEqual([20, 23]);
});
it("reports a line that already has that quantity as unchanged and writes nothing", async () => {
const { pb, ledgerCreates, calls } = stubPb();
const res = await setStockQuantitiesBulk({
pb,
lines: [{ sku: "M16-FLAT-WASHER", qty: 12 }],
mode: "set",
dryRun: false,
actor: { id: "u1", name: "Dave" },
});
expect(res.summary.unchanged).toBe(1);
expect(ledgerCreates).toHaveLength(0);
expect(calls).toHaveLength(0);
});
it("writes a negative quantity without complaint", async () => {
const { pb, ledgerCreates, stockUpdates } = stubPb();
const res = await setStockQuantitiesBulk({
pb,
lines: [{ sku: "M16-FLAT-WASHER", qty: -20 }],
mode: "set",
dryRun: false,
actor: { id: "u1", name: "Dave" },
});
expect(res.summary.applied).toBe(1);
expect(res.results[0].new_qty).toBe(-20);
expect(ledgerCreates[0].change_qty).toBe(-32);
expect(stockUpdates[0].patch.current_qty).toBe(-20);
});
it("keeps the rest of the batch going when one write fails", async () => {
const { pb } = stubPb([], { failUpdateIds: ["id-def"] });
const res = await setStockQuantitiesBulk({
pb,
lines: [
{ sku: "M16-FLAT-WASHER", qty: 50 },
{ sku: "BOLT-M6-40", qty: 9 },
],
mode: "set",
dryRun: false,
actor: { id: "u1", name: "Dave" },
});
// The good row still landed.
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");
expect(res.results[1].message).toContain("Failed to update");
});
it("applies the good lines and reports the unmatched one", async () => {
const { pb, ledgerCreates } = stubPb();
const res = await setStockQuantitiesBulk({
pb,
lines: [
{ sku: "M16-FLAT-WASHER", qty: 50 },
{ sku: "ZZZ-9", qty: 30 },
],
mode: "set",
dryRun: false,
actor: { id: "u1", name: "Dave" },
});
expect(res.summary.applied).toBe(1);
expect(res.summary.unmatched).toBe(1);
expect(res.results[1].status).toBe("unmatched");
expect(ledgerCreates).toHaveLength(1);
});
});
describe("setStockQuantitiesBulk — price_list mirror", () => {
it("mirrors the new quantity onto every catalogue row for that SKU", async () => {
const { pb, priceListUpdates } = stubPb([
{ id: "pl1", sku: "M16-FLAT-WASHER", sku_normalised: "M16FLATWASHER" },
{ id: "pl2", sku: "M16 Flat Washer", sku_normalised: "M16FLATWASHER" },
]);
const res = await setStockQuantitiesBulk({
pb,
lines: [{ sku: "M16-FLAT-WASHER", qty: 50 }],
mode: "set",
dryRun: false,
actor: { id: "u1", name: "Dave" },
});
expect(res.results[0].price_list_rows_touched).toBe(2);
expect(priceListUpdates).toHaveLength(2);
expect(priceListUpdates[0].patch.stock_qty).toBe(50);
});
it("reports zero catalogue rows without failing the write", async () => {
const { pb, ledgerCreates } = stubPb([]);
const res = await setStockQuantitiesBulk({
pb,
lines: [{ sku: "M16-FLAT-WASHER", qty: 50 }],
mode: "set",
dryRun: false,
actor: { id: "u1", name: "Dave" },
});
expect(res.summary.applied).toBe(1);
expect(res.results[0].price_list_rows_touched).toBe(0);
expect(ledgerCreates).toHaveLength(1);
});
});

View file

@ -0,0 +1,406 @@
/**
* Bulk stock quantity writes — the engine behind the MCP tool
* `stock_items.set_quantities`.
*
* The operator pastes lines of "SKU description qty" into the Giellie
* chat. This service resolves them against `stock_items`, works out
* the signed delta, and hands the write to
* `updateStockItemsQuantities` so the stock_ledger row is written
* first and stamped with the acting user. It then mirrors the new
* quantity onto `price_list.stock_qty`, which is a denormalised
* snapshot of our own quantity (see `price-list-service.ts`).
*
* Nothing here blocks on a low or negative quantity. The till
* oversells on purpose and the stocktake is what corrects it.
*
* Two rules the whole batch follows:
*
* - Per-row independence. One unmatched or ambiguous line never
* stops the rest of the batch, and one failed write never stops
* the others.
* - A dry run writes nothing at all — no ledger row, no
* `stock_items` update, no `price_list` update. It still
* reports the full plan, so the operator can see what a real
* run would do.
*
* The matching itself lives in `stock-quantity-match.ts` (pure, no
* PocketBase) and the audited write lives in
* `stock-quantities-service.ts`. This module is the plumbing that
* joins the two.
*/
import {
updateStockItemsQuantities,
type StockItemsPb,
} from "./stock-quantities-service";
import {
resolveStockQuantityLines,
type ResolvedStockQuantityLine,
type StockQuantityCandidate,
type StockQuantityLine,
} from "./stock-quantity-match";
import {
escapeForPbString,
normaliseSku,
} from "@/lib/server/price-list-match";
export type StockQuantityMode = "set" | "add";
export type StockQuantitiesBulkPb = {
collection: (id: string) => {
getFullList: <T = unknown>(opts?: Record<string, unknown>) => Promise<T[]>;
getList: <T = unknown>(
page: number,
perPage: number,
opts?: Record<string, unknown>,
) => Promise<{ items: T[]; totalItems: number }>;
getOne: <T = unknown>(id: string, opts?: Record<string, unknown>) => Promise<T>;
update: (id: string, patch: Record<string, unknown>) => Promise<unknown>;
create: (data: Record<string, unknown>) => Promise<unknown>;
};
};
export type StockQuantitiesBulkInput = {
/** PB client; passed in so tests can swap a stub. */
pb: StockQuantitiesBulkPb;
/** The pasted lines, already split into sku / description / qty. */
lines: StockQuantityLine[];
/** "set" replaces the quantity, "add" moves it by the given amount. */
mode: StockQuantityMode;
/** When true nothing is written — the plan is returned instead. */
dryRun: boolean;
/** The acting user; stamped on every ledger row we write. */
actor: { id: string; name: string };
};
export type StockQuantitiesBulkRowStatus =
| "applied"
| "unchanged"
| "unmatched"
| "ambiguous";
export type StockQuantitiesBulkResultRow = {
/** The SKU as the operator typed it. */
sku: string;
/** The description as the operator typed it. */
description: string;
/** The quantity as the operator typed it, truncated to whole units. */
qty: number;
status: StockQuantitiesBulkRowStatus;
match_method?: "sku_exact" | "description_fuzzy";
/** The stock item the line resolved to. */
id?: string;
name?: string;
previous_qty?: number;
new_qty?: number;
/** Signed change — negative when the quantity went down. */
delta?: number;
/** Catalogue rows mirrored onto. Zero when the SKU has no catalogue row. */
price_list_rows_touched?: number;
/** The equally-good candidates, when the line was ambiguous. */
candidates?: Array<{ id: string; name: string; current_qty: number }>;
/** Why the line did not apply, in the operator's words. */
message?: string;
};
export type StockQuantitiesBulkResult = {
ok: boolean;
mode: StockQuantityMode;
dry_run: boolean;
summary: {
total: number;
applied: number;
unchanged: number;
unmatched: number;
ambiguous: number;
};
results: StockQuantitiesBulkResultRow[];
};
type PriceListRow = {
id: string;
sku?: string;
sku_normalised?: string;
};
type StockCandidate = {
id: string;
sku?: string;
name?: string;
description?: string;
current_qty?: number;
is_active?: boolean;
};
/** One resolved line and the target we worked out for it. */
type PlannedRow = {
line: ResolvedStockQuantityLine;
/** The quantity the item held when this line was planned. */
current?: number;
/** The quantity the item should end up with. */
target?: number;
/** Signed change from the quantity the item held when planned. */
delta?: number;
};
/** One row of the audited write, as the write service reports it. */
type QuantityWriteRow =
Awaited<ReturnType<typeof updateStockItemsQuantities>>["results"][number];
function toCandidate(row: StockCandidate): StockQuantityCandidate {
return {
id: row.id,
sku: row.sku,
name: row.name,
description: row.description,
current_qty: Number(row.current_qty ?? 0),
is_active: row.is_active !== false,
};
}
/**
* Mirror our quantity onto the price-list catalogue. The catalogue
* holds one row per SKU per supplier, so one stock item can touch
* several rows. Matching is on the canonical SKU form, which is what
* the catalogue's own `sku_normalised` column stores.
*
* A SKU with no catalogue row is not an error — it just means the
* count of touched rows is zero.
*/
async function mirrorPriceListQty(
pb: StockQuantitiesBulkPb,
rawSku: string,
newQty: number,
): Promise<number> {
const norm = normaliseSku(rawSku);
if (!norm) return 0;
const page = await pb
.collection("price_list")
.getList<PriceListRow>(1, 200, {
filter: `sku_normalised = "${norm}" || sku ~ "${escapeForPbString(rawSku)}"`,
});
// The PB filter is a superset (it also substring-matches the raw
// SKU), so narrow it here to an exact canonical match. A row counts
// when its own SKU canonicalises to the same string, or when its
// stored `sku_normalised` does — the catalogue writes both.
const rows = page.items.filter(
(r) =>
normaliseSku(r.sku) === norm ||
normaliseSku(r.sku_normalised) === norm,
);
for (const row of rows) {
await pb.collection("price_list").update(row.id, { stock_qty: newQty });
}
return rows.length;
}
function candidateSummary(candidates: StockQuantityCandidate[]) {
return candidates.map((c) => ({
id: c.id,
name: c.name ?? c.id,
current_qty: Number(c.current_qty ?? 0),
}));
}
/**
* Set (or add to) the stock quantity of every pasted line.
*
* Returns one result row per pasted line, in the order the lines came
* in, plus a summary for the toast. Per-row failures are reported in
* `results[]` — the function itself does not throw on a bad line.
*/
export async function setStockQuantitiesBulk(
input: StockQuantitiesBulkInput,
): Promise<StockQuantitiesBulkResult> {
const { pb, lines, mode, dryRun, actor } = input;
const raw =
(await pb.collection("stock_items").getFullList<StockCandidate>({
fields: "id,sku,name,description,current_qty,is_active",
})) ?? [];
const resolved: ResolvedStockQuantityLine[] = resolveStockQuantityLines(
lines,
raw.map(toCandidate),
);
// Work out the target for every matched line before touching
// anything. A paste can name the same SKU twice, so the second line
// is planned from the first line's target rather than from the
// snapshot we read — that keeps the plan and the sequential writes
// in agreement. Quantities are whole units: the resolver already
// truncated the line's qty, and the target is truncated again so a
// fractional stored quantity can never leak into the plan.
const plannedQty = new Map<string, number>();
const planned: PlannedRow[] = resolved.map((line) => {
if (line.status !== "matched" || !line.item) {
return {
line,
current: undefined,
target: undefined,
delta: undefined,
};
}
const id = line.item.id;
const current =
plannedQty.get(id) ?? Number(line.item.current_qty ?? 0);
const target =
mode === "set" ? line.qty : Math.trunc(current + line.qty);
plannedQty.set(id, target);
return { line, current, target, delta: target - current };
});
const toWrite: Array<{ id: string; currentQty: number }> = [];
// Which planned row each write belongs to. The write service answers
// in the order it was handed the rows, so the two lists line up — and
// a pasted SKU that appears twice keeps its own result per line.
const writePlanIndexes: number[] = [];
for (let i = 0; i < planned.length; i++) {
const p = planned[i];
if (p.line.status !== "matched" || !p.line.item) continue;
if (p.target === undefined || p.delta === undefined) continue;
// A line that already holds the target needs no ledger row and no
// update — the ledger would only collect no-ops.
if (p.delta === 0) continue;
toWrite.push({ id: p.line.item.id, currentQty: p.target });
writePlanIndexes.push(i);
}
const writeByPlanIndex = new Map<number, QuantityWriteRow>();
if (!dryRun && toWrite.length > 0) {
const write = await updateStockItemsQuantities({
pb: pb as unknown as StockItemsPb,
updates: toWrite,
actor,
allowNegativeQty: true,
noteContext: "via Giellie",
});
write.results.forEach((row, i) => {
const planIndex = writePlanIndexes[i];
if (planIndex !== undefined) writeByPlanIndex.set(planIndex, row);
});
}
const results: StockQuantitiesBulkResultRow[] = [];
const summary = {
total: lines.length,
applied: 0,
unchanged: 0,
unmatched: 0,
ambiguous: 0,
};
for (let i = 0; i < planned.length; i++) {
const p = planned[i];
const line = p.line;
const base = {
sku: line.sku,
description: line.description,
qty: line.qty,
};
if (line.status === "ambiguous") {
summary.ambiguous += 1;
results.push({
...base,
status: "ambiguous",
candidates: candidateSummary(line.candidates ?? []),
message: line.message,
});
continue;
}
if (line.status === "unmatched" || !line.item || p.target === undefined) {
summary.unmatched += 1;
results.push({ ...base, status: "unmatched", message: line.message });
continue;
}
const current = p.current ?? Number(line.item.current_qty ?? 0);
const delta = p.delta ?? 0;
if (delta === 0) {
summary.unchanged += 1;
results.push({
...base,
status: "unchanged",
match_method: line.match_method,
id: line.item.id,
name: line.item.name,
previous_qty: current,
new_qty: p.target,
delta: 0,
});
continue;
}
// A dry run has nothing to write, so the plan is the answer:
// "applied" here means "would be applied".
if (dryRun) {
summary.applied += 1;
results.push({
...base,
status: "applied",
match_method: line.match_method,
id: line.item.id,
name: line.item.name,
previous_qty: current,
new_qty: p.target,
delta,
price_list_rows_touched: 0,
});
continue;
}
const writeRow = writeByPlanIndex.get(i);
if (!writeRow || writeRow.ok === false) {
summary.unmatched += 1;
results.push({
...base,
status: "unmatched",
match_method: line.match_method,
id: line.item.id,
name: line.item.name,
previous_qty: current,
message:
writeRow && writeRow.ok === false
? writeRow.message
: "The quantity write did not complete.",
});
continue;
}
summary.applied += 1;
let priceListRowsTouched = 0;
if (line.item.sku) {
priceListRowsTouched = await mirrorPriceListQty(
pb,
line.item.sku,
p.target,
);
}
results.push({
...base,
status: "applied",
match_method: line.match_method,
id: line.item.id,
name: line.item.name,
previous_qty: current,
new_qty: p.target,
delta,
price_list_rows_touched: priceListRowsTouched,
});
}
return {
ok: true,
mode,
dry_run: dryRun,
summary,
results,
};
}