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