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 { 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({ 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 { const pb = await getAdminPocketBase(); const [customerRows, stockRows] = await Promise.all([ pb.collection(getOrderItemsCollectionName()).getFullList({ 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(); 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>(); // 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 { // 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 { 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> { 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 { 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).category === "price_diff_report", ) as Array>; 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; 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 { 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>, id: string, ): Promise { // (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; actor: ConfirmActor; }, ): Promise { 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[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); }