WFDASH-WEBAPP/src/lib/server/stock-purchase-items-service.ts
Dave d0fb8e81f0 feat(collection-confirm): add quantity collection handling and deduplication improvements
- Introduced a new `handleQtyChange` function to manage per-row quantity updates for both customer and stock items, ensuring correct data submission to the appropriate endpoints.
- Enhanced the `dedupeAttachments` function to handle additional document sources, including parent purchase orders, and skip empty filenames to prevent 404 errors.
- Updated the `ItemsTable` and `PoCard` components to support quantity editing, allowing users to modify collected quantities directly in the UI.
- Added tests to validate the new quantity handling logic and attachment deduplication, ensuring robust functionality across various scenarios.
2026-08-17 02:17:27 +02:00

643 lines
No EOL
21 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 UpdateStockPurchaseItemQtyInput = {
id: string;
/**
* New value for `quantity_received`. Must be a non-negative
* integer. No upper bound — a row can be legitimately
* over-collected (supplier shipped more than PO'd) so we don't
* clamp at `quantity`.
*/
quantity_received: number;
actor: { id: string; name: string };
};
export type UpdateStockPurchaseItemQtyResult = {
item: StockPurchaseItem;
previous_quantity_received: number;
};
/**
* Edit `quantity_received` on a single collected stock_purchase_item
* from the collection-confirm page's Qty pencil.
*
* The pencil is wired when the operator notices the warehouse-side
* count disagrees with what the driver originally reported (e.g.
* re-counted at intake, found a damaged unit, etc.). The change is
* stamped onto the line's `notes` feed as a `quantity`-category
* entry so the audit trail records the before/after values.
*
* Validation: non-negative integer (PB stores quantity_received as
* a number; fractions are rejected because the UI's pencil is
* integer-only and downstream invoices expect whole units). No
* upper bound — see input type.
*
* No-op when the new value equals the existing one (route returns
* 200 without writing a new audit entry). Mirrors
* `updateOrderItemQty` in the order-items service.
*/
export async function updateStockPurchaseItemQty(
input: UpdateStockPurchaseItemQtyInput,
): Promise<UpdateStockPurchaseItemQtyResult> {
if (
typeof input.quantity_received !== "number" ||
!Number.isFinite(input.quantity_received)
) {
throw new Error("quantity_received must be a finite number");
}
if (input.quantity_received < 0) {
throw new Error("quantity_received must be a non-negative number");
}
if (!Number.isInteger(input.quantity_received)) {
throw new Error("quantity_received must be a whole number");
}
const pb = await getAdminPocketBase();
const before = (await pb
.collection(STOCK_PURCHASE_ITEMS)
.getOne(input.id)) as StockPurchaseItem;
const previous_quantity_received = Number(
before.quantity_received ?? 0,
);
if (previous_quantity_received === input.quantity_received) {
return { item: before, previous_quantity_received };
}
const existingNotes = (before as { notes?: string | null }).notes ?? "";
const qtyNote = {
id: `qty-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
ts: new Date().toISOString(),
actorId: input.actor.id,
actorName: input.actor.name,
category: "quantity" as const,
body: {
qtyChange: {
prev: previous_quantity_received,
next: input.quantity_received,
},
},
};
const updated = (await pb.collection(STOCK_PURCHASE_ITEMS).update(input.id, {
quantity_received: input.quantity_received,
notes: JSON.stringify([
...parseStoredNotesForQty(existingNotes),
qtyNote,
]),
})) as StockPurchaseItem;
return { item: updated, previous_quantity_received };
}
function parseStoredNotesForQty(raw: string): Array<Record<string, unknown>> {
const trimmed = (raw ?? "").trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
if (!Array.isArray(parsed)) return [];
return parsed.filter((entry) => entry && typeof entry === "object");
} catch {
return [];
}
}
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,
};
}