WFDASH-WEBAPP/src/lib/stock-match.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

133 lines
5.1 KiB
TypeScript

/**
* Stock item matching — given an order line and a list of stock items,
* find the one that should receive the surplus (or the stock-out qty).
*
* ## Why this is its own module
*
* The v1 surplus split (and v3 stock-out flow) both need to know
* "which stock_item does this order_item correspond to?" The answer
* is non-obvious because the same physical product can appear in
* stock with slightly different wording, no SKU, or a different
* unit. We don't want the operator to do this lookup by hand every
* time — the system should suggest a match and let the operator
* confirm/override.
*
* ## Algorithm
*
* 1. **Exact SKU match** (fast path). If both sides have a non-empty
* SKU and they normalise equal → match.
* 2. **Supplier + normalised description** (the fallback the
* operator chose). Two inputs match when they have the same
* `supplier` AND their descriptions normalise equal. Normalisation
* is whitespace-folded, lowercased, and stripped of common noise
* characters (`*-_/()[]`).
* 3. Otherwise → no match. The API surfaces this to the operator
* as "no stock item found — create one?" (or they can pick from
* a list of close candidates as a v4 enhancement).
*
* The helper is **pure**: it does no PB calls. Callers fetch the
* stock_items list (filtered by `is_active=true` to keep the result
* list small) and pass it in. This keeps the function trivially
* testable and lets the same logic be reused in the API layer and
* the driver-app offline path (where there may be no live PB).
*/
import type { OrderItem, StockItem } from "./types";
/** All characters we strip during description normalisation. */
const NOISE_CHARS = /[\s\-_/()\[\]*~`,.;:!?'"\\]+/g;
/** Lowercase, fold whitespace, strip noise. */
export function normaliseMatchKey(s: string | null | undefined): string {
if (!s) return "";
return s.toLowerCase().replace(NOISE_CHARS, " ").trim().replace(/\s+/g, " ");
}
/** Normalised SKU — uppercase, strip noise. */
export function normaliseSku(s: string | null | undefined): string {
if (!s) return "";
return s.toUpperCase().replace(NOISE_CHARS, "").trim();
}
export interface MatchInput {
orderItem: Pick<OrderItem, "sku" | "description" | "po_description" | "supplier">;
stockItems: Array<Pick<StockItem, "id" | "sku" | "name" | "description" | "supplier">>;
}
export interface MatchResult {
/** The matched stock_item id, or null if no match. */
stockItemId: string | null;
/**
* The supplier/description key that matched, for surfacing in the
* UI ("matched on description 'ABC-100 filter 10um'"). One of:
* - "sku" — exact SKU match
* - "description" — supplier + normalised description match
* - null — no match
*/
matchedOn: "sku" | "description" | null;
}
/**
* Find the stock_item that should receive the surplus / fulfil the
* stock-out for the given order_item. Returns null if nothing fits.
*
* Order of preference:
* 1. Exact SKU match (when order_item has a SKU and at least one
* stock_item shares it).
* 2. Supplier + description match. The order_item's `description`
* is the customer-facing copy and `po_description` is the
* supplier-facing copy. We try both, preferring the match
* against `description` because that's what the operator sees
* on the order row.
*/
export function findMatchingStockItem(input: MatchInput): MatchResult {
const { orderItem, stockItems } = input;
// ── 1. SKU exact match ──────────────────────────────────────────
const orderSku = normaliseSku(orderItem.sku);
if (orderSku) {
const skuHit = stockItems.find(
(s) => normaliseSku(s.sku) === orderSku,
);
if (skuHit) {
return { stockItemId: skuHit.id, matchedOn: "sku" };
}
}
// ── 2. Supplier + description match ─────────────────────────────
const orderSupplier = orderItem.supplier || "";
const orderDesc = normaliseMatchKey(orderItem.description);
const orderPoDesc = normaliseMatchKey(orderItem.po_description);
if (!orderSupplier || (!orderDesc && !orderPoDesc)) {
return { stockItemId: null, matchedOn: null };
}
const candidates = stockItems.filter(
(s) => s.supplier === orderSupplier,
);
if (candidates.length === 0) {
return { stockItemId: null, matchedOn: null };
}
// Build a normalised lookup once per candidate so the inner loop is cheap.
const keyed = candidates.map((c) => ({
id: c.id,
name: normaliseMatchKey(c.name),
description: normaliseMatchKey(c.description),
}));
// Prefer `description` (customer-facing copy) over `po_description`.
for (const c of keyed) {
if (orderDesc && (c.description === orderDesc || c.name === orderDesc)) {
return { stockItemId: c.id, matchedOn: "description" };
}
}
for (const c of keyed) {
if (orderPoDesc && (c.description === orderPoDesc || c.name === orderPoDesc)) {
return { stockItemId: c.id, matchedOn: "description" };
}
}
return { stockItemId: null, matchedOn: null };
}