WFDASH-WEBAPP/src/lib/server/orders-service.ts
SJCoetz 74c73e9f17 feat(orders): totals + add/remove items in review modal
Two related enhancements to the orders review modal:

* Tier 1 — header totals: the "Order items" header now shows the
  item count plus the cost and selling totals (qty*price summed
  across the live draft state). Uses formatCurrency from
  pb-helpers.ts for the ZAR formatting.

* Tier 2 — add/remove items: the edit-mode items list now has an
  "Add item" button (Plus icon) and a per-row Trash icon. New rows
  get a sentinel id like "__new_1" and a left-edge accent stripe;
  rows flagged for removal flip to a strikethrough + reduced opacity
  with disabled inputs (toggle button flips to an Undo2 icon).

  On save, the route posts to /api/v1/orders/[id]/review-save with
  new itemsToAdd and itemsToRemove arrays. The service layer adds
  createOrderItems + softFlagOrderItems handlers (set
  item_status="removed" — operator confirmed the value is in the
  PB admin schema). The modal re-keys the new drafts with the
  returned PB ids so a follow-up save treats them as existing
  items.

  Partial-failure UI now has three sections (updates / adds /
  removes) sharing a labelled FailureSection helper. Response
  envelope now includes itemsAdded and itemsRemoved alongside the
  existing items block, plus a flat partialFailures array for
  the toast summary.

  Type cleanup: "removed" added to ItemStatus and the
  ITEM_STATUS_VISUAL map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
2026-07-29 18:54:49 +02:00

637 lines
22 KiB
TypeScript

import { getAdminPocketBase } from "./pocketbase-admin";
const ORDER_COLLECTION = "orders";
const QUOTE_COLLECTION = "quotes";
const QUOTE_ITEMS_COLLECTION = "quote_items";
const ORDER_COUNTERS_COLLECTION = "order_counters";
export function getOrderItemsCollectionName() {
return process.env.ORDER_ITEMS_COLLECTION || "order_items";
}
export async function createOrderFromQuote(quoteId: string) {
const pb = await getAdminPocketBase();
const orderItemsCollection = getOrderItemsCollectionName();
const existingOrderForQuote = await pb.collection(ORDER_COLLECTION).getList(1, 1, {
filter: `quote = "${quoteId}"`,
sort: "-created",
});
if (existingOrderForQuote.items.length > 0) {
return {
order: existingOrderForQuote.items[0],
created_items: 0,
order_items_collection: orderItemsCollection,
already_exists: true,
};
}
const quote = await pb.collection(QUOTE_COLLECTION).getOne(quoteId, {
expand: "customer,company",
});
const quoteItems = await pb.collection(QUOTE_ITEMS_COLLECTION).getFullList({
filter: `quote = "${quoteId}"`,
sort: "created",
});
if (quoteItems.length === 0) {
throw new Error("Quote has no items to convert into an order.");
}
// Atomic order number generation.
//
// Two bugs the old (read-then-write) code had:
// 1. **Month rollover**: it used `new Date()` for the year/month prefix
// even when the most recent order was from a prior month, so the
// sequence reset at every month boundary. The contract is: the
// order number follows the most recent order's month until the
// sequence is manually rolled (or naturally dies).
// 2. **Concurrency race**: read-then-write meant two simultaneous
// `createOrderFromQuote` calls could both observe 007 and both
// write 008, producing two orders with the same number.
//
// The fix mirrors the quote side: a dedicated `order_counters` collection
// keyed on `(year, month)` with a unique index, and an atomic
// `last_seq+` increment. The first request for a (year, month) pair
// creates the row; concurrent creates swallow the unique-constraint
// error and re-fetch. Sequence is reserved server-side, so the gap on
// rollback is the standard "monotonic counter" trade-off — much
// simpler than reservation + release.
const orderNumber = await generateOrderNumberServer(pb);
const order = await pb.collection(ORDER_COLLECTION).create({
order_number: orderNumber,
company: quote.company,
customer: quote.customer || null,
quote: quote.id,
notes: quote.notes || "",
quote_matching_attempted: true,
quote_match_confidence: 1,
potential_quote: quote.id,
potential_quote_confidence: 1,
is_repair_order: false,
invoiced: false,
});
for (const item of quoteItems) {
const quantity = Number(item.quantity || 1);
const payload = {
order: order.id,
description: item.description || "",
po_description: item.description || "",
quantity,
order_qty: quantity,
qty_ordered: String(quantity),
selling_price: Number(item.selling_price || 0),
supplier: item.supplier || null,
cost_price: Number(item.cost_price || 0),
markup: Number(item.markup || 0),
markup_percent: Number(item.markup || 0),
item_status: "pending",
status: "active",
drv_status: "unassigned",
notes: item.notes || "",
quote_item: item.id,
};
await pb.collection(orderItemsCollection).create(payload);
}
return { order, created_items: quoteItems.length, order_items_collection: orderItemsCollection, already_exists: false };
}
/**
* Generates the next order number (`ORD-YYYY-MM-NNN`) atomically.
*
* Sequence is keyed on the **(year, month) of the most recent order**,
* not on wall-clock time. This means the sequence does NOT reset at
* month boundary — if the latest order is `ORD-2026-06-007` and we're
* now in July, the next order is `ORD-2026-06-008`. The sequence
* restarts at 001 only when the `order_counters` row for the relevant
* month is absent (i.e. no order has ever been created for that month,
* or the row was wiped by a destructive operation).
*
* The (year, month) is discovered from the latest order in a single
* `getList` call. The counter row for that pair is then incremented
* atomically with `last_seq+`. Two concurrent callers cannot collide
* — the first commits the increment, the second reads the new value
* and commits +1 from there.
*
* If the `orders` collection is empty, the sequence starts at
* `ORD-{now.year}-{now.month}-001` (i.e. a fresh month starts at 001).
*
* Mirrors `generateQuoteNumberServer` in `quotes-service.ts`; the two
* counters are independent but use the same pattern.
*/
export async function generateOrderNumberServer(
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>
): Promise<string> {
const pb = pbArg || (await getAdminPocketBase());
const { year, month } = await resolveCurrentSequenceMonth(pb);
const counter = await getOrCreateOrderCounter(pb, year, month);
const updated = await pb.collection(ORDER_COUNTERS_COLLECTION).update(counter.id, {
"last_seq+": 1,
});
return formatOrderNumber(year, month, updated.last_seq);
}
export async function listCollectionPool(params: { status?: string; page?: number; perPage?: number }) {
const pb = await getAdminPocketBase();
const page = params.page || 1;
const perPage = params.perPage || 50;
const status = params.status || "";
const filter = status ? `pool_status = "${status}"` : "";
return pb.collection("collection_pool").getList(page, perPage, {
filter,
sort: "-created",
expand: "order_item,order,supplier,drv_id",
});
}
/**
* Returns the (year, month) that the next order number should be keyed
* on. If there's a most recent order, that's the (year, month) — the
* sequence continues across month boundaries. If the table is empty
* (or the latest order's prefix doesn't parse), fall back to
* wall-clock year/month so a brand-new month starts at 001.
*/
async function resolveCurrentSequenceMonth(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>
): Promise<{ year: number; month: number }> {
const now = new Date();
const fallback = { year: now.getFullYear(), month: now.getMonth() + 1 };
try {
const latest = await pb.collection(ORDER_COLLECTION).getList<{ order_number?: string }>(1, 1, {
sort: "-order_number",
});
if (latest.items.length === 0) return fallback;
const parsed = parseOrderNumberPrefix(latest.items[0].order_number || "");
return parsed ?? fallback;
} catch {
return fallback;
}
}
/**
* Parses an `ORD-YYYY-MM-NNN` (or legacy `ORD-YYYY-MM-NN`) string and
* returns the (year, month) prefix, or null if the input doesn't look
* like a recognized order number. Used to discover which (year, month)
* the counter should be keyed on.
*/
function parseOrderNumberPrefix(s: string): { year: number; month: number } | null {
const m = (s || "").match(/^ORD-(\d{4})-(\d{2})-\d+$/);
if (!m) return null;
const year = Number(m[1]);
const month = Number(m[2]);
if (!Number.isFinite(year) || !Number.isFinite(month) || month < 1 || month > 12) {
return null;
}
return { year, month };
}
async function getOrCreateOrderCounter(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
year: number,
month: number
) {
try {
return await pb
.collection(ORDER_COUNTERS_COLLECTION)
.getFirstListItem(`year=${year} && month=${month}`);
} catch (err) {
// First request for this (year, month) — backfill the counter from
// any pre-existing orders in that month, then create it. Without
// the backfill, the new code would start at ORD-{year}-{month}-001
// and collide with the existing sequence. The `last_seq` we set is
// the max sequence in (year, month) — the next `last_seq+1`
// increment will land on the correct next number.
const backfill = await getMaxOrderSequenceInMonth(pb, year, month);
try {
return await pb.collection(ORDER_COUNTERS_COLLECTION).create({
year,
month,
last_seq: backfill,
});
} catch {
// Concurrent caller beat us to it — re-fetch the row they made.
return await pb
.collection(ORDER_COUNTERS_COLLECTION)
.getFirstListItem(`year=${year} && month=${month}`);
}
}
}
/**
* Returns the highest sequence number found in `orders.order_number` for
* the given (year, month) — or 0 if no orders exist for that month. Used
* to seed the counter when it's first created for a (year, month) pair,
* so the generator continues from the existing sequence rather than
* resetting to 001.
*/
async function getMaxOrderSequenceInMonth(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
year: number,
month: number
): Promise<number> {
const mm = String(month).padStart(2, "0");
const prefix = `ORD-${year}-${mm}-`;
try {
const all = await pb
.collection(ORDER_COLLECTION)
.getFullList<{ order_number?: string }>({
filter: `order_number ~ "${prefix}"`,
});
let max = 0;
for (const o of all) {
const m = (o.order_number || "").match(/^ORD-\d{4}-\d{2}-(\d+)$/);
if (m) {
const n = parseInt(m[1], 10);
if (Number.isFinite(n) && n > max) max = n;
}
}
return max;
} catch {
return 0;
}
}
function formatOrderNumber(year: number, month: number, seq: number): string {
return `ORD-${year}-${String(month).padStart(2, "0")}-${String(seq).padStart(3, "0")}`;
}
/**
* Thrown by `updateOrderReviewFields` when the requested `order_number`
* is already in use by a different order. Carries the colliding
* order's id so the route handler can include it in the 409 response
* (the modal renders it as a link to the conflicting order).
*/
export class OrderNumberCollisionError extends Error {
readonly collidingOrderId: string;
readonly attemptedNumber: string;
constructor(collidingOrderId: string, attemptedNumber: string) {
super(
`Order number "${attemptedNumber}" is already in use by another order.`,
);
this.name = "OrderNumberCollisionError";
this.collidingOrderId = collidingOrderId;
this.attemptedNumber = attemptedNumber;
}
}
/**
* Patches the order header fields editable from the review modal.
* Currently supports `order_number` and `customer`. Validates
* `order_number` uniqueness against the `orders` collection
* (excluding self) before write; throws
* `OrderNumberCollisionError` on collision.
*/
export async function updateOrderReviewFields(
orderId: string,
patch: { order_number?: string; customer?: string },
) {
const pb = await getAdminPocketBase();
// Validate order_number uniqueness before write — we do this as a
// separate query rather than letting PB throw on its unique index
// so we can return the colliding row's id to the UI (PB's own
// 400 doesn't carry it).
if (patch.order_number !== undefined) {
const existing = await pb
.collection(ORDER_COLLECTION)
.getOne(orderId);
if (existing.order_number !== patch.order_number) {
// Escape embedded quotes before interpolating into the PB
// filter — an order_number containing `"` would otherwise break
// the filter expression (or worse, alter it).
const escapedNumber = patch.order_number.replace(/"/g, '\\"');
const escapedId = orderId.replace(/"/g, '\\"');
const conflict = await pb
.collection(ORDER_COLLECTION)
.getList<{ id: string; order_number: string }>(1, 1, {
filter: `order_number = "${escapedNumber}" && id != "${escapedId}"`,
});
if (conflict.items.length > 0) {
throw new OrderNumberCollisionError(
conflict.items[0].id,
patch.order_number,
);
}
}
}
// Build a clean patch object with only the defined keys — passing
// `undefined` to PB is harmless but we keep the wire payload tight.
const cleanPatch: Record<string, unknown> = {};
if (patch.order_number !== undefined) cleanPatch.order_number = patch.order_number;
if (patch.customer !== undefined) cleanPatch.customer = patch.customer;
if (Object.keys(cleanPatch).length === 0) {
// No-op — just re-fetch and return.
return pb.collection(ORDER_COLLECTION).getOne(orderId);
}
try {
return await pb.collection(ORDER_COLLECTION).update(orderId, cleanPatch);
} catch (err) {
// Catch the unique-index race: two callers can both pass the
// pre-write collision check above, then race to commit. The loser's
// update then fails on the unique index. Map that to the same
// structured error class so the route can still 409 — and re-
// query to surface the colliding row's id (PB's native error
// string doesn't carry it).
if (err && /UNIQUE constraint failed/i.test(String(err))) {
if (patch.order_number !== undefined) {
const escapedNumber = patch.order_number.replace(/"/g, '\\"');
const conflict = await pb
.collection(ORDER_COLLECTION)
.getList<{ id: string }>(1, 1, {
filter: `order_number = "${escapedNumber}"`,
});
if (conflict.items[0]) {
throw new OrderNumberCollisionError(
conflict.items[0].id,
patch.order_number,
);
}
}
}
throw err;
}
}
/**
* Patches price/qty fields on the order's line items in a non-
* transactional batch. Each item is PATCHed individually; per-row
* failures are accumulated into `failed` rather than aborting the
* batch, so the operator sees which lines couldn't save and can
* retry just those.
*
* Markup computation: if the caller supplies `selling_price` OR
* `cost_price` (but not both) and we know the other from the
* current row, we recompute `markup` from the supplied + known
* values. If the caller supplies `markup` explicitly, we pass it
* through verbatim.
*/
export async function updateOrderItemsPrices(
orderId: string,
items: Array<{
id: string;
quantity?: number | null;
selling_price?: number | null;
cost_price?: number | null;
markup?: number | null;
}>,
): Promise<{
updated: number;
failed: Array<{ id: string; error: string }>;
}> {
const pb = await getAdminPocketBase();
const orderItemsCollection = getOrderItemsCollectionName();
if (items.length === 0) return { updated: 0, failed: [] };
const updated: string[] = [];
const failed: Array<{ id: string; error: string }> = [];
for (const item of items) {
try {
// Read the current row so we can compute markup when only
// one of selling_price/cost_price is supplied.
const current = await pb.collection(orderItemsCollection).getOne(item.id);
// Reject items that don't belong to the requested order. Without
// this, a request to order A can include an item ID from order B
// and modify it — a serious cross-order write vulnerability.
if (current.order !== orderId) {
failed.push({
id: item.id,
error: "Item does not belong to this order",
});
continue;
}
const patch: Record<string, unknown> = {};
if (item.quantity !== undefined) patch.quantity = item.quantity;
if (item.selling_price !== undefined) patch.selling_price = item.selling_price;
if (item.cost_price !== undefined) patch.cost_price = item.cost_price;
// Markup computation:
// - explicit markup in patch → pass through
// - selling_price supplied, cost_price not → recompute from selling/current-cost
// - cost_price supplied, selling_price not → recompute from current-selling/cost
// - both supplied → recompute from the new pair
// - neither supplied, no explicit markup → leave current markup alone
//
// `markup_percent` mirrors `markup` (the two fields are kept in
// lock-step by `createOrderFromQuote`); write both together so
// the row never goes stale after a price edit.
if (item.markup !== undefined) {
patch.markup = item.markup;
patch.markup_percent = item.markup;
} else if (
item.selling_price !== undefined ||
item.cost_price !== undefined
) {
const newSelling = item.selling_price ?? current.selling_price;
const newCost = item.cost_price ?? current.cost_price;
const newMarkup =
Number(newCost) > 0
? Math.round(((Number(newSelling) - Number(newCost)) / Number(newCost)) * 10000) / 100
: 0;
patch.markup = newMarkup;
patch.markup_percent = newMarkup;
}
// Empty patch: nothing to write. Skip the PB round-trip so we
// don't stamp `updated` and don't bump the row's `updated` ts.
if (Object.keys(patch).length === 0) {
continue;
}
await pb.collection(orderItemsCollection).update(item.id, patch);
updated.push(item.id);
} catch (err) {
failed.push({
id: item.id,
error: err instanceof Error ? err.message : String(err),
});
}
}
return { updated: updated.length, failed };
}
/**
* Creates new line items on the order in a non-transactional batch.
*
* Each draft is created independently; per-row failures are accumulated
* into `failed` rather than aborting the batch (matching the
* partial-failure semantics of `updateOrderItemsPrices`). The route
* handler can surface the failed rows so the operator can retry just
* those.
*
* Markup is written in lock-step with `markup_percent` (mirrors the
* existing `createOrderFromQuote` pattern). New rows default to
* `item_status = "pending"` so the standard order workflow picks them
* up; the operator can change the status later via the process page.
*/
export async function createOrderItems(
orderId: string,
drafts: Array<{
description: string;
quantity: number | null;
cost_price: number;
markup: number;
selling_price: number;
}>,
): Promise<{
created: Array<{
id: string;
description: string;
quantity: number | null;
cost_price: number;
selling_price: number;
markup: number;
}>;
failed: Array<{ draft: typeof drafts[number]; error: string }>;
}> {
const pb = await getAdminPocketBase();
const orderItemsCollection = getOrderItemsCollectionName();
if (drafts.length === 0) return { created: [], failed: [] };
// Verify the parent order exists before we touch any items. PB
// would otherwise happily create orphan rows on a non-existent
// order id and the operator would only learn about it from the
// partial-failure UI. A 404 on the parent is a clean signal.
try {
await pb.collection(ORDER_COLLECTION).getOne(orderId);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
created: [],
failed: drafts.map((d) => ({ draft: d, error: `Order not found: ${message}` })),
};
}
const created: Array<{
id: string;
description: string;
quantity: number | null;
cost_price: number;
selling_price: number;
markup: number;
}> = [];
const failed: Array<{ draft: typeof drafts[number]; error: string }> = [];
for (const draft of drafts) {
try {
// Description is required (PB would 400 on a missing value
// anyway, but we surface a clear message in the per-row failure
// list rather than a generic PB error). Empty description is
// treated as a validation failure so the operator gets a
// targeted hint instead of a 500.
if (!draft.description || !draft.description.trim()) {
failed.push({
draft,
error: "Description is required",
});
continue;
}
const payload = {
order: orderId,
description: draft.description,
po_description: draft.description,
quantity: draft.quantity ?? 1,
order_qty: draft.quantity ?? 1,
selling_price: draft.selling_price,
cost_price: draft.cost_price,
markup: draft.markup,
markup_percent: draft.markup,
item_status: "pending",
status: "active",
drv_status: "unassigned",
in_stock: false,
notes: "",
};
const row = await pb.collection(orderItemsCollection).create(payload);
created.push({
id: row.id,
description: draft.description,
quantity: draft.quantity ?? null,
cost_price: draft.cost_price,
selling_price: draft.selling_price,
markup: draft.markup,
});
} catch (err) {
failed.push({
draft,
error: err instanceof Error ? err.message : String(err),
});
}
}
return { created, failed };
}
/**
* Soft-flags line items by setting `item_status = "removed"`. We
* don't delete the rows — the order page, PO PDFs, and historical
* reports all reference them. The "removed" status is a value the PB
* admin has added to the `item_status` select field (added by the
* operator before this code landed; see AGENTS.md — the API will 500
* with "Failed to update record." if the value isn't in the schema).
*
* Cross-order check: each target is read and verified to belong to
* `orderId` before the flag is written. Same pattern as
* `updateOrderItemsPrices` — without this, a request against order A
* could soft-flag items owned by order B.
*/
export async function softFlagOrderItems(
orderId: string,
ids: string[],
): Promise<{
softFlagged: string[];
failed: Array<{ id: string; error: string }>;
}> {
const pb = await getAdminPocketBase();
const orderItemsCollection = getOrderItemsCollectionName();
if (ids.length === 0) return { softFlagged: [], failed: [] };
const softFlagged: string[] = [];
const failed: Array<{ id: string; error: string }> = [];
for (const id of ids) {
try {
const current = await pb.collection(orderItemsCollection).getOne(id);
if (current.order !== orderId) {
failed.push({
id,
error: "Item does not belong to this order",
});
continue;
}
await pb.collection(orderItemsCollection).update(id, {
item_status: "removed",
});
softFlagged.push(id);
} catch (err) {
failed.push({
id,
error: err instanceof Error ? err.message : String(err),
});
}
}
return { softFlagged, failed };
}