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
244 lines
7.7 KiB
TypeScript
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 };
|
|
}
|