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>
546 lines
No EOL
18 KiB
TypeScript
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,
|
|
};
|
|
} |