WFDASH-WEBAPP/src/lib/server/stock-items-service.ts
SJCoetz c4125ee40a feat: add split-invoice stock PO workflow and supporting tooling
Ship full split-invoice stock purchase order workflow:
- Add 5 new MCP tools for per-line stock purchase item management and catalogue updates
- Add idempotent database migration adding sku, unit, and supplier_sku fields to stock_purchase_items
- Implement service layers for all new operations, including auto-closing the parent PO when all lines are fully collected
- Add complete test coverage for new services and API routes
- Update all relevant documentation including MCP API specs, agent instructions, and feature request logs
2026-08-07 00:53:29 +02:00

244 lines
7.7 KiB
TypeScript

import type { StockItem, Supplier } from "@/lib/types";
/**
* PB client type kept loose so both the real `PocketBase` instance
* and the test stub satisfy the contract. The service uses
* `getFullList` (stocktake listing) plus `getOne` / `update`
* (`updateStockItem`).
*/
export type StockItemsPb = {
collection: (id: string) => {
getFullList: <T = unknown>(opts?: Record<string, unknown>) => Promise<T[]>;
getOne: <T = unknown>(
id: string,
opts?: Record<string, unknown>,
) => Promise<T>;
update: (
id: string,
patch: Record<string, unknown>,
) => Promise<unknown>;
};
};
/**
* Fetch every active stock item sorted by supplier name (then by
* item name within each supplier), with the supplier relation
* pre-expanded. The "Stocktake PDF" download endpoint uses this to
* render the printable stocktake list.
*
* Differs from the inline `stock_items` reads on the dashboard
* page (which filter by `is_active = true` AND sort by `current_qty`)
* because a stocktake wants all SKUs grouped by supplier so the
* operator can do a single walk-through per vendor, not the
* low-stock ones first.
*
* PB's `sort` parameter sorts by the supplier relation field (the
* ID), not by the expanded supplier's name — so we re-sort in JS by
* `expand.supplier.suppliername` to get the printable grouping
* right. Items without a supplier sort to the end. Name is the
* secondary sort so items within the same supplier stay
* alphabetically grouped.
*
* No pagination — the stocktake is always the whole list. If the
* catalogue grows past `getFullList`'s default page size, PB will
* fetch subsequent pages transparently. The PDF renderer downstream
* is the only consumer; if it becomes a bottleneck, this is the
* place to swap in a paginated stream.
*/
export async function listStockItemsForStocktake(
pb: StockItemsPb
): Promise<StockItem[]> {
const items = (await pb.collection("stock_items").getFullList<StockItem>({
filter: "is_active = true",
// PB-side sort by supplier (relation ID), then by name. The JS
// re-sort below overrides this with the expanded supplier name,
// but the PB-side sort keeps the input roughly grouped so the
// JS comparator does fewer swaps on large lists.
sort: "supplier,name",
expand: "supplier",
})) as StockItem[];
return items.sort((a, b) => {
const aName = a.expand?.supplier?.suppliername ?? "";
const bName = b.expand?.supplier?.suppliername ?? "";
// Items without a supplier sort to the end of the PDF.
if (aName === "" && bName !== "") return 1;
if (aName !== "" && bName === "") return -1;
const cmp = aName.localeCompare(bName);
if (cmp !== 0) return cmp;
return a.name.localeCompare(b.name);
});
}
// Re-export the type so the route can alias it without poking
// into `@/lib/types` directly.
export type { StockItem, Supplier };
// ─── updateStockItem (catalogue patch) ───────────────────────────────────
/**
* Fields a caller may patch on a `stock_items` row. Anything
* outside this set returns `forbidden_field`.
*
* Catalogue fields only. Inventory quantities are managed via
* `stock_items.adjust`; the supplier relation is fixed once the
* item is created; `cost_history` and `cost_price` are owned by
* the booking flow and not user-editable from this surface.
*/
export const STOCK_ITEM_PATCHABLE_FIELDS = [
"sku",
"description",
"notes",
"unit",
"selling_price",
"markup",
] as const;
export type StockItemPatch = Partial<
Pick<
StockItem,
"sku" | "description" | "notes" | "unit" | "selling_price" | "markup"
>
>;
export type UpdateStockItemInput = {
id: string;
patch: StockItemPatch;
};
export type UpdateStockItemResult =
| { ok: true; item: StockItem; previous: StockItemPatch }
| {
ok: false;
reason: "forbidden_field" | "validation_error" | "not_found" | "pb_error";
message: string;
field?: string;
};
/**
* Patch whitelisted catalogue fields on a single `stock_items` row.
*
* Used by the operator's external agent (Hermes) to keep the
* catalogue in sync with the supplier's invoice — for example,
* when the supplier renames a SKU on the invoice, Hermes can
* mirror the rename onto the master catalogue row.
*
* Inventory (`current_qty`) is NOT patchable here — use
* `stock_items.adjust` so the ledger stays the source of truth.
*
* Returns `{ ok: true, item, previous }` on success or
* `{ ok: false, reason, message, field? }` on rejection. The MCP
* route maps `reason` to HTTP status:
* - `not_found` → 404
* - `forbidden_field` → 400
* - `validation_error` → 400
* - `pb_error` → 422
*/
export async function updateStockItem(
input: UpdateStockItemInput,
): Promise<UpdateStockItemResult> {
// Lazy import so the production code can boot without dragging
// pocketbase-admin into pages that only use stocktake listing.
const { getAdminPocketBase } = await import("./pocketbase-admin");
const pb = await getAdminPocketBase();
return updateStockItemWithPb(pb, input);
}
/**
* PB-injectable variant. Public so the route can pass the live
* admin PB in production and tests can pass a stub.
*/
export async function updateStockItemWithPb(
pb: StockItemsPb,
input: UpdateStockItemInput,
): Promise<UpdateStockItemResult> {
if (!input.id) {
return {
ok: false,
reason: "validation_error",
message: "id is required",
};
}
if (!input.patch || typeof input.patch !== "object") {
return {
ok: false,
reason: "validation_error",
message: "patch object is required",
};
}
// 1. Validate the patch against the whitelist.
for (const key of Object.keys(input.patch)) {
if (!STOCK_ITEM_PATCHABLE_FIELDS.includes(key as (typeof STOCK_ITEM_PATCHABLE_FIELDS)[number])) {
return {
ok: false,
reason: "forbidden_field",
message: `forbidden_field: ${key}`,
field: key,
};
}
}
// 2. Validate numeric fields.
for (const field of ["selling_price", "markup"] as const) {
if (field in input.patch) {
const value = input.patch[field];
if (typeof value !== "number" || !Number.isFinite(value)) {
return {
ok: false,
reason: "validation_error",
message: `${field} must be a finite number`,
field,
};
}
}
}
// 3. Load the existing row for the return value + not_found check.
let before: StockItem;
try {
before = (await pb
.collection("stock_items")
.getOne(input.id)) as StockItem;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (/not found|404/i.test(message)) {
return {
ok: false,
reason: "not_found",
message: `stock_items row ${input.id} not found`,
};
}
return {
ok: false,
reason: "pb_error",
message: `Failed to load stock_items row ${input.id}: ${message}`,
};
}
const previous: StockItemPatch = {};
for (const key of STOCK_ITEM_PATCHABLE_FIELDS) {
if (key in input.patch) {
(previous as Record<string, unknown>)[key] = (
before as unknown as Record<string, unknown>
)[key];
}
}
// 4. Write the patch.
let updated: StockItem;
try {
updated = (await pb
.collection("stock_items")
.update(input.id, input.patch as Record<string, unknown>)) as StockItem;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
reason: "pb_error",
message: `Failed to update stock_items row ${input.id}: ${message}`,
};
}
return { ok: true, item: updated, previous };
}