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
133 lines
5.1 KiB
TypeScript
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 };
|
|
}
|