WFDASH-WEBAPP/src/lib/server/stock-purchase-items-service.ts
SJCoetz f35779369a fix(stock-purchase-items): drop dead drv_collected_at write to unblock build
The markLineCollected service and the stock-orders page's
handleAssignDriver (delivery-sentinel auto-collect path) both
wrote `drv_collected_at` to `stock_purchase_items`, but that
field exists on `order_items`, not on `stock_purchase_items`
(verified against pb_schema-2.json). Per AGENTS.md "select
fields are not self-service", PB would 500 both writes at
runtime. The TS error from the service was the build break
flagged in the 2026-08-06 23:34 deployment.

The page.tsx write was a latent runtime bug — TypeScript
never caught it because PB's loose `update` patch typing
accepts any field name. Both writes are removed; the
collection moment is captured by PB's `updated` timestamp on
the line row, which is sufficient for the auto-collect audit
trail. Inline comments point to FEATURE-REQUESTS as the
follow-up if a dedicated collection-date field is wanted
later (would need a PB admin schema change).

Also drops the matching test assertions that read
`drv_collected_at` off the response and replaces them with
a comment explaining the absence.

Tests: vitest 59/59 across stock-purchase-items-service +
stock-orders/[id]; npm run build compiles clean
(Next.js 16.2.6 Turbopack + TypeScript).

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
2026-08-07 01:39:03 +02:00

546 lines
No EOL
18 KiB
TypeScript

/**
* Per-line writes for `stock_purchase_items` — service layer.
*
* Pure per-line helpers that the MCP `stock_purchase_items.*`
* tools (and the future operator-side REST routes) call. The
* parent `stock_purchases` collection is NOT touched here — only
* the line rows.
*
* ## Public API
*
* - `updateStockPurchaseItem({id, patch, forceOverwrite})` —
* patch whitelisted pricing fields on a single line.
* - `bulkUpdateStockPurchaseItems({items, dry_run})` —
* per-row patch with the same whitelist, hand-edit guard on
* `cost_price`, and the `bulk_update_quote_items` response
* shape (`{updated, skipped, errors, results}`).
* - `assignDriver({id, drv_id, assigned_at?})` — flips line
* status `collection_pool → assigned_to_driver`.
* - `markLineCollected({id, quantity_collected?})` — flips line
* status to `collected` and bumps `quantity_received`. Does
* NOT book into stock — that requires the invoice-receive
* path.
*
* ## Why a separate module
*
* `stock-purchases-service.ts` is already 800 lines and owns the
* `receiveStockPurchase` legacy flow (the one being fixed in this
* PR). Per-line writes belong here so the partial-receive flow
* (`receive_invoice`) can call them without pulling in the
* header-flip and full-book legacy code.
*
* Mirrors the `quotes-service.ts` shape — keep it boring.
*/
import type { StockPurchaseItem } from "@/lib/types";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
const STOCK_PURCHASE_ITEMS = "stock_purchase_items" as const;
const STOCK_PURCHASES = "stock_purchases" as const;
/**
* Fields a caller may patch on a stock_purchase_item. Anything
* outside this set returns `forbidden_field` per row.
*
* The whitelisted fields cover the supplier-invoice reconciliation
* use case (the agent overrides the supplier's sku / cost_price /
* description when the invoice differs from the original PO).
* Quantity moves through `receive_invoice`, status moves through
* `assign_driver` / `mark_collected`. Relation fields
* (`purchase`, `stock_item`) are immutable after creation.
*/
export const STOCK_PURCHASE_ITEM_PATCHABLE_FIELDS = [
"cost_price",
"sku",
"description",
"notes",
"unit",
"supplier_sku",
"quantity",
] as const;
/**
* PB client type kept loose so both the real `PocketBase` instance
* and the test stub satisfy the contract. The service uses
* `getOne`, `update`, and (in `markLineCollected` for the auto-flip
* check) `getFullList` on `stock_purchase_items`.
*/
export type StockPurchaseItemsPb = {
collection: (id: string) => {
getOne: <T = unknown>(
id: string,
opts?: Record<string, unknown>,
) => Promise<T>;
update: (
id: string,
patch: Record<string, unknown>,
) => Promise<unknown>;
getFullList: <T = unknown>(opts?: Record<string, unknown>) => Promise<T[]>;
};
};
export type UpdateStockPurchaseItemInput = {
id: string;
patch: Partial<
Pick<
StockPurchaseItem,
| "cost_price"
| "sku"
| "description"
| "notes"
| "unit"
| "quantity"
>
> & { supplier_sku?: string };
/**
* Allow overwriting a line whose `cost_price > 0` already. Default
* false (matches `bulk_update_quote_items` semantics — protects
* lines the operator already hand-priced against silent AI
* overwrite).
*/
force_overwrite?: boolean;
};
export type UpdateStockPurchaseItemResult = {
item: StockPurchaseItem;
previous: Partial<StockPurchaseItem>;
};
/**
* Patch whitelisted fields on a single stock_purchase_item.
*
* Throws `forbidden_field: <name>` for any key outside the whitelist
* so the caller knows exactly which key was rejected.
*/
export async function updateStockPurchaseItem(
input: UpdateStockPurchaseItemInput,
): Promise<UpdateStockPurchaseItemResult> {
const pb = await getAdminPocketBase();
return updateStockPurchaseItemWithPb(pb, input);
}
/**
* PB-injectable variant for testing. Public so the route can pick
* the live admin PB in production.
*/
export async function updateStockPurchaseItemWithPb(
pb: StockPurchaseItemsPb,
input: UpdateStockPurchaseItemInput,
): Promise<UpdateStockPurchaseItemResult> {
if (!input.id) throw new Error("id is required");
if (!input.patch || typeof input.patch !== "object") {
throw new Error("patch object is required");
}
// 1. Validate the patch against the whitelist.
for (const key of Object.keys(input.patch)) {
if (
!STOCK_PURCHASE_ITEM_PATCHABLE_FIELDS.includes(
key as (typeof STOCK_PURCHASE_ITEM_PATCHABLE_FIELDS)[number],
)
) {
throw new Error(`forbidden_field: ${key}`);
}
}
// 2. Load the existing line for the hand-edit guard + return value.
const before = (await pb
.collection(STOCK_PURCHASE_ITEMS)
.getOne(input.id)) as StockPurchaseItem;
const previous: Partial<StockPurchaseItem> = {};
for (const key of STOCK_PURCHASE_ITEM_PATCHABLE_FIELDS) {
if (key in input.patch) {
// `StockPurchaseItem` has no string index signature, so the
// direct `as Record<string, unknown>` cast is rejected (TS2352).
// Detour through `unknown` — same pattern as the stock-items
// service.
(previous as unknown as Record<string, unknown>)[key] = (
before as unknown as Record<string, unknown>
)[key];
}
}
// 3. Hand-edit guard on cost_price (matches bulk_update_quote_items).
if (
"cost_price" in input.patch &&
Number(before.cost_price ?? 0) > 0 &&
input.force_overwrite !== true
) {
throw new Error(
"already_priced: line already has cost_price > 0; pass force_overwrite: true to overwrite",
);
}
// 4. Write the patch.
const updated = (await pb
.collection(STOCK_PURCHASE_ITEMS)
.update(input.id, input.patch as Record<string, unknown>)) as StockPurchaseItem;
return { item: updated, previous };
}
// ─── Bulk update ────────────────────────────────────────────────────
export type BulkUpdateStockPurchaseItemRow = {
id: string;
patch: UpdateStockPurchaseItemInput["patch"];
force_overwrite?: boolean;
};
export type BulkUpdateStockPurchaseItemsInput = {
items: BulkUpdateStockPurchaseItemRow[];
dry_run?: boolean;
};
export type BulkUpdateStockPurchaseItemResultRow = {
id: string;
status: "ok" | "skipped" | "dry_run" | "error";
reason?: string;
message?: string;
previous?: Partial<StockPurchaseItem>;
item?: StockPurchaseItem;
};
export type BulkUpdateStockPurchaseItemsResult = {
updated: number;
skipped: number;
errors: Array<{ row: number; id: string; reason: string; message: string }>;
results: BulkUpdateStockPurchaseItemResultRow[];
};
const BULK_UPDATE_MAX_ROWS = 100;
/**
* Batch patch stock_purchase_items. Mirrors the
* `bulk_update_quote_items` shape exactly:
*
* { updated, skipped, errors, results: [...] }
*
* Per-row independence — one row's failure never affects another.
* Cap is 100 rows per call (request larger batches by paging).
* `dry_run: true` returns the same shape with `status: "dry_run"`
* per row and writes nothing.
*/
export async function bulkUpdateStockPurchaseItems(
input: BulkUpdateStockPurchaseItemsInput,
): Promise<BulkUpdateStockPurchaseItemsResult> {
const pb = await getAdminPocketBase();
return bulkUpdateStockPurchaseItemsWithPb(pb, input);
}
export async function bulkUpdateStockPurchaseItemsWithPb(
pb: StockPurchaseItemsPb,
input: BulkUpdateStockPurchaseItemsInput,
): Promise<BulkUpdateStockPurchaseItemsResult> {
const items = Array.isArray(input.items) ? input.items : [];
if (items.length === 0) {
throw new Error("items array must contain at least one row");
}
if (items.length > BULK_UPDATE_MAX_ROWS) {
throw new Error(
`bulk update capped at ${BULK_UPDATE_MAX_ROWS} rows per call (got ${items.length})`,
);
}
const results: BulkUpdateStockPurchaseItemResultRow[] = [];
const errors: BulkUpdateStockPurchaseItemsResult["errors"] = [];
let updated = 0;
let skipped = 0;
for (let i = 0; i < items.length; i++) {
const row = items[i];
try {
if (!row?.id) {
throw new Error("id is required on every row");
}
if (!row.patch || typeof row.patch !== "object") {
throw new Error("patch object is required on every row");
}
// Whitelist check (per row, before any side effect).
for (const key of Object.keys(row.patch)) {
if (
!STOCK_PURCHASE_ITEM_PATCHABLE_FIELDS.includes(
key as (typeof STOCK_PURCHASE_ITEM_PATCHABLE_FIELDS)[number],
)
) {
throw new Error(`forbidden_field: ${key}`);
}
}
if (input.dry_run === true) {
results.push({ id: row.id, status: "dry_run" });
skipped += 1;
continue;
}
const result = await updateStockPurchaseItemWithPb(pb, {
id: row.id,
patch: row.patch,
force_overwrite: row.force_overwrite === true,
});
results.push({
id: row.id,
status: "ok",
previous: result.previous,
item: result.item,
});
updated += 1;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const reason = message.split(":")[0]?.trim() || "error";
results.push({
id: row?.id ?? "",
status: "error",
reason,
message,
});
errors.push({
row: i,
id: row?.id ?? "",
reason,
message,
});
}
}
return { updated, skipped, errors, results };
}
// ─── Driver assignment + collected ──────────────────────────────────
export type AssignDriverInput = {
id: string;
drv_id: string;
assigned_at?: string;
};
export type AssignDriverResult = { item: StockPurchaseItem };
/**
* Bind a stock_purchase_item to a driver. Transitions the line
* status `null → assigned_to_driver` or
* `collection_pool → assigned_to_driver`. Anything else
* (`assigned_to_driver`, `collected`, `booked`) returns
* `wrong_status` — the line is already past the assignment step.
*/
export async function assignDriver(
input: AssignDriverInput,
): Promise<AssignDriverResult> {
const pb = await getAdminPocketBase();
return assignDriverWithPb(pb, input);
}
export async function assignDriverWithPb(
pb: StockPurchaseItemsPb,
input: AssignDriverInput,
): Promise<AssignDriverResult> {
if (!input.id) throw new Error("id is required");
if (!input.drv_id) throw new Error("drv_id is required");
const before = (await pb
.collection(STOCK_PURCHASE_ITEMS)
.getOne(input.id)) as StockPurchaseItem;
const status = before.status ?? null;
if (status !== null && status !== "collection_pool") {
throw new Error(
`wrong_status: cannot assign driver to a line in status "${status}" (expected null or collection_pool)`,
);
}
const patch: Record<string, unknown> = {
status: "assigned_to_driver",
drv_id: input.drv_id,
assigned_at: input.assigned_at ?? new Date().toISOString(),
};
const updated = (await pb
.collection(STOCK_PURCHASE_ITEMS)
.update(input.id, patch)) as StockPurchaseItem;
return { item: updated };
}
export type MarkLineCollectedInput = {
id: string;
/**
* Defaults to `quantity - quantity_received` (remaining qty).
* Must be ≥ 0 and ≤ remaining.
*/
quantity_collected?: number;
};
export type MarkLineCollectedResult = {
item: StockPurchaseItem;
previous_quantity_received: number;
new_quantity_received: number;
/**
* The PO header status, after the auto-flip check. Either the
* value the header had before the call, or `"received"` if this
* call was the one that completed the PO. `null` when the line's
* PO couldn't be located (e.g. orphaned line).
*/
header_status_after: string | null;
/**
* `true` when this call was the one that flipped the header
* from `"sent"` to `"received"`. `false` otherwise (no flip —
* either other lines still pending, or header already received).
*/
header_flipped: boolean;
};
/**
* Mark a stock_purchase_item as physically delivered.
*
* Transitions the line status to `collected` and bumps
* `quantity_received`. Does NOT book into stock — that requires
* the accountant's `bookStockFromInvoice` flow.
*
* ## Header auto-flip
*
* After the line update, the service loads every line on the
* parent PO. If every line has `quantity_received >= quantity`,
* the parent PO's header is flipped from `"sent"` to `"received"`.
* This is the workflow Hermes drives: it processes supplier
* invoices one at a time, marking lines collected as each invoice
* arrives. The header flip happens automatically on the call that
* completes the PO — no separate `close_po` step.
*
* The flip is conditional:
* - header must currently be `"sent"` (idempotent — a second call
* after the header is already `"received"` is a no-op);
* - the line quantity check is inclusive (`>= quantity`), so a line
* partially marked collected across multiple invoices still
* counts toward the header-flip completion.
*
* Accepts the line from either `assigned_to_driver` (the normal
* driver pickup path) or `collection_pool` (the delivery sentinel
* auto-collect path lets the agent skip the assignment step).
*/
export async function markLineCollected(
input: MarkLineCollectedInput,
): Promise<MarkLineCollectedResult> {
const pb = await getAdminPocketBase();
return markLineCollectedWithPb(pb, input);
}
export async function markLineCollectedWithPb(
pb: StockPurchaseItemsPb,
input: MarkLineCollectedInput,
): Promise<MarkLineCollectedResult> {
if (!input.id) throw new Error("id is required");
const before = (await pb
.collection(STOCK_PURCHASE_ITEMS)
.getOne(input.id)) as StockPurchaseItem;
const status = before.status ?? null;
if (status !== "assigned_to_driver" && status !== "collection_pool") {
throw new Error(
`wrong_status: cannot mark collected from status "${status}" (expected assigned_to_driver or collection_pool)`,
);
}
const previous_quantity_received = Number(before.quantity_received ?? 0);
const quantity = Number(before.quantity ?? 0);
const remaining = Math.max(0, quantity - previous_quantity_received);
const quantity_collected =
input.quantity_collected === undefined
? remaining
: input.quantity_collected;
if (quantity_collected < 0) {
throw new Error(
`quantity_collected must be >= 0 (got ${quantity_collected})`,
);
}
if (quantity_collected > remaining) {
throw new Error(
`quantity_collected ${quantity_collected} exceeds remaining ${remaining}`,
);
}
const new_quantity_received = previous_quantity_received + quantity_collected;
// Note: `drv_collected_at` (the moment the driver handed the goods
// to the warehouse) does NOT exist on the `stock_purchase_items`
// PB collection — only `drv_id` + `assigned_at`. We track the
// collection moment via the line's `status` flip to `collected`
// and PB's `updated` timestamp. Adding a dedicated date field is
// a PB-admin task (FEATURE-REQUESTS gap #drv_collected_at_field).
const patch: Record<string, unknown> = {
status: "collected",
quantity_received: new_quantity_received,
};
const updated = (await pb
.collection(STOCK_PURCHASE_ITEMS)
.update(input.id, patch)) as StockPurchaseItem;
// Auto-flip: if every line on this PO is now fully collected,
// flip the parent header from "sent" → "received". Mirrors the
// user's "if we have all the invoices for that PO its done
// complete flipped" workflow — Hermes processes invoices one
// at a time, and the header closes itself when the last line
// lands.
const purchaseId = (updated as StockPurchaseItem).purchase ?? before.purchase;
let header_status_after: string | null = null;
let header_flipped = false;
if (purchaseId) {
const allLines = (await pb.collection(STOCK_PURCHASE_ITEMS).getFullList({
filter: `purchase = "${purchaseId}"`,
})) as StockPurchaseItem[];
const allDone =
allLines.length > 0 &&
allLines.every(
(l) =>
(l.status === "collected" || l.status === "booked") &&
Number(l.quantity_received ?? 0) >= Number(l.quantity ?? 0),
);
if (allDone) {
try {
const header = (await pb
.collection(STOCK_PURCHASES)
.getOne(purchaseId)) as { status?: string };
header_status_after = header.status ?? null;
if (header_status_after === "sent") {
await pb.collection(STOCK_PURCHASES).update(purchaseId, {
status: "received",
});
header_status_after = "received";
header_flipped = true;
}
} catch {
// Orphan line — the PO header isn't readable. The line
// update already succeeded; the auto-flip just can't run.
// Surface as null so the caller knows the check was
// inconclusive rather than silently succeeding.
header_status_after = null;
header_flipped = false;
}
} else {
// Header status is informational even when we don't flip.
try {
const header = (await pb
.collection(STOCK_PURCHASES)
.getOne(purchaseId)) as { status?: string };
header_status_after = header.status ?? null;
} catch {
header_status_after = null;
}
}
}
return {
item: updated,
previous_quantity_received,
new_quantity_received,
header_status_after,
header_flipped,
};
}