WFDASH-WEBAPP/src/lib/stock-fulfillment.ts
SJCoetz e9cf8aaf04 feat(stock): implement stock management workflow
This commit adds the complete v2/v3 stock management functionality:
- add new stock-specific item statuses and update the status visual map
- implement full stock purchase API suite (create, list, retrieve, send, receive)
- reorganize sidebar navigation to group stock-related pages together
- add stock item CRUD APIs, status utilities, low stock filtering, and surplus booking logic
- add database migration for the `stock_items.target_qty` field
- update type definitions and add comprehensive tests for all new stock components
2026-07-15 21:53:20 +02:00

336 lines
11 KiB
TypeScript

/**
* Stock-fulfillment logic — pure helpers used by the API route and
* the line-item picker on the order pages.
*
* ## What this module owns
*
* - `validateFulfilmentRequest` — input shape + business rules
* (qty > 0, current_qty ≥ qty, reason is in the allowed set,
* reference_type/id are present when needed). Returns a typed
* error so the route can map it to the right HTTP status.
* - `buildLedgerRow` — shapes the row that goes into `stock_ledger`
* for a customer_fulfillment event. Pure function; the route does
* the actual PB write.
* - `buildOrderItemUpdate` — shapes the PATCH that lands on the
* freshly-created order_item (item_status + stock_booked_from +
* in_stock + supplier + cleared PO).
* - `compensationNotes` — appends the "COMPENSATION NEEDED" tag to
* a ledger row's notes when the subsequent current_qty update
* fails. Audit-only — the ledger entry stays; we just flag it.
*
* ## Why a separate module
*
* The shape of the ledger row + the order_item update are stable
* contracts — both the API route and the operator-readable "what
* just happened" summary on the order detail page need to derive
* the same fields from the same inputs. Keeping the shaping in one
* pure module means the test suite can pin down the contract
* without spinning up PB.
*
* Mirrors `stock-match.ts` (the v1 surplus-side matcher): pure
* functions over typed inputs, no PB dependency, easy to test.
*/
import type {
StockItem,
StockLedger,
OrderItem,
StockReason,
} from "./types";
import { STOCK_REASON_VALUES } from "./types";
/* ============================================================================
* Validation
* ========================================================================== */
export interface FulfilmentRequest {
order_item_id: string;
stock_item_id: string;
qty: number;
/**
* Override of the stock_ledger reason. Defaults to
* "customer_fulfillment" — exposed for future extensions (e.g. a
* separate "stock_credit" reason for returns). The v3 flow
* always uses the default.
*/
reason?: StockReason;
/**
* Optional override of `reference_type` on the ledger row.
* Defaults to "order_item" — exposed so the route can stamp a
* different tag if the route is ever reused for non-order
* references.
*/
reference_type?: string;
/** Optional notes to attach to the ledger row (audit-only). */
notes?: string;
}
export type FulfilmentValidationError =
| { code: "missing_order_item_id"; message: string }
| { code: "missing_stock_item_id"; message: string }
| { code: "invalid_qty"; message: string; qty: number }
| { code: "insufficient_stock"; message: string; available: number; requested: number }
| { code: "inactive_stock_item"; message: string }
| { code: "invalid_reason"; message: string; reason: string };
export type FulfilmentValidationResult =
| { ok: true; normalised: NormalisedFulfilmentRequest }
| { ok: false; error: FulfilmentValidationError };
export interface NormalisedFulfilmentRequest {
order_item_id: string;
stock_item_id: string;
qty: number;
reason: StockReason;
reference_type: string;
notes: string;
}
/**
* Validate the request body shape + business rules.
*
* `stockItem` is the freshly-fetched row (NOT the value the picker
* showed the operator — that may be stale by the time the request
* lands). Passing the live row is what lets us detect the
* "operator tried to fulfil 5 but stock just dropped to 3" race.
*
* `inactiveStockItemError` is the flag we use to distinguish
* "operator hit Save on a deactivated item" from "qty too high" —
* both surface as 422 but the UI wants a different message.
*/
export function validateFulfilmentRequest(
body: Partial<FulfilmentRequest> | null | undefined,
stockItem: Pick<StockItem, "id" | "current_qty" | "is_active"> | null
): FulfilmentValidationResult {
if (!body || typeof body !== "object") {
return {
ok: false,
error: { code: "missing_order_item_id", message: "Request body is required" },
};
}
if (!body.order_item_id || typeof body.order_item_id !== "string") {
return {
ok: false,
error: {
code: "missing_order_item_id",
message: "order_item_id is required",
},
};
}
if (!body.stock_item_id || typeof body.stock_item_id !== "string") {
return {
ok: false,
error: {
code: "missing_stock_item_id",
message: "stock_item_id is required",
},
};
}
const qty = Number(body.qty);
if (!Number.isFinite(qty) || qty <= 0) {
return {
ok: false,
error: {
code: "invalid_qty",
message: `qty must be a positive number (got ${body.qty})`,
qty,
},
};
}
if (!stockItem) {
// Caller (the route) failed to fetch the stock_item. Treat as
// insufficient stock — the operator can retry, and the error
// is clear enough that they can re-pick a different item if
// the original was deleted between picker and save.
return {
ok: false,
error: {
code: "insufficient_stock",
message: "stock_item not found",
available: 0,
requested: qty,
},
};
}
if (stockItem.is_active === false) {
return {
ok: false,
error: {
code: "inactive_stock_item",
message: "stock_item is deactivated and cannot be fulfilled from",
},
};
}
const available = Number(stockItem.current_qty) || 0;
if (qty > available) {
return {
ok: false,
error: {
code: "insufficient_stock",
message: `Only ${available} in stock — cannot fulfil ${qty}`,
available,
requested: qty,
},
};
}
// Reason default + validation. The route always sends
// "customer_fulfillment" today; the override lets the route stamp
// a different reason if/when this endpoint is reused.
const reason: StockReason = body.reason || "customer_fulfillment";
if (!STOCK_REASON_VALUES.includes(reason)) {
return {
ok: false,
error: {
code: "invalid_reason",
message: `reason must be one of ${STOCK_REASON_VALUES.join(", ")} (got "${reason}")`,
reason,
},
};
}
return {
ok: true,
normalised: {
order_item_id: body.order_item_id,
stock_item_id: body.stock_item_id,
qty,
reason,
reference_type: body.reference_type || "order_item",
notes: body.notes || "",
},
};
}
/* ============================================================================
* Row shaping
* ========================================================================== */
export interface BuiltLedgerRow
extends Pick<
StockLedger,
"stock_item" | "change_qty" | "reason" | "reference_type" | "reference_id" | "notes"
> {
/** Bookkeeping marker — the route writes this as a relation
* field on the ledger row so the audit trail can be reconstructed
* by querying `created_by = <userId>`. Stored as `created_by`
* in the PB row; the helper just passes it through. */
created_by: string;
}
/**
* Shape the `stock_ledger` row for a customer-fulfillment event.
*
* `change_qty` is signed NEGATIVE — qty going OUT of stock. The
* `findMatchingStockItem` family of helpers always work with
* positive stock counts, so callers can negate this value when
* rendering summaries.
*
* `created_by` is a relation → `users`; we accept an empty string
* for the "no auth context" case (e.g. server scripts that
* legitimately have no operator identity to stamp).
*/
export function buildLedgerRow(args: {
stockItemId: string;
qty: number;
reason: StockReason;
orderItemId: string;
referenceType: string;
notes: string;
createdBy: string;
}): BuiltLedgerRow {
return {
stock_item: args.stockItemId,
change_qty: -Math.abs(args.qty),
reason: args.reason,
reference_type: args.referenceType,
reference_id: args.orderItemId,
notes: args.notes,
created_by: args.createdBy || "",
};
}
/* ============================================================================
* Order-item PATCH shaping
* ========================================================================== */
export type BuiltOrderItemUpdate = Pick<
OrderItem,
| "item_status"
| "in_stock"
| "stock_booked_from"
| "purchase_order"
| "supplier"
>;
/**
* Shape the PATCH that lands on the freshly-created order_item.
*
* Decisions baked in here (mirrored from the API spec):
*
* - `item_status = "stock_booked"` — the order line is no longer
* pending; the goods are already on hand and the deduction has
* happened.
* - `in_stock = true` — semantically true (the goods are in our
* warehouse, not on a supplier's lorry).
* - `stock_booked_from = <stock_item.id>` — back-link for the
* surplus/stock flow + the /stock drill-down.
* - `purchase_order = ""` — no PO is needed for a stock-out line
* (the goods are already on hand). We deliberately blank the
* field rather than leave it null so the PB field-type is
* satisfied and the order page filters by `purchase_order != ""`
* continue to work as expected.
* - `supplier = <stock_item.supplier>` — the supplier from the
* stock row, so the invoicing math (cost basis + tax lines) has
* a real supplier to bill against. Empty string is the
* "stock_item has no default supplier" fallback; the operator can
* override on the order detail page if needed.
*
* If you'd rather keep the original supplier from the picker (or
* none at all), pass `supplierOverride` — the API route doesn't
* expose this today but the helper does so future work doesn't
* need a signature change.
*/
export function buildOrderItemUpdate(args: {
stockItemId: string;
stockItemSupplier: string;
supplierOverride?: string;
}): BuiltOrderItemUpdate {
const supplier =
args.supplierOverride !== undefined
? args.supplierOverride
: args.stockItemSupplier || "";
return {
item_status: "stock_booked",
in_stock: true,
stock_booked_from: args.stockItemId,
purchase_order: "",
supplier,
};
}
/* ============================================================================
* Compensation
* ========================================================================== */
/**
* Append the "COMPENSATION NEEDED" tag to an existing ledger row's
* notes. The route calls this when the subsequent `current_qty`
* update fails (PB error, network blip, etc.) — the ledger entry
* stays as the audit record, but the next operator to drill in
* sees an obvious red flag and knows to manually reconcile.
*
* Returns the new notes string. Pure: no PB calls, no side effects.
*/
export function compensationNotes(existing: string | null | undefined): string {
const base = (existing || "").trim();
const tag = "COMPENSATION NEEDED — ledger created, current_qty update failed";
if (!base) return tag;
// Avoid duplicating the tag if the same row is patched twice.
if (base.includes(tag)) return base;
return `${base}\n${tag}`;
}