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:
parent
36894a2908
commit
22305645e7
2 changed files with 677 additions and 0 deletions
271
src/lib/server/stock-quantities-bulk-service.test.ts
Normal file
271
src/lib/server/stock-quantities-bulk-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
406
src/lib/server/stock-quantities-bulk-service.ts
Normal file
406
src/lib/server/stock-quantities-bulk-service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue