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 { if (!raw) return {}; if (typeof raw === "object" && !Array.isArray(raw)) { return raw as Record; } 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; } } 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; remainder: Record; remainderQty: number; }> { const pb = await getAdminPocketBase(); const orderItemsCollection = getOrderItemsCollectionName(); let item: Record; try { item = (await pb.collection(orderItemsCollection).getOne(itemId)) as Record; } 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[]; 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; // 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; return { original, remainder, remainderQty }; }