/** * Stock surplus booking — the v1 surplus split on /collection-confirm. * * ## Flow (called from the surplus-book API route) * * 1. **Validate the order_item.** Must be in `item_status === "collected"` * (the post-driver-collection state). Rows that have already been * booked to stock (`stock_booked`) are rejected — re-booking would * double the surplus. v3 stock-out will re-use the existing * ledger row by `reference_id`, but that's not this endpoint's job. * * 2. **Validate the surplus quantity.** Must be positive, numeric, * and at most `qty_collected - customer_qty` (where customer_qty * is `order_qty ?? quantity ?? 0`). The driver/operator might * have adjusted the collected qty upward after the original * customer qty was set; that headroom is what becomes the surplus. * * 3. **Resolve the stock_item.** If the caller supplied a * `stock_item_id`, use it as-is. Otherwise fetch the active * `stock_items` list (`is_active=true`) and run * `findMatchingStockItem()`. If no match, return * `{ reason: "no_match" }` so the API can surface a 422 to the * picker modal in the UI. * * 4. **Book the surplus in three steps, in this order:** * (a) insert a `stock_ledger` row — the audit record; * (b) update `stock_items.current_qty += surplus_qty` — the * denormalized cache; * (c) update the `order_item` with `surplus_qty`, * `stock_booked_from`, and `item_status="stock_booked"`. * * The order matters: if the ledger insert fails, the cache must * not be ahead of the audit. PB has no multi-collection * transactions; we serialize the calls so a partial failure * leaves the system in a recoverable state (no cache drift * without an audit row). * * ## Why this lives in its own module * * The route file is intentionally thin — the happy-path, the * rejection paths, and the no-match path all live here so a unit * test can drive them with a mocked admin PB. The route layer * only handles request/response shape + auth. */ import type { OrderItem, StockItem, StockReason } from "@/lib/types"; import { findMatchingStockItem } from "@/lib/stock-match"; import { customerQty as customerQtyOf } from "@/lib/order-item-qty"; /** * Minimal shape the surplus-booking helper needs off the order_item. * The local `ConfirmRow` type in the page (and the route layer's * plain `OrderItem` cast) both satisfy this — declaring it as a * `Pick` keeps the helper decoupled from the wider OrderItem shape * that grows over time (each new field the order form adds would * otherwise require a churn here too). */ export type SurplusBookOrderItem = Pick< OrderItem, "id" | "item_status" | "quantity" | "order_qty" | "sku" | "description" | "po_description" | "supplier" > & { /** Driver-recorded collected qty. Lives on the PB row but not in the canonical OrderItem interface. */ qty_collected?: number | null; }; /** * Customer-facing line quantity the order was raised for. * * This reads `quantity`, NOT `order_qty`. The two names invite the * opposite conclusion, so to be explicit: `order_qty` is the * *supplier*-facing number (what we put on the PO), and on a supplier-MOQ * line it is deliberately larger than what the customer asked for. * * Measuring the surplus against `order_qty` cancelled out the entire MOQ * overflow — buy 10 for a customer who wants 4, receive all 10, and * `collected - order_qty` is 0, so the 6 spare units could not be booked * into stock at all. See * `docs/superpowers/specs/2026-08-30-quantity-handling-audit.md`. */ function customerQty(orderItem: Pick): number { return customerQtyOf(orderItem); } /** Maximum surplus an order_item can produce: collected minus customer qty. */ export function maxSurplusFor( orderItem: Pick, ): number { const collected = Number(orderItem.qty_collected ?? 0); return Math.max(0, collected - customerQty(orderItem)); } /* ============================================================================ * Public types * ========================================================================== */ export type SurplusBookInput = { /** The order_item being split. Must be in `collected` status. */ orderItem: SurplusBookOrderItem; /** * How much of the collected qty should be diverted to stock. Must be * a positive number, integer or fractional, no larger than * `maxSurplusFor(orderItem)`. */ surplus_qty: number; /** * Optional explicit stock_item. When omitted, the helper auto-picks * via `findMatchingStockItem()`. When auto-pick fails the helper * returns `{ reason: "no_match" }` so the UI can open a picker. */ stock_item_id?: string; /** * Free-text audit note (e.g. "MOQ bump"). Stored on the ledger row. * Also folded into the order_item note feed. */ notes?: string; /** * Server-resolved dev identity (email). Stored on `stock_ledger.created_by` * — the relation currently expects a PB `users` id, but the auth * collection is empty by design, so we write the email as the * `created_by` string for now (relation tolerates the empty case * in dev; the production schema swap-in will require a real user id). */ actor_email: string; }; export type SurplusBookOk = { ok: true; ledger_id: string; stock_item_id: string; new_current_qty: number; /** Which match key the helper landed on when the caller didn't specify a stock_item_id. */ matched_on: "sku" | "description" | null; }; export type SurplusBookNoMatch = { ok: false; reason: "no_match"; message: string; }; export type SurplusBookRejected = { ok: false; reason: | "invalid_qty" | "wrong_status" | "stock_item_inactive" | "stock_item_missing" | "ledger_failed" | "stock_update_failed" | "order_update_failed"; message: string; }; export type SurplusBookResult = | SurplusBookOk | SurplusBookNoMatch | SurplusBookRejected; /* ============================================================================ * Minimal PB client shape this module needs. * * The route layer passes in `pb.collection("…")` directly; the unit * test passes in a tiny mock. Either way we only touch the methods * we actually use, which keeps the contract easy to fake. * ========================================================================== */ export interface SurplusBookPb { collection: (name: string) => { getOne: (id: string) => Promise>; update: (id: string, data: Record) => Promise>; create: (data: Record) => Promise>; getFullList: (opts?: { filter?: string }) => Promise>>; }; } const STOCK_ITEMS = "stock_items"; const STOCK_LEDGER = "stock_ledger"; const ORDER_ITEMS = "order_items"; const PURCHASE_SURPLUS: StockReason = "purchase_surplus"; /** * Book the surplus from an order_item into a stock_item, writing the * audit row, the cache increment, and the order_item transition in * sequence. See the file header for the rationale. * * Errors are returned as `{ ok: false, reason, message }` — never * thrown — so the API layer can map them directly to HTTP status * codes without re-trying to catch. */ export async function bookSurplus( pb: SurplusBookPb, input: SurplusBookInput, ): Promise { // ── 1. Reject unless the order_item is in `collected` ─────────── if (input.orderItem.item_status !== "collected") { return { ok: false, reason: "wrong_status", message: `Surplus booking only applies to collected items (item_status was "${input.orderItem.item_status}").`, }; } // ── 2. Validate surplus_qty ───────────────────────────────────── if ( typeof input.surplus_qty !== "number" || !Number.isFinite(input.surplus_qty) || input.surplus_qty <= 0 ) { return { ok: false, reason: "invalid_qty", message: "surplus_qty must be a positive number.", }; } const cap = maxSurplusFor(input.orderItem); if (input.surplus_qty > cap) { return { ok: false, reason: "invalid_qty", message: `surplus_qty (${input.surplus_qty}) exceeds the available surplus for this item (${cap}).`, }; } // ── 3. Resolve stock_item ─────────────────────────────────────── let stockItemId = input.stock_item_id; let matchedOn: "sku" | "description" | null = null; if (!stockItemId) { // Fetch the active stock_items list once. Filter is_active=true // server-side so we never offer a deactivated row to the operator. const activeStockItems = (await pb .collection(STOCK_ITEMS) .getFullList({ filter: 'is_active=true' })) as unknown as Array< Pick >; const match = findMatchingStockItem({ orderItem: { sku: input.orderItem.sku, description: input.orderItem.description, po_description: input.orderItem.po_description, supplier: input.orderItem.supplier, }, stockItems: activeStockItems, }); if (!match.stockItemId) { return { ok: false, reason: "no_match", message: "Pick a stock item manually", }; } stockItemId = match.stockItemId; matchedOn = match.matchedOn; } // Load the resolved stock_item to read current_qty and verify it's // still active. (If the caller passed an explicit id, it might be // a row that was deactivated between page load and submit.) let stockItem: Record; try { stockItem = await pb.collection(STOCK_ITEMS).getOne(stockItemId); } catch { return { ok: false, reason: "stock_item_missing", message: "The selected stock item no longer exists.", }; } if (stockItem.is_active === false) { return { ok: false, reason: "stock_item_inactive", message: "The selected stock item is deactivated — pick another.", }; } const previousCurrent = Number(stockItem.current_qty ?? 0); const newCurrent = previousCurrent + input.surplus_qty; // ── 4a. Insert the ledger row (audit) ─────────────────────────── let ledger: Record; try { ledger = await pb.collection(STOCK_LEDGER).create({ stock_item: stockItemId, change_qty: input.surplus_qty, reason: PURCHASE_SURPLUS, reference_type: "order_item", reference_id: input.orderItem.id, notes: input.notes ?? "", created_by: input.actor_email, }); } catch (err) { return { ok: false, reason: "ledger_failed", message: `Failed to write stock ledger row: ${errMessage(err)}`, }; } // ── 4b. Update stock_items.current_qty (cache) ────────────────── // NOTE: audit before cache, always. If we got here the ledger // row is committed — if the cache update fails, the system is in // a recoverable state (a drift-repair job can rebuild the cache // from the ledger). The reverse order would be a data-integrity // disaster: cache ahead of audit. try { await pb.collection(STOCK_ITEMS).update(stockItemId, { current_qty: newCurrent, }); } catch (err) { return { ok: false, reason: "stock_update_failed", message: `Ledger row written but stock cache update failed: ${errMessage(err)}. The cache will be rebuilt from the ledger; please retry the booking once the cache is recovered.`, }; } // ── 4c. Update the order_item with surplus_qty + stock_booked_from + item_status try { await pb.collection(ORDER_ITEMS).update(input.orderItem.id, { surplus_qty: input.surplus_qty, stock_booked_from: stockItemId, item_status: "stock_booked", }); } catch (err) { return { ok: false, reason: "order_update_failed", message: `Stock booked but order item transition failed: ${errMessage(err)}. The order_item is out of sync with the ledger; re-trigger this booking from the order_item row once the issue is resolved.`, }; } return { ok: true, ledger_id: String(ledger.id), stock_item_id: stockItemId, new_current_qty: newCurrent, matched_on: matchedOn, }; } function errMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); }