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

137 lines
4.9 KiB
TypeScript

/**
* Shared "low stock" filter helper.
*
* Three places in the app need the same definition of "low stock":
* 1. `/stock` list with `low_stock=true` toggle (server-side filter)
* 2. `/stock-orders/new` Auto-fill button (per-supplier)
* 3. `/stock-orders/reorder` suppliers list (cross-supplier)
*
* All three must agree on what "low stock" means or the operator
* gets inconsistent results. This module is the single source of
* truth for the rule, plus the per-stock-item filter that runs
* after the server-side query (since PB can't compose `current_qty
* <= min_qty` with `target_qty > 0 && target_qty > current_qty`
* in a single filter expression).
*
* The rule, in plain English:
* "An item is 'low stock' if current_qty <= min_qty AND the
* operator has set a target_qty > 0 AND target_qty > current_qty."
*
* Items with target_qty = 0 / null are opted out of bulk
* re-ordering (the operator hasn't decided to auto-reorder them
* yet). Items at-or-above their target_qty are also excluded —
* there's nothing to order.
*/
import type { StockItem } from "./types";
/** Stock item shape the helper needs (subset of `StockItem`). */
export type LowStockCandidate = Pick<
StockItem,
"id" | "current_qty" | "min_qty" | "supplier" | "is_active"
> & {
/** `target_qty` is optional on the type; we read it explicitly. */
target_qty?: number | null;
};
/**
* Server-side filter to feed PocketBase's `getFullList`. We can't
* express the full "low stock" rule in a single PB filter
* expression (PB filter operators don't support comparisons
* across fields like `current_qty <= min_qty`), so this just
* narrows the result set to the candidates. The full rule is
* applied client-side via `isLowStockItem()` below.
*
* Use this to keep the page-load bounded when the catalog is
* large (skipping `is_active=false` and supplier-less rows
* server-side is a meaningful cut).
*/
export function buildLowStockServerFilter(opts: { supplierId?: string } = {}): string {
const clauses: string[] = ["is_active=true"];
if (opts.supplierId) {
// PB filter string values must be quoted to survive special chars.
const safe = opts.supplierId.replace(/"/g, '\\"');
clauses.push(`supplier="${safe}"`);
}
return clauses.join(" && ");
}
/**
* The client-side half of the rule. Apply after the server filter
* to get the items that genuinely need re-ordering.
*
* Excludes:
* - Items at-or-above their `min_qty` (not low right now)
* - Items without a `target_qty` set (opted out of bulk)
* - Items at-or-above their `target_qty` (nothing to order)
* - Items with no supplier (can't generate a PO)
*/
export function isLowStockItem(item: LowStockCandidate): boolean {
const current = Number(item.current_qty ?? 0);
const min = Number(item.min_qty ?? 0);
const target = Number(item.target_qty ?? 0);
if (current > min) return false;
if (target <= 0) return false;
if (target <= current) return false;
if (!item.supplier) return false;
return true;
}
/**
* Group a list of low-stock items by supplier. The supplier
* relation needs to be `expand`-ed in the fetch for the supplier
* name to be available. Items where the supplier expand is missing
* are silently dropped (defensive — shouldn't happen if the server
* filter is correct).
*
* Returned groups are sorted by total `target_qty - current_qty`
* descending, so the supplier with the most-reorder-volume shows
* up first on the reorder page.
*/
export type LowStockGroup = {
supplier_id: string;
supplier_name: string;
item_count: number;
total_qty: number;
items: Array<
LowStockCandidate & {
/** Pre-computed `target_qty - current_qty` (clamped at 0). */
order_qty: number;
}
>;
};
export function groupLowStockBySupplier<
T extends LowStockCandidate & {
expand?: { supplier?: { id: string; suppliername?: string } };
},
>(items: T[]): LowStockGroup[] {
const bySupplier = new Map<string, LowStockGroup>();
for (const item of items) {
if (!isLowStockItem(item)) continue;
const supplier = item.expand?.supplier;
if (!supplier?.id) continue;
let group = bySupplier.get(supplier.id);
if (!group) {
group = {
supplier_id: supplier.id,
supplier_name: supplier.suppliername ?? "Unknown supplier",
item_count: 0,
total_qty: 0,
items: [],
};
bySupplier.set(supplier.id, group);
}
const current = Number(item.current_qty ?? 0);
const target = Number(item.target_qty ?? 0);
const orderQty = Math.max(0, target - current);
group.item_count += 1;
group.total_qty += orderQty;
group.items.push({ ...item, order_qty: orderQty });
}
// Sort by total reorder volume desc, then by supplier name asc.
return Array.from(bySupplier.values()).sort((a, b) => {
if (a.total_qty !== b.total_qty) return b.total_qty - a.total_qty;
return a.supplier_name.localeCompare(b.supplier_name);
});
}