/** * Stock-out (v3) — fulfill a customer order from existing stock. * * Use case: a customer places an order for an item that the operator * already has on the shelf. Instead of placing a new PO to the * supplier + waiting for delivery + running the collection-confirm * pipeline, the operator just books the qty out of stock and (if the * customer is at the counter) immediately marks the line as * delivered. * * Distinct from the v1 surplus-booking flow (`stock-surplus.ts`): * - Surplus-booking = the customer ordered N, the supplier shipped * N+headroom, the headroom becomes stock. Reference: the * `order_item` that was collected. * - Stock-out = the customer ordered N, we already had N in stock * (no PO needed). Reference: the `order_item` we're fulfilling. * Decrement `current_qty` instead of incrementing it. * * ## Flow * * 1. **Validate the order_item.** Must be in an "active" state * (anything before `delivered` / `cancelled` / `invoiced`). * Re-fulfilling an already-fulfilled line would double-decrement * stock. * 2. **Validate the stock_item.** Must exist, must be active, * must have enough `current_qty` to cover the requested qty. * Partial stock blocks the fulfillment (we don't go negative). * 3. **Three writes, in this order:** * (a) insert a `stock_ledger` row — audit BEFORE cache; * (b) decrement `stock_items.current_qty` — the cache; * (c) update the `order_item` to either `collected` (normal * delivery path — same as the in_stock flow lands) or * `delivered` (counter pick-up, customer has it now). * 4. **Optional cascade.** If the parent order has every line * now in a terminal/active-stock state, the parent order's * `Complete` badge can flip. v1 doesn't do this — the * operator drives completion via the existing "Mark Complete" * button. Stock-out is just a per-line write. * * ## Why this lives in its own module * * Mirrors the `stock-surplus.ts` pattern — the route layer is thin, * the happy / rejected / no-match paths all live here so a unit * test can drive them with a mocked admin PB. The route only * handles request/response shape + auth. */ import type { OrderItem, StockItem, StockReason } from "@/lib/types"; /** * Minimal shape the stock-out helper needs off the order_item. * `Pick`-style to keep this module decoupled from the wider * OrderItem type (which grows as the form adds fields). */ export type FulfillOrderItem = Pick< OrderItem, "id" | "item_status" | "quantity" | "order_qty" | "description" >; /** Active order_item statuses the stock-out flow can act on. */ const FULFILLABLE_STATUSES = new Set([ "pending", "processed", "po_generated", "collection_pool", "assigned", "collected", "stock_verified", "picking", "ready_delivery", ]); /** * PB-shape the helper needs. Same convention as `stock-surplus.ts` * — the route passes in `pb.collection(...)` directly; unit tests * pass in a tiny mock. */ export interface FulfillFromStockPb { collection: (name: string) => { getOne: (id: string) => Promise>; update: (id: string, data: Record) => Promise>; create: (data: Record) => Promise>; getList: (page?: number, perPage?: number, opts?: { filter?: string }) => Promise<{ items: Array>; totalItems: number; }>; }; } export type FulfillFromStockInput = { /** The order_item being fulfilled from stock. */ orderItem: FulfillOrderItem; /** PB id of the stock_item to decrement. */ stock_item_id: string; /** * How much to book out. Must be > 0 and ≤ stock_item.current_qty. * Operator types it in the modal; the helper rejects out-of-range. */ qty: number; /** * Where the order_item lands after fulfillment: * - "collected" — mirror the `in_stock: true` flow; the order * still needs to go through the normal * delivery / invoice pipeline. * - "delivered" — customer is at the counter, the operator * hands it over. Terminal-for-stock; the * invoice / delivery-note flows can pick it * up from here. */ intent: "collected" | "delivered"; /** Free-text audit note. Stored on the ledger row. */ notes?: string; /** * Server-resolved dev identity. Stored on `stock_ledger.created_by` * — same convention as the surplus-book flow (email string for * now; the production schema swap-in will require a real user id). */ actor_email: string; }; export type FulfillFromStockOk = { ok: true; ledger_id: string; new_current_qty: number; /** Where the order_item landed after fulfillment. */ resulting_status: "collected" | "delivered"; }; export type FulfillFromStockRejected = { ok: false; reason: | "invalid_qty" | "wrong_status" | "stock_item_inactive" | "stock_item_missing" | "insufficient_stock" | "ledger_failed" | "stock_update_failed" | "order_update_failed"; message: string; }; export type FulfillFromStockResult = | FulfillFromStockOk | FulfillFromStockRejected; const STOCK_ITEMS = "stock_items"; const STOCK_LEDGER = "stock_ledger"; const ORDER_ITEMS = "order_items"; const CUSTOMER_FULFILLMENT: StockReason = "customer_fulfillment"; /** * Fulfill an order_item from existing stock. The order of writes * (ledger → cache → order_item) is intentional: if the ledger * insert fails, the cache stays in sync; if the cache update fails, * the audit row exists to reconcile. PB has no multi-collection * transactions; we serialize so a partial failure leaves a * recoverable trail. */ export async function fulfillOrderItemFromStock( pb: FulfillFromStockPb, input: FulfillFromStockInput ): Promise { // ── 1. Validate order_item status ───────────────────────────────── if (!FULFILLABLE_STATUSES.has(input.orderItem.item_status)) { return { ok: false, reason: "wrong_status", message: `Cannot fulfill from stock while order item is in status "${input.orderItem.item_status}". Allowed: ${[ ...FULFILLABLE_STATUSES, ].join(", ")}.`, }; } // ── 2. Validate qty ─────────────────────────────────────────────── if ( typeof input.qty !== "number" || !Number.isFinite(input.qty) || input.qty <= 0 ) { return { ok: false, reason: "invalid_qty", message: "Quantity must be a positive number.", }; } // ── 3. Load + validate stock_item ──────────────────────────────── let stockItem: Record; try { stockItem = await pb.collection(STOCK_ITEMS).getOne(input.stock_item_id); } catch { return { ok: false, reason: "stock_item_missing", message: "Stock item does not exist.", }; } if (stockItem.is_active === false) { return { ok: false, reason: "stock_item_inactive", message: "This stock item is deactivated. Reactivate it on /stock before fulfilling.", }; } const onHand = Number(stockItem.current_qty ?? 0); if (input.qty > onHand) { return { ok: false, reason: "insufficient_stock", message: `Only ${onHand} on hand; requested ${input.qty}. Partial fulfillment is not supported — PO the rest.`, }; } // ── 4. Audit BEFORE cache: ledger row first ─────────────────────── let ledger: Record; try { ledger = await pb.collection(STOCK_LEDGER).create({ stock_item: input.stock_item_id, // Signed: negative = stock-out. change_qty: -input.qty, reason: CUSTOMER_FULFILLMENT, reference_type: "order_item", reference_id: input.orderItem.id, notes: (input.notes ?? "").slice(0, 1000), created_by: input.actor_email || "", }); } catch (err) { return { ok: false, reason: "ledger_failed", message: err instanceof Error ? err.message : String(err), }; } // ── 5. Cache decrement ─────────────────────────────────────────── const newCurrent = onHand - input.qty; try { await pb.collection(STOCK_ITEMS).update(input.stock_item_id, { current_qty: newCurrent, }); } catch (err) { return { ok: false, reason: "stock_update_failed", message: err instanceof Error ? err.message : String(err), }; } // ── 6. Order-item transition ───────────────────────────────────── // Two landing states per the `intent` parameter: // - "collected" — mirrors the in_stock: true path; the order // still needs to go through delivery / invoice. // - "delivered" — counter pick-up; the operator is handing it // over to the customer right now. const updatePatch: Record = { item_status: input.intent, }; try { await pb.collection(ORDER_ITEMS).update(input.orderItem.id, updatePatch); } catch (err) { return { ok: false, reason: "order_update_failed", message: err instanceof Error ? err.message : String(err), }; } return { ok: true, ledger_id: String(ledger.id), new_current_qty: newCurrent, resulting_status: input.intent, }; } /** * Look up stock_items that could fulfill a given order line. v1 * does the most-straightforward thing: fetch the active list and * let the operator pick. Future versions could pre-score by SKU * match + description similarity (using the existing * `findMatchingStockItem` helper) and pre-select the best match. */ export async function listActiveStockItems( pb: FulfillFromStockPb ): Promise { const list = await pb.collection(STOCK_ITEMS).getList(1, 200, { filter: "is_active=true", }); return list.items as unknown as StockItem[]; }