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
137 lines
4.9 KiB
TypeScript
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);
|
|
});
|
|
}
|