WFDASH-WEBAPP/src/lib/server/purchase-order-agent-service.ts
Dave 59f245d798 feat(collection-confirm): add stock item edits
- Add new POST API endpoints at `/api/v1/stock-purchase-items/[id]/sku` and `/api/v1/stock-purchase-items/[id]/cost` for updating stock purchase item details
- Update ItemsTable to support cost and SKU edits for all row types, add supplier SKU column and editing for stock rows
- Extend PoCard to pass edit callbacks and add stock-specific invoice upload functionality
- Update all related test suites to cover new editing and UI features
- Adjust invoice upload messaging to differentiate between customer and stock purchase orders
2026-08-14 16:39:46 +02:00

366 lines
No EOL
12 KiB
TypeScript

/**
* Purchase-Order Agent service — read-side triage for the
* `po-agent` workflow described in `docs/agents/purchase-order-agent.md`.
*
* What this exists for
* ─────────────────────
* The MCP `purchase_orders.scan` tool exists so the agent can answer
* "what POs need my attention right now?" with a single round-trip
* instead of `list → get → get → get`. That triage query crosses
* BOTH `stock_purchases` (supplier pickups) and `purchase_orders`
* (customer-fulfilment POs), so the service is the one place that
* knows about both collections.
*
* Why a separate file
* ────────────────────
* `stock-purchases-service.ts` is already 1,472 lines and
* `purchase-orders-service.ts` is the customer-side equivalent. A
* dedicated file keeps the agent's read concerns together and avoids
* making either existing service larger. If the agent grows more
* write-side helpers, this file is the natural home for them too.
*
* Read-only — never writes to PB
* ───────────────────────────────
* This module is intentionally write-free. The agent's commit path
* is composed of existing per-collection writes
* (`stock_purchase_items.bulk_update`, `mark_collected`,
* `stock_purchases.add_attachment`, etc.) — keeping the scan pure
* makes it safe to call repeatedly without side-effects.
*/
import type { StockPurchaseStatus } from "@/lib/types";
const STOCK_PURCHASES = "stock_purchases";
const PURCHASE_ORDERS = "purchase_orders";
const STOCK_PURCHASE_ITEMS = "stock_purchase_items";
const ORDER_ITEMS = "order_items";
/**
* Minimal PB client shape this module needs. Mirrors the convention
* in `stock-purchases-service.ts` and `purchase-orders-service.ts`
* — the route hands in the real `getAdminPocketBase()`, the unit
* test hands in a tiny mock.
*/
export interface ScanPb {
collection: (name: string) => {
getFullList: (opts?: {
filter?: string;
sort?: string;
expand?: string;
fields?: string;
}) => Promise<Array<Record<string, unknown>>>;
getList: (
page?: number,
perPage?: number,
opts?: { filter?: string; sort?: string; expand?: string },
) => Promise<{
items: Array<Record<string, unknown>>;
totalItems: number;
page: number;
perPage: number;
totalPages: number;
}>;
};
}
/** Either PO kind the agent owns. Mirrors the `PoKind` union used
* inside `stock-purchase-invoice-matching-service.ts`. */
export type ScanPoKind = "stock" | "customer";
/** Filters accepted by `scanPurchaseOrders`. Empty defaults to
* "everything in a `sent`-equivalent state with at least one
* diagnostic flag" — the agent's typical morning sweep. */
export interface ScanPurchaseOrdersFilters {
/** Restrict to one kind, or `"both"` (default). */
kind?: ScanPoKind | "both";
/** One or more statuses to scan. Default `["sent", "sending", "mail_failed"]`. */
status?: string[];
/** Override the row cap (default 100 per kind). */
maxRowsPerKind?: number;
}
const DEFAULT_STATUSES = ["sent", "sending", "mail_failed"];
const DEFAULT_MAX_ROWS = 100;
/** Per-PO summary surfaced in the scan report. Kept narrow on
* purpose — the agent can drill into a single PO via
* `stock_purchases.get` / `purchase_orders.get`. */
export interface ScanPoRow {
/** Collection id (`stock_purchases` | `purchase_orders`). */
id: string;
kind: ScanPoKind;
/** Human-readable PO number, normalised. */
po_number: string;
status: string;
/** ISO timestamp the row was last touched. */
updated: string | null;
/** Supplier denormalised for convenience. */
supplier: { id: string | null; name: string | null };
/** Filenames attached at header level. Empty array = no invoice
* yet (this is the headline diagnostic for the agent). */
attachment_filenames: string[];
/** Quick line summary so the agent can decide whether to drill. */
diagnostics: {
line_count: number;
/** Lines with empty `sku`. */
lines_missing_sku: number;
/** Lines with `cost_price` of 0 (operator forgot to price the
* PO before sending). */
lines_missing_cost: number;
/** Lines with empty `supplier_sku` (used by the
* `supplier_sku_exact` matcher in
* `stock-purchase-invoice-matching-service.ts`). */
lines_missing_supplier_sku: number;
};
}
export interface ScanPurchaseOrdersResult {
total_needing_attention: number;
by_kind: Record<ScanPoKind, number>;
by_status: Record<string, number>;
/** Stable sort: most-recently-updated first within each kind. */
pos: ScanPoRow[];
}
/**
* Resolve a single PO's `additional_attachments` field into a
* filename array. PB returns the file field as `string[]`,
* `string`, or `null` depending on age + writes; normalise all of
* those to `string[]`.
*/
function readAttachmentFilenames(
raw: string[] | string | null | undefined,
): string[] {
if (raw == null) return [];
if (Array.isArray(raw)) return raw;
if (typeof raw === "string" && raw.length > 0) {
return raw.split(",").map((s) => s.trim()).filter(Boolean);
}
return [];
}
/**
* Run the per-line diagnostics for one PO. Reads lines with the
* minimum field set needed (sku, supplier_sku, cost_price) to
* keep the round-trip cheap — we don't need the full line record
* here, just the counts.
*/
async function computeDiagnostics(
pb: ScanPb,
collection: "stock_purchase_items" | "order_items",
filter: string,
): Promise<{
line_count: number;
lines_missing_sku: number;
lines_missing_cost: number;
lines_missing_supplier_sku: number;
}> {
type LineShape = {
sku?: string | null;
supplier_sku?: string | null;
cost_price?: number | null;
};
const lines = (await pb.collection(collection).getFullList({
filter,
fields: "id,sku,supplier_sku,cost_price",
})) as unknown as LineShape[];
let missingSku = 0;
let missingCost = 0;
let missingSupplierSku = 0;
for (const line of lines) {
if (!line.sku || String(line.sku).trim().length === 0) missingSku++;
if (!line.supplier_sku || String(line.supplier_sku).trim().length === 0)
missingSupplierSku++;
if (Number(line.cost_price ?? 0) === 0) missingCost++;
}
return {
line_count: lines.length,
lines_missing_sku: missingSku,
lines_missing_cost: missingCost,
lines_missing_supplier_sku: missingSupplierSku,
};
}
/** Build the per-PO diagnostic row from a raw header record. */
function shapePoRow(
raw: Record<string, unknown>,
kind: ScanPoKind,
diagnostics: ScanPoRow["diagnostics"],
): ScanPoRow {
const expand = (raw.expand ?? {}) as {
supplier?: { id?: string; suppliername?: string };
};
const supplierId =
(raw.supplier as string | null | undefined) ??
expand.supplier?.id ??
null;
return {
id: String(raw.id ?? ""),
kind,
po_number:
kind === "stock"
? String(raw.purchase_number ?? "")
: String(raw.po_number ?? ""),
status: String(raw.status ?? ""),
updated: (raw.updated as string | null | undefined) ?? null,
supplier: {
id: supplierId,
name: expand.supplier?.suppliername ?? null,
},
attachment_filenames: readAttachmentFilenames(
raw.additional_attachments as
| string[]
| string
| null
| undefined,
),
diagnostics,
};
}
/**
* `scanPurchaseOrders` — the engine behind the
* `purchase_orders.scan` MCP tool.
*
* Behaviour
* ─────────
* Reads both `stock_purchases` and `purchase_orders` filtered to
* `sent`/`sending`/`mail_failed` (configurable), runs per-line
* diagnostics on each PO, and returns the unified list sorted by
* `updated` desc.
*
* Caps each kind at `maxRowsPerKind` (default 100) so a runaway
* count can't OOM the route. The cap is the agent's signal to
* paginate via the `purchase_orders.list` / `stock_purchases.list`
* / `order_items.list` tools if it needs more.
*
* The function is intentionally cheap: two `getFullList` calls
* (one per collection) + N parallel `getFullList` calls for line
* diagnostics (≤5 in flight via `Promise.all` chunks). On a busy
* day with 50 POs needing attention this is ~55 round-trips; well
* inside the agent's per-call budget.
*/
export async function scanPurchaseOrders(
pb: ScanPb,
filters: ScanPurchaseOrdersFilters = {},
): Promise<ScanPurchaseOrdersResult> {
const kindFilter: ScanPoKind | "both" = filters.kind ?? "both";
const statuses = filters.status ?? DEFAULT_STATUSES;
const maxRows = filters.maxRowsPerKind ?? DEFAULT_MAX_ROWS;
// Kick off both kinds in parallel — neither depends on the other.
const stockPromise =
kindFilter === "customer"
? Promise.resolve([])
: scanStockPurchases(pb, statuses, maxRows).catch((err) => {
console.error("[scanPurchaseOrders] stock leg threw", err);
throw err;
});
const customerPromise =
kindFilter === "stock"
? Promise.resolve([])
: scanPurchaseOrdersList(pb, statuses, maxRows).catch((err) => {
console.error("[scanPurchaseOrders] customer leg threw", err);
throw err;
});
const [stockRows, customerRows] = await Promise.all([
stockPromise,
customerPromise,
]);
const all: ScanPoRow[] = [...stockRows, ...customerRows];
// Stable sort: most-recently-updated first, then alphabetical by
// PO number for deterministic output (important for tests + diff
// visibility).
all.sort((a, b) => {
const ta = a.updated ? Date.parse(a.updated) : 0;
const tb = b.updated ? Date.parse(b.updated) : 0;
if (tb !== ta) return tb - ta;
return a.po_number.localeCompare(b.po_number);
});
const byKind: Record<ScanPoKind, number> = { stock: 0, customer: 0 };
const byStatus: Record<string, number> = {};
for (const row of all) {
byKind[row.kind]++;
byStatus[row.status] = (byStatus[row.status] ?? 0) + 1;
}
return {
total_needing_attention: all.length,
by_kind: byKind,
by_status: byStatus,
pos: all,
};
}
/**
* PB filter fragment for "status is one of these". This PB instance
* rejects the `IN` operator outright (HTTP 400 "Something went wrong
* while processing your request." — confirmed 2026-08-14 on both
* `purchase_orders` and `stock_purchases`, even for `id IN (...)`),
* so we OR a series of `status = "..."` clauses together — the same
* approach `tabGroupFilter` in item-status-ui.ts uses.
*/
function statusFilterClause(statuses: string[]): string {
return statuses.map((s) => `status = "${s}"`).join(" || ");
}
/** Scan stock_purchases. Filters to the given statuses, runs per-PO
* line diagnostics, returns the unified rows. */
async function scanStockPurchases(
pb: ScanPb,
statuses: string[],
maxRows: number,
): Promise<ScanPoRow[]> {
const headers = await pb.collection(STOCK_PURCHASES).getFullList({
filter: statusFilterClause(statuses),
sort: "-updated",
expand: "supplier",
});
const slice = headers.slice(0, maxRows);
const rows = await Promise.all(
slice.map(async (raw) => {
const id = String(raw.id ?? "");
const diagnostics = await computeDiagnostics(
pb,
STOCK_PURCHASE_ITEMS,
`purchase = "${id}"`,
);
return shapePoRow(raw, "stock", diagnostics);
}),
);
return rows;
}
/** Scan purchase_orders (customer POs). Mirrors `scanStockPurchases`
* but reads `order_items` instead. */
async function scanPurchaseOrdersList(
pb: ScanPb,
statuses: string[],
maxRows: number,
): Promise<ScanPoRow[]> {
const headers = await pb.collection(PURCHASE_ORDERS).getFullList({
filter: statusFilterClause(statuses),
sort: "-updated",
expand: "supplier",
});
const slice = headers.slice(0, maxRows);
const rows = await Promise.all(
slice.map(async (raw) => {
const id = String(raw.id ?? "");
const diagnostics = await computeDiagnostics(
pb,
ORDER_ITEMS,
`purchase_order = "${id}"`,
);
return shapePoRow(raw, "customer", diagnostics);
}),
);
return rows;
}
// Re-export the status type so route handlers can declare filter
// inputs without importing from `@/lib/types` directly.
export type { StockPurchaseStatus };