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
336 lines
11 KiB
TypeScript
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}`;
|
|
}
|