- 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
366 lines
No EOL
12 KiB
TypeScript
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 }; |