WFDASH-WEBAPP/src/lib/server/orders-service.ts
Dave 46e0683635 feat(duplicate-orders): enhance duplicate order detection and UI
- Introduced a new `DuplicateKind` type to differentiate between "order_number" and "line_aggregate" duplicates.
- Updated the `DuplicateOrdersDialog` component to support tabbed navigation between order number and line aggregate views, improving user experience.
- Enhanced the data structure for duplicate records to include `totalQuantity` and `totalSellingPrice`, facilitating more accurate duplicate detection.
- Implemented logic to auto-flip to the tab with available content, ensuring operators do not encounter empty views.
- Expanded test coverage for the `findDuplicateOrders` function to validate new line-aggregate matching logic and ensure accurate detection of duplicates across different scenarios.
- Improved documentation for the duplicate order handling process, clarifying the criteria for identifying duplicates.
2026-08-16 11:25:00 +02:00

3211 lines
116 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 { getAdminPocketBase } from "./pocketbase-admin";
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",
);
/** 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[]> {
const items = await pb
.collection(getOrderItemsCollectionName())
.getFullList<{ order?: string }>({
filter: tabGroupFilter(tabGroup),
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 four
* active workflow tabs (new, processing, collection_pool, collected) —
* i.e. the orders /orders is responsible for. Delivered + cancelled are
* excluded; they belong to /orders/archive.
*
* This is the search candidate set. One `order_items` scan over the
* active statuses (~255 rows on live PB) yielding ~91 order ids, which
* comfortably fits the inline `id = "..."` filter path in
* `fetchOrdersFilteredByIds`.
*/
export async function getActiveOrderIds(
pb: AdminPocketBase,
): Promise<string[]> {
const items = await pb
.collection(getOrderItemsCollectionName())
.getFullList<{ order?: string }>({
filter: ACTIVE_TAB_STATUSES.map((s) => `item_status = "${s}"`).join(" || "),
fields: "order",
batch: TAB_COUNTS_BATCH_SIZE,
});
const ids = new Set<string>();
for (const item of items) {
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]++;
}
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[];
}
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 }>;
}
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})`);
}
// --- Order row ------------------------------------------------------------
// Mirrors the field set `createOrderFromQuote` writes, minus the
// quote-related fields (no quote on a standalone order).
const orderNumber = await generateOrderNumberServer(pb);
const 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 || "",
})) as Order;
// --- 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)
}`,
});
}
}
}
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 };
}
/* ============================================================================
* 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;
}
export type DuplicateMatchKind = "order_number" | "line_aggregate";
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[];
}
export async function findDuplicateOrders(): Promise<DuplicateOrderGroup[]> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
// Statuses that count as "on the board" — the four active workflow
// tabs. Records whose items are entirely in delivered/cancelled
// are off the board and we hide their duplicates from the operator.
// The match against the active set is "≥1 active item per record",
// so we keep records that still have any in-flight work.
const ACTIVE_STATUSES = Array.from(
new Set(
TAB_GROUP_ORDER
.filter((t) => t !== "delivered" && t !== "cancelled")
.flatMap((t) => TAB_GROUP_ITEM_STATUSES[t]),
),
);
// Pull the set of order ids that have at least one active item.
// We need this BEFORE fetching the orders so we can filter at the
// PB level rather than walking every order record.
const activeItems = await pb
.collection(getOrderItemsCollectionName())
.getFullList<{ order?: string }>({
filter: ACTIVE_STATUSES.map((s) => `item_status = "${s}"`).join(" || "),
fields: "order",
batch: 5000,
});
const activeOrderIds = new Set<string>();
for (const it of activeItems) {
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).
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.
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 });
}
// 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.
out.sort((a, b) => b.records.length - a.records.length);
return out;
}
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;
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),
};
}
// ──────────────────────────────────────────────────────────────────────
// 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",
] 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 };
}
// ──────────────────────────────────────────────────────────────────────
// 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,
};
}