WFDASH-WEBAPP/src/lib/server/collection-confirm-service.ts
Dave d29b91c7c0 feat(collection-confirm): update stock item filtering logic in listCollectedItems
- Modified the filter criteria in the `listCollectedItems` function to exclude stock lines that have been fully received, ensuring only pending items are displayed.
- Updated related tests to reflect the new filtering logic, enhancing clarity on the conditions under which stock items are included in the list.
- Improved documentation comments to explain the filtering behavior and its implications for the stock confirmation process.
2026-08-17 08:27:36 +02:00

884 lines
33 KiB
TypeScript

import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getOrderItemsCollectionName } from "@/lib/server/orders-service";
import {
appendCostNote,
buildConfirmPatch,
type ConfirmActor,
} from "@/app/(app)/collection-confirm/view-model";
/**
* Validation failure raised by the collection-confirm service when
* caller-supplied arguments don't match the expected shape.
*
* Mirrors the `OrderItemSplitError` pattern (`src/lib/server/order-items-service.ts`):
* a small dedicated Error subclass with a `status` property the route
* handler can map directly to an HTTP response. The `code` field is a
* stable machine-readable tag the client can switch on; the `message`
* is human-readable.
*
* Routes are expected to validate first and return 400 directly; this
* class is the defence-in-depth layer for any caller (the MCP
* endpoint, a future internal tool, etc.) that bypasses the route
* validation.
*/
export class CollectionConfirmValidationError extends Error {
readonly code: string;
readonly status: number;
constructor(message: string, code: string, status: number) {
super(message);
this.name = "CollectionConfirmValidationError";
this.code = code;
this.status = status;
}
}
/**
* Where the row came from. The collection-confirm / collection-pool
* pages now read from BOTH `order_items` (customer deliveries) and
* `stock_purchase_items` (supplier pickups). The two have different
* field names + state machines, so we normalise to a `ConfirmRow`
* with a `source` discriminator and conditional fields:
* - `source: "customer"` → `expand.order` is the customer order
* - `source: "stock"` → `expand.order` is null, `stock_purchase_id` holds the parent
*
* This type is the new authoritative source for the shape consumed by
* the collection-confirm page. The duplicate in `page.tsx` will be
* migrated in a follow-up task.
*/
export type ConfirmRowSource = "customer" | "stock";
export type ConfirmRow = {
id: string;
/**
* Which collection this row came from. Drives the routing on
* the collection-confirm page (customer lines → `ready_invoice`,
* stock lines → `booked` to `stock_items`).
*/
source?: ConfirmRowSource;
description?: string;
po_description?: string;
order_qty?: number;
qty_collected?: number;
quantity?: number;
cost_price?: number | null;
item_status?: string;
problem_flag?: boolean | null;
problem_type?: string | null;
problem_notes?: string | null;
notes?: string | null;
drv_collected_at?: string | null;
collection_photo?: string;
// Canonical PocketBase field name has the typo (`attachements`). Read
// both names for back-compat with any pre-existing rows that were
// written under the spelled-correct variant.
order_item_attachments?: string[];
order_item_attachements?: string[];
/**
* Stock-only: filenames on the PARENT stock_purchase's
* `additional_attachments` file field. The stock-side invoice
* upload (handled by `handleStockInvoiceUpload` on the page)
* writes here, not on the line itself. The page's
* `dedupeAttachments` reads BOTH this field (for stock rows) and
* `order_item_attachements` (for customer rows) so the chip
* strip surfaces the agent's just-uploaded invoice.
*/
stock_purchase_attachments?: string[];
order_item_details?: string | null;
// Surplus split fields (added by 2026-07-14-create-stock migration).
// The chip + confirm button only render when the row has positive
// headroom AND has not yet been booked (surplus_qty undefined/null).
surplus_qty?: number | null;
stock_booked_from?: string | null;
// Match-key fields consumed by `findMatchingStockItem` when the
// surplus-book flow auto-picks a target stock_item.
sku?: string;
// Stock-only (customer order items have no such field). Read by the
// collection-confirm page's supplier-SKU pencil and written via
// `updateStockPurchaseItem` (patchable whitelist member).
supplier_sku?: string;
supplier?: string;
poNumber?: string;
/**
* Lightweight summary of the most recent `price_diff_report` entry
* the `po-agent` wrote to this PO's `notes` field. Populated by
* `listCollectedItems` so the operator (and the agent) can see at a
* glance which POs the agent has already triaged — the verdict +
* summary drive a small badge on each PoCard.
*
* `exists` is true when at least one `category: "price_diff_report"`
* entry is in the notes feed; `verdict` / `summary` come from the
* most recent one (newest `ts`). Null when the agent hasn't
* reviewed the PO yet.
*/
agent_report?: {
exists: boolean;
verdict?: string;
summary?: string;
recorded_at?: string;
} | null;
/**
* Stock-only: PB id of the parent `stock_purchase`. Always set
* for `source: "stock"` rows; null for `source: "customer"` rows.
* Used by the driver-assign / book-to-stock / status-flow paths
* to know which table to write back to.
*/
stock_purchase_id?: string | null;
/**
* Stock-only: the per-line `status` value copied from
* `stock_purchase_items.status` at fetch time. Possible values:
* `collection_pool` | `assigned_to_driver` | `collected` | `booked`.
*/
stock_line_status?: string | null;
/**
* Stock-only: timestamp the line was bound to a driver. Mirrors
* `stock_purchase_items.assigned_at`.
*/
assigned_at?: string | null;
expand?: {
purchase_order?: {
id: string;
po_number?: string;
/**
* Multi-file field on the parent `purchase_orders` record.
* Surfaces the PO PDF (and any operator-uploaded scans) as
* chips on the collection-confirm page. The collection-confirm
* page's `dedupeAttachments` reads this so a PO whose file was
* uploaded directly to the parent PO (rather than the line's
* `order_item_attachements`) still shows a document chip.
*/
documents?: string[];
expand?: { supplier?: { id: string; suppliername?: string } };
};
order?: {
id: string;
order_number?: string;
expand?: {
company?: { company?: string };
customer?: { customer?: string };
};
};
};
};
/**
* Read-side service for the collection-confirm page.
*
* Mirrors the union query the page's `load()` performs client-side, but
* runs through the admin PocketBase client so it can be exposed via
* `/api/v1/collection-confirm` once the page is migrated. The write
* helpers (`updateOrderItemCost`, `confirmOrderItemsForInvoicing`) use the
* same admin client and preserve the collection-confirm audit trail.
*/
/**
* Return every collected row (customer + stock legs) that the
* collection-confirm page needs to render.
*
* Customer leg: `order_items` filtered by `item_status="collected"`,
* expanded with `purchase_order.supplier,order.company,order.customer`.
* Stock leg: `stock_purchase_items` filtered by `status="collected"`,
* expanded with `purchase.supplier,purchase,stock_item`.
*
* Both legs are normalised into `ConfirmRow[]`. Customer rows are
* tagged `source: "customer"`, stock rows are mapped onto the shared
* `expand.purchase_order` shape (so the page's supplier link still
* works without branching) and tagged `source: "stock"`.
*/
export type UploadInvoiceResult = {
filename: string;
itemIds: string[];
};
/**
* Store a buyer-supplied invoice on every customer line in a collected PO.
* The existing multi-file field is deliberately reused so invoices stay
* attached to the order item and the document lookup remains stable.
*/
export async function uploadInvoiceToOrderItems(
ids: string[],
file: File,
): Promise<UploadInvoiceResult> {
if (ids.length === 0) {
throw new CollectionConfirmValidationError(
"itemIds must be a non-empty array",
"invalid_item_ids",
400,
);
}
if (!file || file.size === 0) {
throw new CollectionConfirmValidationError("file is required", "missing_file", 400);
}
if (file.size > 25 * 1024 * 1024) {
throw new CollectionConfirmValidationError("File too large (max 25 MB)", "file_too_large", 413);
}
const pb = await getAdminPocketBase();
const collectionName = getOrderItemsCollectionName();
const rows = await pb.collection(collectionName).getFullList<ConfirmRow>({
filter: ids.map((id) => `id = "${id.replace(/"/g, '\\\"')}"`).join(" || "),
fields: "id,item_status",
});
const validIds = rows.filter((row) => row.item_status === "collected").map((row) => row.id);
if (validIds.length === 0) {
throw new CollectionConfirmValidationError(
"No collected customer items matched",
"items_not_collected",
409,
);
}
// Prefix the operator upload so it is distinguishable from a driver's
// delivery note, even when both documents came in with generic filenames.
const taggedFile = new File([await file.arrayBuffer()], `[invoice] ${file.name || "invoice"}`, {
type: file.type || "application/octet-stream",
});
for (const id of validIds) {
const form = new FormData();
form.append("order_item_attachements", taggedFile);
await pb.collection(collectionName).update(id, form);
}
return { filename: taggedFile.name, itemIds: validIds };
}
export async function listCollectedItems(): Promise<ConfirmRow[]> {
const pb = await getAdminPocketBase();
const [customerRows, stockRows] = await Promise.all([
pb.collection(getOrderItemsCollectionName()).getFullList<ConfirmRow>({
filter: 'item_status = "collected"',
sort: "-drv_collected_at",
expand: "purchase_order.supplier,order.company,order.customer",
}),
pb
.collection("stock_purchase_items")
.getFullList<
ConfirmRow & {
id: string;
stock_item: string;
quantity: number;
quantity_received: number;
cost_price: number;
status?: string | null;
assigned_at?: string | null;
expand?: {
purchase?: {
id: string;
purchase_number: string;
expand?: { supplier?: { id: string; suppliername?: string } };
};
stock_item?: {
id: string;
sku?: string;
name?: string;
description?: string;
};
};
}
>({
// Filter: status is "collected" AND the warehouse hasn't
// fully received the line yet. The warehouse-side
// `confirmStockRows` call snaps `quantity_received` to
// `quantity`; the qty pencil (collection-confirm page) does
// the same when the operator manually snaps the qty to the
// full amount. Either path lands `quantity_received >=
// quantity` and the row drops off the list. Conversely, a
// line where `quantity_received` is below `quantity` is
// still pending the warehouse's action and stays on the
// page. The status field is deliberately left at
// "collected" — only the accountant's booking flow flips
// it to "booked" (and is gated on the warehouse's
// `confirmStockRows` having run first).
filter: 'status = "collected" && quantity_received < quantity',
sort: "-created",
expand: "purchase.supplier,purchase.stock_item,purchase.additional_attachments",
})
// Fail-soft: matches the page's `load()` at page.tsx:294 — a
// stock-side outage must not wipe the customer-deliveries view.
.catch(() => [] as unknown[]),
]);
// Normalise stock rows into ConfirmRow shape. Stock fields that don't
// map (problem_flag, drv_collected_at, attachments) are left undefined
// so the type stays honest.
const stockAsConfirm: ConfirmRow[] = (
stockRows as unknown as Array<{
id: string;
stock_item: string;
description: string;
quantity: number;
quantity_received: number;
cost_price: number;
status?: string | null;
assigned_at?: string | null;
expand?: ConfirmRow["expand"] & {
purchase?: {
id: string;
purchase_number: string;
expand?: { supplier?: { id: string; suppliername?: string } };
};
stock_item?: {
id: string;
sku?: string;
name?: string;
description?: string;
};
};
}>
).map((s) => ({
id: s.id,
source: "stock" as const,
description: s.description,
qty_collected: s.quantity_received ?? 0,
order_qty: s.quantity,
quantity: s.quantity,
item_status: s.status ?? undefined,
sku: s.expand?.stock_item?.sku,
supplier: s.expand?.purchase?.expand?.supplier?.id ?? undefined,
stock_line_status: s.status ?? null,
assigned_at: s.assigned_at ?? null,
stock_purchase_id: s.expand?.purchase?.id ?? null,
poNumber: s.expand?.purchase?.purchase_number ?? undefined,
// Filenames on the parent stock_purchase.additional_attachments
// (the agent / operator writes the supplier invoice here via
// `/api/v1/stock-purchases/[id]/attachments/upload`). The page
// exposes these through the same chip strip as customer
// attachments so the operator doesn't have to navigate to the
// detail page just to see the latest upload.
//
// PB file fields normally return string[]. A few legacy rows
// were written under a text-typed variant of this column; coerce
// string-to-array here so the chip strip doesn't iterate the
// string character-by-character (the v1 dev bug that surfaced
// 2026-08-14). `null`/`undefined` collapses to [].
stock_purchase_attachments: (() => {
const raw = (
s.expand?.purchase as { additional_attachments?: unknown } | undefined
)?.additional_attachments;
if (raw == null) return [];
return Array.isArray(raw) ? raw : [raw];
})(),
expand: {
// Reuse the same shape so the supplier link works (the page
// looks at `expand.purchase_order.expand.supplier.suppliername`).
purchase_order: s.expand?.purchase
? {
id: s.expand.purchase.id,
po_number: s.expand.purchase.purchase_number,
expand: { supplier: s.expand.purchase.expand?.supplier },
}
: undefined,
},
}));
// Mark customer rows with their source so downstream code can
// branch without inspecting field presence.
const customerAsConfirm: ConfirmRow[] = customerRows.map((r) => ({
...r,
source: "customer" as const,
}));
// Sort the union by created-desc-ish — both legs return at most ~500
// rows; using id-desc gives a stable, predictable order across loads.
const merged = [...customerAsConfirm, ...stockAsConfirm].sort((a, b) =>
b.id > a.id ? 1 : -1,
);
// Augment each row with the agent's most recent `price_diff_report`
// entry, if any. The agent writes to the parent PO's `notes` field
// (not the line's), so we fetch one notes-read per unique parent
// PO and attach the parsed entry to every line that points at it.
// For rows with `source: "stock"` the parent PO id lives at
// `stock_purchase_id`; for customer rows it's the `expand.purchase_order.id`
// (or, if the customer's PO is missing, the row's `order` relation
// — but that maps to `orders` not `purchase_orders`, so we ignore it
// for the report lookup).
const parentPoIds = new Set<string>();
for (const row of merged) {
if (row.source === "stock" && row.stock_purchase_id) {
parentPoIds.add(row.stock_purchase_id);
} else if (row.expand?.purchase_order?.id) {
parentPoIds.add(row.expand.purchase_order.id);
}
}
if (parentPoIds.size > 0) {
try {
const notesByPoId = new Map<string, ReturnType<typeof latestPriceDiffReport>>();
// Stock PO notes live on stock_purchases; customer PO notes
// live on purchase_orders. The two collections don't share a
// schema field, so we fetch both in parallel — but we don't know
// which collection each id belongs to without a probe. The
// simpler path: try both per id, taking whichever returns a row.
// (Stock PO ids start with `PO...STK`; customer PO ids start
// with `PO-...` — that's a quick discriminator.)
const stockIds = [...parentPoIds].filter((id) => /^PO\d/.test(id));
const customerIds = [...parentPoIds].filter((id) => /^PO-/.test(id));
const [stockNotes, customerNotes] = await Promise.all([
stockIds.length > 0
? pb.collection("stock_purchases").getFullList({
filter: stockIds.map((id) => `id = "${id}"`).join(" || "),
fields: "id,notes",
})
: Promise.resolve([] as Array<{ id: string; notes?: string }>),
customerIds.length > 0
? pb.collection("purchase_orders").getFullList({
filter: customerIds.map((id) => `id = "${id}"`).join(" || "),
fields: "id,notes",
})
: Promise.resolve([] as Array<{ id: string; notes?: string }>),
]);
for (const row of stockNotes as Array<{ id: string; notes?: string }>) {
notesByPoId.set(row.id, latestPriceDiffReport(row.notes ?? ""));
}
for (const row of customerNotes as Array<{ id: string; notes?: string }>) {
notesByPoId.set(row.id, latestPriceDiffReport(row.notes ?? ""));
}
for (const row of merged) {
const parentId =
row.source === "stock"
? row.stock_purchase_id
: row.expand?.purchase_order?.id;
if (parentId && notesByPoId.has(parentId)) {
row.agent_report = notesByPoId.get(parentId) ?? null;
} else {
row.agent_report = null;
}
}
} catch {
// Fail-soft: a notes-read outage must not wipe the collected-rows
// view. Just leave agent_report undefined.
for (const row of merged) {
row.agent_report = null;
}
}
}
return merged;
}
/**
* Update the cost price on a single collected order_item and stamp a
* `cost`-category note onto the row so the audit trail is preserved.
*
* No-op when the new cost equals the existing cost — `prevCost === newCost`
* returns the existing row unchanged (no write, no note append). For a
* genuine change, the patch writes both the new `cost_price` and the
* newly-appended notes feed (preserving any prior entries) in a single
* round-trip.
*
* `prevCost` is read from the existing row's `cost_price` and threaded
* through to `appendCostNote` verbatim (including `null` when the row
* has no prior cost). The `null === 0` check in `appendCostNote` is
* `false`, so `null → 0` is treated as a real change and a cost note
* IS appended with `{ prevCost: null, newCost: 0 }`.
*/
export async function updateOrderItemCost(
id: string,
args: { cost_price: number; actor: ConfirmActor },
): Promise<ConfirmRow> {
// Defence-in-depth validation. The route is the primary gate
// (returns 400 on bad input) but a non-numeric / NaN / negative
// cost_price reaching PB would silently coerce, the prev/new cost
// audit note would get `NaN`, and we'd hand the operator a broken
// audit trail. Refuse upfront instead.
if (
typeof args.cost_price !== "number" ||
!Number.isFinite(args.cost_price)
) {
throw new CollectionConfirmValidationError(
"cost_price must be a finite number",
"invalid_cost_price",
400,
);
}
if (args.cost_price < 0) {
throw new CollectionConfirmValidationError(
"cost_price must be a non-negative number",
"invalid_cost_price",
400,
);
}
const pb = await getAdminPocketBase();
const collectionName = getOrderItemsCollectionName();
const existing = await pb.collection(collectionName).getOne(id);
const prevCost =
(existing as { cost_price?: number | null }).cost_price ?? null;
if (prevCost === args.cost_price) {
return existing as ConfirmRow;
}
const newNotes = appendCostNote(
(existing as { notes?: string | null }).notes ?? null,
args.actor,
prevCost,
args.cost_price,
);
const updated = await pb
.collection(collectionName)
.update(id, { cost_price: args.cost_price, notes: newNotes });
return updated as ConfirmRow;
}
/**
* Update the SKU on a single collected order_item and stamp a
* `cost`-category note onto the row so the audit trail records the
* before/after values.
*
* Mirrors `updateOrderItemCost`: a no-op when the new SKU is
* byte-for-byte equal to the existing one (so the route returns
* 200 without writing a new audit entry). For a genuine change,
* the patch writes both the new `sku` and the appended notes feed
* in a single round-trip.
*
* SKU is required (the column is a PB text field but the rest of
* the app reads it as the canonical join key with `stock_items`).
* Empty / whitespace-only input is rejected with 400. We also
* cap length at 64 characters to match the existing schema and
* keep the data type sane.
*/
export async function updateOrderItemSku(
id: string,
args: { sku: string; actor: ConfirmActor },
): Promise<ConfirmRow> {
const trimmed = args.sku.trim();
if (trimmed.length === 0) {
throw new CollectionConfirmValidationError(
"sku must not be empty",
"invalid_sku",
400,
);
}
if (trimmed.length > 64) {
throw new CollectionConfirmValidationError(
"sku must be 64 characters or fewer",
"invalid_sku",
400,
);
}
const pb = await getAdminPocketBase();
const collectionName = getOrderItemsCollectionName();
const existing = await pb.collection(collectionName).getOne(id);
const prevSku = ((existing as { sku?: string | null }).sku ?? "").trim();
if (prevSku === trimmed) {
return existing as ConfirmRow;
}
const existingNotes = (existing as { notes?: string | null }).notes ?? "";
const skuNote = {
id: `sku-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
ts: new Date().toISOString(),
actorId: args.actor.id,
actorName: args.actor.name,
category: "cost" as const,
body: { skuChange: { prev: prevSku || null, next: trimmed } },
};
const updated = await pb.collection(collectionName).update(id, {
sku: trimmed,
notes: JSON.stringify([...parseStoredNotesForSku(existingNotes), skuNote]),
});
return updated as ConfirmRow;
}
function parseStoredNotesForSku(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 [];
}
}
/**
* Update the `qty_collected` value on a single collected order_item
* and stamp a `quantity`-category note onto the row so the audit trail
* records the before/after values.
*
* Mirrors `updateOrderItemCost`: a no-op when the new qty is
* byte-for-byte equal to the existing one (so the route returns 200
* without writing a new audit entry). For a genuine change the
* patch writes both the new `qty_collected` and the appended notes
* feed in a single round-trip.
*
* Validates non-negative integer input (PB stores `qty_collected`
* as a number; we treat fractions as 400 because the UI's pencil
* is integer-only and the downstream invoices expect whole units).
* Upper bound: we deliberately do NOT cap at the ordered quantity
* — a row can be legitimately over-collected (driver delivered
* +extras, supplier shipped more than PO'd). The "all received"
* badge in the items table is purely informational; the qty pencil
* never silently clamps.
*/
export async function updateOrderItemQty(
id: string,
args: { qty_collected: number; actor: ConfirmActor },
): Promise<ConfirmRow> {
if (
typeof args.qty_collected !== "number" ||
!Number.isFinite(args.qty_collected)
) {
throw new CollectionConfirmValidationError(
"qty_collected must be a finite number",
"invalid_qty_collected",
400,
);
}
if (args.qty_collected < 0) {
throw new CollectionConfirmValidationError(
"qty_collected must be a non-negative number",
"invalid_qty_collected",
400,
);
}
if (!Number.isInteger(args.qty_collected)) {
throw new CollectionConfirmValidationError(
"qty_collected must be a whole number",
"invalid_qty_collected",
400,
);
}
const pb = await getAdminPocketBase();
const collectionName = getOrderItemsCollectionName();
const existing = await pb.collection(collectionName).getOne(id);
const prevQty = Number(
(existing as { qty_collected?: number | null }).qty_collected ?? 0,
);
if (prevQty === args.qty_collected) {
return existing as ConfirmRow;
}
const existingNotes =
(existing as { notes?: string | null }).notes ?? "";
const qtyNote = {
id: `qty-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
ts: new Date().toISOString(),
actorId: args.actor.id,
actorName: args.actor.name,
category: "quantity" as const,
body: { qtyChange: { prev: prevQty, next: args.qty_collected } },
};
const updated = await pb.collection(collectionName).update(id, {
qty_collected: args.qty_collected,
notes: JSON.stringify([...parseStoredNotesForSku(existingNotes), qtyNote]),
});
return updated as ConfirmRow;
}
/**
* Pick the most recent `price_diff_report` entry from a notes feed.
* The `po-agent` writes one of these per scanned PO (see
* `docs/agents/purchase-order-agent.md`); the UI uses the verdict +
* summary on the latest one to badge the PoCard "Agent reviewed".
*
* Returns `null` when the feed is empty / no price_diff_report
* entries. The function is intentionally lenient — corrupt feeds
* return null rather than throwing, because the UI only uses the
* result for a badge.
*/
function latestPriceDiffReport(raw: string): {
exists: boolean;
verdict?: string;
summary?: string;
recorded_at?: string;
} | null {
const entries = parseStoredNotesForSku(raw);
const reports = entries.filter(
(e) => (e as Record<string, unknown>).category === "price_diff_report",
) as Array<Record<string, unknown>>;
if (reports.length === 0) return null;
// The feed isn't formally sorted, so sort by `ts` descending and
// take the first. Fall back to array order if `ts` is missing on
// every entry.
const sorted = reports.slice().sort((a, b) => {
const ta = String(a.ts ?? "");
const tb = String(b.ts ?? "");
return tb.localeCompare(ta);
});
const latest = sorted[0]!;
const body = (latest.body ?? {}) as Record<string, unknown>;
return {
exists: true,
verdict: typeof body.verdict === "string" ? body.verdict : undefined,
summary: typeof body.summary === "string" ? body.summary : undefined,
recorded_at: typeof latest.ts === "string" ? latest.ts : undefined,
};
}
/**
* Confirm collection of the given stock-source rows.
*
* History: this function used to *book* the rows into `stock_items`
* (ledger row → cache increment → line status → header auto-derive).
* That's been moved to the accountant's invoicing flow
* (`bookStockFromInvoice` in `stock-booking-service.ts`) so the
* payment-cycle reasoning lines up with the moment the supplier
* invoice is processed. The warehouse-side flow now only records
* what physically arrived — `quantity_received` on each line.
*
* For each id:
* 1. Reads the `stock_purchase_items` row to get `stock_item`,
* `quantity`, `quantity_received`.
* 2. Validates: has `quantity > 0`, has a parent
* `stock_purchase_id`, and has not already been fully received
* (a partial receipt is allowed — operators may have to leave
* a damaged-out line unconfirmed and confirm the rest).
* 3. Snaps `quantity_received` to `quantity` on the line.
*
* The line status is deliberately left untouched — the driver app
* sets it to `collected`; the accountant's `bookStockFromInvoice`
* flips it to `booked` once payment is committed. The header status
* (`sent` → `received`) is also stamped by the accountant's flow,
* because that's the moment the row is closed for stock purposes.
*
* Each row is processed independently — a failure on one row leaves
* the others untouched and surfaces the error to the caller.
*
* Returns the list of row ids that were successfully confirmed. An
* empty `ids` array is a no-op (`[]`).
*/
export async function confirmStockRows(
ids: string[],
_args: { actor: ConfirmActor },
): Promise<string[]> {
if (ids.length === 0) return [];
const pb = await getAdminPocketBase();
const updatedIds: string[] = [];
for (const id of ids) {
const ok = await confirmStockRowForId(pb, id);
if (ok) updatedIds.push(id);
}
return updatedIds;
}
/**
* Per-row confirm-collection implementation. Throws whenever
* validation fails so the route returns a 500 with the message; the
* caller (the service) is responsible for collecting a boolean back
* into the `updatedIds` list.
*
* `pb` is the admin PocketBase client; the function reads / updates
* the relevant collections directly.
*
* ## Idempotency
*
* If the line is already fully received (`quantity_received >=
* quantity`), `confirmStockRowForId` returns `false` so the caller
* drops it from the `updatedIds` list. The route surfaces this as a
* 200 with the row omitted from `updatedIds` — the warehouse-side
* "Confirm collection" button is the operator's explicit close-of-
* day action; a previous confirm (or the qty pencil that snapped
* `quantity_received` up to the full qty) means there's nothing
* left to advance. The previous behaviour threw a plain Error →
* 500, which was a foot-gun once the qty pencil arrived: setting
* the qty to the full value would leave the confirm-stock button
* inoperable for that card.
*/
async function confirmStockRowForId(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
id: string,
): Promise<boolean> {
// (a) Read the line so we have quantity, quantity_received, and
// the parent purchase id (the parent id is required for the row
// to be a valid stock_purchase_item — guards against orphan rows).
const line = (await pb
.collection("stock_purchase_items")
.getOne(id)) as unknown as {
id: string;
stock_item: string;
quantity: number;
quantity_received: number;
purchase?: string;
};
const qty = Number(line.quantity ?? 0);
if (!(qty > 0)) {
throw new Error(`Stock line ${id} has zero quantity — nothing to confirm.`);
}
const alreadyReceived = Number(line.quantity_received ?? 0);
if (alreadyReceived >= qty) {
// Idempotent no-op: the line is already at full received. The
// qty pencil is one way to land here (operator snapped the qty
// up to the full qty before clicking confirm); a previous
// confirm-stock is another. Either way there's nothing to do.
return false;
}
if (!line.purchase) {
throw new Error(
`Stock line ${id} is missing the parent purchase id — cannot confirm.`,
);
}
// (b) Snap quantity_received to the full qty. The line status is
// left as-is — the driver app will have set it to `collected`,
// and the accountant's `bookStockFromInvoice` is what flips it
// to `booked` once the supplier invoice is processed.
await pb.collection("stock_purchase_items").update(id, {
quantity_received: qty,
});
return true;
}
/**
* Confirm collected order_items for invoicing in one transactional batch.
*
* The rows are read first so each cost override can be compared with the
* current value and its audit note can be built from the correct previous
* cost. The SDK batch queues all updates and sends them together; if the
* batch request fails, PocketBase does not apply any individual update.
*/
export async function confirmOrderItemsForInvoicing(
ids: string[],
args: {
invoice: { date: string; number: string };
costOverrides: Record<string, number>;
actor: ConfirmActor;
},
): Promise<string[]> {
if (ids.length === 0) return [];
const pb = await getAdminPocketBase();
const collectionName = getOrderItemsCollectionName();
const rows = await pb.collection(collectionName).getList(1, ids.length, {
filter: ids.map((id) => `id="${id}"`).join(" || "),
});
const batch = pb.createBatch();
const batchCollection = batch.collection(collectionName);
for (const row of rows.items) {
const currentCost =
(row as { cost_price?: number | null }).cost_price ?? null;
const override = args.costOverrides[row.id];
const costOverride =
typeof override === "number" && override !== currentCost
? override
: undefined;
// Build the standard confirmation patch first. Cost changes are appended
// below so the resulting notes feed contains both confirm and cost audit
// entries in that order.
const confirmPatch = buildConfirmPatch(
row as Parameters<typeof buildConfirmPatch>[0],
{ actor: args.actor, invoice: args.invoice },
);
let notes = confirmPatch.notes;
if (typeof costOverride === "number") {
notes = appendCostNote(
confirmPatch.notes,
args.actor,
currentCost,
costOverride,
);
}
const patch = {
item_status: confirmPatch.item_status,
notes,
order_item_details: confirmPatch.order_item_details,
...(typeof costOverride === "number" ? { cost_price: costOverride } : {}),
};
batchCollection.update(row.id, patch);
}
await batch.send();
return rows.items.map((row) => row.id);
}