- 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>
223 lines
8.6 KiB
TypeScript
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 };
|
|
}
|