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; } } /** * Thrown by `confirmOrderItemsCollected` when a row can't be confirmed. * Mirrors `OrderItemSplitError`: a small dedicated subclass with a * `status` property the route handler can map directly to an HTTP code. * * - 404 — row missing * - 409 — row is past the editable window (already collected / delivered / invoiced / …) * - 422 — quantity inputs are inconsistent (qty > remaining, etc.) */ export class OrderItemConfirmError extends Error { readonly code: string; readonly status: number; constructor(message: string, code: string, status: number) { super(message); this.name = "OrderItemConfirmError"; this.code = code; this.status = status; } } /** * Canonical whitelist of fields a caller may patch on an * `order_items` row. This is the **agent / MCP** surface — it is * intentionally broader than the operator popover whitelist * (`PATCHABLE_FIELDS` in `src/app/api/v1/order-items/[id]/route.ts`) * because the agent reconciles invoices, walks statuses through * the lifecycle, and overwrites supplier SKUs during PO * matching. The REST popover keeps its narrower whitelist because * it doesn't need the lifecycle / invoice fields. * * Both the REST PATCH route and the MCP `order_items.update` * dispatcher import from here so a field accepted by one path * but not the other can't drift silently. Anything outside this * set returns `forbidden_field` with the rejected key names so * the caller knows exactly what was rejected. * * Controlled / immutable fields (`id`, `created`, `updated`, * `order`, `purchase_order`, `quote_item`, `product`) are * excluded. `category` mirrors `QUOTE_ITEM_PATCH_FIELDS` and is * a PB relation to `categories`. `unit` was added because * invoice reconciliation often exposes a different shipping * unit than the order was placed in (e.g. the supplier sends * "BOX OF 12" where the PO said "EA"). */ export const ORDER_ITEM_PATCHABLE_FIELDS = [ "cost_price", "selling_price", "quantity", "sku", "unit", "category", "description", "supplier", "markup", "markup_percent", "pricing_source", "pricing_notes", "matched_price_list_id", "user_edited", "notes", "qty_processed", "qty_assigned", "qty_collected", "qty_verified", "qty_stocked", "qty_picked", "qty_loaded", "qty_delivered", "qty_invoiced", "qty_cancelled", "supplier_invoice_number", "customer_invoice_number", "collection_date", "collected_date", "verified_date", "delivered_date", "invoiced_date", ] as const; /** * Statuses from which an order_item can transition to `collected` via * the agent-driven confirm path. Mirrors the stock-side * `markLineCollected` rule (`collection_pool | assigned_to_driver`): * the customer line is eligible while it's still in the pre-pickup * pool or already bound to a driver. Anything past `collected` is * downstream and refuses (you don't want to overwrite an invoiced or * delivered status by accident). */ const CONFIRM_ELIGIBLE_STATUSES = new Set(["collection_pool", "assigned"]); /** * Audit-note entry appended by `confirmOrderItemsCollected`. Mirrors the * shape used by the existing collection-confirm / appendCostNote helpers * (ts / actorId / actorName / category / body) so the rendered feed in * the UI parses these entries the same way. */ type ConfirmAuditEntry = { id: string; ts: string; actorId: string; actorName: string; category: "collection"; body: string; }; /** * Read existing notes (any category, well-formed) so the new entry is * merged into the feed rather than replacing the whole array. Lenient on * category — we keep `note`, `problem`, `cost`, `confirm`, etc. so the * operator-side audit trail stays intact. */ function parseStoredNotesForMerge( raw: string | null | undefined, ): Array> { const trimmed = (raw ?? "").trim(); if (!trimmed) return []; try { const parsed = JSON.parse(trimmed); if (!Array.isArray(parsed)) return []; return parsed.filter( (entry): entry is Record => !!entry && typeof entry === "object", ); } catch { return []; } } /** * Result for a single row passed to `confirmOrderItemsCollected`. * Mirrors the per-row shape used by `bulk_update_quote_items` / * `stock_purchase_items.bulk_update` so the agent can switch on * `result.status` (or `result.reason`) consistently. */ export type ConfirmCollectedRowResult = { id: string; status: "ok" | "skipped" | "failed"; reason?: string; item?: Record; previous?: { item_status: string | null; qty_collected: number; }; }; /** * Confirm collection of customer order_items without requiring a driver * assignment. * * Background: the in-UI collection-pool page forces a `drv_id` * assignment before "Mark Collected" becomes available. The data layer * doesn't enforce that coupling (see `src/lib/server/order-item-status.ts` * — drv_id and drv_status are intentionally decoupled), so external * systems (the agent API, n8n workflows, future integrations) need a * way to mark items collected straight from `collection_pool`. This * service is that path. * * For each id: * 1. Reads the row to get `item_status`, `quantity`, and the * existing `qty_collected` (so a partial-confirm math holds). * 2. Validates: row exists, `item_status` ∈ {`collection_pool`, * `assigned`}, and the requested `qty_collected` (when supplied) * is ≤ the remaining qty. Throws structured `OrderItemConfirmError` * on any miss. * 3. Patches `item_status="collected"`, `drv_status="collected"`, * `drv_collected_at` (today), `collected_date` (today), * `qty_collected` (defaults to remaining), and appends a * `category: "collection"` audit note tagged with the actor. * * Per-row independence: a failure on one id leaves the others * untouched and surfaces the reason in the result entry. Returns * `{ results: ConfirmCollectedRowResult[], confirmed: string[], * failed: { id, reason }[] }` so callers can summarise either side. * * Empty `ids` is a no-op (returns `{ results: [], confirmed: [], * failed: [] }`). */ export async function confirmOrderItemsCollected( ids: string[], args: { actor: { id: string; name: string }; note?: string; quantity_collected?: Record; collected_at?: string; }, ): Promise<{ results: ConfirmCollectedRowResult[]; confirmed: string[]; failed: { id: string; reason: string }[]; }> { if (ids.length === 0) { return { results: [], confirmed: [], failed: [] }; } const pb = await getAdminPocketBase(); const collectionName = getOrderItemsCollectionName(); const collectedAt = (args.collected_at ?? new Date().toISOString()).slice(0, 10); const actorId = String(args.actor?.id ?? "").trim(); const actorName = String(args.actor?.name ?? "").trim(); if (!actorId || !actorName) { throw new OrderItemConfirmError( "actor.{id,name} are required for confirm_collected.", "missing_actor", 400, ); } const results: ConfirmCollectedRowResult[] = []; const confirmed: string[] = []; const failed: { id: string; reason: string }[] = []; for (const id of ids) { let row: Record; try { row = (await pb.collection(collectionName).getOne(id)) as Record; } catch { results.push({ id, status: "failed", reason: "not_found" }); failed.push({ id, reason: "not_found" }); continue; } const currentStatus = (row.item_status as string | null) ?? null; if (!currentStatus || !CONFIRM_ELIGIBLE_STATUSES.has(currentStatus)) { const reason = `wrong_status: cannot confirm collection from "${currentStatus ?? "null"}" (expected collection_pool or assigned)`; results.push({ id, status: "failed", reason }); failed.push({ id, reason }); continue; } const quantity = Number(row.quantity ?? 0); const existingQtyCollected = Number(row.qty_collected ?? 0); const remaining = Math.max(0, quantity - existingQtyCollected); const override = args.quantity_collected?.[id]; let nextQtyCollected = existingQtyCollected; if (typeof override === "number") { if (!Number.isFinite(override) || override < 0) { const reason = "invalid_quantity_collected: must be a non-negative finite number"; results.push({ id, status: "failed", reason }); failed.push({ id, reason }); continue; } if (override > remaining) { const reason = `qty_overrun: requested ${override} > remaining ${remaining}`; results.push({ id, status: "failed", reason }); failed.push({ id, reason }); continue; } nextQtyCollected = existingQtyCollected + override; } else if (remaining > 0) { nextQtyCollected = quantity; } const ts = new Date().toISOString(); const noteBody = args.note?.trim() ? `${args.note.trim()} (collected ${nextQtyCollected - existingQtyCollected || remaining} unit${nextQtyCollected - existingQtyCollected === 1 ? "" : "s"})` : `Confirmed collection without driver assignment — ${nextQtyCollected - existingQtyCollected || remaining} unit${nextQtyCollected - existingQtyCollected === 1 ? "" : "s"} added to qty_collected (now ${nextQtyCollected}/${quantity}).`; const entry: ConfirmAuditEntry = { id: `confirm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, ts, actorId, actorName, category: "collection", body: noteBody, }; const existingNotes = parseStoredNotesForMerge( (row.notes as string | null | undefined) ?? "", ); const mergedNotes = JSON.stringify([...existingNotes, entry]); try { const updated = (await pb.collection(collectionName).update(id, { item_status: "collected", drv_status: "collected", drv_collected_at: collectedAt, collected_date: collectedAt, qty_collected: nextQtyCollected, notes: mergedNotes, })) as Record; results.push({ id, status: "ok", item: updated, previous: { item_status: currentStatus, qty_collected: existingQtyCollected, }, }); confirmed.push(id); } catch (err) { const message = err instanceof Error ? err.message : String(err); const reason = `pb_error: ${message}`; results.push({ id, status: "failed", reason }); failed.push({ id, reason }); } } return { results, confirmed, failed }; } /** * 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. */ /** * Input shape for `splitOrderItemRemainder`. The optional * `excludeFromInvoice` flag was added so the operator can mark the * remainder as a "supplier-side component" of a multi-supplier * customer line (e.g. "M20 bolt with nut and washer" split into 3 * supplier order_items — only one is the customer's billable line; * the others get the supplier-side flag and never reach the * customer's invoice or delivery note). * * The default behaviour is unchanged: a split without the flag * stamps the remainder as a regular customer line (the operator can * still flag it later). */ export type SplitOrderItemRemainderInput = { excludeFromInvoice?: boolean; }; export async function splitOrderItemRemainder( itemId: string, input: SplitOrderItemRemainderInput = {}, ): 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, // `null` rather than `""` — PB relation fields reject empty strings // in stricter SDK builds; the remainder is intentionally unlinked. purchase_order: null, item_status: "pending", in_stock: !!item.in_stock, // Supplier-side flag. When the operator ticks "Mark this remainder // as a supplier-side component" in the split modal, the remainder // rides along for PO / driver / collection but never appears on // the customer invoice or delivery note. Selling price is forced // to 0 so a stale quote-item view can't accidentally bill the // customer for it. The flag is forwarded by the API route from // the split dialog — default false keeps the old behaviour // (the operator can still flip the flag later via /order-items). exclude_from_invoice: input.excludeFromInvoice === true, 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, // Stamp the supplier-side flag into the audit trail too so // a follow-up operator can see why this remainder was // excluded from the customer invoice (without having to // reverse-engineer the flow from the boolean column). excludeFromInvoice: input.excludeFromInvoice === true, }, }), // 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, // Defensive: carry the original's category tag so category filters // on the order detail page don't silently drop the new line. Only // written when present — schema lists it as optional. ...(item.category ? { category: item.category } : {}), })) 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 }; }