WFDASH-WEBAPP/src/lib/server/stock-out.ts
SJCoetz d306b75bdb feat(stock-purchases,driver-app): add stock purchase workflow and driver pickup support
Add complete stock purchase order workflow including:
- Status tracking with sending/mail_failed states and UI labels
- Server-side PDF rendering and document storage
- File proxy API support for stock PO attachments
- Send outcome webhook callback for email notifications

Add driver app support:
- Unified stop list API for both customer deliveries and stock pickups
- Status update endpoint for collected quantities and problem reporting
- Collection map page support for stock pickup locations

Include matching PocketBase migrations, type definitions, and stock inventory fulfillment logic.
2026-07-18 22:43:50 +02:00

292 lines
10 KiB
TypeScript

/**
* Stock-out (v3) — fulfill a customer order from existing stock.
*
* Use case: a customer places an order for an item that the operator
* already has on the shelf. Instead of placing a new PO to the
* supplier + waiting for delivery + running the collection-confirm
* pipeline, the operator just books the qty out of stock and (if the
* customer is at the counter) immediately marks the line as
* delivered.
*
* Distinct from the v1 surplus-booking flow (`stock-surplus.ts`):
* - Surplus-booking = the customer ordered N, the supplier shipped
* N+headroom, the headroom becomes stock. Reference: the
* `order_item` that was collected.
* - Stock-out = the customer ordered N, we already had N in stock
* (no PO needed). Reference: the `order_item` we're fulfilling.
* Decrement `current_qty` instead of incrementing it.
*
* ## Flow
*
* 1. **Validate the order_item.** Must be in an "active" state
* (anything before `delivered` / `cancelled` / `invoiced`).
* Re-fulfilling an already-fulfilled line would double-decrement
* stock.
* 2. **Validate the stock_item.** Must exist, must be active,
* must have enough `current_qty` to cover the requested qty.
* Partial stock blocks the fulfillment (we don't go negative).
* 3. **Three writes, in this order:**
* (a) insert a `stock_ledger` row — audit BEFORE cache;
* (b) decrement `stock_items.current_qty` — the cache;
* (c) update the `order_item` to either `collected` (normal
* delivery path — same as the in_stock flow lands) or
* `delivered` (counter pick-up, customer has it now).
* 4. **Optional cascade.** If the parent order has every line
* now in a terminal/active-stock state, the parent order's
* `Complete` badge can flip. v1 doesn't do this — the
* operator drives completion via the existing "Mark Complete"
* button. Stock-out is just a per-line write.
*
* ## Why this lives in its own module
*
* Mirrors the `stock-surplus.ts` pattern — the route layer is thin,
* the happy / rejected / no-match paths all live here so a unit
* test can drive them with a mocked admin PB. The route only
* handles request/response shape + auth.
*/
import type { OrderItem, StockItem, StockReason } from "@/lib/types";
/**
* Minimal shape the stock-out helper needs off the order_item.
* `Pick`-style to keep this module decoupled from the wider
* OrderItem type (which grows as the form adds fields).
*/
export type FulfillOrderItem = Pick<
OrderItem,
"id" | "item_status" | "quantity" | "order_qty" | "description"
>;
/** Active order_item statuses the stock-out flow can act on. */
const FULFILLABLE_STATUSES = new Set<OrderItem["item_status"]>([
"pending",
"processed",
"po_generated",
"collection_pool",
"assigned",
"collected",
"stock_verified",
"picking",
"ready_delivery",
]);
/**
* PB-shape the helper needs. Same convention as `stock-surplus.ts`
* — the route passes in `pb.collection(...)` directly; unit tests
* pass in a tiny mock.
*/
export interface FulfillFromStockPb {
collection: (name: string) => {
getOne: (id: string) => Promise<Record<string, unknown>>;
update: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown>>;
create: (data: Record<string, unknown>) => Promise<Record<string, unknown>>;
getList: (page?: number, perPage?: number, opts?: { filter?: string }) => Promise<{
items: Array<Record<string, unknown>>;
totalItems: number;
}>;
};
}
export type FulfillFromStockInput = {
/** The order_item being fulfilled from stock. */
orderItem: FulfillOrderItem;
/** PB id of the stock_item to decrement. */
stock_item_id: string;
/**
* How much to book out. Must be > 0 and ≤ stock_item.current_qty.
* Operator types it in the modal; the helper rejects out-of-range.
*/
qty: number;
/**
* Where the order_item lands after fulfillment:
* - "collected" — mirror the `in_stock: true` flow; the order
* still needs to go through the normal
* delivery / invoice pipeline.
* - "delivered" — customer is at the counter, the operator
* hands it over. Terminal-for-stock; the
* invoice / delivery-note flows can pick it
* up from here.
*/
intent: "collected" | "delivered";
/** Free-text audit note. Stored on the ledger row. */
notes?: string;
/**
* Server-resolved dev identity. Stored on `stock_ledger.created_by`
* — same convention as the surplus-book flow (email string for
* now; the production schema swap-in will require a real user id).
*/
actor_email: string;
};
export type FulfillFromStockOk = {
ok: true;
ledger_id: string;
new_current_qty: number;
/** Where the order_item landed after fulfillment. */
resulting_status: "collected" | "delivered";
};
export type FulfillFromStockRejected = {
ok: false;
reason:
| "invalid_qty"
| "wrong_status"
| "stock_item_inactive"
| "stock_item_missing"
| "insufficient_stock"
| "ledger_failed"
| "stock_update_failed"
| "order_update_failed";
message: string;
};
export type FulfillFromStockResult =
| FulfillFromStockOk
| FulfillFromStockRejected;
const STOCK_ITEMS = "stock_items";
const STOCK_LEDGER = "stock_ledger";
const ORDER_ITEMS = "order_items";
const CUSTOMER_FULFILLMENT: StockReason = "customer_fulfillment";
/**
* Fulfill an order_item from existing stock. The order of writes
* (ledger → cache → order_item) is intentional: if the ledger
* insert fails, the cache stays in sync; if the cache update fails,
* the audit row exists to reconcile. PB has no multi-collection
* transactions; we serialize so a partial failure leaves a
* recoverable trail.
*/
export async function fulfillOrderItemFromStock(
pb: FulfillFromStockPb,
input: FulfillFromStockInput
): Promise<FulfillFromStockResult> {
// ── 1. Validate order_item status ─────────────────────────────────
if (!FULFILLABLE_STATUSES.has(input.orderItem.item_status)) {
return {
ok: false,
reason: "wrong_status",
message: `Cannot fulfill from stock while order item is in status "${input.orderItem.item_status}". Allowed: ${[
...FULFILLABLE_STATUSES,
].join(", ")}.`,
};
}
// ── 2. Validate qty ───────────────────────────────────────────────
if (
typeof input.qty !== "number" ||
!Number.isFinite(input.qty) ||
input.qty <= 0
) {
return {
ok: false,
reason: "invalid_qty",
message: "Quantity must be a positive number.",
};
}
// ── 3. Load + validate stock_item ────────────────────────────────
let stockItem: Record<string, unknown>;
try {
stockItem = await pb.collection(STOCK_ITEMS).getOne(input.stock_item_id);
} catch {
return {
ok: false,
reason: "stock_item_missing",
message: "Stock item does not exist.",
};
}
if (stockItem.is_active === false) {
return {
ok: false,
reason: "stock_item_inactive",
message: "This stock item is deactivated. Reactivate it on /stock before fulfilling.",
};
}
const onHand = Number(stockItem.current_qty ?? 0);
if (input.qty > onHand) {
return {
ok: false,
reason: "insufficient_stock",
message: `Only ${onHand} on hand; requested ${input.qty}. Partial fulfillment is not supported — PO the rest.`,
};
}
// ── 4. Audit BEFORE cache: ledger row first ───────────────────────
let ledger: Record<string, unknown>;
try {
ledger = await pb.collection(STOCK_LEDGER).create({
stock_item: input.stock_item_id,
// Signed: negative = stock-out.
change_qty: -input.qty,
reason: CUSTOMER_FULFILLMENT,
reference_type: "order_item",
reference_id: input.orderItem.id,
notes: (input.notes ?? "").slice(0, 1000),
created_by: input.actor_email || "",
});
} catch (err) {
return {
ok: false,
reason: "ledger_failed",
message: err instanceof Error ? err.message : String(err),
};
}
// ── 5. Cache decrement ───────────────────────────────────────────
const newCurrent = onHand - input.qty;
try {
await pb.collection(STOCK_ITEMS).update(input.stock_item_id, {
current_qty: newCurrent,
});
} catch (err) {
return {
ok: false,
reason: "stock_update_failed",
message: err instanceof Error ? err.message : String(err),
};
}
// ── 6. Order-item transition ─────────────────────────────────────
// Two landing states per the `intent` parameter:
// - "collected" — mirrors the in_stock: true path; the order
// still needs to go through delivery / invoice.
// - "delivered" — counter pick-up; the operator is handing it
// over to the customer right now.
const updatePatch: Record<string, unknown> = {
item_status: input.intent,
};
try {
await pb.collection(ORDER_ITEMS).update(input.orderItem.id, updatePatch);
} catch (err) {
return {
ok: false,
reason: "order_update_failed",
message: err instanceof Error ? err.message : String(err),
};
}
return {
ok: true,
ledger_id: String(ledger.id),
new_current_qty: newCurrent,
resulting_status: input.intent,
};
}
/**
* Look up stock_items that could fulfill a given order line. v1
* does the most-straightforward thing: fetch the active list and
* let the operator pick. Future versions could pre-score by SKU
* match + description similarity (using the existing
* `findMatchingStockItem` helper) and pre-select the best match.
*/
export async function listActiveStockItems(
pb: FulfillFromStockPb
): Promise<StockItem[]> {
const list = await pb.collection(STOCK_ITEMS).getList(1, 200, {
filter: "is_active=true",
});
return list.items as unknown as StockItem[];
}