WFDASH-WEBAPP/src/lib/server/order-items-service.ts
SJCoetz 4444842ef5 feat: collection-map updates, todays-stops, quote/approval rework, buyer-tasks, e2e
- Add todays-stops test and component to collection-map
- Expand collection-map sidebar and pin-model
- Rework orders/[id]/process page
- Add quotes/[id]/page and process pages
- Add quotes/approvals/[id] page
- Add buyer-tasks generate-po route
- Add quotes partial-approve and pdf routes
- Add purchase-orders [id]/pdf route
- Add e2e create-flows spec
- Add superpowers plan doc
- Update pb_schema-2.json
- Update form components (company, customer, order, quote, supplier)
- Update UI components (button, command, dialog)
- Add collection-pool test components

Co-authored-by: Orca <help@stably.ai>
2026-08-03 09:33:45 +02:00

223 lines
8.6 KiB
TypeScript

import { getAdminPocketBase } from "./pocketbase-admin";
import { getOrderItemsCollectionName } from "./orders-service";
const ORDER_COLLECTION = "orders";
/**
* Merge-safe reader for the `order_item_details` JSON blob. Other
* features (invoicing, collection-confirm) write their own keys into
* the same field (`invoice`, `print`, `deliveryNote`), so the split
* marker must never clobber them — always merge and re-stringify.
*/
function parseDetails(raw: unknown): Record<string, unknown> {
if (!raw) return {};
if (typeof raw === "object" && !Array.isArray(raw)) {
return raw as Record<string, unknown>;
}
if (typeof raw !== "string") return {};
try {
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// Not valid JSON — treat as empty.
}
return {};
}
/**
* Thrown when an order_item can't be split (not found, wrong status,
* or no remainder to split off). Carries an HTTP status so the route
* handler can map it to the right response code:
*
* - 404 — item missing
* - 409 — item is past the editable window (already PO'd / collected / …)
* - 422 — order_qty is 0, missing, or ≥ quantity (no remainder exists)
*/
export class OrderItemSplitError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = "OrderItemSplitError";
this.status = status;
}
}
/**
* Splits a shorted order line into its remainder.
*
* Background: the process page lets the operator enter a supplier-facing
* `order_qty` that is LOWER than the customer-facing `quantity` (e.g.
* customer ordered 100, first supplier can only fulfil 40). The operator
* then wants a second line for the remaining 60 so it can be sent to a
* different supplier. This function creates that remainder line.
*
* Rules:
* - The original line keeps its `order_qty` untouched — that number is
* exactly what the first supplier's PO will print. Its customer-facing
* `quantity` is reduced to match `order_qty` so the two lines sum to
* the original request (12 + 8 = 20, not 20 + 8 = 28) and the split
* button disappears (order_qty == quantity → no shortfall).
* - The remainder line is created with `supplier = null` so it sorts to
* the top of the process page (un-assigned pin) and flags as
* "needs supplier" for the operator to route to the next supplier.
* - The remainder copies the pricing + audit-trail fields from the
* original (selling_price, cost_price, markup, sku, pricing_source,
* matched_price_list_id, pricing_notes) so the operator doesn't
* re-enter them, but clears `quote_item` (the original already owns
* the quote match) and starts `item_status = "pending"` so the
* standard "needs PO" workflow picks it up.
* - The split is only valid while `order_qty` is in (0, quantity) —
* a zero/blank order_qty means the operator deliberately ordered
* nothing from this supplier (no remainder to split), and an
* order_qty ≥ quantity means there is no shortfall.
* - The parent order must exist so the remainder line isn't orphaned.
*/
export async function splitOrderItemRemainder(itemId: string): Promise<{
original: Record<string, unknown>;
remainder: Record<string, unknown>;
remainderQty: number;
}> {
const pb = await getAdminPocketBase();
const orderItemsCollection = getOrderItemsCollectionName();
let item: Record<string, unknown>;
try {
item = (await pb.collection(orderItemsCollection).getOne(itemId)) as Record<string, unknown>;
} catch {
throw new OrderItemSplitError("Order item not found.", 404);
}
const status = String(item.item_status ?? "");
if (!["pending", "processed"].includes(status)) {
throw new OrderItemSplitError(
`Cannot split an item in status "${status}" — only pending/processed lines are still editable.`,
409,
);
}
const quantity = Number(item.quantity ?? 0);
const orderQty = item.order_qty === null || item.order_qty === undefined
? Number(item.quantity ?? 0)
: Number(item.order_qty);
// order_qty 0 (or missing → defaulted to quantity) means there is no
// shortfall to split. Explicitly exclude 0: the operator told us they
// don't want this supplier's line at all — splitting would create a
// bogus remainder.
if (!Number.isFinite(orderQty) || orderQty <= 0) {
throw new OrderItemSplitError(
"Cannot split this line — the supplier quantity is 0. Enter a quantity above 0 for the first supplier first.",
422,
);
}
if (!Number.isFinite(quantity) || orderQty >= quantity) {
throw new OrderItemSplitError(
"Cannot split this line — the supplier quantity already covers the full customer quantity.",
422,
);
}
const remainderQty = quantity - orderQty;
const splitAt = new Date().toISOString();
// Verify the parent order exists so the remainder line is never
// orphaned (PB would happily create it against a bogus id).
const orderId = item.order as string | undefined;
if (!orderId) {
throw new OrderItemSplitError(
"Cannot split this line — it has no parent order.",
409,
);
}
try {
await pb.collection(ORDER_COLLECTION).getOne(orderId, { fields: "id" });
} catch {
throw new OrderItemSplitError("Parent order not found.", 404);
}
// Refuse to create a duplicate remainder. A previous split already
// produced a supplier-less line for the exact shortfall — clicking
// split again (e.g. a stale button on a line split before the
// quantity-reduce change) would double the order total to 28.
// Matched on order + no supplier + quantity == remainder + same sku
// (falling back to description when the line has no sku).
const siblings = (await pb.collection(orderItemsCollection).getFullList({
filter: `order = "${orderId}"`,
sort: "created",
})) as Record<string, unknown>[];
const alreadySplit = siblings.some(
(s) =>
s.id !== itemId &&
!s.supplier &&
Number(s.quantity ?? 0) === remainderQty &&
((s.sku && s.sku === item.sku) ||
(!s.sku && s.description === item.description)),
);
if (alreadySplit) {
throw new OrderItemSplitError(
"This line was already split — a remainder line for the shortfall already exists.",
409,
);
}
const remainder = (await pb.collection(orderItemsCollection).create({
order: orderId,
description: item.description || "",
po_description: item.po_description || item.description || "",
quantity: remainderQty,
order_qty: remainderQty,
selling_price: item.selling_price ?? null,
cost_price: Number(item.cost_price ?? 0),
markup: Number(item.markup ?? 0),
markup_percent: Number(item.markup ?? 0),
supplier: null,
purchase_order: "",
item_status: "pending",
status: "active",
drv_status: "unassigned",
in_stock: !!item.in_stock,
notes: item.notes || "",
// Split marker — lets the process page badge this line as the
// remainder of its source so splits are spottable. Stored in the
// shared `order_item_details` JSON blob (merged with any existing
// keys), the same field invoicing/collection write to.
order_item_details: JSON.stringify({
split: {
sourceItemId: itemId,
at: splitAt,
remainderQty,
fromOrderQty: orderQty,
},
}),
// Pricing audit-trail — carried over so the operator doesn't
// re-enter them when routing the remainder to the next supplier.
// `quote_item` is deliberately NOT copied: the original line owns
// the quote match and the remainder is a second procurement, not a
// second quote line.
sku: item.sku || null,
pricing_source: item.pricing_source || null,
matched_price_list_id: item.matched_price_list_id || null,
pricing_notes: item.pricing_notes || null,
})) as Record<string, unknown>;
// Reduce the original's customer-facing `quantity` to the supplier
// qty so the two lines sum to the original request (12 + 8 = 20) and
// the split button hides (order_qty == quantity). Keep `order_qty`
// — that is what the first supplier's PO prints. Also stamp the
// source-side split marker so the original is badged too.
const original = (await pb.collection(orderItemsCollection).update(itemId, {
quantity: orderQty,
order_item_details: JSON.stringify({
...parseDetails(item.order_item_details),
split: {
remainderItemId: remainder.id,
at: splitAt,
remainderQty,
},
}),
})) as Record<string, unknown>;
return { original, remainder, remainderQty };
}