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>; type RepUser = Pick; /** * 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; 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 = Object.freeze( Object.fromEntries(TAB_GROUP_ORDER.map((tab) => [tab, 0])), ) as Record; /** * 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; } /** * 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 { 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 & { /** * 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; } > { 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( 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 = {}; 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(); 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 { // 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(); 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 { 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(); 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(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( 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; const rowB = b as Record; 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 { const needed = new Set(); 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({ filter, fields: "id,name,email", }); } export async function getOrderItemStatusTabCounts( pbArg?: AdminPocketBase, options: { bypassCache?: boolean; includeArchivedTabs?: boolean } = {}, ): Promise> { 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; const seen: Record> = {} as Record>; 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 { 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(page, perPage, { filter: archiveBackRelationFilter, sort, expand, }), pb.collection(ORDER_COLLECTION).getList(1, 1, { filter: `order_items_via_order.item_status ?= "delivered"`, }), pb.collection(ORDER_COLLECTION).getList(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 = {}; 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(); 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(); const distinctByStatus: Record> = { 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({ 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)[sortKey]; const bv = (b as unknown as Record)[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 = {}; 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(); 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(orderId, { expand: "company,customer,customer.company,customer.rep,quote", }), pb.collection(getOrderItemsCollectionName()).getFullList({ filter: `order = "${escapedOrderId}"`, expand: "supplier", sort: "created", }), pb.collection("customers").getFullList({ 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 { 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({ 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> ): Promise { 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> ): 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>, 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>, year: number, month: number ): Promise { 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 = {}; 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 = {}; 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 { 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 = {}; 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 { 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(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 = { 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 { 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 { 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_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 { 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(`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; 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 { 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 = 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(); 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(); 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(); 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(); 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: ""` // 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 { return pb .collection(getOrderItemsCollectionName()) .getFullList({ filter: `order = "${orderId}"`, fields: "description,quantity,item_status,selling_price", batch: 500, }); } function buildRecord( order: OrderHeaderShape, items: AggregateItemShape[], ): DuplicateOrderRecord { const summary: Record = {}; 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 { 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({ 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(); 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({ 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(); 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 { 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({ 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({ 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({ 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(); 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({ 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 { const pb = await getAdminPocketBase(); pb.autoCancellation(false); 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, }); 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(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 { const pb = await getAdminPocketBase(); pb.autoCancellation(false); 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) { 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>; /** * 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: ` 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(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 = {}; 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)[k] as OrderPatch[keyof OrderPatch]; } await pb.collection(ORDER_COLLECTION).update(orderId, cleaned); const updated = await pb.collection(ORDER_COLLECTION).getOne(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 { 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(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({ 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(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 = { 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(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 { 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 = {}; 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, }; }