WFDASH-WEBAPP/src/lib/server/order-items-service.ts

561 lines
21 KiB
TypeScript
Raw Normal View History

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;
}
}
/**
* 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<Record<string, unknown>> {
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<string, unknown> =>
!!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<string, unknown>;
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<string, number>;
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<string, unknown>;
try {
row = (await pb.collection(collectionName).getOne(id)) as Record<string, unknown>;
} 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<string, unknown>;
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.
*/
fix(invoicing): bill only what's collected, support partial pickups + kit splits The invoice qty rule used to read the customer's order qty regardless of how much the driver actually collected. QuickBooks ended up billing 8-of-10 on one line and 7-of-10 on the other for Kusasalethu PO 390588 (INV10924) — a 5-unit overbilling that the mine's receiving team flags as suspicious. The rule now caps the invoice qty at min(qty_collected, quantity): - Short pickup → bill what's collected, the row stays at item_status="ready_invoice" with qty_invoiced < quantity, and a follow-up invoice fires automatically when the rest arrives. - MOQ overflow → still capped at the customer's order qty; the surplus-book flow continues to route the overflow to stock. The Invoicing page now shows "N short" / "N units short" badges on short-pickup rows, a "N hidden" badge when supplier-side rows are suppressed, and a "Show supplier-side lines" toggle (default OFF) so the invoicing operator doesn't see kit-component remainders unless she needs to. A split-remainder dialog on the order process page lets the operator explicitly flag a remainder as exclude_from_invoice=true when the original line was a kit (e.g. "M20 bolt comes with nut and washer" split across 3 suppliers). PB schema additions (one-time, run scripts/nss-add-invoice-columns.ts): - order_items.qty_invoiced (number) - order_items.customer_invoice_number (text) - order_items.invoiced_date (date) These three columns were referenced throughout the codebase but never existed on the live schema, so writes were being silently dropped. The schema fix unblocks the reconcile flow that stamps them after every QB webhook returns. Backfill for the live Kusasalethu order: scripts/nss-fix-kusasalethu-inv10924.ts updates order_items.quantity 10→8 / 10→7 to match what was collected, stamps the new columns, and flips order.invoiced=true. New server helper reconcileOrderItemInvoicedStatus() runs after each QB webhook and per-row decides: - item_status = "invoiced" if qty_invoiced >= quantity (fully billed) - item_status = "ready_invoice" otherwise (still partial — stays in the to-invoice tab for a follow-up invoice once the rest arrives) Wired into both the bulk invoicing page and the delivery-note-invoice route. markOrderInvoiced now compares against qty_invoiced vs quantity rather than the flat item_status check, and its error message names the outstanding shortfall per row.
2026-09-17 11:53:36 +00:00
/**
* 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<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,
// `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,
fix(invoicing): bill only what's collected, support partial pickups + kit splits The invoice qty rule used to read the customer's order qty regardless of how much the driver actually collected. QuickBooks ended up billing 8-of-10 on one line and 7-of-10 on the other for Kusasalethu PO 390588 (INV10924) — a 5-unit overbilling that the mine's receiving team flags as suspicious. The rule now caps the invoice qty at min(qty_collected, quantity): - Short pickup → bill what's collected, the row stays at item_status="ready_invoice" with qty_invoiced < quantity, and a follow-up invoice fires automatically when the rest arrives. - MOQ overflow → still capped at the customer's order qty; the surplus-book flow continues to route the overflow to stock. The Invoicing page now shows "N short" / "N units short" badges on short-pickup rows, a "N hidden" badge when supplier-side rows are suppressed, and a "Show supplier-side lines" toggle (default OFF) so the invoicing operator doesn't see kit-component remainders unless she needs to. A split-remainder dialog on the order process page lets the operator explicitly flag a remainder as exclude_from_invoice=true when the original line was a kit (e.g. "M20 bolt comes with nut and washer" split across 3 suppliers). PB schema additions (one-time, run scripts/nss-add-invoice-columns.ts): - order_items.qty_invoiced (number) - order_items.customer_invoice_number (text) - order_items.invoiced_date (date) These three columns were referenced throughout the codebase but never existed on the live schema, so writes were being silently dropped. The schema fix unblocks the reconcile flow that stamps them after every QB webhook returns. Backfill for the live Kusasalethu order: scripts/nss-fix-kusasalethu-inv10924.ts updates order_items.quantity 10→8 / 10→7 to match what was collected, stamps the new columns, and flips order.invoiced=true. New server helper reconcileOrderItemInvoicedStatus() runs after each QB webhook and per-row decides: - item_status = "invoiced" if qty_invoiced >= quantity (fully billed) - item_status = "ready_invoice" otherwise (still partial — stays in the to-invoice tab for a follow-up invoice once the rest arrives) Wired into both the bulk invoicing page and the delivery-note-invoice route. markOrderInvoiced now compares against qty_invoiced vs quantity rather than the flat item_status check, and its error message names the outstanding shortfall per row.
2026-09-17 11:53:36 +00:00
// 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,
fix(invoicing): bill only what's collected, support partial pickups + kit splits The invoice qty rule used to read the customer's order qty regardless of how much the driver actually collected. QuickBooks ended up billing 8-of-10 on one line and 7-of-10 on the other for Kusasalethu PO 390588 (INV10924) — a 5-unit overbilling that the mine's receiving team flags as suspicious. The rule now caps the invoice qty at min(qty_collected, quantity): - Short pickup → bill what's collected, the row stays at item_status="ready_invoice" with qty_invoiced < quantity, and a follow-up invoice fires automatically when the rest arrives. - MOQ overflow → still capped at the customer's order qty; the surplus-book flow continues to route the overflow to stock. The Invoicing page now shows "N short" / "N units short" badges on short-pickup rows, a "N hidden" badge when supplier-side rows are suppressed, and a "Show supplier-side lines" toggle (default OFF) so the invoicing operator doesn't see kit-component remainders unless she needs to. A split-remainder dialog on the order process page lets the operator explicitly flag a remainder as exclude_from_invoice=true when the original line was a kit (e.g. "M20 bolt comes with nut and washer" split across 3 suppliers). PB schema additions (one-time, run scripts/nss-add-invoice-columns.ts): - order_items.qty_invoiced (number) - order_items.customer_invoice_number (text) - order_items.invoiced_date (date) These three columns were referenced throughout the codebase but never existed on the live schema, so writes were being silently dropped. The schema fix unblocks the reconcile flow that stamps them after every QB webhook returns. Backfill for the live Kusasalethu order: scripts/nss-fix-kusasalethu-inv10924.ts updates order_items.quantity 10→8 / 10→7 to match what was collected, stamps the new columns, and flips order.invoiced=true. New server helper reconcileOrderItemInvoicedStatus() runs after each QB webhook and per-row decides: - item_status = "invoiced" if qty_invoiced >= quantity (fully billed) - item_status = "ready_invoice" otherwise (still partial — stays in the to-invoice tab for a follow-up invoice once the rest arrives) Wired into both the bulk invoicing page and the delivery-note-invoice route. markOrderInvoiced now compares against qty_invoiced vs quantity rather than the flat item_status check, and its error message names the outstanding shortfall per row.
2026-09-17 11:53:36 +00:00
// 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<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 };
}