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.
292 lines
10 KiB
TypeScript
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[];
|
|
}
|