`order_items.order_qty` is the SUPPLIER-facing quantity — what prints
on the PO. Five call sites read it as the customer quantity, which is a
money bug in both directions:
- supplier MOQ (customer 4, we buy 10) → invoiced 10, over-billing 6
- shorted line (customer 100, supplier covers 40) → invoiced 40,
under-billing 60 until the operator splits the remainder
The invoicing screen never fetched `quantity` at all (zero occurrences
in page.tsx), so it could not bill the customer quantity even in
principle. Added to the PB field projection, which is load-bearing:
drop it and PB returns undefined and every line silently falls back to
order_qty. A page-level test now fails if the projection drifts.
`maxSurplusFor` measured the surplus as `collected - order_qty`, which
on an MOQ line is always 0 — the overflow was mathematically unbookable
and vanished from inventory by construction, not by operator error.
Four call sites built delivery-note lines by hand; two passed
`quantity` and two forgot it, so the same DN printed the customer's 4
via the service layer and the supplier's 10 via the API routes. All
four now share `buildDeliveryNoteLine`, so they cannot diverge again.
`order_qty` had no doc comment at all next to an 11-line one on
`quantity` — that absence is why five subsystems guessed differently.
Now documented, with a new `src/lib/order-item-qty.ts` holding
`customerQty` / `supplierQty` as the single definition.
driver-stops.ts and collection-confirm/po-card.tsx read `order_qty`
CORRECTLY (they check what the supplier delivered against what we
ordered from them) and carry comments saying so, so the next cleanup
doesn't "fix" them into breaking.
23 new tests. Full suite: no regressions — the 15 failures are
identical before and after (verified by stashing and re-running).
Audit: docs/superpowers/specs/2026-08-30-quantity-handling-audit.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
/**
|
|
* 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<SurplusBookOrderItem, "quantity" | "order_qty">): number {
|
|
return customerQtyOf(orderItem);
|
|
}
|
|
|
|
/** Maximum surplus an order_item can produce: collected minus customer qty. */
|
|
export function maxSurplusFor(
|
|
orderItem: Pick<SurplusBookOrderItem, "quantity" | "order_qty" | "qty_collected">,
|
|
): 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<Record<string, unknown>>;
|
|
update: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
create: (data: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
getFullList: (opts?: { filter?: string }) => Promise<Array<Record<string, unknown>>>;
|
|
};
|
|
}
|
|
|
|
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<SurplusBookResult> {
|
|
// ── 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<StockItem, "id" | "sku" | "name" | "description" | "supplier">
|
|
>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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);
|
|
}
|