WFDASH-WEBAPP/src/lib/server/orders-service.ts
Dave 093bbafe76 fix(orders): cover the after() fallback path and log when it engages
The mock for next/server's after() detaches the callback regardless of
what production code does, which left the outer catch's fire-and-forget
fallback untested and the runQuoteMatch inner catch tested for nothing.

- New test 'falls back to an inline match when after() is unavailable'
  forces after() to throw and asserts suggestQuoteForOrder was called
  anyway, plus that the fallback log fired. Mutation: deleting the
  'void runQuoteMatch()' line breaks it.
- 'never fails order creation when the matcher throws' now spies on
  console.error and asserts the inner catch's swallow actually logged.
  Mutation: deleting the inner console.error breaks it.
- The outer catch now logs [createOrder] after() unavailable... so a
  silent switch to fire-and-forget (after upgrade, waitUntil missing on
  deploy target) is impossible to miss in production logs.
- Comment names error code E468, Next 16.2.6, and
  node_modules/next/dist/server/after/after.js, and corrects the
  justification: createOrder is an exported service function reachable
  from multiple layers, not 'a smoke script or another background job'.

Verified against the documented baseline — same single pre-existing
failure (splitOrderForContinuation > moves pending items...), no new
failures introduced.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 08:40:17 +02:00

3975 lines
147 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { after } from "next/server";
import { getAdminPocketBase } from "./pocketbase-admin";
import { pairOrderLines, suggestQuoteForOrder } from "./order-quote-link-service";
import {
TAB_GROUP_ITEM_STATUSES,
TAB_GROUP_ORDER,
itemStatusToTabGroup,
tabGroupFilter,
type TabGroup,
} from "../item-status-ui";
import { resolveRepDisplayName } from "../order-rep";
import type { Customer, ItemStatus, Order, OrderItem, User } from "../types";
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";
}
type AdminPocketBase = Awaited<ReturnType<typeof getAdminPocketBase>>;
type RepUser = Pick<User, "id" | "name" | "email">;
/**
* Cached counts from `getOrderItemStatusTabCounts`. Tab badges stay
* stable across rapid realtime refetches and across search/tab
* changes — counts are global, so the value doesn't move when the
* page query moves. `bypassCache` is set on realtime refetches so
* the badges update the moment an item changes status.
*/
const cachedTabCounts = new Map<
"all" | "active",
{ value: Record<TabGroup, number>; expiresAt: number }
>();
// 60s TTL — counts are global and don't change on every realtime
// event, but operators want them fresh enough to trust. The realtime
// path bypasses the cache via the `bypassCache` option, so changes
// the current operator makes are reflected immediately; the 60s
// window is the staleness budget for changes made by other operators.
const TAB_COUNTS_CACHE_TTL_MS = 60_000;
/** Invalidate the tab-counts cache. Exported for tests + realtime handlers. */
export function clearOrderItemStatusTabCountsCache(): void {
cachedTabCounts.clear();
}
/**
* All item statuses that map to a tab group, deduplicated. The
* status-to-tab mapping in `TAB_GROUP_ITEM_STATUSES` is a partition
* (each status belongs to exactly one tab), so this set covers the
* statuses that contribute to ANY tab count. Statuses outside this
* set (e.g. `invoiced`, `with_driver`, `removed`) don't drive any
* tab and are excluded from the counts query to keep the result
* set tight.
*/
const TAB_STATUSES: string[] = Array.from(
new Set(TAB_GROUP_ORDER.flatMap((tab) => TAB_GROUP_ITEM_STATUSES[tab])),
);
const ACTIVE_TAB_STATUSES = TAB_STATUSES.filter(
(status) => status !== "delivered" && status !== "cancelled",
);
/**
* Sliding window for the `completed` tab — how recently an order
* needs to have reached its terminal state to count as "just
* completed" vs "old enough to fall through to /orders/archive".
*
* Why a dedicated constant: the same window drives three call sites
* (id-fetch, tab counts, search candidate set) — keeping one source
* of truth means a future "make it 60 days" tweak is a single edit.
*
* 30 days matches the operator's mental model ("the last month
* before it goes to cost"). Knob for a follow-up if the buffer
* ends up too tight or too loose in practice.
*/
export const COMPLETED_WINDOW_DAYS = 30;
/**
* ISO timestamp (UTC, millisecond precision) marking the start of
* the "completed" tab's sliding window. Items whose terminal-state
* timestamp falls on or after this cutoff count as "recently
* completed"; older ones are out of scope for the tab and live on
* `/orders/archive`.
*
* Pure function (no clock dependency) so tests can pin `Date.now`
* without coupling to wall-clock time.
*/
export function completedWindowCutoff(now: Date = new Date()): string {
const cutoff = new Date(now.getTime() - COMPLETED_WINDOW_DAYS * 24 * 60 * 60 * 1000);
return cutoff.toISOString();
}
/**
* PB filter fragment for the `completed` tab.
*
* Splits the qualifying statuses (`delivered` + `completed`) on
* the best available terminal-state timestamp:
* - `delivered` items use the dedicated `delivered_at` field,
* stamped by `markOrderDelivered` when the operator flips the
* row.
* - `completed` items use `updated` because no `completed_at`
* field exists on the schema — `updated` is set on every write
* so it reflects the moment the row transitioned to `completed`.
*
* The OR-of-ANDs shape is required: PB can't mix status and date
* predicates inside a single clause without parentheses, and
* grouping is what lets `delivered_at >= cutoff` apply only to
* `delivered` rows (otherwise `completed` rows with a null
* `delivered_at` would fail the comparison).
*/
export function completedTabFilter(now: Date = new Date()): string {
const cutoff = completedWindowCutoff(now);
return [
`(item_status = "delivered" && delivered_at >= "${cutoff}")`,
`(item_status = "completed" && updated >= "${cutoff}")`,
].join(" || ");
}
/** Zero-filled per-tab counts. Spread it; never mutate the original. */
const EMPTY_TAB_COUNTS: Record<TabGroup, number> = Object.freeze(
Object.fromEntries(TAB_GROUP_ORDER.map((tab) => [tab, 0])),
) as Record<TabGroup, number>;
/**
* PB `getFullList` batch size for the tab-counts combined query.
* Default is 500; raising to 5000 cuts the per-load internal query
* count by ~10x for a 18k-item `order_items` collection. The
* tradeoff is a larger per-batch payload, but `fields: "item_status,
* order"` keeps each row under ~50 bytes, so 5000 rows = ~250 KB
* per round-trip — well within reason.
*/
const TAB_COUNTS_BATCH_SIZE = 5000;
export interface OrderWithItems {
order: Order;
items: OrderItem[];
/** Tab groups this order has at least one line item in. */
tabGroups: TabGroup[];
/** Resolved sales-rep label. Never contains an opaque PB record id. */
repName: string;
}
export interface OrdersDashboardData {
items: OrderWithItems[];
totalPages: number;
totalItems: number;
/**
* Per-tab counts of DISTINCT orders. Normally global (every active
* order, regardless of the current query). When a search is active
* these are scoped to the search matches instead — so the tab strip
* reads "New (2) · Processing (1) · Pool (0) · Collected (0)" and
* tells the operator which tab their hit is sitting in. See
* `searchCounts` in `listOrdersWithItems`.
*/
counts: Record<TabGroup, number>;
}
/**
* Safety ceiling on the search candidate set. Search only ever covers
* the ACTIVE order set (orders with ≥1 item in new / processing /
* collection_pool / collected) — delivered + cancelled are served by
* /orders/archive and are deliberately out of scope.
*
* Measured on live PB (2026-08): 91 active orders out of 4,782 total
* (~2%). The cap is therefore unreachable in practice — it exists only
* so a pathological data state degrades into a truncated result rather
* than an unbounded fetch.
*
* This is why search is NOT affected by the list's sort direction. The
* earlier implementation fetched the first 500 orders by `sort` and
* filtered those, which silently limited search to the 500 newest
* orders — and would have limited it to the 500 OLDEST once the list
* flipped to oldest-first. Scoping by active-id set removes that
* coupling entirely.
*/
const ORDER_SEARCH_HARD_CAP = 500;
const ORDER_ITEM_FILTER_CHUNK = 20;
/**
* Read model for the internal /orders page. Business logic lives here rather
* than in the Client Component or the legacy `pb-helpers.ts` layer.
*
* A blank search returns one paginated order page. A search loads the capped
* order set and applies the existing cross-collection matching rules against
* company, customer, descriptions, and prices. Rep names are resolved with
* admin-expanded `customer.rep`, then the minimal users lookup for legacy
* `orders.rep` rows that stored a user id instead of a name.
*
* `tabGroup` (when set) narrows the result set to orders with at least one
* line item in that tab — so the pagination footer reflects the visible
* total rather than the global order count, and the badge "Processing (4)"
* matches what you actually see on page 1.
*/
export async function getOrdersDashboardData(
params: {
page?: number;
perPage?: number;
search?: string;
sort?: string;
tabGroup?: TabGroup;
bypassCountsCache?: boolean;
includeArchivedCounts?: boolean;
} = {},
): Promise<OrdersDashboardData> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const [list, globalCounts] = await Promise.all([
listOrdersWithItems(params, pb),
getOrderItemStatusTabCounts(pb, {
bypassCache: params.bypassCountsCache,
includeArchivedTabs: params.includeArchivedCounts ?? true,
}),
]);
// When searching, the tab badges show how many MATCHES are in each
// tab rather than the global totals — that's what makes the tab strip
// answer "where is the thing I searched for?". Falls back to the
// global counts for the normal browse case.
const counts = list.searchCounts ?? globalCounts;
const { searchCounts, ...rest } = list;
void searchCounts;
return { ...rest, counts };
}
export async function listOrdersWithItems(
params: {
page?: number;
perPage?: number;
search?: string;
sort?: string;
tabGroup?: TabGroup;
} = {},
pbArg?: AdminPocketBase,
): Promise<
Omit<OrdersDashboardData, "counts"> & {
/**
* Per-tab counts of matching orders — only present when a search is
* active. `getOrdersDashboardData` promotes this over the global tab
* counts so the tab strip reports match locations during a search.
*/
searchCounts?: Record<TabGroup, number>;
}
> {
const pb = pbArg || (await getAdminPocketBase());
pb.autoCancellation(false);
const page = Math.max(1, params.page || 1);
const perPage = Math.min(100, Math.max(1, params.perPage || 20));
const search = (params.search || "").trim();
const sort = params.sort || "created";
const tabGroup = params.tabGroup;
// `potential_quote` is dropped from the expand: the list view only checks
// the field's existence (the "Suggested" pill in the header), and
// PB issues a separate IN query per expanded relation. Without it, we
// save one round-trip per page load and per realtime refetch.
//
// NOTE on the `fields` parameter (and why it isn't here): earlier
// I tried to limit wire payload by passing a `fields` list with
// per-expand syntax like `company.company,customer.customer,...`.
// PB 0.26 doesn't support that — the `fields` parameter is a hard
// allowlist of MAIN record columns, and specifying it makes PB
// drop the `expand` payload entirely. Result: customer/company/
// rep/quote names disappeared from every order card. Restored by
// removing the parameter; the page now works and the wire payload
// is bigger than I'd like, but the deep expand works.
//
// Future: either upgrade PB (0.22+ supports per-expand field
// limits properly) or move the heavy fields off the orders/quotes
// collections (PB has a free `notes` text field with no max, and
// unbounded `email_body` / `email_message` on quotes — these are
// the dominant wire-payload cost). The schema-level fix would
// permanently trim the payload.
const expand = "company,customer,customer.rep,quote";
// Tab filter path — three sub-cases:
// (a) no tab, no search → original PB-paginated path
// (b) tab + no search → resolve matching order ids, then
// PB-paginate the filtered set (small tabs
// fall through to `getFullList`+slice when
// the `id = "..." || ...` filter would
// exceed PB's URL length cap)
// (c) any search → candidate set is the ACTIVE order set
// (union of the four workflow tabs), which
// is ~91 orders on live PB. We fetch those,
// match in memory, and collapse to a single
// page. Scoping by active id set (rather
// than "first 500 by sort") is what makes
// search independent of sort direction —
// see ORDER_SEARCH_HARD_CAP.
let ordersPage: { items: Order[]; totalItems: number; totalPages: number; page: number; perPage: number };
if (search) {
// (c) Search — over the active set only. `tabGroup` is NOT applied
// here: we need every active match in order to compute the per-tab
// match counts below. The tab narrowing happens after counting.
const activeIds = await getActiveOrderIds(pb);
if (activeIds.length === 0) {
ordersPage = { items: [], totalItems: 0, totalPages: 0, page: 1, perPage };
} else {
ordersPage = await fetchOrdersFilteredByIds(pb, activeIds, {
page: 1,
perPage: ORDER_SEARCH_HARD_CAP,
sort,
expand,
});
}
} else if (tabGroup) {
const matchingIds = await getOrderIdsForTabGroup(pb, tabGroup);
if (matchingIds.length === 0) {
ordersPage = { items: [], totalItems: 0, totalPages: 0, page, perPage };
} else {
ordersPage = await fetchOrdersFilteredByIds(pb, matchingIds, {
page,
perPage,
sort,
expand,
});
}
} else {
// (a) no tab, no search — original path.
ordersPage = await pb.collection(ORDER_COLLECTION).getList<Order>(
page,
perPage,
{ sort, expand },
);
}
const orderIds = ordersPage.items.map((order) => order.id);
const [allItems, users] = await Promise.all([
fetchOrderItemsForOrders(pb, orderIds),
// Targeted rep lookup: only the user ids the loaded orders actually
// reference. Replaces a `users.getFullList` (which pulled every
// user every page load) with a small `id = ...` filter — typically
// a handful of ids, vs every user in PB.
fetchRepUsersForOrders(pb, ordersPage.items),
]);
const usersById = new Map(users.map((user) => [user.id, user]));
const itemsByOrder: Record<string, OrderItem[]> = {};
for (const item of allItems) {
const key = item.order || "";
(itemsByOrder[key] ||= []).push(item);
}
const searchLower = search.toLowerCase();
const numericMatch = isNumericOrderSearch(search) ? Number(search) : null;
const items: OrderWithItems[] = [];
/**
* Per-tab counts of DISTINCT orders matching the search — "how many
* of my hits live in each tab". Built from every active match BEFORE
* the `tabGroup` narrowing below, which is the whole point: standing
* on New, the operator still sees "Processing (1)" and knows where to
* click. An order with items in two tabs counts once in each.
*/
const searchCounts = search ? { ...EMPTY_TAB_COUNTS } : null;
for (const order of ordersPage.items) {
const orderItems = itemsByOrder[order.id] || [];
const customer = order.expand?.customer as Customer | undefined;
const repName = resolveRepDisplayName({
expandedCustomerRep: customer?.expand?.rep,
customerRepId: customer?.rep,
orderRep: order.rep,
usersById,
});
if (
search &&
!orderMatchesDashboardSearch(
order,
orderItems,
searchLower,
numericMatch,
)
) {
continue;
}
const tabGroups = new Set<TabGroup>();
for (const item of orderItems) {
tabGroups.add(itemStatusToTabGroup(item.item_status));
}
// Tally the match into every tab it appears in, then narrow. Order
// matters — counting after the `continue` below would only ever
// report the tab the operator is already looking at.
if (searchCounts) {
for (const tab of tabGroups) searchCounts[tab]++;
}
// Tab narrowing for the visible list. The counts above already
// recorded this match, so hiding it here doesn't lose the signal.
if (search && tabGroup && !tabGroups.has(tabGroup)) continue;
items.push({ order, items: orderItems, tabGroups: [...tabGroups], repName });
}
// For the search path, items.length is the post-filter count and
// pagination collapses to 1 page (the active set is ~91 orders, well
// under ORDER_SEARCH_HARD_CAP, and we filter in memory). For the
// tab-only path, PB already paginated the matching set, so
// ordersPage.totalItems/totalPages are the truth. For the plain
// PB-paginated path (no tab, no search), PB's own metadata is also
// authoritative.
const finalTotalItems = search ? items.length : ordersPage.totalItems;
const finalTotalPages = search ? 1 : ordersPage.totalPages;
return {
items,
totalPages: finalTotalPages,
totalItems: finalTotalItems,
...(searchCounts ? { searchCounts } : {}),
};
}
/**
* Returns the distinct order ids that have at least one line item in the
* given tab group. Used by the server-side tab filter to build the
* `id = "..." || ...` predicate for the orders query.
*/
export async function getOrderIdsForTabGroup(
pb: AdminPocketBase,
tabGroup: TabGroup,
): Promise<string[]> {
// The `completed` tab is the only tab whose matching set is
// time-windowed (see `completedTabFilter` above). Every other tab
// maps 1:1 to a fixed status set, so `tabGroupFilter` is enough.
const filter =
tabGroup === "completed"
? completedTabFilter()
: tabGroupFilter(tabGroup);
const items = await pb
.collection(getOrderItemsCollectionName())
.getFullList<{ order?: string }>({
filter,
fields: "order",
});
const ids = new Set<string>();
for (const item of items) {
if (item.order) ids.add(item.order);
}
return [...ids];
}
/**
* Distinct order ids with at least one line item in ANY of the
* active workflow tabs — i.e. the orders /orders is responsible
* for. Delivered + cancelled are excluded; they belong to
* /orders/archive.
*
* The `completed` tab is special: it's time-windowed, so we can't
* just include `completed` in the status filter (that would let
* 60-day-old `completed` items leak into search). Instead we
* union two PB scans:
*
* 1. The non-time-windowed active statuses (~4 statuses).
* 2. The `completedTabFilter` slice, which restricts to items
* delivered/completed in the last COMPLETED_WINDOW_DAYS.
*
* This is the search candidate set. Measured on live PB (2026-08):
* ~91 order ids, well within the inline `id = "..."` filter cap
* used by `fetchOrdersFilteredByIds`. Two PB scans here instead of
* one is fine — both are fields-only and cheap.
*/
export async function getActiveOrderIds(
pb: AdminPocketBase,
): Promise<string[]> {
const nonCompletedActiveStatuses = ACTIVE_TAB_STATUSES.filter(
(s) => s !== "completed",
);
const [nonCompletedItems, completedItems] = await Promise.all([
pb
.collection(getOrderItemsCollectionName())
.getFullList<{ order?: string }>({
filter: nonCompletedActiveStatuses
.map((s) => `item_status = "${s}"`)
.join(" || "),
fields: "order",
batch: TAB_COUNTS_BATCH_SIZE,
}),
pb
.collection(getOrderItemsCollectionName())
.getFullList<{ order?: string }>({
filter: completedTabFilter(),
fields: "order",
batch: TAB_COUNTS_BATCH_SIZE,
}),
]);
const ids = new Set<string>();
for (const item of [...nonCompletedItems, ...completedItems]) {
if (item.order) ids.add(item.order);
}
return [...ids];
}
/**
* Fetch orders whose id is in `ids`, paginated.
*
* The id set is chunked so that no single `id = "..." || ...` filter
* crosses PB's inline filter length cap. Measured against live PB
* (2026-08-14): a filter with 107 ids (~3.5 KB) is rejected with HTTP
* 400 ("Something went wrong while processing your request.") while
* 106 ids pass. `ID_FILTER_CHUNK_SIZE` sits comfortably below that so
* the query string's other params (sort, expand, perPage) can't push
* a chunk over the edge either.
*
* Each chunk is fetched in parallel, the results are merged, re-sorted
* by `sort` (PB only sorts within a chunk), and the requested page is
* sliced in memory. The row shape is identical to a single paginated
* `getList` — the caller can't tell the difference.
*/
async function fetchOrdersFilteredByIds(
pb: AdminPocketBase,
ids: string[],
opts: { page: number; perPage: number; sort: string; expand: string },
): Promise<{ items: Order[]; totalItems: number; totalPages: number; page: number; perPage: number }> {
const { page, perPage, sort, expand } = opts;
const chunks: string[][] = [];
for (let i = 0; i < ids.length; i += ID_FILTER_CHUNK_SIZE) {
chunks.push(ids.slice(i, i + ID_FILTER_CHUNK_SIZE));
}
const results = await Promise.all(
chunks.map((chunk) =>
pb
.collection(ORDER_COLLECTION)
.getList<Order>(1, chunk.length, {
filter: chunk
.map((id) => `id = "${id.replace(/"/g, '\\"')}"`)
.join(" || "),
sort,
expand,
}),
),
);
const all = results.flatMap((result) => result.items);
// PB sorts within each chunk only; re-sort the merged set so the
// combined list honours `sort` globally.
sortRowsByField(all, sort);
const totalItems = all.length;
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
const start = (page - 1) * perPage;
return {
items: all.slice(start, start + perPage),
totalItems,
totalPages,
page,
perPage,
};
}
/** In-place merge-sort of PB rows by a `sort` param (`created`,
* `-created`, ...). Used to re-apply the global order after
* chunked fetches, where PB's per-chunk sorting is not enough. */
function sortRowsByField<T extends object>(
rows: T[],
sort: string,
): void {
const descending = sort.startsWith("-");
const field = descending ? sort.slice(1) : sort;
rows.sort((a, b) => {
const rowA = a as Record<string, unknown>;
const rowB = b as Record<string, unknown>;
const av = rowA[field];
const bv = rowB[field];
if (av === bv) return 0;
if (av == null) return 1;
if (bv == null) return -1;
const at = typeof av === "number" ? av : Date.parse(String(av));
const bt = typeof bv === "number" ? bv : Date.parse(String(bv));
const cmp =
!Number.isNaN(at) && !Number.isNaN(bt)
? at - bt
: String(av).localeCompare(String(bv));
return descending ? -cmp : cmp;
});
}
/**
* Safe ceiling on the number of ids in one inline `id = "..." || ...`
* filter. Live PB rejects the filter above ~106 ids (~3.5 KB) with
* HTTP 400 — see `fetchOrdersFilteredByIds`.
*/
const ID_FILTER_CHUNK_SIZE = 80;
/**
* Fetches only the rep users referenced by the loaded orders, instead of
* `users.getFullList` (every user in PB every page load).
*
* Coverage:
* - `customer.rep` ids where the expand didn't return a record (PB
* occasionally returns the relation id but not the expanded record
* — e.g. when the user has been soft-deleted)
* - `orders.rep` strings that look like PB record ids (15-char
* alphanumeric). Older orders stored a user id here instead of a
* name; the lookup is the fallback path in `resolveRepDisplayName`.
*
* Returns an empty array when no lookup is needed.
*/
async function fetchRepUsersForOrders(
pb: AdminPocketBase,
orders: Order[],
): Promise<RepUser[]> {
const needed = new Set<string>();
for (const order of orders) {
const customer = order.expand?.customer as Customer | undefined;
// customer.rep id without an expanded record — we need to look it up
if (customer?.rep && !customer.expand?.rep) {
needed.add(customer.rep);
}
// Legacy: order.rep sometimes stored a PB user id (15-char alphanumeric)
if (order.rep && /^[a-z0-9]{15}$/i.test(order.rep)) {
needed.add(order.rep);
}
}
if (needed.size === 0) return [];
const filter = [...needed].map((id) => `id = "${id.replace(/"/g, '\\"')}"`).join(" || ");
return pb.collection("users").getFullList<RepUser>({
filter,
fields: "id,name,email",
});
}
export async function getOrderItemStatusTabCounts(
pbArg?: AdminPocketBase,
options: { bypassCache?: boolean; includeArchivedTabs?: boolean } = {},
): Promise<Record<TabGroup, number>> {
const pb = pbArg || (await getAdminPocketBase());
pb.autoCancellation(false);
const includeArchivedTabs = options.includeArchivedTabs ?? true;
const cacheKey = includeArchivedTabs ? "all" : "active";
if (
!options.bypassCache &&
cachedTabCounts.get(cacheKey)?.expiresAt &&
cachedTabCounts.get(cacheKey)!.expiresAt > Date.now()
) {
return cachedTabCounts.get(cacheKey)!.value;
}
// Combined query: one PB request, one filter scan, all tab statuses
// at once. Replaces the previous 6 parallel `getFullList` calls
// (one per tab), each of which did an independent filter scan over
// `order_items`. With a non-indexed `item_status` field on a large
// collection (50k+ rows is plausible), the 6-scan path dominates
// the page-load budget; collapsing to 1 scan is the single biggest
// perf win on the orders page after the earlier expand + users
// changes. Bucket in memory — the dataset is small (only `item_status`
// and `order` fields, both short strings).
const statuses = includeArchivedTabs ? TAB_STATUSES : ACTIVE_TAB_STATUSES;
const filter = statuses.map((s) => `item_status = "${s}"`).join(" || ");
const allItems = await pb
.collection(getOrderItemsCollectionName())
.getFullList<{ item_status: string; order?: string }>({
filter,
fields: "item_status,order",
batch: TAB_COUNTS_BATCH_SIZE,
});
const counts = {} as Record<TabGroup, number>;
const seen: Record<TabGroup, Set<string>> = {} as Record<TabGroup, Set<string>>;
for (const tab of TAB_GROUP_ORDER) {
counts[tab] = 0;
seen[tab] = new Set();
}
for (const item of allItems) {
const orderId = item.order;
if (!orderId) continue;
const tab = itemStatusToTabGroup(item.item_status as ItemStatus);
if (seen[tab].has(orderId)) continue;
seen[tab].add(orderId);
counts[tab]++;
}
// The `completed` tab is the only tab whose matching set is
// time-windowed (see `completedTabFilter` above). The first scan
// bucketed every `delivered` and `completed` item into their
// nominal tabs without applying the freshness check — which is
// correct for `delivered` (those rows are what /orders/archive
// wants), but wrong for `completed` (the tab badge would show
// "47" when only 6 are within the last month).
//
// Reset the `completed` bucket and re-populate it from a
// second, time-windowed scan. One extra PB query, fields-only,
// no expand — cheap relative to the big combined scan we just
// did. We don't touch `seen.delivered` so the archive count
// stays intact.
counts.completed = 0;
seen.completed = new Set();
const completedRows = await pb
.collection(getOrderItemsCollectionName())
.getFullList<{ item_status: string; order?: string }>({
filter: completedTabFilter(),
fields: "item_status,order",
batch: TAB_COUNTS_BATCH_SIZE,
});
for (const item of completedRows) {
const orderId = item.order;
if (!orderId) continue;
const tab = "completed" as TabGroup;
if (seen[tab].has(orderId)) continue;
seen[tab].add(orderId);
counts[tab]++;
}
if (!includeArchivedTabs) {
counts.delivered = 0;
counts.cancelled = 0;
}
cachedTabCounts.set(cacheKey, {
value: counts,
expiresAt: Date.now() + TAB_COUNTS_CACHE_TTL_MS,
});
return counts;
}
/* ============================================================================
* Orders archive
* ----------------------------------------------------------------------------
* Read model for `/orders/archive` — a dedicated route for delivered and
* cancelled orders. Why a separate page rather than a tab on the main
* `/orders` view:
*
* On the live PB: 4,326 of 4,514 orders have at least one delivered
* item, and another 111 have a cancelled item — ~99% of the orders
* collection is historical. The main `/orders` page is for the
* 22-28 active orders an operator actually works on; trying to
* paginate a "Delivered" tab over 4,326 rows gave the operator
* "Page 1 of 217" and an error when PB's filter-length cap was
* hit by the 4,326-id IN clause. Moving historical orders to a
* dedicated page:
* - keeps the main page snappy (small active set, no archive
* noise in the tab counts)
* - lets the archive page be honest about its pagination
* ("Page 1 of 217" is expected when the operator is hunting
* for something from last month)
* - leaves a clean upgrade path: if a real archive flag gets
* added to the schema later, this function is the single
* place that needs to change
*
* The matching set is "orders with at least one item in
* (delivered, cancelled) status" — same as the old Delivered +
* Cancelled tabs combined. 28 active orders that have a delivered
* straggler item also appear here; the operator reads them as
* "mostly done, one item left" which is honest.
*
* Strategy for fetching: the matching set is small (a few thousand
* ids), so we materialise it in memory once, sort by the requested
* `sort` field, paginate in memory, and fetch the page's order
* details with deep expand. This avoids the 4,326-id IN-clause
* problem (PB's filter cap is ~4 KB; 4,326 ids is ~120 KB) and
* keeps the page's round-trip count at 3:
* 1. order_items.getFullList (ids only, ~400 KB)
* 2. orders.getFullList (page, with deep expand)
* 3. order_items.getFullList (items for the page's orders)
* ========================================================================== */
export interface OrdersArchiveData {
items: OrderWithItems[];
totalItems: number;
totalPages: number;
/**
* Counts for the "Delivered" and "Cancelled" sub-totals shown
* at the top of the archive page. Per-tab counts of distinct
* orders, just like the main page's tab counts but scoped to
* the archive set. The data is GLOBAL (not page-scoped) so
* the operator can see the totals at a glance.
*/
counts: {
delivered: number;
cancelled: number;
};
}
const ARCHIVE_STATUSES = ["delivered", "cancelled"];
export async function getOrdersArchiveData(
params: {
page?: number;
perPage?: number;
search?: string;
sort?: string;
} = {},
pbArg?: AdminPocketBase,
): Promise<OrdersArchiveData> {
const pb = pbArg || (await getAdminPocketBase());
pb.autoCancellation(false);
const page = Math.max(1, params.page || 1);
const perPage = Math.min(100, Math.max(1, params.perPage || 20));
const search = (params.search || "").trim();
const sort = params.sort || "created";
const archiveBackRelationFilter = ARCHIVE_STATUSES
.map((status) => `order_items_via_order.item_status ?= "${status}"`)
.join(" || ");
if (!search) {
const expand = "company,customer,customer.rep,quote";
const [ordersPage, deliveredResp, cancelledResp] = await Promise.all([
pb.collection(ORDER_COLLECTION).getList<Order>(page, perPage, {
filter: archiveBackRelationFilter,
sort,
expand,
}),
pb.collection(ORDER_COLLECTION).getList<unknown>(1, 1, {
filter: `order_items_via_order.item_status ?= "delivered"`,
}),
pb.collection(ORDER_COLLECTION).getList<unknown>(1, 1, {
filter: `order_items_via_order.item_status ?= "cancelled"`,
}),
]);
const orderIds = ordersPage.items.map((order) => order.id);
const [allItemsForPage, users] = await Promise.all([
fetchOrderItemsForOrders(pb, orderIds),
fetchRepUsersForOrders(pb, ordersPage.items),
]);
const usersById = new Map(users.map((user) => [user.id, user]));
const itemsByOrder: Record<string, OrderItem[]> = {};
for (const item of allItemsForPage) {
const key = item.order || "";
(itemsByOrder[key] ||= []).push(item);
}
const items: OrderWithItems[] = ordersPage.items.map((order) => {
const orderItems = itemsByOrder[order.id] || [];
const customer = order.expand?.customer as Customer | undefined;
const repName = resolveRepDisplayName({
expandedCustomerRep: customer?.expand?.rep,
customerRepId: customer?.rep,
orderRep: order.rep,
usersById,
});
const tabGroups = new Set<TabGroup>();
for (const it of orderItems) {
tabGroups.add(itemStatusToTabGroup(it.item_status));
}
return {
order,
items: orderItems,
tabGroups: [...tabGroups],
repName,
};
});
return {
items,
totalItems: ordersPage.totalItems,
totalPages: ordersPage.totalPages,
counts: {
delivered: deliveredResp.totalItems,
cancelled: cancelledResp.totalItems,
},
};
}
// Step 1: every order id that has at least one item in
// (delivered, cancelled). Single query, fields-only, batch 5000.
// Returns ~13k rows for a typical 4k-order archive; PB paginates
// internally with the batch size. Then dedupe in memory.
//
// On a 4.4k-order archive this single query takes ~17s end-to-end
// (PB paginates 13256 rows × 2 fields over many round-trips).
// It's the dominant cost; the orders fetch in step 2 is much
// cheaper once we have the id set.
const allItems = await pb
.collection(getOrderItemsCollectionName())
.getFullList<{ item_status?: string; order?: string }>({
filter: ARCHIVE_STATUSES.map((s) => `item_status = "${s}"`).join(" || "),
fields: "item_status,order",
batch: 5000,
});
// Two parallel reductions in one pass: distinct order ids
// (for the orders query) and per-status distinct counts (for
// the header totals). Same input, two outputs, single walk.
const matchingIds: string[] = [];
const seen = new Set<string>();
const distinctByStatus: Record<string, Set<string>> = {
delivered: new Set(),
cancelled: new Set(),
};
for (const item of allItems) {
const id = item.order;
const status = item.item_status;
if (!id) continue;
if (!seen.has(id)) {
seen.add(id);
matchingIds.push(id);
}
if (status === "delivered" || status === "cancelled") {
distinctByStatus[status].add(id);
}
}
if (matchingIds.length === 0) {
return {
items: [],
totalItems: 0,
totalPages: 0,
counts: { delivered: 0, cancelled: 0 },
};
}
// Step 2: filter by search (in memory — we already have the
// matching order id set; search hits order fields so we still
// need the order details to evaluate it). To keep the memory
// footprint reasonable, fetch the candidate set first, then
// filter, then paginate.
//
// We chunk the order id set into PB-safe filters (200 ids per
// chunk, ~6 KB each, well under PB's filter cap) and fetch all
// matching orders in parallel — one round-trip per chunk, all
// parallel.
const expand = "company,customer,customer.rep,quote";
// The `fields` projection here is safe (no expand on this query
// path in the archive — we re-expand when we re-fetch the
// page below). Wait, we DO want expand here. Let me drop
// `fields` and just rely on PB returning the full record.
// (Same PB-0.26 constraint as the dashboard list: passing
// `fields` drops `expand`.)
// We chunk the order id set into PB-safe filters (100 ids per
// chunk — empirically the max that survives PB's URL/filter
// cap, which sits between 4571 and 6771 bytes for the encoded
// `id = "..." || ...` chain). Each chunk is one HTTP request;
// we fire them in parallel so a 4400-id archive is ~45 chunks
// fired concurrently rather than sequentially. PB's Go server
// handles the load fine; the bottleneck is the per-request
// deep-expand IN scan, not connection count.
const ARCHIVE_CHUNK_SIZE = 100;
const chunks: string[][] = [];
for (let i = 0; i < matchingIds.length; i += ARCHIVE_CHUNK_SIZE) {
chunks.push(matchingIds.slice(i, i + ARCHIVE_CHUNK_SIZE));
}
const candidateOrders = (
await Promise.all(
chunks.map((chunk) => {
const filter = chunk
.map((id) => `id = "${id.replace(/"/g, '\\"')}"`)
.join(" || ");
return pb.collection(ORDER_COLLECTION).getFullList<Order>({
filter,
sort,
expand,
});
}),
)
).flat();
// Step 3: apply search in memory. Matches order_number, company
// name, customer name, or contact_name. (Item-description /
// price search would require the items fetch on every candidate;
// skipped for the archive — it's a "find my historical order"
// tool, not a "find what we sold last Tuesday" tool. The
// Orders page search still has item-level matching.)
const searchLower = search.toLowerCase();
let filtered = candidateOrders;
if (search) {
filtered = candidateOrders.filter((o) => {
if ((o.order_number || "").toLowerCase().includes(searchLower)) return true;
if ((o.expand?.company?.company || "").toLowerCase().includes(searchLower)) return true;
if ((o.expand?.customer?.customer || "").toLowerCase().includes(searchLower)) return true;
if ((o.contact_name || "").toLowerCase().includes(searchLower)) return true;
return false;
});
}
// Step 4: paginate. (No further sort needed — each chunk came
// back from PB already sorted by `sort`, and we concatenated
// chunks in id order, which is arbitrary. Apply a final sort
// here so the per-page order is deterministic regardless of
// chunk boundaries.)
const sortKey = sort.replace(/^-/, "");
const sortDir = sort.startsWith("-") ? -1 : 1;
filtered.sort((a, b) => {
const av = (a as unknown as Record<string, unknown>)[sortKey];
const bv = (b as unknown as Record<string, unknown>)[sortKey];
if (av === bv) return 0;
// PB returns `null` for unset fields (e.g. `updated`, `email_body`)
// and `undefined` for absent fields. Treat both as "missing"
// and sink them to the end of an ascending sort, the start of
// a descending sort. Avoids the `av > bv` comparison where one
// side is null and TS strict mode rejects the >.
if (av == null) return 1;
if (bv == null) return -1;
if (av > bv) return 1 * sortDir;
if (av < bv) return -1 * sortDir;
return 0;
});
const totalItems = filtered.length;
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
const start = (page - 1) * perPage;
const pageOrders = filtered.slice(start, start + perPage);
// Step 5: line items + rep users for the page. Same pattern as
// the dashboard read model — the items fetch is parallel with
// the rep-user lookup.
const orderIds = pageOrders.map((o) => o.id);
const [allItemsForPage, users] = await Promise.all([
fetchOrderItemsForOrders(pb, orderIds),
fetchRepUsersForOrders(pb, pageOrders),
]);
const usersById = new Map(users.map((u) => [u.id, u]));
const itemsByOrder: Record<string, OrderItem[]> = {};
for (const item of allItemsForPage) {
const key = item.order || "";
(itemsByOrder[key] ||= []).push(item);
}
const items: OrderWithItems[] = pageOrders.map((order) => {
const orderItems = itemsByOrder[order.id] || [];
const customer = order.expand?.customer as Customer | undefined;
const repName = resolveRepDisplayName({
expandedCustomerRep: customer?.expand?.rep,
customerRepId: customer?.rep,
orderRep: order.rep,
usersById,
});
const tabGroups = new Set<TabGroup>();
for (const it of orderItems) {
tabGroups.add(itemStatusToTabGroup(it.item_status));
}
return {
order,
items: orderItems,
tabGroups: [...tabGroups],
repName,
};
});
return {
items,
totalItems,
totalPages,
counts: {
delivered: distinctByStatus.delivered.size,
cancelled: distinctByStatus.cancelled.size,
},
};
}
export interface OrderReviewEmail {
/**
* From: address of the customer email that originated this order.
* Resolved with the same fallback chain as `subject` / `body` —
* see `getOrderReviewData` below. Empty string means the sender
* is genuinely unknown (the modal renders `(unknown sender)`).
*/
sender: string;
subject: string;
body: string;
}
export async function getOrderReviewData(orderId: string): Promise<{
order: Order;
items: OrderItem[];
customers: Customer[];
/**
* Source email for the review modal. Prefers the order's own
* `email_subject` / `email_body` / `email_sender` (populated by
* the n8n agent when it ingests the customer email that
* triggered the order). Falls back to the related `quote.email_*`
* for older orders that were created before the snapshot
* columns existed on `orders`.
*/
quoteEmail: OrderReviewEmail;
}> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const escapedOrderId = orderId.replace(/"/g, '\\"');
const [order, items, customers] = await Promise.all([
pb.collection(ORDER_COLLECTION).getOne<Order>(orderId, {
expand: "company,customer,customer.company,customer.rep,quote",
}),
pb.collection(getOrderItemsCollectionName()).getFullList<OrderItem>({
filter: `order = "${escapedOrderId}"`,
expand: "supplier",
sort: "created",
}),
pb.collection("customers").getFullList<Customer>({
sort: "-created",
expand: "company,rep",
}),
]);
// Resolve the source email: prefer the order's own columns
// (populated by the n8n ingest agent on order creation). Fall
// back to the related quote's outbound email so orders that
// pre-date the snapshot columns still render something useful.
// The quote side carries the *operator's* email_body, not the
// customer's, so this fallback is best-effort — the order
// columns are the audit trail we actually want to show.
//
// Sender resolution: order.email_sender → quote.email_sender →
// customer.email (last resort). The customer's general email
// isn't necessarily the address that triggered this specific
// order — it's only used so the modal has *something* to show
// for pre-snapshot orders. The empty-string fallthrough renders
// as "(unknown sender)" in the modal.
const orderSubject = (order.email_subject || "").trim();
const orderBody = (order.email_body || "").trim();
const orderSender = (order.email_sender || "").trim();
const quote = order.expand?.quote as
| { email_subject?: string; email_body?: string; email_sender?: string }
| undefined;
const quoteSubject = (quote?.email_subject || "").trim();
const quoteBody = (quote?.email_body || "").trim();
const quoteSender = (quote?.email_sender || "").trim();
const customerEmail = ((order.expand?.customer as Customer | undefined)?.email || "").trim();
const sender = orderSender || quoteSender || customerEmail;
return {
order,
items,
customers,
quoteEmail: {
sender,
subject: orderSubject || quoteSubject,
body: orderBody || quoteBody,
},
};
}
async function fetchOrderItemsForOrders(
pb: AdminPocketBase,
orderIds: string[],
): Promise<OrderItem[]> {
if (orderIds.length === 0) return [];
// Limit the wire payload to just what the list view + search
// predicate read. The `order_items` schema has many unbounded text
// columns (description 5k, notes 5k, problem_notes 5k, ai_item_insights
// 64k json, etc.) that the list view never displays but would all
// be pulled over the wire without this. `supplier` was an expand
// here historically but the list view doesn't render the supplier
// per line (that's the order-level PO concern, not the per-line
// card), so we drop it.
const ITEM_FIELDS = [
"id",
"order",
"description",
"po_description",
"quantity",
"selling_price",
"cost_price",
"item_status",
].join(",");
const chunks: string[][] = [];
for (let index = 0; index < orderIds.length; index += ORDER_ITEM_FILTER_CHUNK) {
chunks.push(orderIds.slice(index, index + ORDER_ITEM_FILTER_CHUNK));
}
const batches = await Promise.all(
chunks.map((ids) =>
pb.collection(getOrderItemsCollectionName()).getFullList<OrderItem>({
filter: ids
.map((id) => `order = "${id.replace(/"/g, '\\"')}"`)
.join(" || "),
fields: ITEM_FIELDS,
sort: "created",
}),
),
);
return batches.flat();
}
function isNumericOrderSearch(search: string): boolean {
return /^\d+(\.\d+)?$/.test(search) && !Number.isNaN(Number(search));
}
function orderMatchesDashboardSearch(
order: Order,
items: OrderItem[],
searchLower: string,
numericMatch: number | null,
): boolean {
if ((order.order_number || "").toLowerCase().includes(searchLower)) return true;
if ((order.expand?.company?.company || "").toLowerCase().includes(searchLower)) {
return true;
}
if ((order.expand?.customer?.customer || "").toLowerCase().includes(searchLower)) {
return true;
}
for (const item of items) {
if ((item.description || "").toLowerCase().includes(searchLower)) return true;
if ((item.po_description || "").toLowerCase().includes(searchLower)) return true;
}
if (numericMatch !== null) {
const epsilon = 0.001;
return items.some(
(item) =>
Math.abs(Number(item.selling_price || 0) - numericMatch) < epsilon ||
Math.abs(Number(item.cost_price || 0) - numericMatch) < epsilon,
);
}
return false;
}
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 {
// 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.
* Supports `order_number`, `customer`, `company` (relation id), and
* `rep` (free-text snapshot of the rep's name). The `company` and
* `rep` fields are cascaded from the selected customer by the modal
* (see `orders-review-modal.tsx`). 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;
company?: string;
rep?: 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 (patch.company !== undefined) cleanPatch.company = patch.company;
if (patch.rep !== undefined) cleanPatch.rep = patch.rep;
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;
description?: string | null;
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;
// Description edits keep `description` and `po_description` in
// lock-step. PB's review step lets the operator correct a
// misread item title — both fields must move together so the
// line on the PO PDF and the row on the order stay aligned.
// Reject an empty description so the operator gets a targeted
// error rather than a 500.
if (item.description !== undefined) {
const trimmed = (item.description || "").trim();
if (!trimmed) {
failed.push({
id: item.id,
error: "Description is required",
});
continue;
}
patch.description = trimmed;
patch.po_description = trimmed;
}
// 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 };
}
/**
* Standalone order creation — without going through a quote. Use when
* the operator wants to capture a manual order (counter sale, walk-in,
* internal transfer) that doesn't have an upstream quote record.
*
* Validation:
* - customer_id and company_id must resolve in PB (404 otherwise).
* - items[] must contain at least one entry with a non-empty description.
*
* Quote-related fields are intentionally blank — there's no originating
* quote, so `quote`, `potential_quote`, `quote_match_confidence`, and
* `potential_quote_confidence` are all empty/null/0. The matcher fields
* stay defaulted so a later `orders.update` can populate them if the
* order later matches a quote retroactively.
*
* Line items reuse `createOrderItems` for consistent defaults
* (`item_status: "pending"`, `drv_status: "unassigned"`, etc.).
*/
export interface CreateOrderItemDraft {
description: string;
quantity?: number | null;
cost_price?: number;
markup?: number;
selling_price?: number;
sku?: string;
unit?: string;
supplier_id?: string;
notes?: string;
}
export interface CreateOrderInput {
customer_id: string;
company_id: string;
notes?: string;
contact_name?: string;
description?: string;
rep?: string;
items: CreateOrderItemDraft[];
/**
* RFC 5322 Message-ID of the inbound email that originated this
* order (set by the n8n ingestion pipeline). Used as the dedup
* key — if an order already exists with this `email_message_id`,
* `createOrder` returns the existing record instead of creating
* a duplicate. Optional: counter/agent-created orders without
* an originating email pass this as undefined and skip the
* dedup check entirely.
*/
email_message_id?: string;
/**
* Free-text sender address of the inbound email that originated
* this order (RFC 5322 From: address). Optional — the n8n intake
* pipeline stamps it for traceability so the operator can see who
* emailed the PO even when customer matching falls back to a
* placeholder.
*/
email_sender?: string;
/**
* Free-text subject line of the inbound email. Optional — same
* audit purpose as `email_sender`.
*/
email_subject?: string;
/**
* Plain-text body of the inbound email. Optional. Trimmed by the
* caller; large HTML attachments stay on `quote_attachments` /
* `order_attachements` — this field is the lightweight text-only
* snapshot for the order's audit trail.
*/
email_body?: string;
/**
* Optional customer-supplied order number (e.g. the PO/reference
* printed on the inbound PO PDF that the n8n intake pipeline
* extracts from the attachment). When provided, the value is
* used verbatim as `orders.order_number` instead of the auto-
* generated `ORD-YYYY-MM-NNN`. Empty / whitespace-only values
* are ignored — `createOrder` falls back to auto-generation,
* same as if the field were omitted.
*
* Uniqueness is enforced against the existing `orders` collection
* before write (and again via DB-level unique index on race);
* collisions throw `OrderNumberCollisionError` so the caller can
* surface a 409 with the colliding order id.
*/
order_number?: string;
}
export interface CreateOrderResult {
order: Order;
order_items_collection: string;
items_created: Array<{
id: string;
description: string;
quantity: number | null;
cost_price: number;
selling_price: number;
markup: number;
}>;
items_failed: Array<{ draft: CreateOrderItemDraft; error: string }>;
/**
* True when the call was a no-op because an order with the same
* `email_message_id` already existed. In that case `order` is the
* existing record, `items_created` is empty, and `items_failed`
* is empty. The caller (the n8n ingestion pipeline, or the agent
* API) can use this flag to skip downstream side-effects (e.g. an
* "order created" webhook) without needing to inspect the items
* arrays.
*/
duplicate?: boolean;
}
export async function createOrder(input: CreateOrderInput): Promise<CreateOrderResult> {
const pb = await getAdminPocketBase();
const orderItemsCollection = getOrderItemsCollectionName();
// --- Input validation ----------------------------------------------------
if (!input || !input.customer_id || !input.company_id) {
throw new Error("customer_id and company_id are required");
}
if (!Array.isArray(input.items) || input.items.length === 0) {
throw new Error("items must be a non-empty array");
}
// Pre-flight existence checks for the relations. PB would 400 on a
// missing relation, but with a less-actionable message. A 404 from
// getOne is a clean signal we can re-throw to the caller.
try {
await pb.collection("customers").getOne(input.customer_id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Customer not found: ${input.customer_id} (${message})`);
}
try {
await pb.collection("companies").getOne(input.company_id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Company not found: ${input.company_id} (${message})`);
}
// --- Dedup pre-check (email_message_id) --------------------------------
// The n8n email-intake pipeline can poll the same IMAP message
// multiple times if the seen-flag races the workflow execution
// (see docs/audit/order-pipeline-investigation-2026-08-20.md §2).
// Without this check, every poll cycle creates a new `orders`
// record. With this check, the second-and-later calls short-circuit
// and return the existing order — same input, same output, no
// duplicate rows.
//
// Counter-form and agent-API-created orders pass `email_message_id`
// as undefined; the check is skipped. The DB-level UNIQUE index
// (added separately by the operator in the PB admin) is the
// second line of defense — see the try/catch around the create
// call below.
if (input.email_message_id && input.email_message_id.trim()) {
const trimmedMid = input.email_message_id.trim();
try {
const existing = await pb
.collection(ORDER_COLLECTION)
.getFirstListItem(`email_message_id = "${trimmedMid.replace(/"/g, '\"')}"`);
if (existing) {
return {
order: existing as Order,
order_items_collection: orderItemsCollection,
items_created: [],
items_failed: [],
duplicate: true,
};
}
} catch (err) {
// PB throws 404 when no record matches — that's the happy path,
// we just continue to create. Any other error (network, auth)
// we let propagate.
const status = (err as { status?: number })?.status;
if (status !== 404) {
throw err;
}
}
}
// --- Order row ------------------------------------------------------------
// Mirrors the field set `createOrderFromQuote` writes, minus the
// quote-related fields (no quote on a standalone order).
//
// `orderNumber` resolution: caller-supplied custom number wins
// (after trim); empty / whitespace / undefined fall through to the
// auto-generated `ORD-YYYY-MM-NNN`. The custom path runs the same
// collision pre-check `updateOrderReviewFields` uses, so a 409
// surfaces the conflicting order id to the caller. Race recovery
// for the custom path lives in the catch block below.
const customOrderNumber = input.order_number?.trim();
let orderNumber: string;
if (customOrderNumber) {
const escapedNumber = customOrderNumber.replace(/"/g, '\\"');
const conflict = await pb
.collection(ORDER_COLLECTION)
.getList<{ id: string }>(1, 1, {
filter: `order_number = "${escapedNumber}"`,
});
if (conflict.items.length > 0) {
throw new OrderNumberCollisionError(
conflict.items[0].id,
customOrderNumber,
);
}
orderNumber = customOrderNumber;
} else {
orderNumber = await generateOrderNumberServer(pb);
}
let order: Order;
try {
order = (await pb.collection(ORDER_COLLECTION).create({
order_number: orderNumber,
company: input.company_id,
customer: input.customer_id || null,
quote: null,
notes: input.notes || "",
quote_matching_attempted: false,
quote_match_confidence: 0,
potential_quote: null,
potential_quote_confidence: 0,
is_repair_order: false,
invoiced: false,
rep: input.rep || "",
description: input.description || "",
contact_name: input.contact_name || "",
email_message_id: input.email_message_id?.trim() || "",
// Inbound email metadata — optional, stamped by the n8n intake
// pipeline so the operator can trace this order back to its
// source PO email. Trimmed; empty / undefined collapses to "".
email_sender: input.email_sender?.trim() || "",
email_subject: input.email_subject?.trim() || "",
email_body: input.email_body?.trim() || "",
})) as Order;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Race 1 — `email_message_id` collision: another concurrent
// createOrder call (or the n8n workflow calling
// pb.collection('orders').create directly) inserted the same
// email_message_id between our pre-check and this create. The
// DB-level UNIQUE index on email_message_id rejected the
// insert. Return the existing record instead of throwing — the
// caller asked "create or get"; this is the idempotent answer.
if (input.email_message_id && /unique|email_message_id/i.test(message)) {
try {
const existing = await pb
.collection(ORDER_COLLECTION)
.getFirstListItem(`email_message_id = "${input.email_message_id.replace(/"/g, '\"')}"`);
if (existing) {
return {
order: existing as Order,
order_items_collection: orderItemsCollection,
items_created: [],
items_failed: [],
duplicate: true,
};
}
} catch {
// Existing record not found even after the unique violation —
// fall through to the original error.
}
}
// Race 2 — `order_number` collision: a concurrent caller
// committed the same custom order_number between our pre-check
// and this create. The DB-level UNIQUE index on order_number
// rejected the insert. Map to the structured collision error so
// the route can 409 with the colliding order id (PB's native
// 400 string doesn't carry it).
if (customOrderNumber && /UNIQUE constraint failed/i.test(message)) {
try {
const escapedNumber = customOrderNumber.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,
customOrderNumber,
);
}
} catch (collisionErr) {
if (collisionErr instanceof OrderNumberCollisionError) throw collisionErr;
// Fall through to the original error if the re-query itself blew up.
}
}
throw err;
}
// --- Line items -----------------------------------------------------------
// Reuse `createOrderItems` so the per-row defaults stay in lock-step
// with everything else. Translate the public CreateOrderItemDraft shape
// into the internal one. supplier_id → supplier.
const drafts = input.items.map((it) => ({
description: it.description,
quantity: it.quantity ?? 1,
cost_price: Number(it.cost_price ?? 0),
markup: Number(it.markup ?? 0),
selling_price: Number(it.selling_price ?? 0),
}));
const itemsResult = await createOrderItems(order.id, drafts);
// Carry sku/unit/supplier_id/notes through via patch-after-create —
// `createOrderItems` doesn't take these (kept narrow for the
// process-page form). For standalone creation we want the operator
// to be able to seed all the fields in one shot, so we patch them in.
for (let i = 0; i < itemsResult.created.length; i++) {
const created = itemsResult.created[i];
const original = input.items[i];
if (!original) continue;
const patch: Record<string, unknown> = {};
if (original.sku !== undefined) patch.sku = original.sku;
if (original.unit !== undefined) patch.unit = original.unit;
if (original.supplier_id !== undefined) patch.supplier = original.supplier_id || null;
if (original.notes !== undefined) patch.notes = original.notes;
if (Object.keys(patch).length > 0) {
try {
await pb.collection(orderItemsCollection).update(created.id, patch);
} catch (err) {
// Don't abort the whole order — record the patch failure on
// the per-row failure list. The row was created, just with
// defaults instead of the requested extras.
const failedDraft: {
description: string;
quantity: number | null;
cost_price: number;
markup: number;
selling_price: number;
} = {
description: original.description,
quantity: original.quantity ?? null,
cost_price: Number(original.cost_price ?? 0),
markup: Number(original.markup ?? 0),
selling_price: Number(original.selling_price ?? 0),
};
itemsResult.failed.push({
draft: failedDraft,
error: `Row created but secondary fields failed: ${
err instanceof Error ? err.message : String(err)
}`,
});
}
}
}
// Quote auto-detection. ~85% of orders arrive against a quote we
// already produced, but nothing recorded that unless an operator
// linked it by hand — this is what closes that gap.
//
// Runs via `after()` so the candidate scan never sits in the intake
// response's critical path. Wrapped because a matcher failure must
// never fail an order creation: an unmatched order is a minor
// inconvenience, a lost order is not.
//
// Writes only the soft suggestion (`potential_quote`). Promoting it
// to a hard link stays a deliberate human click.
const runQuoteMatch = async () => {
try {
await suggestQuoteForOrder(order.id);
} catch (err) {
console.error(
`[createOrder] quote auto-detection failed for order ${order.id}:`,
err instanceof Error ? err.message : err,
);
}
};
try {
after(runQuoteMatch);
} catch (err) {
// `after()` throws synchronously when called outside a request
// scope — `workAsyncStorage.getStore()` comes back empty, throwing
// error code E468. Next 16.2.6,
// node_modules/next/dist/server/after/after.js.
//
// `createOrder` is an exported service function reachable from
// multiple layers (route handlers, server components, agents),
// not only from in-scope callers. Letting the throw escape would
// turn "matcher unavailable" into "order creation failed" —
// exactly the failure mode this whole block exists to avoid. Fall
// back to firing the match without blocking the return.
//
// Logged because a silent fallback here is invisible: orders
// would lose the latency guarantee permanently with no trace.
console.error(
`[createOrder] after() unavailable, running quote match inline for ${order.id}:`,
err,
);
void runQuoteMatch();
}
return {
order,
order_items_collection: orderItemsCollection,
items_created: itemsResult.created,
items_failed: itemsResult.failed,
};
}
/**
* Order-level split. Caller passes the order_item IDs they want moved
* to a new continuation order; unlisted items stay on the parent.
*
* Semantics:
* - The parent order keeps its number and is unchanged structurally —
* moved items get a `notes` breadcrumb but are not deleted (audit
* trail) and their `order` relation stays pointing at the parent
* (so historical queries / POs / delivery notes continue to resolve).
* - The new continuation order inherits the parent's customer, company,
* quote, description, notes, rep, contact_name, rfq_number and
* quote_reference_text — it's the same customer engagement, just a
* fresh operational batch.
* - The new order gets a fresh `ORD-YYYY-MM-NNN` number from
* `generateOrderNumberServer` and a `notes` breadcrumb recording the
* parent order number.
*
* Guard rails:
* - The parent order must exist (404 otherwise).
* - All requested item IDs must belong to the parent order
* (per-item 422 otherwise; nothing is moved).
* - All requested items must have `item_status === "pending"`. Splitting
* an item that has progressed (po_generated, assigned, collected,
* delivered, invoiced, etc.) would orphan the PO / driver assignment,
* so we refuse and return a per-item error. The caller can re-issue
* with only the still-pending IDs.
*
* Returns:
* { continuation, itemsMoved, itemsKept, itemsFailed }
* On total failure (no items moved) the continuation row is still
* created — caller can decide whether to delete it. The PB write is
* cheap and the row preserves the audit trail of "we tried".
*/
export interface SplitOrderResult {
continuation: Order;
itemsMoved: number;
itemsKept: number;
itemsFailed: Array<{ id: string; error: string }>;
}
export async function splitOrderForContinuation(
orderId: string,
itemIds: string[],
): Promise<SplitOrderResult> {
const pb = await getAdminPocketBase();
const orderItemsCollection = getOrderItemsCollectionName();
if (!Array.isArray(itemIds) || itemIds.length === 0) {
throw new Error("itemIds must be a non-empty array");
}
// Parent existence check.
let parent: Order;
try {
parent = await pb.collection(ORDER_COLLECTION).getOne<Order>(orderId);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Order not found: ${orderId} (${message})`);
}
// Pull every item on the parent. We need the full set to enforce the
// "item belongs to this order" cross-check and to compute itemsKept.
const allItems = await pb.collection(orderItemsCollection).getFullList({
filter: `order = "${orderId}"`,
sort: "created",
});
const requestedSet = new Set(itemIds);
const toMove = allItems.filter((it) => requestedSet.has(it.id));
const failed: Array<{ id: string; error: string }> = [];
// Track IDs that weren't even on the order — surface them in the
// per-item failure list rather than silently dropping.
const foundIds = new Set(toMove.map((it) => it.id));
for (const id of itemIds) {
if (!foundIds.has(id)) {
failed.push({
id,
error: `Item does not belong to order ${orderId}`,
});
}
}
// Reject items that have progressed past "pending" — splitting would
// orphan POs / driver assignments. We don't abort the whole split on
// the first offender; accumulate and skip so the caller sees every
// bad ID in one round-trip.
const splittable = toMove.filter((it) => {
if (it.item_status === "pending") return true;
failed.push({
id: it.id,
error: `Cannot split item in status "${it.item_status}" — only "pending" items can be moved`,
});
return false;
});
// --- New continuation order ----------------------------------------------
// Inherit every identity / context field from the parent. The new
// order is the same customer engagement, just a fresh operational
// batch with its own number.
const newOrderNumber = await generateOrderNumberServer(pb);
const now = new Date().toISOString();
const parentBreadcrumb = parent.notes
? parent.notes
: "";
const continuation = (await pb.collection(ORDER_COLLECTION).create({
order_number: newOrderNumber,
company: parent.company,
customer: parent.customer || null,
quote: parent.quote || null,
notes:
(parentBreadcrumb ? parentBreadcrumb + "\n" : "") +
`Split from ${parent.order_number} on ${now}`,
quote_matching_attempted: parent.quote_matching_attempted ?? false,
quote_match_confidence: parent.quote_match_confidence ?? 0,
potential_quote: parent.potential_quote || null,
potential_quote_confidence: parent.potential_quote_confidence ?? 0,
is_repair_order: parent.is_repair_order ?? false,
invoiced: false,
rep: parent.rep || "",
contact_name: parent.contact_name || "",
})) as Order;
// --- Clone items into the new order --------------------------------------
// Same shape as the original — operational flags reset to defaults
// because the new order is a fresh batch (no PO / no driver yet).
for (const it of splittable) {
const quantity = Number(it.quantity ?? 1);
const payload: Record<string, unknown> = {
order: continuation.id,
description: it.description || "",
po_description: it.po_description || it.description || "",
quantity,
order_qty: quantity,
qty_ordered: String(quantity),
selling_price: Number(it.selling_price ?? 0),
supplier: it.supplier || null,
cost_price: Number(it.cost_price ?? 0),
markup: Number(it.markup ?? 0),
markup_percent: Number(it.markup ?? 0),
item_status: "pending",
status: "active",
drv_status: "unassigned",
in_stock: it.in_stock ?? false,
notes: "",
sku: it.sku || "",
unit: it.unit || "",
quote_item: it.quote_item || null,
pricing_source: it.pricing_source || "",
pricing_notes: it.pricing_notes || "",
matched_price_list_id: it.matched_price_list_id || "",
};
await pb.collection(orderItemsCollection).create(payload);
}
// --- Audit breadcrumb on the original items ------------------------------
// Original items stay on the parent (their `order` relation keeps
// pointing there) but get a notes line so anyone reading the row
// knows where the line went. The `quote_item` back-reference and
// any PB-side state (po_description etc.) is preserved as-is.
for (const it of splittable) {
const existingNotes = it.notes ? String(it.notes) : "";
const breadcrumb = `Moved to ${newOrderNumber} via order split on ${now}`;
const mergedNotes = existingNotes ? `${existingNotes}\n${breadcrumb}` : breadcrumb;
await pb.collection(orderItemsCollection).update(it.id, {
notes: mergedNotes,
});
}
const itemsKept = allItems.length - splittable.length;
return {
continuation,
itemsMoved: splittable.length,
itemsKept,
itemsFailed: failed,
};
}
/* ============================================================================
* Order cancellation (soft)
* ----------------------------------------------------------------------------
* Soft-cancel an order without deleting the PB record. The order moves to
* /orders/archive under "Cancelled" because every line item's `item_status`
* flips to `cancelled` — which the existing tab taxonomy already maps to
* the cancelled tab group, and which /orders/page.tsx:342-354 then drops
* from the active tabs in favour of the archive view.
*
* Why "cancelled" rather than a hard delete:
* - Audit history. Invoiced POs, supplier replies, and the original
* email intake stay attached to the order record so reports and
* disputes can still reconstruct what happened.
* - Reversible in principle. A future "un-cancel" path can flip the
* items back to `pending` if the operator mistyped.
* - Consistent with how /orders/archive already treats delivered +
* cancelled orders.
*
* Guardrails:
* - Refuses to cancel an order that has any `delivered` items.
* Those have physically reached the customer — you can't un-ship.
* - Refuses to cancel an order that has any `invoiced` items.
* Money's been billed; reverting requires a credit note (out of
* scope for this action).
* - Refuses to cancel an order that already has any item with
* `item_status = cancelled` (idempotency — caller should refresh).
*
* The "downstream items" check uses the canonical tab taxonomy — every
* ItemStatus that's a downstream lifecycle state is excluded. The list
* mirrors TAB_GROUP_ORDER minus the in-flight tabs.
* ========================================================================== */
const CANCEL_BLOCKED_ITEM_STATUSES: ItemStatus[] = [
"delivered",
"invoiced",
// Already cancelled — re-cancelling is a no-op signal at best and
// can mask a real bug at worst (e.g. "I cancelled this twice" — was
// the second call against the same order or a different one?).
"cancelled",
];
export interface CancelOrderResult {
orderId: string;
itemsCancelled: number;
itemsSkipped: Array<{ id: string; item_status: string }>;
}
export async function cancelOrder(
orderId: string,
options: { allowDelivered?: boolean } = {},
): Promise<CancelOrderResult> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
// Confirm the order exists. PB's update/delete return clear 404s,
// but we want a friendlier message up front for the UI.
const order = await pb.collection(ORDER_COLLECTION).getOne(orderId).catch(() => null);
if (!order) {
const err = new Error(`Order ${orderId} not found`);
(err as Error & { status?: number }).status = 404;
throw err;
}
const items = await pb.collection(getOrderItemsCollectionName()).getFullList<{
id: string;
item_status: string;
}>({
filter: `order = "${orderId}"`,
fields: "id,item_status",
batch: 1000,
});
if (items.length === 0) {
// Empty order — nothing to flip. Idempotent success.
return { orderId, itemsCancelled: 0, itemsSkipped: [] };
}
const blocked = options.allowDelivered
? CANCEL_BLOCKED_ITEM_STATUSES.filter((s) => s !== "delivered")
: CANCEL_BLOCKED_ITEM_STATUSES;
const skipped = items
.filter((i) => blocked.includes(i.item_status as ItemStatus))
.map((i) => ({ id: i.id, item_status: i.item_status }));
if (skipped.length > 0 && !options.allowDelivered) {
// If EVERY item is blocked, refuse outright — nothing changed.
if (skipped.length === items.length) {
const reasons = [...new Set(skipped.map((s) => s.item_status))].join(", ");
const err = new Error(
`Cannot cancel order — every line item is in a terminal state (${reasons}). ` +
`Use Delete if you really want it gone.`,
);
(err as Error & { status?: number }).status = 409;
throw err;
}
// Otherwise let the partial cancel proceed and report the skipped
// items in the response so the caller can show the operator what
// didn't move.
}
const toCancel = items.filter(
(i) => !blocked.includes(i.item_status as ItemStatus),
);
// Bulk update — one PB round-trip per write is fine here; we cap at
// a few dozen items per order in practice.
for (const item of toCancel) {
await pb.collection(getOrderItemsCollectionName()).update(item.id, {
item_status: "cancelled",
});
}
return {
orderId,
itemsCancelled: toCancel.length,
itemsSkipped: skipped,
};
}
export async function cancelOrdersBulk(
orderIds: string[],
): Promise<{
succeeded: CancelOrderResult[];
failed: Array<{ orderId: string; error: string }>;
}> {
const succeeded: CancelOrderResult[] = [];
const failed: Array<{ orderId: string; error: string }> = [];
// Sequential — `cancelOrder` already fans out to N item updates per
// order, and parallelising at the order level would risk stomping on
// PB's autosubmit batching for no real wall-clock win at this scale
// (the operator is reviewing the dialog, not waiting on the network).
for (const id of orderIds) {
try {
succeeded.push(await cancelOrder(id, { allowDelivered: true }));
} catch (err) {
failed.push({
orderId: id,
error: err instanceof Error ? err.message : "Unknown error",
});
}
}
return { succeeded, failed };
}
// ──────────────────────────────────────────────────────────────────────
// findOrderByOrderNumber / findOrderByEmailMessageId (Hermes unblock)
// ──────────────────────────────────────────────────────────────────────
/**
* Exact-match lookup for an order by its `order_number`.
* Returns the first hit (the `order_number` column has a unique
* index in PB so there should only ever be one) or `null` on
* miss.
*
* Hermes needs this for the "is there already an order for this
* inbound email's tagged order number?" check before creating a
* new order from the email. The existing `orders.list` does a
* substring search on `order_number ~ "..."`, which is the
* wrong tool for the job (catches SO-12345 when looking for
* SO-1234, etc.).
*
* Quotes in user input are escaped before interpolation into
* the PB filter — same pattern as `createOrder` at line 1869.
*/
export async function findOrderByOrderNumber(
orderNumber: string,
): Promise<Order | null> {
const trimmed = (orderNumber ?? "").trim();
if (!trimmed) return null;
const pb = await getAdminPocketBase();
const esc = trimmed.replace(/"/g, '\"');
try {
const hit = await pb
.collection(ORDER_COLLECTION)
.getFirstListItem<Order>(`order_number = "${esc}"`);
return hit;
} catch (err) {
const status = (err as { status?: number })?.status;
if (status === 404) return null;
throw err;
}
}
/**
* Exact-match lookup for an order by its `email_message_id`.
* Returns the first hit or `null` on miss.
*
* Hermes needs this for the "is there already an order for this
* inbound email?" check before creating a new order from the
* email body. The existing `orders.list` does a substring
* search on `order_number`, not on `email_message_id`, so
* there was no exact-match path before this.
*
* The `email_message_id` column on PB has a unique index so a
* miss means "no order exists for this message" (return null)
* and a hit is guaranteed singular.
*/
export async function findOrderByEmailMessageId(
emailMessageId: string,
): Promise<Order | null> {
const trimmed = (emailMessageId ?? "").trim();
if (!trimmed) return null;
const pb = await getAdminPocketBase();
const esc = trimmed.replace(/"/g, '\"');
try {
const hit = await pb
.collection(ORDER_COLLECTION)
.getFirstListItem<Order>(`email_message_id = "${esc}"`);
return hit;
} catch (err) {
const status = (err as { status?: number })?.status;
if (status === 404) return null;
throw err;
}
}
/* ============================================================================
* Duplicate order detection
* ----------------------------------------------------------------------------
* Surface order records that share an `order_number` with at least one
* other order record. The operator decides what to cancel — the
* detection is intentionally dumb (just same-string match on
* order_number) because determining "is this a real duplicate" needs
* context the system doesn't have.
*
* The dialog shows every record under each shared number, including
* the company, customer, created timestamp, full item list, and
* per-item status — so the operator can see exactly what each copy
* contains and make the call.
*
* Why the dumb version: PB has counter-reuse bugs where genuinely
* different orders get the same number. The composite-key approach
* (matching on number + customer + company) was too strict and
* missed real intake-race duplicates where the racing copies share
* number + customer + company. The match-everything-on-number
* approach is too lax and surfaces counter-reuse cases. The operator
* is the only one who can tell which is which. See the live data:
* `389020` for Mark Wilkie appears 4 times — two of those are a
* real duplicate pair, the other two are a different intake that
* got the same number reused.
* ========================================================================== */
export interface DuplicateOrderRecord {
id: string;
orderNumber: string;
created: string;
customer: string | null;
company: string | null;
customerName: string | null;
companyName: string | null;
itemCount: number;
itemStatusSummary: Record<string, number>;
items: Array<{ description: string; quantity: number | null; item_status: string }>;
/**
* Sum of `quantity` across all line items on this order. Used by the
* line-aggregate duplicate pass to flag orders where the line totals
* (count + qty + selling-price) are identical even though descriptions
* differ or `order_number` was re-used. Null when the order has no
* items — such orders are excluded from line-aggregate matching.
*/
totalQuantity: number | null;
/**
* Sum of `selling_price * quantity` across all lines, rounded to 2
* decimal places. Same use as `totalQuantity`. Null when the order
* has no items.
*/
totalSellingPrice: number | null;
/**
* RFC 5322 Message-ID of the inbound email that originated this
* order, if known. Mirrored from `orders.email_message_id`.
* Null when the order was created without an originating email
* (counter-form, agent-created walk-in, etc.). Used by the
* `email_message_id` duplicate pass — records with a non-empty
* value are grouped together; empty values are skipped.
*/
emailMessageId: string | null;
}
export type DuplicateMatchKind =
| "order_number"
| "line_aggregate"
/**
* Two (or more) orders that share the same `email_message_id`
* — usually a race between two intake paths that both tried to
* ingest the same inbound email. Distinct from
* `order_number` because the order_number counter is recycled
* across months while email_message_id is RFC 5322-unique.
* Empty values are excluded; the unique index on
* `orders.email_message_id` should keep the count low but the
* agent surfaces any existing race to the operator.
*/
| "email_message_id";
export interface DuplicateOrderGroup {
/**
* What the records under this group have in common.
* - "order_number": same `order_number` (the existing intake-race case).
* - "line_aggregate": same `(itemCount, totalQuantity, totalSellingPrice)`
* — flagged as a likely duplicate because the chance of two distinct
* orders sharing an identical line breakdown is small.
*/
kind: DuplicateMatchKind;
/** The shared identifier — order_number, or aggregate label. */
orderNumber: string;
/**
* Human-readable aggregate label for `kind: "line_aggregate"`
* (e.g. "5 lines · 100 · R1,500.00"). Absent for `order_number` groups.
*/
aggregateKey?: string;
records: DuplicateOrderRecord[];
}
/**
* Filter for which duplicate-pass kinds to return. `"all"` is
* the default and returns every kind; pass a specific kind to
* scope the response (e.g. agent tools that only care about
* email-message-id races).
*/
export type DuplicateOrderKindFilter = "all" | DuplicateMatchKind;
export async function findDuplicateOrders(
options: { kind?: DuplicateOrderKindFilter } = {},
): Promise<DuplicateOrderGroup[]> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const kindFilter: DuplicateOrderKindFilter = options.kind ?? "all";
// Resolve to the set of kinds the caller wants. `"all"` means
// every kind; a specific kind means just that one.
const wantKinds: Set<DuplicateMatchKind> =
kindFilter === "all"
? new Set(["order_number", "line_aggregate", "email_message_id"])
: new Set([kindFilter as DuplicateMatchKind]);
// Statuses that count as "on the board" — every workflow tab the
// operator can see on /orders, MINUS the `delivered` and `completed`
// statuses (which need a separate time-windowed pass; see below).
// Records whose items are entirely off the board are hidden from
// the duplicate pass. The match is "≥1 on-board item per record",
// so an order with one stale `delivered` item + one `po_generated`
// item still surfaces — same semantics as before.
const TAB_ONLY_STATUSES = Array.from(
new Set(
TAB_GROUP_ORDER
.filter((t) => t !== "delivered" && t !== "cancelled")
.flatMap((t) => TAB_GROUP_ITEM_STATUSES[t]),
),
).filter(
(s) => s !== "delivered" && s !== "completed",
);
// Pull the set of order ids that have at least one on-board item.
// Two PB scans: the non-terminal active statuses, plus the
// time-windowed `completed`/`delivered` slice (the new Completed
// tab — items that fell out of the window live only on
// /orders/archive and shouldn't show up in duplicate cleanup).
// We need this BEFORE fetching the orders so we can filter at the
// PB level rather than walking every order record.
const [activeItems, recentItems] = await Promise.all([
pb.collection(getOrderItemsCollectionName()).getFullList<{
order?: string;
}>({
filter: TAB_ONLY_STATUSES
.map((s) => `item_status = "${s}"`)
.join(" || "),
fields: "order",
batch: 5000,
}),
pb.collection(getOrderItemsCollectionName()).getFullList<{
order?: string;
}>({
filter: completedTabFilter(),
fields: "order",
batch: 5000,
}),
]);
const activeOrderIds = new Set<string>();
for (const it of [...activeItems, ...recentItems]) {
if (it.order) activeOrderIds.add(it.order);
}
// Only fetch orders that are still on the board. This is the
// single biggest pruning step — without it we'd enumerate ~4,800
// order records, of which ~120 are active.
//
// Routed through `fetchOrdersFilteredByIds` so the inline `id = ...`
// filter is chunked under PB's ~3.5 KB filter cap (today's active
// set is ~138 ids, which blows the cap un-chunked — same fix as the
// main `/orders` list and search path).
const ordersPage = await fetchOrdersFilteredByIds(pb, [...activeOrderIds], {
page: 1,
// `perPage` here is the page size we ask the merged result for.
// `fetchOrdersFilteredByIds` chunks internally, then slices on
// top of the sort — every chunk's rows come back as items[0..N],
// and perPage=500 comfortably returns the full ~138-row active
// set on page 1.
perPage: 500,
sort: "created",
// No `fields` here — PB 0.26 drops the expand payload when
// `fields` is set (same trap as the main list path). The
// duplicates endpoint is hit rarely (operator-initiated), so
// the wire-payload cost is acceptable.
expand: "customer,company",
});
const orders = ordersPage.items;
// Group by order_number. Only keep groups with ≥2 records (otherwise
// a singleton can't be a duplicate).
const out: DuplicateOrderGroup[] = [];
const allRecords: DuplicateOrderRecord[] = [];
// Fetch items + build a record for every active order up front so
// both the order-number pass and the line-aggregate pass can read
// from the same flat list. Without this, records that don't share
// an order_number (the line-aggregate-only case) would have no
// items fetched and skip the aggregate pass.
for (const o of orders) {
const items = await loadOrderItemsForRecord(pb, o.id);
allRecords.push(buildRecord(o, items));
}
// Order-number pass. Only groups with ≥2 records qualify as a
// duplicate (a singleton can't be a duplicate). Skipped when
// the caller filtered to a different kind.
if (wantKinds.has("order_number")) {
const byNum = new Map<string, DuplicateOrderRecord[]>();
for (const r of allRecords) {
const arr = byNum.get(r.orderNumber) ?? [];
arr.push(r);
byNum.set(r.orderNumber, arr);
}
for (const [orderNumber, records] of byNum) {
if (records.length < 2) continue;
sortRecordsByCreated(records);
out.push({ kind: "order_number", orderNumber, records });
}
}
// Line-aggregate pass. Flags orders whose line breakdown
// (count + total qty + total selling price) matches across ≥2
// distinct order_numbers. Descriptions may differ but the line
// totals staying identical is rare and a strong dupe signal.
//
// Indexed by `"count|qty|price"` (2-dp on price; qty rounded to int).
// Records with no items are excluded — otherwise every empty draft
// would match every other empty draft. Skipped when the caller
// filtered to a different kind.
if (wantKinds.has("line_aggregate")) {
const byAggregate = new Map<string, DuplicateOrderRecord[]>();
for (const r of allRecords) {
if (r.itemCount === 0) continue;
const key = `${r.itemCount}|${r.totalQuantity}|${r.totalSellingPrice}`;
const arr = byAggregate.get(key) ?? [];
arr.push(r);
byAggregate.set(key, arr);
}
for (const [key, arr] of byAggregate) {
if (arr.length < 2) continue;
// Skip groups where every record shares one `order_number` —
// that's already covered by the order-number pass, and listing
// them again here would double-flag.
const orderNumbers = new Set(arr.map((r) => r.orderNumber));
if (orderNumbers.size < 2) continue;
const [countStr, qtyStr, priceStr] = key.split("|");
const aggregateKey = `${countStr} line${countStr === "1" ? "" : "s"} · ${qtyStr} · R${formatRand(Number(priceStr))}`;
sortRecordsByCreated(arr);
out.push({ kind: "line_aggregate", orderNumber: aggregateKey, aggregateKey, records: arr });
}
}
// Email-message-id pass. Flags orders that share the same
// `email_message_id` (Hermes needs this — two intakes that
// raced on the same inbound email end up as two orders
// despite the DB-level UNIQUE index, when one path bypasses
// the admin client). Empty values are excluded. The shared
// `orderNumber` field on the group carries the
// `email_message_id` itself so the operator sees the
// identifier in the existing UI.
if (wantKinds.has("email_message_id")) {
const byEmail = new Map<string, DuplicateOrderRecord[]>();
for (const r of allRecords) {
if (!r.emailMessageId) continue;
const arr = byEmail.get(r.emailMessageId) ?? [];
arr.push(r);
byEmail.set(r.emailMessageId, arr);
}
for (const [emailMessageId, records] of byEmail) {
if (records.length < 2) continue;
sortRecordsByCreated(records);
out.push({
kind: "email_message_id",
orderNumber: emailMessageId,
records,
});
}
}
// Filter by the caller's kindFilter (the per-kind passes
// already gated themselves on `wantKinds`, but the
// `order_number` and `line_aggregate` passes don't gate their
// pushes on kindFilter because they share the same loop
// structure — so trim the output here to honour `kind: "<x>"`
// for those kinds too).
const filtered =
kindFilter === "all"
? out
: out.filter((g) => g.kind === kindFilter);
// Largest groups first — the most painful to fix manually.
// Stable so order-number groups keep their relative order before
// line-aggregate groups of the same size.
filtered.sort((a, b) => b.records.length - a.records.length);
return filtered;
}
function sortRecordsByCreated(records: DuplicateOrderRecord[]): void {
// Sort by created asc — Date.getTime() rather than localeCompare
// because ISO 8601 sorts incorrectly when the fractional-second
// part is missing (".050Z" vs "Z" — code-point bug).
records.sort(
(a, b) => new Date(a.created).getTime() - new Date(b.created).getTime(),
);
}
/** Round to two decimal places without floating-point drift. */
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
/** Format a number as Rand — R1,500.00. Used for the aggregate label. */
function formatRand(n: number): string {
return n.toLocaleString("en-ZA", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
type AggregateItemShape = {
description: string;
quantity: number | null;
item_status: string;
selling_price?: number | null;
};
type OrderHeaderShape = {
id: string;
order_number: string;
created: string;
customer?: string;
company?: string;
email_message_id?: string;
expand?: {
customer?: { id: string; customer: string };
company?: { id: string; company: string };
};
};
async function loadOrderItemsForRecord(
pb: AdminPocketBase,
orderId: string,
): Promise<AggregateItemShape[]> {
return pb
.collection(getOrderItemsCollectionName())
.getFullList<AggregateItemShape>({
filter: `order = "${orderId}"`,
fields: "description,quantity,item_status,selling_price",
batch: 500,
});
}
function buildRecord(
order: OrderHeaderShape,
items: AggregateItemShape[],
): DuplicateOrderRecord {
const summary: Record<string, number> = {};
let totalQty = 0;
let totalSellingPrice = 0;
for (const it of items) {
summary[it.item_status] = (summary[it.item_status] || 0) + 1;
const qty = Number(it.quantity ?? 0);
const price = Number(it.selling_price ?? 0);
if (Number.isFinite(qty)) totalQty += qty;
if (Number.isFinite(price)) totalSellingPrice += price * qty;
}
return {
id: order.id,
orderNumber: order.order_number,
created: order.created,
customer: order.customer || null,
company: order.company || null,
customerName: order.expand?.customer?.customer ?? null,
companyName: order.expand?.company?.company ?? null,
itemCount: items.length,
itemStatusSummary: summary,
items: items.map((i) => ({
description: i.description,
quantity: i.quantity,
item_status: i.item_status,
})),
// Null totals when the order has no items — those rows are excluded
// from line-aggregate matching.
totalQuantity: items.length === 0 ? null : Math.round(totalQty),
totalSellingPrice: items.length === 0 ? null : round2(totalSellingPrice),
emailMessageId: (order.email_message_id ?? "").trim() || null,
};
}
// ──────────────────────────────────────────────────────────────────────
// Stats & summary helpers (Phase H — orders MCP coverage expansion)
// ──────────────────────────────────────────────────────────────────────
/**
* Stats bucket produced by `getOrderStats`. The shape is identical
* across `groupBy=day | customer | supplier` — only the `key`
* changes:
* - day → "YYYY-MM-DD"
* - customer → customer name (or "(unknown)" for orphaned orders)
* - supplier → supplier name (or "(no supplier)" if the item's
* supplier relation is empty)
*/
export interface OrderStatsBucket {
key: string;
count: number;
sellingPrice: number;
costPrice: number;
}
export interface OrderStatsResult {
total: { count: number; sellingPrice: number; costPrice: number };
buckets: OrderStatsBucket[];
range: { from: string | null; to: string | null };
}
const ORDER_STATS_CAP = 10_000;
/**
* Aggregate orders + their line items over a date range. The cap
* (`ORDER_STATS_CAP`) bounds runtime on the assumption that an
* operator looking at stats rarely wants more than the last few
* weeks/months — past that, they should be looking at the archive
* directly.
*
* Grouping happens in-memory after a single PB `getFullList` on
* `order_items` with only the fields we need (selling_price,
* cost_price, order.created, order.customer, order.supplier).
* Returns `total` + `buckets[]` for the chosen `groupBy`.
*
* `from` / `to` are YYYY-MM-DD strings (inclusive lower, exclusive
* upper on the day boundary). Both default to "last 30 days" when
* omitted.
*/
export async function getOrderStats(params: {
from?: string;
to?: string;
groupBy?: "day" | "customer" | "supplier"; // supplier groupBy reports "(no order-level supplier)" — suppliers live on order_items
}): Promise<OrderStatsResult> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const groupBy = params.groupBy || "day";
const today = new Date();
const defaultTo = today.toISOString().slice(0, 10);
const defaultFrom = new Date(today.getTime() - 29 * 86_400_000)
.toISOString()
.slice(0, 10);
const from = params.from || defaultFrom;
const to = params.to || defaultTo;
// Fetch every order_item in range with just the fields we need.
// The order_items collection has a `order` relation, but
// PocketBase won't join through relations in `fields` — we have
// to fetch the order separately for the customer / supplier
// groupBys. Strategy: fetch every order in the range with the
// customer / supplier expanded, then join on `order` client-side.
const orderFilter = `created >= "${from} 00:00:00.000Z" && created < "${to} 00:00:00.000Z"`;
const orders = await pb.collection(ORDER_COLLECTION).getFullList<Order>({
filter: orderFilter,
expand: "customer",
fields: "id,customer,created",
batch: 5000,
});
if (orders.length === 0) {
return {
total: { count: 0, sellingPrice: 0, costPrice: 0 },
buckets: [],
range: { from, to },
};
}
// Map: order_id → order (for the day / customer / supplier lookup).
const orderById = new Map<string, Order>();
for (const o of orders) orderById.set(o.id, o);
const orderIds = orders.map((o) => `"${o.id}"`).join(",");
const items = await pb
.collection(getOrderItemsCollectionName())
.getFullList<OrderItem>({
filter: `order IN (${orderIds})`,
fields: "order,selling_price,cost_price",
batch: 5000,
});
if (items.length > ORDER_STATS_CAP) {
// Cap silently — the caller can split the range. We still
// process the first ORDER_STATS_CAP rows so we don't blow up.
items.length = ORDER_STATS_CAP;
}
const buckets = new Map<string, OrderStatsBucket>();
let totalCount = 0;
let totalSelling = 0;
let totalCost = 0;
for (const it of items) {
const orderId = typeof it.order === "string" ? it.order : "";
const order = orderId ? orderById.get(orderId) : undefined;
if (!order) continue;
let key: string;
if (groupBy === "day") {
key = (order.created || "").slice(0, 10);
} else if (groupBy === "customer") {
key = order.expand?.customer?.customer ?? "(unknown)";
} else {
// supplier — the `orders` collection has no top-level
// `supplier` relation (suppliers live on `order_items`).
// For per-line accuracy we'd need to expand order_items
// .supplier, but that doubles the row size — keep the
// surface simple and report "(no order-level supplier)".
key = "(no order-level supplier)";
}
if (!key) key = "(unknown)";
let bucket = buckets.get(key);
if (!bucket) {
bucket = { key, count: 0, sellingPrice: 0, costPrice: 0 };
buckets.set(key, bucket);
}
bucket.count += 1;
bucket.sellingPrice += Number(it.selling_price) || 0;
bucket.costPrice += Number(it.cost_price) || 0;
totalCount += 1;
totalSelling += Number(it.selling_price) || 0;
totalCost += Number(it.cost_price) || 0;
}
return {
total: { count: totalCount, sellingPrice: totalSelling, costPrice: totalCost },
buckets: [...buckets.values()].sort((a, b) => {
// Day: ascending date; others: descending count.
if (groupBy === "day") return a.key.localeCompare(b.key);
return b.count - a.count;
}),
range: { from, to },
};
}
export interface DailyOrderSummaryResult {
date: string;
placed: { count: number; totalSell: number; totalCost: number };
delivered: { count: number; totalSell: number; totalCost: number };
invoiced: { count: number; totalSell: number; totalCost: number };
pending: number;
topCustomers: Array<{ customer: string; count: number }>;
}
/**
* One-shot summary for a single day. Used at the top of the day to
* answer "what did we do yesterday?" — a flat roll-up of placed /
* delivered / invoiced counts + selling / cost totals + the top 3
* customers by order count.
*
* `date` is YYYY-MM-DD (defaults to today).
*/
export async function getDailyOrderSummary(params: { date?: string }): Promise<DailyOrderSummaryResult> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const date = params.date || new Date().toISOString().slice(0, 10);
const dayStart = `${date} 00:00:00.000Z`;
const dayEnd = `${date} 23:59:59.999Z`;
const nextDay = new Date(new Date(date).getTime() + 86_400_000)
.toISOString()
.slice(0, 10);
// Placed: orders created today
const placedOrders = await pb.collection(ORDER_COLLECTION).getFullList<Order>({
filter: `created >= "${dayStart}" && created <= "${dayEnd}"`,
expand: "customer",
fields: "id,customer,created",
});
// Delivered: items with delivered_date in range
const deliveredItems = await pb
.collection(getOrderItemsCollectionName())
.getFullList<OrderItem>({
filter: `delivered_date >= "${date}" && delivered_date < "${nextDay}"`,
fields: "selling_price,cost_price",
});
// Invoiced: items with invoiced_date in range
const invoicedItems = await pb
.collection(getOrderItemsCollectionName())
.getFullList<OrderItem>({
filter: `invoiced_date >= "${date}" && invoiced_date < "${nextDay}"`,
fields: "selling_price,cost_price",
});
// Pending: orders not yet invoiced (order_items with item_status
// not in {delivered, invoiced, cancelled}).
const pendingItems = await pb
.collection(getOrderItemsCollectionName())
.getFullList<{ id: string }>({
filter: `item_status != "delivered" && item_status != "invoiced" && item_status != "cancelled"`,
fields: "id",
batch: 5000,
});
// Top customers by placed-order count.
const customerCount = new Map<string, number>();
for (const o of placedOrders) {
const name = o.expand?.customer?.customer || "(unknown)";
customerCount.set(name, (customerCount.get(name) ?? 0) + 1);
}
const topCustomers = [...customerCount.entries()]
.map(([customer, count]) => ({ customer, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 3);
const sumTotals = (items: OrderItem[]) =>
items.reduce(
(acc, it) => ({
totalSell: acc.totalSell + (Number(it.selling_price) || 0),
totalCost: acc.totalCost + (Number(it.cost_price) || 0),
}),
{ totalSell: 0, totalCost: 0 },
);
const placedTotals = sumTotals(
await pb.collection(getOrderItemsCollectionName()).getFullList<OrderItem>({
filter: `order IN (${placedOrders.map((o) => `"${o.id}"`).join(",") || '""'})`,
fields: "selling_price,cost_price",
}),
);
return {
date,
placed: {
count: placedOrders.length,
totalSell: placedTotals.totalSell,
totalCost: placedTotals.totalCost,
},
delivered: {
count: deliveredItems.length,
totalSell: deliveredItems.reduce((s, it) => s + (Number(it.selling_price) || 0), 0),
totalCost: deliveredItems.reduce((s, it) => s + (Number(it.cost_price) || 0), 0),
},
invoiced: {
count: invoicedItems.length,
totalSell: invoicedItems.reduce((s, it) => s + (Number(it.selling_price) || 0), 0),
totalCost: invoicedItems.reduce((s, it) => s + (Number(it.cost_price) || 0), 0),
},
pending: pendingItems.length,
topCustomers,
};
}
// ──────────────────────────────────────────────────────────────────────
// mark_invoiced + mark_delivered (Phase H)
// ──────────────────────────────────────────────────────────────────────
/**
* Result shape returned by both `markOrderInvoiced` and
* `markOrderDelivered`. Mirrors `CancelOrderResult` so the agent
* can branch on `items_failed` without parsing three different
* shapes.
*/
export interface OrderHeaderActionResult {
order: Order;
itemsInspected: number;
itemsUpdated: number;
itemsFailed: Array<{ id: string; reason: string }>;
statusChanged: boolean;
previousInvoiced: boolean;
appliedAt: string;
}
/**
* Mark an order as invoiced. Refuses if any line item has
* `item_status !== "invoiced"` — operator must complete invoicing
* per item first.
*
* Idempotent: re-calling on an already-invoiced order returns
* `statusChanged: false` without touching anything.
*
* Note: `invoiced_at` is NOT a real PB column — we stamp the
* `appliedAt` ISO string into the result so callers can persist it
* if they want, mirroring how `quotes.submit` stamps `submitted_at`
* on quotes (Phase F).
*/
export async function markOrderInvoiced(orderId: string): Promise<OrderHeaderActionResult> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const order = await pb.collection(ORDER_COLLECTION).getOne<Order>(orderId).catch(() => null);
if (!order) {
const err = new Error(`Order ${orderId} not found`);
(err as Error & { status?: number }).status = 404;
throw err;
}
const items = await pb.collection(getOrderItemsCollectionName()).getFullList<{
id: string;
item_status: string;
}>({
filter: `order = "${orderId}"`,
fields: "id,item_status",
batch: 1000,
});
const previousInvoiced = Boolean(order.invoiced);
if (previousInvoiced) {
return {
order,
itemsInspected: items.length,
itemsUpdated: 0,
itemsFailed: [],
statusChanged: false,
previousInvoiced,
appliedAt: new Date().toISOString(),
};
}
const uninvoiced = items
.filter((i) => i.item_status !== "invoiced")
.map((i) => ({ id: i.id, reason: `item_status is "${i.item_status}", expected "invoiced"` }));
if (uninvoiced.length > 0) {
const err = new Error(
`Cannot mark invoiced — ${uninvoiced.length} of ${items.length} line items are not yet invoiced. ` +
`Complete per-item invoicing first.`,
);
(err as Error & { status?: number }).status = 409;
(err as Error & { data?: unknown }).data = { itemsFailed: uninvoiced };
throw err;
}
await pb.collection(ORDER_COLLECTION).update(orderId, { invoiced: true });
const updated = await pb.collection(ORDER_COLLECTION).getOne<Order>(orderId);
return {
order: updated,
itemsInspected: items.length,
itemsUpdated: items.length,
itemsFailed: [],
statusChanged: true,
previousInvoiced,
appliedAt: new Date().toISOString(),
};
}
/**
* Statuses from which an order_item can transition to `delivered`
* via `markOrderDelivered`. Anything past `delivered` is downstream
* (invoiced is fine — invoiced items are already delivered).
*/
const DELIVERABLE_ITEM_STATUSES: ItemStatus[] = [
"delivered",
"ready_delivery",
"collected",
"with_driver",
"checked_in",
"ready_invoice",
"invoiced",
"stock_booked",
"picking",
"stock_verified",
];
/**
* Mark every eligible line item on the order as `delivered`.
* Stamps `delivered_at` (PB ISO timestamp). Items in non-deliverable
* states are returned in `items_failed` so the operator can resolve
* them before retrying.
*
* Idempotent: re-calling on an already-fully-delivered order
* returns `statusChanged: false` without touching anything.
*
* Does NOT flip `order.invoiced` — delivery and invoicing are
* independent. Use `markOrderInvoiced` for that.
*/
export async function markOrderDelivered(orderId: string): Promise<OrderHeaderActionResult> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const order = await pb.collection(ORDER_COLLECTION).getOne<Order>(orderId).catch(() => null);
if (!order) {
const err = new Error(`Order ${orderId} not found`);
(err as Error & { status?: number }).status = 404;
throw err;
}
const items = await pb.collection(getOrderItemsCollectionName()).getFullList<{
id: string;
item_status: string;
}>({
filter: `order = "${orderId}"`,
fields: "id,item_status",
batch: 1000,
});
if (items.length === 0) {
return {
order,
itemsInspected: 0,
itemsUpdated: 0,
itemsFailed: [],
statusChanged: false,
previousInvoiced: Boolean(order.invoiced),
appliedAt: new Date().toISOString(),
};
}
const alreadyDelivered = items.filter((i) => i.item_status === "delivered");
const eligible = items.filter(
(i) => i.item_status !== "delivered" && DELIVERABLE_ITEM_STATUSES.includes(i.item_status as ItemStatus),
);
const ineligible = items.filter(
(i) => i.item_status !== "delivered" && !DELIVERABLE_ITEM_STATUSES.includes(i.item_status as ItemStatus),
);
const itemsFailed = ineligible.map((i) => ({
id: i.id,
reason: `item_status "${i.item_status}" is not deliverable from this state`,
}));
if (itemsFailed.length > 0) {
const err = new Error(
`Cannot mark delivered — ${itemsFailed.length} of ${items.length} line items are in non-deliverable states. ` +
`Resolve them first.`,
);
(err as Error & { status?: number }).status = 409;
(err as Error & { data?: unknown }).data = { itemsFailed };
throw err;
}
const deliveredAt = new Date().toISOString();
for (const item of eligible) {
await pb.collection(getOrderItemsCollectionName()).update(item.id, {
item_status: "delivered",
delivered_at: deliveredAt,
delivered_date: deliveredAt.slice(0, 10),
});
}
// statusChanged is true if we updated at least one item.
// (re-calling on a fully-delivered order returns statusChanged=false
// because eligible.length === 0.)
return {
order,
itemsInspected: items.length,
itemsUpdated: eligible.length + alreadyDelivered.length,
itemsFailed: [],
statusChanged: eligible.length > 0,
previousInvoiced: Boolean(order.invoiced),
appliedAt: deliveredAt,
};
}
// ──────────────────────────────────────────────────────────────────────
// updateOrder (Phase H) — header-level PATCH with field whitelist
// ──────────────────────────────────────────────────────────────────────
/**
* Canonical whitelist of fields a caller may patch on an `orders`
* row. Anything outside this set returns `forbidden_field` so the
* caller knows exactly which key was rejected.
*
* Mirrors the `QUOTE_PATCH_FIELDS` pattern from `quotes-service.ts`
* — service-layer constant, REST + MCP both import from here so
* the surface can't drift silently.
*/
export const ORDER_PATCHABLE_FIELDS = [
"notes",
"contact_name",
"description",
"rep",
"email_cc",
"email_bcc",
"quote_reference_text",
"invoiced",
"quote_match_method",
// Hermes back-fill: when inbound email metadata arrives *after*
// the order is already created (walk-in order we want to tag with
// the originating email so the review modal and the
// duplicate-by-email-message-id check both work), the agent can
// PATCH these into place. Whitelist only - anything outside this
// set still returns `forbidden_field`.
"email_subject",
"email_sender",
"email_body",
"email_message_id",
"rfq_number",
] as const;
export type OrderPatch = Partial<Record<
(typeof ORDER_PATCHABLE_FIELDS)[number],
string | boolean | number | null
>>;
/**
* Patch whitelisted header fields on an `orders` row. Returns the
* updated order + a `previous` delta so the caller can render
* "before/after" diffs (mirrors `quotes.update`).
*
* Throws 400 `forbidden_field: <name>` for any key outside
* `ORDER_PATCHABLE_FIELDS`.
*
* Idempotent. Refuses with 404 if the order doesn't exist.
*/
export async function updateOrder(
orderId: string,
patch: OrderPatch,
): Promise<{ order: Order; previous: OrderPatch }> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const existing = await pb.collection(ORDER_COLLECTION).getOne<Order>(orderId).catch(() => null);
if (!existing) {
const err = new Error(`Order ${orderId} not found`);
(err as Error & { status?: number }).status = 404;
throw err;
}
const rejected: string[] = [];
const cleaned: Record<string, unknown> = {};
for (const [k, v] of Object.entries(patch)) {
if ((ORDER_PATCHABLE_FIELDS as readonly string[]).includes(k)) {
cleaned[k] = v;
} else {
rejected.push(k);
}
}
if (rejected.length > 0) {
const err = new Error(
`forbidden_field(s): ${rejected.join(", ")}. Allowed: ${ORDER_PATCHABLE_FIELDS.join(", ")}.`,
);
(err as Error & { status?: number }).status = 400;
throw err;
}
if (Object.keys(cleaned).length === 0) {
const err = new Error("Patch is empty");
(err as Error & { status?: number }).status = 400;
throw err;
}
const previous: OrderPatch = {};
for (const k of Object.keys(cleaned)) {
previous[k as keyof OrderPatch] = (existing as unknown as Record<string, unknown>)[k] as OrderPatch[keyof OrderPatch];
}
await pb.collection(ORDER_COLLECTION).update(orderId, cleaned);
const updated = await pb.collection(ORDER_COLLECTION).getOne<Order>(orderId);
return { order: updated, previous };
}
// ──────────────────────────────────────────────────────────────────────
// setOrderQuote (Hermes unblock) — link an EXISTING order to a quote
// ──────────────────────────────────────────────────────────────────────
/**
* Shape accepted by `setOrderQuote`. Mirrors the actor pattern
* used by the rest of the agent-facing services
* (`stock_purchase_items.update`, `order_items.confirm_collected`,
* etc.) — `actor` is optional; when supplied, the change is
* stamped onto the order's free-text `notes` field so the
* operator UI surfaces which changes came from the agent.
*/
export interface SetOrderQuoteInput {
orderId: string;
quoteId: string;
actor?: { id: string; name: string };
}
export interface SetOrderQuoteResult {
order: Order;
previous: { quote: string };
}
/**
* Link an EXISTING order to an EXISTING quote.
*
* Background: `createOrderFromQuote` (and only `createOrderFromQuote`)
* writes the `orders.quote` relation today. Walk-in orders
* (`orders.create`) leave it null. The Hermes email-intake workflow
* frequently needs to attach an order to its source quote AFTER
* the order already exists — the order is created from one
* channel (counter-form, n8n email monitor, agent intake) and the
* matching quote is discovered later (e.g. via subject/RFQ search
* during the same email-intake pass). Without this setter the
* downstream tools (`orders.add_attachment`, `orders.list_attachments`,
* `orders.matched_attachments`) all 404 with "Order has no matched
* quote" for every walk-in order.
*
* Refusal semantics:
* - 404 if either the order or the quote doesn't exist.
* - 409 `order_locked` if any line item on the order has
* `item_status !== "pending"`. Mirrors the strict-pending
* gate in `createOrderItemForOrder` — re-linking an in-flight
* order would orphan the operator's downstream state
* (POs, driver assignments, collection entries, etc.).
* - 409 `quote_already_linked` if a DIFFERENT order already
* points at this quote. The `createOrderFromQuote` pre-check
* at line 1096 uses the same filter; we mirror it here so two
* agents calling `orders.set_quote` against the same quote
* can't both succeed.
*
* Idempotency: setting the same quote on an order that already
* points at it is a no-op (PB update is a no-op when the value
* doesn't change). Setting a different quote is the normal use
* case (re-link to the right source after the wrong link was
* caught).
*
* Actor stamping: when `actor` is provided, append a timestamped
* audit line onto `order.notes` so the WFDASH UI surfaces which
* changes came from the agent. The `notes` field on the orders
* collection is free-text (not a JSON feed like `order_items.notes`),
* so we use a single-line human-readable stamp rather than a JSON
* entry — see `stock_purchase_items.update` for the JSON variant.
*/
export async function setOrderQuote(
input: SetOrderQuoteInput,
): Promise<SetOrderQuoteResult> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const orderId = String(input?.orderId ?? "").trim();
const quoteId = String(input?.quoteId ?? "").trim();
if (!orderId) {
const err = new Error("orderId is required");
(err as Error & { status?: number }).status = 400;
throw err;
}
if (!quoteId) {
const err = new Error("quoteId is required");
(err as Error & { status?: number }).status = 400;
throw err;
}
// 404 if the order doesn't exist. Use the same friendly-message
// pattern as cancelOrder / updateOrder.
const existing = await pb.collection(ORDER_COLLECTION).getOne<Order>(orderId).catch(() => null);
if (!existing) {
const err = new Error(`Order ${orderId} not found`);
(err as Error & { status?: number }).status = 404;
throw err;
}
// 404 if the quote doesn't exist. The order.quote relation is
// a FK — PB would 400 on a bad id, but a 404 from getOne is a
// cleaner signal for the caller.
try {
await pb.collection(QUOTE_COLLECTION).getOne(quoteId);
} catch {
const err = new Error(`Quote ${quoteId} not found`);
(err as Error & { status?: number }).status = 404;
throw err;
}
// Strict-pending gate: refuse if any line item is past `pending`.
// Same shape as createOrderItemForOrder.
const nonPending = await pb
.collection(getOrderItemsCollectionName())
.getFullList<{ id: string; item_status: string }>({
filter: `order = "${orderId}" && item_status != "pending"`,
fields: "id,item_status",
batch: 1000,
});
if (nonPending.length > 0) {
const err = new Error(
`Cannot re-link order to a new quote — ${nonPending.length} line item(s) are past 'pending' status. ` +
`Use orders.split to create a continuation order first.`,
);
(err as Error & { status?: number }).status = 409;
(err as Error & { data?: unknown }).data = {
code: "order_locked",
offendingItemIds: nonPending.map((i) => i.id),
offendingStatuses: [...new Set(nonPending.map((i) => i.item_status))],
};
throw err;
}
// 409 if a DIFFERENT order already points at this quote.
// (Same order pointing at the same quote is the idempotent
// no-op path; the existing.value === quoteId check below
// short-circuits before this filter runs.)
const conflicting = await pb
.collection(ORDER_COLLECTION)
.getFullList<Order>({
filter: `quote = "${quoteId.replace(/"/g, '\"')}" && id != "${orderId.replace(/"/g, '\"')}"`,
fields: "id",
batch: 10,
});
if (conflicting.length > 0) {
const err = new Error(
`Quote ${quoteId} is already linked to another order (${conflicting[0].id}). ` +
`Unlink the existing order first, or pick a different quote.`,
);
(err as Error & { status?: number }).status = 409;
(err as Error & { data?: unknown }).data = {
code: "quote_already_linked",
conflictingOrderId: conflicting[0].id,
};
throw err;
}
// Capture the previous quote value for the response.
const previous: { quote: string } = { quote: existing.quote || "" };
// Idempotent no-op when the order already points at this quote.
// Don't write `orders.quote`, don't stamp — just return the order
// as-is. Pairing still runs on this path: it's safe to repeat
// (confident pairs are only ever added, never overwritten by
// `pairOrderLines`), so there's no reason to skip it here.
//
// Note this doesn't buy as much as it might look like: the
// strict-pending gate above already refuses the call the moment any
// line has moved past `pending`, so this path only re-runs pairing
// while every line is still pending — the exact window in which
// lines are least likely to have drifted since the original link.
// It's a real but narrow benefit (e.g. a line added right after
// linking, before anything progressed), not a general "refresh
// pairing any time" mechanism.
if (previous.quote === quoteId) {
const fresh = await pb.collection(ORDER_COLLECTION).getOne<Order>(orderId);
await pairOrderLinesSafely(orderId, quoteId);
return { order: fresh, previous };
}
// Optional actor stamp: append a single-line audit entry onto
// the order's free-text notes field. We always also update the
// `quote` relation; the stamp is just an audit breadcrumb.
const update: Record<string, unknown> = { quote: quoteId };
if (input.actor?.id && input.actor?.name) {
const stamp = `[${new Date().toISOString()}] ${input.actor.name} linked this order to quote ${quoteId}`;
const existingNotes = (existing.notes ?? "").toString();
update.notes = existingNotes ? `${existingNotes}
${stamp}` : stamp;
}
await pb.collection(ORDER_COLLECTION).update(orderId, update);
const updated = await pb.collection(ORDER_COLLECTION).getOne<Order>(orderId);
// Pair the order's lines to the quote's lines now that the link is
// real. Confident pairs persist onto `order_items.quote_item`;
// doubtful ones are surfaced in the UI for confirmation instead.
await pairOrderLinesSafely(orderId, quoteId);
return { order: updated, previous };
}
/**
* Run `pairOrderLines`, never letting it fail the caller.
*
* A pairing failure must not undo or fail a link the operator (or
* agent) just made — the hard link is the important write, pairing
* is a best-effort enhancement on top of it. The pairing can always
* be redone later by re-running `setOrderQuote` with the same
* quoteId (see the idempotent-path comment above).
*
* `console.error` is the only trace of a pairing failure right now —
* there's no user-facing surface for "the link worked but pairing
* didn't." That's a deliberate gap for this task: adding one means
* either a new field on `SetOrderQuoteResult` or a new field on the
* order record, and plumbing it through to the UI/API/MCP callers —
* real scope beyond this two-file change. Worth revisiting if silent
* pairing failures turn out to be common in practice — e.g. surfacing
* them via the `potential_quote` suggestion machinery or an
* operator-visible warning instead of only a server log line.
*/
async function pairOrderLinesSafely(orderId: string, quoteId: string): Promise<void> {
try {
await pairOrderLines(orderId, quoteId);
} catch (err) {
console.error(
`[setOrderQuote] line pairing failed for order ${orderId} / quote ${quoteId}:`,
err instanceof Error ? err.message : err,
);
}
}
// ──────────────────────────────────────────────────────────────────────
// createOrderItemForOrder (Phase H) — strict-pending-gated item create
// ──────────────────────────────────────────────────────────────────────
/**
* Shape accepted by `createOrderItemForOrder`. Mirrors the fields
* surfaced on `orders.create`'s items[] but is for adding to an
* EXISTING order (after the order is created, before its line
* items have moved past `pending`).
*/
export interface CreateOrderItemForOrderDraft {
description: string;
quantity?: number | null;
cost_price?: number;
markup?: number;
selling_price?: number;
sku?: string;
unit?: string;
supplier?: string;
notes?: string;
}
/**
* Add a new line item to an existing order.
*
* **Strict `pending` gate**: refuses with 409 (`order_locked`) if
* any line item on the order is past `item_status === "pending"`.
* Adding a line to an order with a PO in flight would orphan the
* PO's pricing/supplier reference. Operator must split the order
* (use `orders.split`) or finish the existing PO first.
*
* Mirrors what `createOrder` does for new orders: calls
* `createOrderItems` for the primary row, then patches the
* secondary fields (sku/unit/supplier/notes) on success.
*
* The `selling_price` defaults to `cost_price * (1 + markup/100)`
* via `deriveSellingPriceIfOmitted` when omitted. Markup defaults
* to 0 when both `selling_price` and `markup` are absent.
*
* Returns `{ created, failed }` like `createOrderItems`. Per-row
* failures never abort the whole call.
*/
export async function createOrderItemForOrder(
orderId: string,
draft: CreateOrderItemForOrderDraft,
): Promise<{
orderId: string;
created: Array<{
id: string;
description: string;
quantity: number | null;
cost_price: number;
selling_price: number;
markup: number;
}>;
failed: Array<{ error: string }>;
orderLocked: boolean;
offendingItemIds?: string[];
}> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
// 404 if the order doesn't exist.
const order = await pb.collection(ORDER_COLLECTION).getOne(orderId).catch(() => null);
if (!order) {
const err = new Error(`Order ${orderId} not found`);
(err as Error & { status?: number }).status = 404;
throw err;
}
// Strict pending gate: refuse if any line item is past `pending`.
// Statuses here mirror `orders.split` — only `pending` items are
// eligible for mutation (adding a line). Anything else would
// orphan an in-flight PO / driver assignment.
const nonPending = await pb
.collection(getOrderItemsCollectionName())
.getFullList<{ id: string; item_status: string }>({
filter: `order = "${orderId}" && item_status != "pending"`,
fields: "id,item_status",
batch: 1000,
});
if (nonPending.length > 0) {
const err = new Error(
`Cannot add items to order — ${nonPending.length} line item(s) are past 'pending' status. ` +
`Use orders.split to create a continuation order, or finish the existing PO first.`,
);
(err as Error & { status?: number }).status = 409;
(err as Error & { data?: unknown }).data = {
code: "order_locked",
offendingItemIds: nonPending.map((i) => i.id),
offendingStatuses: [...new Set(nonPending.map((i) => i.item_status))],
};
throw err;
}
// Compute selling_price if omitted.
const costPrice = draft.cost_price ?? 0;
const markup = draft.markup ?? 0;
let sellingPrice = draft.selling_price;
if (sellingPrice == null) {
sellingPrice = Number((costPrice * (1 + markup / 100)).toFixed(2));
}
const result = await createOrderItems(orderId, [
{
description: draft.description,
quantity: draft.quantity ?? 1,
cost_price: costPrice,
markup: markup,
selling_price: sellingPrice,
},
]);
// Patch secondary fields on the newly created row (sku / unit /
// supplier / notes) — same pattern as `createOrder` uses.
if (result.created.length > 0) {
const newId = result.created[0].id;
const secondary: Record<string, unknown> = {};
if (draft.sku) secondary.sku = draft.sku;
if (draft.unit) secondary.unit = draft.unit;
if (draft.supplier) secondary.supplier = draft.supplier;
if (draft.notes) secondary.notes = draft.notes;
if (Object.keys(secondary).length > 0) {
await pb.collection(getOrderItemsCollectionName()).update(newId, secondary);
}
}
return {
orderId,
created: result.created,
failed: result.failed.map((f) => ({ error: f.error })),
orderLocked: false,
};
}