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

527 lines
19 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.
*/
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,
// `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,
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,
// 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 };
}