2026-06-11 18:30:02 +00:00
|
|
|
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
|
|
|
|
|
|
|
|
|
|
// --- PocketBase mock ---------------------------------------------------------
|
2026-07-10 20:21:50 +00:00
|
|
|
|
// The mock state has to be hoisted to the top of the file because Vitest
|
|
|
|
|
|
// hoists `vi.mock` factories above all imports. The state lives in a
|
|
|
|
|
|
// `vi.hoisted` block so the factory can close over it.
|
|
|
|
|
|
|
|
|
|
|
|
const { store, pbMock, clearStore } = vi.hoisted(() => {
|
|
|
|
|
|
type StoreMap = Map<string, Record<string, unknown>>;
|
|
|
|
|
|
const store: Record<string, StoreMap> = {};
|
|
|
|
|
|
let seq = 0;
|
|
|
|
|
|
const nextId = () => `mock-${++seq}`;
|
|
|
|
|
|
|
2026-08-13 07:02:27 +00:00
|
|
|
|
// Relation name → collection name. PB stores every relation as the
|
|
|
|
|
|
// singular id and the related records in the pluralised collection
|
|
|
|
|
|
// (e.g. `order.customer` → `customers` collection). The mock's
|
|
|
|
|
|
// getFullList uses this to resolve expand joins. Most words are
|
|
|
|
|
|
// `+s`, but a few are irregular — `company` → `companies`, etc.
|
|
|
|
|
|
const IRREGULAR_PLURALS: Record<string, string> = {
|
|
|
|
|
|
company: "companies",
|
|
|
|
|
|
};
|
|
|
|
|
|
const pluralize = (rel: string): string => {
|
|
|
|
|
|
if (IRREGULAR_PLURALS[rel]) return IRREGULAR_PLURALS[rel];
|
|
|
|
|
|
return rel.endsWith("s") ? rel : `${rel}s`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-10 20:21:50 +00:00
|
|
|
|
const getOrCreateMap = (name: string): StoreMap => {
|
|
|
|
|
|
if (!store[name]) store[name] = new Map();
|
|
|
|
|
|
return store[name];
|
|
|
|
|
|
};
|
2026-06-11 18:30:02 +00:00
|
|
|
|
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
// The mock `where` is intentionally minimal — supports the patterns the
|
|
|
|
|
|
// production service actually issues: `field = "value"` (with or without
|
|
|
|
|
|
// quotes), `field != "value"`, `&&` (AND), `||` (OR), and `id` lookups by
|
|
|
|
|
|
// PB record id. Anything unrecognised passes through (returns all items
|
|
|
|
|
|
// for the AND group), so unknown filters don't false-negative tests.
|
|
|
|
|
|
const matchesClause = (item: Record<string, unknown>, clause: string): boolean => {
|
2026-08-24 05:09:39 +00:00
|
|
|
|
const m = clause.match(/^(\w+)\s*(=~|!~|=|!=|>=|<=|<|>)\s*"?([^"]+?)"?$/);
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
if (!m) return true;
|
|
|
|
|
|
const [, field, op, raw] = m;
|
|
|
|
|
|
const expected = raw.replace(/^"|"$/g, "");
|
|
|
|
|
|
const actual = item[field];
|
|
|
|
|
|
if (field === "year" || field === "month") {
|
|
|
|
|
|
const num = Number(actual);
|
|
|
|
|
|
const want = Number(expected);
|
|
|
|
|
|
switch (op) {
|
|
|
|
|
|
case "=": return num === want;
|
|
|
|
|
|
case "!=": return num !== want;
|
|
|
|
|
|
default: return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const actualStr = String(actual ?? "");
|
2026-08-24 05:09:39 +00:00
|
|
|
|
// Date / numeric comparisons go through `actual` (not the stringified
|
|
|
|
|
|
// version) so PB-style `>=`/`<=`/`>`/`<` against ISO timestamps or
|
|
|
|
|
|
// numbers behaves the way the service layer expects. The mock only
|
|
|
|
|
|
// implements the subset the production code actually emits; if a
|
|
|
|
|
|
// caller adds a new operator the match falls through to `true` so
|
|
|
|
|
|
// tests don't false-negative.
|
|
|
|
|
|
if (op === ">=" || op === "<=" || op === ">" || op === "<") {
|
|
|
|
|
|
const a = actual as string | number | undefined | null;
|
|
|
|
|
|
if (a === undefined || a === null) return false;
|
|
|
|
|
|
switch (op) {
|
|
|
|
|
|
case ">=": return a > expected || a === expected;
|
|
|
|
|
|
case "<=": return a < expected || a === expected;
|
|
|
|
|
|
case ">": return a > expected;
|
|
|
|
|
|
case "<": return a < expected;
|
|
|
|
|
|
default: return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
switch (op) {
|
|
|
|
|
|
case "=": return actualStr === expected;
|
|
|
|
|
|
case "!=": return actualStr !== expected;
|
|
|
|
|
|
case "=~": return actualStr.includes(expected);
|
|
|
|
|
|
case "!~": return !actualStr.includes(expected);
|
|
|
|
|
|
default: return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-10 20:21:50 +00:00
|
|
|
|
const where = (name: string, filter: string): Record<string, unknown>[] => {
|
|
|
|
|
|
const items = Array.from(getOrCreateMap(name).values());
|
|
|
|
|
|
if (!filter || !filter.trim()) return items;
|
2026-08-24 05:09:39 +00:00
|
|
|
|
// Strip outer parens around the whole expression so the splitter
|
|
|
|
|
|
// below doesn't see `(` / `)` as clause-leading characters. PB
|
|
|
|
|
|
// filters are parenthesised when they mix `&&` / `||` — see
|
|
|
|
|
|
// `completedTabFilter` in orders-service.ts for the canonical
|
|
|
|
|
|
// example.
|
|
|
|
|
|
const stripped = filter.replace(/^\s*\(/, "").replace(/\)\s*$/, "");
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
// Split on `||` first (outer OR), then each alternative is a group of
|
|
|
|
|
|
// `&&`-joined AND clauses. An item passes if it satisfies ANY
|
2026-08-24 05:09:39 +00:00
|
|
|
|
// alternative group. We also strip wrapping parens around each
|
|
|
|
|
|
// alternative so individual clauses don't start with `(`.
|
|
|
|
|
|
const alternatives = stripped
|
|
|
|
|
|
.split("||")
|
|
|
|
|
|
.map((a) => a.trim().replace(/^\(/, "").replace(/\)$/, ""))
|
|
|
|
|
|
.filter(Boolean);
|
2026-07-10 20:21:50 +00:00
|
|
|
|
return items.filter((item) =>
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
alternatives.some((alt) => {
|
2026-08-24 05:09:39 +00:00
|
|
|
|
const clauses = alt
|
|
|
|
|
|
.split("&&")
|
|
|
|
|
|
.map((c) => c.trim().replace(/^\(/, "").replace(/\)$/, "").trim())
|
|
|
|
|
|
.filter(Boolean);
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
return clauses.every((clause) => matchesClause(item, clause));
|
2026-07-10 20:21:50 +00:00
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
2026-06-11 18:30:02 +00:00
|
|
|
|
|
2026-08-16 09:01:52 +00:00
|
|
|
|
const applyExpand = (
|
|
|
|
|
|
rows: Record<string, unknown>[],
|
|
|
|
|
|
expand: string | undefined,
|
|
|
|
|
|
): Record<string, unknown>[] => {
|
|
|
|
|
|
if (!expand) return rows;
|
|
|
|
|
|
const relations = expand.split(",").map((s) => s.trim()).filter(Boolean);
|
|
|
|
|
|
return rows.map((it) => {
|
|
|
|
|
|
const exp: Record<string, unknown> = {};
|
|
|
|
|
|
for (const rel of relations) {
|
|
|
|
|
|
const relId = it[rel];
|
|
|
|
|
|
if (typeof relId === "string" && getOrCreateMap(pluralize(rel)).has(relId)) {
|
|
|
|
|
|
exp[rel] = getOrCreateMap(pluralize(rel)).get(relId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ...it, expand: exp };
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-10 20:21:50 +00:00
|
|
|
|
const pbMock = {
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
autoCancellation: () => undefined,
|
2026-07-10 20:21:50 +00:00
|
|
|
|
collection: (name: string) => ({
|
|
|
|
|
|
async getList(
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
page = 1,
|
|
|
|
|
|
perPage = 25,
|
2026-08-16 09:01:52 +00:00
|
|
|
|
opts: { filter?: string; sort?: string; expand?: string } = {},
|
2026-07-10 20:21:50 +00:00
|
|
|
|
) {
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
const all = where(name, opts.filter || "");
|
2026-07-10 20:21:50 +00:00
|
|
|
|
if (opts.sort) {
|
|
|
|
|
|
const sign = opts.sort.startsWith("-") ? -1 : 1;
|
|
|
|
|
|
const field = opts.sort.replace(/^-/, "");
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
all.sort((a, b) => {
|
2026-07-10 20:21:50 +00:00
|
|
|
|
const av = a[field] as string | number | undefined;
|
|
|
|
|
|
const bv = b[field] as string | number | undefined;
|
|
|
|
|
|
if (av === bv) return 0;
|
|
|
|
|
|
if (av === undefined) return 1;
|
|
|
|
|
|
if (bv === undefined) return -1;
|
|
|
|
|
|
return (av > bv ? 1 : -1) * sign;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
// Real pagination — mirror PB's getList semantics so the
|
|
|
|
|
|
// service code (which relies on totalPages) exercises the same
|
|
|
|
|
|
// branches under test as in production.
|
|
|
|
|
|
const totalItems = all.length;
|
|
|
|
|
|
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
|
|
|
|
|
|
const start = (page - 1) * perPage;
|
2026-08-16 09:01:52 +00:00
|
|
|
|
const items = applyExpand(all.slice(start, start + perPage), opts.expand);
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
return { items, totalItems, totalPages, page, perPage };
|
2026-07-10 20:21:50 +00:00
|
|
|
|
},
|
2026-08-13 07:02:27 +00:00
|
|
|
|
async getFullList(opts: { filter?: string; fields?: string; expand?: string } = {}) {
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
// Mirror production: respect the filter. The fields projection
|
|
|
|
|
|
// is a wire-payload optimisation in PB; for tests we just
|
2026-08-13 07:02:27 +00:00
|
|
|
|
// return the matched records as-is. Expand ("customer,company"
|
|
|
|
|
|
// etc.) is honoured by attaching the related records from the
|
|
|
|
|
|
// store under `expand.<relation>` so tests asserting on names
|
|
|
|
|
|
// pulled through the expand join pass.
|
|
|
|
|
|
const items = where(name, opts.filter || "");
|
2026-08-16 09:01:52 +00:00
|
|
|
|
return applyExpand(items, opts.expand);
|
2026-07-10 20:21:50 +00:00
|
|
|
|
},
|
|
|
|
|
|
async getFirstListItem(filter: string) {
|
|
|
|
|
|
const items = where(name, filter);
|
|
|
|
|
|
if (items.length === 0) {
|
|
|
|
|
|
const err = new Error("not found") as Error & { status: number };
|
|
|
|
|
|
err.status = 404;
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
return items[0];
|
|
|
|
|
|
},
|
|
|
|
|
|
async getOne(id: string) {
|
|
|
|
|
|
const item = getOrCreateMap(name).get(id);
|
|
|
|
|
|
if (!item) {
|
|
|
|
|
|
const err = new Error("not found") as Error & { status: number };
|
|
|
|
|
|
err.status = 404;
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
return item;
|
|
|
|
|
|
},
|
|
|
|
|
|
async create(data: Record<string, unknown>) {
|
|
|
|
|
|
if (name === "order_counters") {
|
|
|
|
|
|
const map = getOrCreateMap(name);
|
|
|
|
|
|
for (const item of map.values()) {
|
|
|
|
|
|
if (item.year === data.year && item.month === data.month) {
|
|
|
|
|
|
const err = new Error("unique constraint") as Error & { status: number };
|
|
|
|
|
|
err.status = 409;
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-13 07:02:27 +00:00
|
|
|
|
// Respect an explicit `id` when the test provides one — PB does
|
|
|
|
|
|
// the same, and several tests (expand joins, split continuations)
|
|
|
|
|
|
// rely on the predictable id to wire related records together.
|
2026-07-10 20:21:50 +00:00
|
|
|
|
const item = {
|
2026-08-13 07:02:27 +00:00
|
|
|
|
id: typeof data.id === "string" ? data.id : nextId(),
|
2026-07-10 20:21:50 +00:00
|
|
|
|
created: new Date().toISOString(),
|
|
|
|
|
|
updated: new Date().toISOString(),
|
|
|
|
|
|
...data,
|
|
|
|
|
|
};
|
|
|
|
|
|
getOrCreateMap(name).set(item.id, item);
|
|
|
|
|
|
return item;
|
|
|
|
|
|
},
|
|
|
|
|
|
async update(id: string, data: Record<string, unknown>) {
|
|
|
|
|
|
const map = getOrCreateMap(name);
|
|
|
|
|
|
const existing = map.get(id);
|
|
|
|
|
|
if (!existing) {
|
|
|
|
|
|
const err = new Error("not found") as Error & { status: number };
|
|
|
|
|
|
err.status = 404;
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const [key, value] of Object.entries(data)) {
|
|
|
|
|
|
if (key.endsWith("+")) {
|
|
|
|
|
|
const real = key.slice(0, -1);
|
|
|
|
|
|
existing[real] = Number(existing[real] ?? 0) + Number(value);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
existing[key] = value;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
existing.updated = new Date().toISOString();
|
|
|
|
|
|
return { ...existing };
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
2026-06-11 18:30:02 +00:00
|
|
|
|
|
2026-07-10 20:21:50 +00:00
|
|
|
|
const clearStore = () => {
|
|
|
|
|
|
for (const key of Object.keys(store)) delete store[key];
|
|
|
|
|
|
seq = 0;
|
2026-06-11 18:30:02 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-10 20:21:50 +00:00
|
|
|
|
return { store, pbMock, clearStore };
|
|
|
|
|
|
});
|
2026-06-11 18:30:02 +00:00
|
|
|
|
|
|
|
|
|
|
vi.mock("pocketbase", () => {
|
|
|
|
|
|
return {
|
2026-07-10 20:21:50 +00:00
|
|
|
|
default: vi.fn().mockImplementation(() => pbMock),
|
2026-06-11 18:30:02 +00:00
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
vi.mock("./pocketbase-admin", () => ({
|
2026-07-10 20:21:50 +00:00
|
|
|
|
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
|
2026-06-11 18:30:02 +00:00
|
|
|
|
}));
|
|
|
|
|
|
|
2026-08-29 12:06:10 +00:00
|
|
|
|
vi.mock("./order-quote-link-service", () => ({
|
|
|
|
|
|
pairOrderLines: vi.fn().mockResolvedValue({
|
|
|
|
|
|
confident: [], possible: [], unmatchedOrderItemIds: [],
|
|
|
|
|
|
}),
|
|
|
|
|
|
suggestQuoteForOrder: vi.fn().mockResolvedValue({ best: null }),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-08-30 10:58:36 +00:00
|
|
|
|
vi.mock("./order-delivery-note-link-service", () => ({
|
|
|
|
|
|
suggestDeliveryNoteForOrder: vi.fn().mockResolvedValue({
|
|
|
|
|
|
best: null,
|
|
|
|
|
|
method: "no_match",
|
|
|
|
|
|
}),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2026-08-29 12:22:21 +00:00
|
|
|
|
// `after()` normally defers its callback until the response has been
|
|
|
|
|
|
// sent — outside a real Next.js request, nothing ever flushes that
|
|
|
|
|
|
// queue. Stub it to run the callback inline so `createOrder` tests can
|
|
|
|
|
|
// assert on the scheduled quote-match synchronously.
|
|
|
|
|
|
vi.mock("next/server", async (importOriginal) => {
|
|
|
|
|
|
const actual = await importOriginal<typeof import("next/server")>();
|
|
|
|
|
|
return {
|
|
|
|
|
|
...actual,
|
|
|
|
|
|
after: vi.fn((cb: () => unknown) => { void cb(); }),
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-11 18:30:02 +00:00
|
|
|
|
import {
|
2026-08-11 23:13:46 +00:00
|
|
|
|
createOrder,
|
2026-06-11 18:30:02 +00:00
|
|
|
|
createOrderFromQuote,
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
clearOrderItemStatusTabCountsCache,
|
|
|
|
|
|
getOrderItemStatusTabCounts,
|
|
|
|
|
|
getOrderIdsForTabGroup,
|
2026-06-11 18:30:02 +00:00
|
|
|
|
getOrderItemsCollectionName,
|
feat(orders): move delivered+cancelled to dedicated /orders/archive page
The 'Delivered' tab on /orders was broken \u2014 4,326 of 4,514 orders
(96%) have at least one delivered item. The old tab tried to
build a WHERE id IN (...) filter with 4,326 ids (~120 KB),
blowing past PB's ~4 KB filter cap and 400ing. This commit
moves historical orders to a dedicated page so:
- The main /orders view is fast (22-28 active orders, not 4.5k
historical)
- The archive can be honest about its pagination ('Page 1 of
217' is expected when looking for something from last month)
- The badge counts on the main page stop being dominated by
'Delivered (4,326)' / 'Cancelled (111)'
# Measured against the live PB
/api/v1/orders/archive?page=1&perPage=2 \u2014 15.2s cold, 6.5 KB
response. The 15s is the order_items.getFullList over 13,256
items (the bottleneck; PB paginates over many round-trips).
Per-row payload is the standard 5-6 KB for orders with deep
expand. Subsequent pages within the same minute would be much
faster since PB caches the order_items query.
# Service layer
- getOrdersArchiveData(params) in src/lib/server/orders-service.ts
- Returns paginated list of orders with \u22651 item in
(delivered, cancelled) status, plus per-status distinct
counts for the header totals
- Strategy: getFullList on order_items (ids + status only),
dedupe + per-status counts in one pass, chunk the order ids
by 100 (PB's URL/filter cap is between 4.5-6.7 KB) and fire
parallel getFullList queries on orders with deep expand
- Search hits order_number, company, customer, contact_name
- Sort defaults to -created, override via the sort param
- /api/v1/orders/archive thin route: page, perPage, search, sort
(no tab, no nocache \u2014 archive is one bucket, not a tab split)
# Page
- /orders/archive/page.tsx: card list with header counts
(delivered / cancelled badges), search input, pagination
footer. Click an order to open /orders/[id] in detail.
# Main /orders updates
- Delivered + Cancelled removed from the tab strip (passed
tabKeys=['new','processing','collection_pool','collected'] to
ItemStatusTabs)
- Counts on the main page now exclude delivered + cancelled
- 'Archive' button in the page header takes the operator to
/orders/archive
- Safety useEffect: if activeTab is delivered/cancelled (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in orders-service.test.ts:
- Returns orders with \u22651 delivered/cancelled item, plus counts
- Dedupe: one order with multiple matching items counts as 1
- Search by order_number and contact_name
- Empty result when no orders match
Full suite: 1321/1321 pass.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:58:43 +00:00
|
|
|
|
getOrdersArchiveData,
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
getOrdersDashboardData,
|
|
|
|
|
|
listOrdersWithItems,
|
2026-08-11 23:13:46 +00:00
|
|
|
|
splitOrderForContinuation,
|
2026-08-13 07:02:27 +00:00
|
|
|
|
cancelOrder,
|
|
|
|
|
|
cancelOrdersBulk,
|
|
|
|
|
|
findDuplicateOrders,
|
2026-08-21 17:18:02 +00:00
|
|
|
|
// Hermes unblock (PR 1, 2026-08-21):
|
|
|
|
|
|
setOrderQuote,
|
|
|
|
|
|
findOrderByOrderNumber,
|
|
|
|
|
|
findOrderByEmailMessageId,
|
2026-06-11 18:30:02 +00:00
|
|
|
|
} from "./orders-service";
|
|
|
|
|
|
|
2026-07-10 20:21:50 +00:00
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
clearStore();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-11 18:30:02 +00:00
|
|
|
|
// --- Tests -------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
describe("getOrderItemsCollectionName", () => {
|
|
|
|
|
|
it("defaults to 'order_items' when env var is unset", () => {
|
|
|
|
|
|
const original = process.env.ORDER_ITEMS_COLLECTION;
|
|
|
|
|
|
delete process.env.ORDER_ITEMS_COLLECTION;
|
|
|
|
|
|
try {
|
|
|
|
|
|
expect(getOrderItemsCollectionName()).toBe("order_items");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (original !== undefined) process.env.ORDER_ITEMS_COLLECTION = original;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns the env var when set (e.g. order_items_v2)", () => {
|
|
|
|
|
|
process.env.ORDER_ITEMS_COLLECTION = "order_items_v2";
|
|
|
|
|
|
try {
|
|
|
|
|
|
expect(getOrderItemsCollectionName()).toBe("order_items_v2");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
delete process.env.ORDER_ITEMS_COLLECTION;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("createOrderFromQuote", () => {
|
|
|
|
|
|
it("returns the existing order when one already references the quote", async () => {
|
2026-07-10 20:21:50 +00:00
|
|
|
|
// Pre-seed an existing order in the store; dedupe check should find it
|
|
|
|
|
|
// and short-circuit before the rest of the pipeline runs.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "order-1",
|
|
|
|
|
|
quote: "quote-abc",
|
|
|
|
|
|
order_number: "ORD-2025-01-001",
|
|
|
|
|
|
});
|
2026-06-11 18:30:02 +00:00
|
|
|
|
|
|
|
|
|
|
const result = await createOrderFromQuote("quote-abc");
|
|
|
|
|
|
expect(result.already_exists).toBe(true);
|
2026-07-10 20:21:50 +00:00
|
|
|
|
expect(result.order.id).toBe("order-1");
|
2026-06-11 18:30:02 +00:00
|
|
|
|
expect(result.created_items).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("throws when the quote has no items", async () => {
|
2026-07-10 20:21:50 +00:00
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
|
id: "quote-empty",
|
|
|
|
|
|
company: "comp-1",
|
|
|
|
|
|
customer: "cust-1",
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
// No quote_items rows → service must throw.
|
2026-06-11 18:30:02 +00:00
|
|
|
|
await expect(createOrderFromQuote("quote-empty")).rejects.toThrow(
|
2026-07-10 20:21:50 +00:00
|
|
|
|
/no items to convert/,
|
2026-06-11 18:30:02 +00:00
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("creates an order, copies every quote_item, and sets the right defaults", async () => {
|
2026-07-10 20:21:50 +00:00
|
|
|
|
// Seed the dependencies in the mock store.
|
|
|
|
|
|
await pbMock.collection("quotes").create({
|
2026-06-11 18:30:02 +00:00
|
|
|
|
id: "quote-99",
|
|
|
|
|
|
company: "comp-7",
|
|
|
|
|
|
customer: "cust-3",
|
|
|
|
|
|
notes: "Rush",
|
|
|
|
|
|
});
|
2026-07-10 20:21:50 +00:00
|
|
|
|
await pbMock.collection("quote_items").create({
|
|
|
|
|
|
id: "qi-1",
|
|
|
|
|
|
quote: "quote-99",
|
|
|
|
|
|
description: "Widget",
|
|
|
|
|
|
quantity: 3,
|
|
|
|
|
|
selling_price: 100,
|
|
|
|
|
|
supplier: "sup-1",
|
|
|
|
|
|
cost_price: 50,
|
|
|
|
|
|
markup: 10,
|
|
|
|
|
|
notes: "front",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("quote_items").create({
|
|
|
|
|
|
id: "qi-2",
|
|
|
|
|
|
quote: "quote-99",
|
|
|
|
|
|
description: "Sprocket",
|
|
|
|
|
|
quantity: 2,
|
|
|
|
|
|
selling_price: 75,
|
|
|
|
|
|
supplier: "sup-2",
|
|
|
|
|
|
cost_price: 30,
|
|
|
|
|
|
markup: 0,
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
});
|
2026-06-11 18:30:02 +00:00
|
|
|
|
|
|
|
|
|
|
const result = await createOrderFromQuote("quote-99");
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.already_exists).toBe(false);
|
|
|
|
|
|
expect(result.created_items).toBe(2);
|
|
|
|
|
|
|
2026-07-10 20:21:50 +00:00
|
|
|
|
// Pull the created order directly out of the store.
|
|
|
|
|
|
const orders = await pbMock.collection("orders").getFullList();
|
|
|
|
|
|
const created = orders.find((o) => o.quote === "quote-99") as
|
|
|
|
|
|
| Record<string, unknown>
|
|
|
|
|
|
| undefined;
|
|
|
|
|
|
expect(created).toBeDefined();
|
|
|
|
|
|
expect(created?.quote).toBe("quote-99");
|
|
|
|
|
|
expect(created?.company).toBe("comp-7");
|
|
|
|
|
|
expect(created?.customer).toBe("cust-3");
|
|
|
|
|
|
expect(created?.quote_matching_attempted).toBe(true);
|
|
|
|
|
|
expect(created?.is_repair_order).toBe(false);
|
|
|
|
|
|
expect(created?.invoiced).toBe(false);
|
|
|
|
|
|
// Starts at ORD-{now-year}-{now-month}-001 because the orders table
|
|
|
|
|
|
// is empty for this test (no prior order to extend from).
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const expectedPrefix = `ORD-${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-`;
|
|
|
|
|
|
expect(String(created?.order_number)).toBe(`${expectedPrefix}001`);
|
|
|
|
|
|
|
|
|
|
|
|
// Order-item payload assertions: the two quote_items should each
|
|
|
|
|
|
// have produced one order_item row.
|
|
|
|
|
|
const orderItems = await pbMock.collection("order_items").getFullList();
|
|
|
|
|
|
const ourItems = orderItems.filter((o) => o.order === created?.id);
|
|
|
|
|
|
expect(ourItems.length).toBe(2);
|
|
|
|
|
|
|
|
|
|
|
|
const widget = ourItems.find((o) => o.description === "Widget");
|
|
|
|
|
|
expect(widget).toMatchObject({
|
2026-06-11 18:30:02 +00:00
|
|
|
|
po_description: "Widget",
|
|
|
|
|
|
quantity: 3,
|
|
|
|
|
|
order_qty: 3,
|
|
|
|
|
|
qty_ordered: "3",
|
|
|
|
|
|
selling_price: 100,
|
|
|
|
|
|
supplier: "sup-1",
|
|
|
|
|
|
cost_price: 50,
|
|
|
|
|
|
markup: 10,
|
|
|
|
|
|
markup_percent: 10,
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
status: "active",
|
|
|
|
|
|
drv_status: "unassigned",
|
|
|
|
|
|
notes: "front",
|
|
|
|
|
|
quote_item: "qi-1",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-10 20:21:50 +00:00
|
|
|
|
const sprocket = ourItems.find((o) => o.description === "Sprocket");
|
|
|
|
|
|
expect(sprocket?.quote_item).toBe("qi-2");
|
|
|
|
|
|
expect(sprocket?.supplier).toBe("sup-2");
|
|
|
|
|
|
expect(sprocket?.drv_status).toBe("unassigned");
|
2026-06-11 18:30:02 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-10 20:21:50 +00:00
|
|
|
|
it("extends the latest order's sequence (no month-boundary reset)", async () => {
|
|
|
|
|
|
// The most recent order is in June; we're now in July. The contract
|
|
|
|
|
|
// is that the next order continues June's sequence (NOT a fresh
|
|
|
|
|
|
// July-001). The old code's bug was using `now` month, which would
|
|
|
|
|
|
// have generated `ORD-2026-07-001` here.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
order_number: "ORD-2026-06-007",
|
|
|
|
|
|
quote: "some-other-quote",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("quotes").create({
|
2026-06-11 18:30:02 +00:00
|
|
|
|
id: "quote-seq",
|
|
|
|
|
|
company: "c",
|
|
|
|
|
|
customer: null,
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
});
|
2026-07-10 20:21:50 +00:00
|
|
|
|
await pbMock.collection("quote_items").create({
|
|
|
|
|
|
id: "qi-1",
|
|
|
|
|
|
quote: "quote-seq",
|
|
|
|
|
|
description: "x",
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
selling_price: 1,
|
|
|
|
|
|
cost_price: 0,
|
|
|
|
|
|
markup: 0,
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
});
|
2026-06-11 18:30:02 +00:00
|
|
|
|
|
|
|
|
|
|
await createOrderFromQuote("quote-seq");
|
2026-07-10 20:21:50 +00:00
|
|
|
|
const orders = await pbMock.collection("orders").getFullList();
|
|
|
|
|
|
const newOrder = orders.find((o) => o.quote === "quote-seq");
|
|
|
|
|
|
expect(newOrder?.order_number).toBe("ORD-2026-06-008");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("starts a fresh month at 001 when the orders table is empty", async () => {
|
|
|
|
|
|
// The orders collection is empty → no most-recent order to extend
|
|
|
|
|
|
// from. Falls back to wall-clock year/month, sequence at 001.
|
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
|
id: "q-fresh",
|
|
|
|
|
|
company: "c",
|
|
|
|
|
|
customer: null,
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("quote_items").create({
|
|
|
|
|
|
id: "qi-1",
|
|
|
|
|
|
quote: "q-fresh",
|
|
|
|
|
|
description: "x",
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
selling_price: 1,
|
|
|
|
|
|
cost_price: 0,
|
|
|
|
|
|
markup: 0,
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await createOrderFromQuote("q-fresh");
|
|
|
|
|
|
const orders = await pbMock.collection("orders").getFullList();
|
|
|
|
|
|
const newOrder = orders.find((o) => o.quote === "q-fresh");
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const expected = `ORD-${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-001`;
|
|
|
|
|
|
expect(newOrder?.order_number).toBe(expected);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("falls back to wall-clock year/month when the latest order_number is unparseable", async () => {
|
|
|
|
|
|
// A junk order_number (legacy import, manual edit, or old data with a
|
|
|
|
|
|
// different prefix) should NOT break the generator. The service
|
|
|
|
|
|
// detects unparseable prefixes and uses now year/month.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
order_number: "GARBAGE-2026-06-007",
|
|
|
|
|
|
quote: "old",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
|
id: "q-junk",
|
|
|
|
|
|
company: "c",
|
|
|
|
|
|
customer: null,
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("quote_items").create({
|
|
|
|
|
|
id: "qi-1",
|
|
|
|
|
|
quote: "q-junk",
|
|
|
|
|
|
description: "x",
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
selling_price: 1,
|
|
|
|
|
|
cost_price: 0,
|
|
|
|
|
|
markup: 0,
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await createOrderFromQuote("q-junk");
|
|
|
|
|
|
const orders = await pbMock.collection("orders").getFullList();
|
|
|
|
|
|
const newOrder = orders.find((o) => o.quote === "q-junk");
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const expected = `ORD-${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-001`;
|
|
|
|
|
|
expect(newOrder?.order_number).toBe(expected);
|
2026-06-11 18:30:02 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("writes to the collection named by ORDER_ITEMS_COLLECTION env var", async () => {
|
|
|
|
|
|
process.env.ORDER_ITEMS_COLLECTION = "order_items_v2";
|
2026-07-10 20:21:50 +00:00
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
|
id: "q",
|
|
|
|
|
|
company: "c",
|
|
|
|
|
|
customer: null,
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("quote_items").create({
|
|
|
|
|
|
id: "qi-1",
|
|
|
|
|
|
quote: "q",
|
|
|
|
|
|
description: "x",
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
selling_price: 1,
|
|
|
|
|
|
cost_price: 0,
|
|
|
|
|
|
markup: 0,
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
});
|
2026-06-11 18:30:02 +00:00
|
|
|
|
|
|
|
|
|
|
const result = await createOrderFromQuote("q");
|
|
|
|
|
|
expect(result.order_items_collection).toBe("order_items_v2");
|
2026-07-10 20:21:50 +00:00
|
|
|
|
|
|
|
|
|
|
const v2Rows = await pbMock.collection("order_items_v2").getFullList();
|
|
|
|
|
|
expect(v2Rows.length).toBe(1);
|
|
|
|
|
|
expect(v2Rows[0].order).toBeDefined();
|
2026-06-11 18:30:02 +00:00
|
|
|
|
|
|
|
|
|
|
delete process.env.ORDER_ITEMS_COLLECTION;
|
|
|
|
|
|
});
|
2026-07-10 20:21:50 +00:00
|
|
|
|
|
|
|
|
|
|
it("concurrent calls produce unique order numbers (no race)", async () => {
|
|
|
|
|
|
// Set up the dependencies for a single order.
|
|
|
|
|
|
await pbMock.collection("quotes").create({
|
|
|
|
|
|
id: "q-race",
|
|
|
|
|
|
company: "c",
|
|
|
|
|
|
customer: null,
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("quote_items").create({
|
|
|
|
|
|
id: "qi-1",
|
|
|
|
|
|
quote: "q-race",
|
|
|
|
|
|
description: "x",
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
selling_price: 1,
|
|
|
|
|
|
cost_price: 0,
|
|
|
|
|
|
markup: 0,
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
// Run 5 createOrderFromQuote calls in parallel against distinct
|
|
|
|
|
|
// quotes. The counter must give each a unique number even under
|
|
|
|
|
|
// simulated concurrency. (Note: the test mock is not actually
|
|
|
|
|
|
// concurrent — the `await`s in the implementation interleave the
|
|
|
|
|
|
// operations — so the assertion verifies the counter pattern, not
|
|
|
|
|
|
// true concurrency. Real concurrency safety comes from PB's
|
|
|
|
|
|
// `last_seq+` server-side increment.)
|
|
|
|
|
|
const quotes = ["q-race-a", "q-race-b", "q-race-c", "q-race-d", "q-race-e"];
|
|
|
|
|
|
for (const id of quotes) {
|
|
|
|
|
|
await pbMock.collection("quotes").create({ id, company: "c", customer: null, notes: "" });
|
|
|
|
|
|
await pbMock.collection("quote_items").create({
|
|
|
|
|
|
id: `qi-${id}`,
|
|
|
|
|
|
quote: id,
|
|
|
|
|
|
description: "x",
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
selling_price: 1,
|
|
|
|
|
|
cost_price: 0,
|
|
|
|
|
|
markup: 0,
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
const results = await Promise.all(quotes.map((id) => createOrderFromQuote(id)));
|
|
|
|
|
|
const numbers = results.map((r) => String((r.order as unknown as { order_number: string }).order_number));
|
|
|
|
|
|
expect(new Set(numbers).size).toBe(5);
|
|
|
|
|
|
});
|
2026-06-11 18:30:02 +00:00
|
|
|
|
});
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
|
2026-08-11 23:13:46 +00:00
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
|
|
// Standalone order creation — createOrder. Use for counter sales / walk-ins /
|
|
|
|
|
|
// internal transfers where there's no originating quote.
|
|
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
describe("createOrder", () => {
|
|
|
|
|
|
it("creates an order with no quote and the supplied items", async () => {
|
|
|
|
|
|
// Seed the relation targets.
|
|
|
|
|
|
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
|
|
|
|
|
|
await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" });
|
|
|
|
|
|
|
|
|
|
|
|
const result = await createOrder({
|
|
|
|
|
|
customer_id: "cust-x",
|
|
|
|
|
|
company_id: "comp-x",
|
|
|
|
|
|
notes: "Counter sale — cash",
|
|
|
|
|
|
contact_name: "Mr Dreyer",
|
|
|
|
|
|
rep: "Giellie",
|
|
|
|
|
|
items: [
|
|
|
|
|
|
{ description: "M16 Bolt", quantity: 50, cost_price: 5, markup: 50, selling_price: 7.5 },
|
|
|
|
|
|
{ description: "M16 Nut", quantity: 50, cost_price: 3, markup: 50, selling_price: 4.5 },
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.order.id).toBeDefined();
|
|
|
|
|
|
expect(result.order.customer).toBe("cust-x");
|
|
|
|
|
|
expect(result.order.company).toBe("comp-x");
|
|
|
|
|
|
expect(result.order.notes).toBe("Counter sale — cash");
|
|
|
|
|
|
expect(result.order.contact_name).toBe("Mr Dreyer");
|
|
|
|
|
|
expect(result.order.rep).toBe("Giellie");
|
|
|
|
|
|
// Quote-related fields stay blank — no originating quote.
|
|
|
|
|
|
expect(result.order.quote).toBeNull();
|
|
|
|
|
|
expect(result.order.potential_quote).toBeNull();
|
|
|
|
|
|
expect(result.order.quote_matching_attempted).toBe(false);
|
|
|
|
|
|
expect(result.order.quote_match_confidence).toBe(0);
|
|
|
|
|
|
expect(result.order.potential_quote_confidence).toBe(0);
|
|
|
|
|
|
expect(result.order.is_repair_order).toBe(false);
|
|
|
|
|
|
expect(result.order.invoiced).toBe(false);
|
|
|
|
|
|
|
|
|
|
|
|
// Two items created, with the per-row defaults the rest of the
|
|
|
|
|
|
// pipeline relies on.
|
|
|
|
|
|
expect(result.items_created.length).toBe(2);
|
|
|
|
|
|
expect(result.items_failed.length).toBe(0);
|
|
|
|
|
|
|
|
|
|
|
|
const orderItems = await pbMock.collection("order_items").getFullList();
|
|
|
|
|
|
const ours = orderItems.filter((o) => o.order === result.order.id);
|
|
|
|
|
|
expect(ours.length).toBe(2);
|
|
|
|
|
|
for (const it of ours) {
|
|
|
|
|
|
expect(it.item_status).toBe("pending");
|
|
|
|
|
|
expect(it.drv_status).toBe("unassigned");
|
|
|
|
|
|
expect(it.status).toBe("active");
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("patches secondary fields (sku/unit/supplier_id/notes) onto each item", async () => {
|
|
|
|
|
|
await pbMock.collection("customers").create({ id: "cust-y" });
|
|
|
|
|
|
await pbMock.collection("companies").create({ id: "comp-y" });
|
|
|
|
|
|
await pbMock.collection("suppliers").create({ id: "sup-y" });
|
|
|
|
|
|
|
|
|
|
|
|
const result = await createOrder({
|
|
|
|
|
|
customer_id: "cust-y",
|
|
|
|
|
|
company_id: "comp-y",
|
|
|
|
|
|
items: [
|
|
|
|
|
|
{
|
|
|
|
|
|
description: "Bearing 6205",
|
|
|
|
|
|
quantity: 4,
|
|
|
|
|
|
cost_price: 80,
|
|
|
|
|
|
markup: 25,
|
|
|
|
|
|
selling_price: 100,
|
|
|
|
|
|
sku: "BRG-6205",
|
|
|
|
|
|
unit: "EA",
|
|
|
|
|
|
supplier_id: "sup-y",
|
|
|
|
|
|
notes: "From shelf",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.items_created.length).toBe(1);
|
|
|
|
|
|
expect(result.items_failed.length).toBe(0);
|
|
|
|
|
|
|
|
|
|
|
|
const orderItems = await pbMock.collection("order_items").getFullList();
|
|
|
|
|
|
const ours = orderItems.filter((o) => o.order === result.order.id);
|
|
|
|
|
|
expect(ours[0]).toMatchObject({
|
|
|
|
|
|
sku: "BRG-6205",
|
|
|
|
|
|
unit: "EA",
|
|
|
|
|
|
supplier: "sup-y",
|
|
|
|
|
|
notes: "From shelf",
|
|
|
|
|
|
selling_price: 100,
|
|
|
|
|
|
cost_price: 80,
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("throws when customer_id does not resolve", async () => {
|
|
|
|
|
|
// No customers seeded — getOne will 404.
|
|
|
|
|
|
await pbMock.collection("companies").create({ id: "comp-z" });
|
|
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
createOrder({
|
|
|
|
|
|
customer_id: "ghost",
|
|
|
|
|
|
company_id: "comp-z",
|
|
|
|
|
|
items: [{ description: "x" }],
|
|
|
|
|
|
}),
|
|
|
|
|
|
).rejects.toThrow(/Customer not found/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("throws when company_id does not resolve", async () => {
|
|
|
|
|
|
await pbMock.collection("customers").create({ id: "cust-z" });
|
|
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
createOrder({
|
|
|
|
|
|
customer_id: "cust-z",
|
|
|
|
|
|
company_id: "ghost",
|
|
|
|
|
|
items: [{ description: "x" }],
|
|
|
|
|
|
}),
|
|
|
|
|
|
).rejects.toThrow(/Company not found/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("throws when items array is empty", async () => {
|
|
|
|
|
|
await pbMock.collection("customers").create({ id: "cust-1" });
|
|
|
|
|
|
await pbMock.collection("companies").create({ id: "comp-1" });
|
|
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
createOrder({ customer_id: "cust-1", company_id: "comp-1", items: [] }),
|
|
|
|
|
|
).rejects.toThrow(/items must be a non-empty array/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("throws when customer_id is missing", async () => {
|
|
|
|
|
|
await pbMock.collection("companies").create({ id: "comp-1" });
|
|
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
createOrder({
|
|
|
|
|
|
customer_id: "",
|
|
|
|
|
|
company_id: "comp-1",
|
|
|
|
|
|
items: [{ description: "x" }],
|
|
|
|
|
|
}),
|
|
|
|
|
|
).rejects.toThrow(/customer_id and company_id are required/);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-21 17:18:02 +00:00
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
|
|
// createOrder email_message_id dedup pre-check.
|
|
|
|
|
|
//
|
|
|
|
|
|
// The n8n email-intake pipeline can pick up the same IMAP message multiple
|
|
|
|
|
|
// times if the seen-flag races the workflow execution. Without this
|
|
|
|
|
|
// dedup check, every poll cycle creates a brand new `orders` row.
|
|
|
|
|
|
// `createOrder` (the service layer) is the line of defense for the
|
|
|
|
|
|
// agent-API / counter / quote pipeline; the n8n workflow has its own
|
|
|
|
|
|
// parallel check at the workflow layer.
|
|
|
|
|
|
//
|
|
|
|
|
|
// See docs/audit/order-pipeline-investigation-2026-08-20.md §2.
|
|
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
describe("createOrder — email_message_id dedup", () => {
|
|
|
|
|
|
it("returns the existing order when an email_message_id already exists, and marks duplicate=true", async () => {
|
|
|
|
|
|
await pbMock.collection("customers").create({ id: "cust-d1" });
|
|
|
|
|
|
await pbMock.collection("companies").create({ id: "comp-d1" });
|
|
|
|
|
|
|
|
|
|
|
|
// Seed an existing order with the same email_message_id — this is
|
|
|
|
|
|
// what would happen if the n8n pipeline already created one and
|
|
|
|
|
|
// the next poll cycle is asking for the same write.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "order-existing",
|
|
|
|
|
|
order_number: "ZA10PO26002142",
|
|
|
|
|
|
company: "comp-d1",
|
|
|
|
|
|
customer: "cust-d1",
|
|
|
|
|
|
email_message_id: "<DUPLICATE-MSG-ID@eurprd05.prod.outlook.com>",
|
|
|
|
|
|
contact_name: "Neville Benadé",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await createOrder({
|
|
|
|
|
|
customer_id: "cust-d1",
|
|
|
|
|
|
company_id: "comp-d1",
|
|
|
|
|
|
contact_name: "Nbenade", // a different LLM-extracted name — irrelevant, we should NOT overwrite
|
|
|
|
|
|
email_message_id: "<DUPLICATE-MSG-ID@eurprd05.prod.outlook.com>",
|
|
|
|
|
|
items: [{ description: "M16 Bolt", quantity: 50 }],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Same record came back, no new order created.
|
|
|
|
|
|
expect(result.duplicate).toBe(true);
|
|
|
|
|
|
expect(result.order.id).toBe("order-existing");
|
|
|
|
|
|
expect(result.order.order_number).toBe("ZA10PO26002142");
|
|
|
|
|
|
expect(result.order.contact_name).toBe("Neville Benadé");
|
|
|
|
|
|
expect(result.items_created).toEqual([]);
|
|
|
|
|
|
expect(result.items_failed).toEqual([]);
|
|
|
|
|
|
|
|
|
|
|
|
// The store should still have exactly one record keyed by this id.
|
|
|
|
|
|
const orders = await pbMock.collection("orders").getFullList();
|
|
|
|
|
|
expect(orders.filter((o) => o.email_message_id === "<DUPLICATE-MSG-ID@eurprd05.prod.outlook.com>")).toHaveLength(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("creates a brand-new order when email_message_id is unique", async () => {
|
|
|
|
|
|
await pbMock.collection("customers").create({ id: "cust-d2" });
|
|
|
|
|
|
await pbMock.collection("companies").create({ id: "comp-d2" });
|
|
|
|
|
|
|
|
|
|
|
|
const result = await createOrder({
|
|
|
|
|
|
customer_id: "cust-d2",
|
|
|
|
|
|
company_id: "comp-d2",
|
|
|
|
|
|
contact_name: "Mr Fresh",
|
|
|
|
|
|
email_message_id: "<FRESH-MSG-ID@eurprd05.prod.outlook.com>",
|
|
|
|
|
|
items: [{ description: "M16 Bolt", quantity: 50 }],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.duplicate).toBeUndefined();
|
|
|
|
|
|
expect(result.order.id).toBeDefined();
|
|
|
|
|
|
expect(result.order.email_message_id).toBe("<FRESH-MSG-ID@eurprd05.prod.outlook.com>");
|
|
|
|
|
|
expect(result.items_created.length).toBe(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("skips the dedup check entirely when email_message_id is empty string", async () => {
|
|
|
|
|
|
await pbMock.collection("customers").create({ id: "cust-d3" });
|
|
|
|
|
|
await pbMock.collection("companies").create({ id: "comp-d3" });
|
|
|
|
|
|
|
|
|
|
|
|
const first = await createOrder({
|
|
|
|
|
|
customer_id: "cust-d3",
|
|
|
|
|
|
company_id: "comp-d3",
|
|
|
|
|
|
email_message_id: "",
|
|
|
|
|
|
items: [{ description: "Counter sale item" }],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Counter-form orders don't carry an email_message_id — we never
|
|
|
|
|
|
// dedup them, so two should be creatable.
|
|
|
|
|
|
const second = await createOrder({
|
|
|
|
|
|
customer_id: "cust-d3",
|
|
|
|
|
|
company_id: "comp-d3",
|
|
|
|
|
|
email_message_id: "",
|
|
|
|
|
|
items: [{ description: "Another counter sale" }],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(first.duplicate).toBeUndefined();
|
|
|
|
|
|
expect(second.duplicate).toBeUndefined();
|
|
|
|
|
|
expect(first.order.id).not.toBe(second.order.id);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("skips the dedup check when email_message_id is undefined", async () => {
|
|
|
|
|
|
await pbMock.collection("customers").create({ id: "cust-d4" });
|
|
|
|
|
|
await pbMock.collection("companies").create({ id: "comp-d4" });
|
|
|
|
|
|
|
|
|
|
|
|
const result = await createOrder({
|
|
|
|
|
|
customer_id: "cust-d4",
|
|
|
|
|
|
company_id: "comp-d4",
|
|
|
|
|
|
// email_message_id omitted entirely
|
|
|
|
|
|
items: [{ description: "Walk-in" }],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.duplicate).toBeUndefined();
|
|
|
|
|
|
expect(result.order.id).toBeDefined();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("trims whitespace before the dedup lookup", async () => {
|
|
|
|
|
|
await pbMock.collection("customers").create({ id: "cust-d5" });
|
|
|
|
|
|
await pbMock.collection("companies").create({ id: "comp-d5" });
|
|
|
|
|
|
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "order-padded",
|
|
|
|
|
|
order_number: "ZA10PO26002142",
|
|
|
|
|
|
company: "comp-d5",
|
|
|
|
|
|
customer: "cust-d5",
|
|
|
|
|
|
email_message_id: "<PADDED@x.com>",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Caller passes the same id with surrounding whitespace — should
|
|
|
|
|
|
// still hit the dedup path.
|
|
|
|
|
|
const result = await createOrder({
|
|
|
|
|
|
customer_id: "cust-d5",
|
|
|
|
|
|
company_id: "comp-d5",
|
|
|
|
|
|
email_message_id: " <PADDED@x.com>\n",
|
|
|
|
|
|
items: [{ description: "x" }],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.duplicate).toBe(true);
|
|
|
|
|
|
expect(result.order.id).toBe("order-padded");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns the existing record when the DB-level UNIQUE constraint rejects the insert (race)", async () => {
|
|
|
|
|
|
await pbMock.collection("customers").create({ id: "cust-d6" });
|
|
|
|
|
|
await pbMock.collection("companies").create({ id: "comp-d6" });
|
|
|
|
|
|
|
|
|
|
|
|
// Race scenario: the pre-check finds NO existing record (no
|
|
|
|
|
|
// contention yet), but by the time we INSERT, another worker
|
|
|
|
|
|
// has already created the row (parallel n8n execution, or a
|
|
|
|
|
|
// manual /api/v1/orders/create call). The DB rejects our insert
|
|
|
|
|
|
// with a unique-constraint error. The catch block in createOrder
|
|
|
|
|
|
// re-queries by email_message_id, finds the existing record, and
|
|
|
|
|
|
// returns it as duplicate.
|
|
|
|
|
|
//
|
|
|
|
|
|
// The mock's `collection(name)` returns a fresh object every call,
|
|
|
|
|
|
// so we wrap `pbMock.collection` itself to cache the per-collection
|
|
|
|
|
|
// objects — that way monkey-patching `ordersColl.create` actually
|
|
|
|
|
|
// affects the object createOrder will see.
|
|
|
|
|
|
const collectionCache = new Map<string, ReturnType<typeof pbMock.collection>>();
|
|
|
|
|
|
const originalCollection = pbMock.collection;
|
|
|
|
|
|
pbMock.collection = ((name: string) => {
|
|
|
|
|
|
if (!collectionCache.has(name)) {
|
|
|
|
|
|
collectionCache.set(name, originalCollection(name));
|
|
|
|
|
|
}
|
|
|
|
|
|
return collectionCache.get(name)!;
|
|
|
|
|
|
}) as typeof pbMock.collection;
|
|
|
|
|
|
|
|
|
|
|
|
const ordersColl = pbMock.collection("orders") as unknown as { create: (data: Record<string, unknown>) => Promise<Record<string, unknown>> };
|
|
|
|
|
|
const originalCreate = ordersColl.create;
|
|
|
|
|
|
let insertAttempts = 0;
|
|
|
|
|
|
ordersColl.create = async (data: Record<string, unknown>) => {
|
|
|
|
|
|
insertAttempts++;
|
|
|
|
|
|
if (insertAttempts === 1) {
|
|
|
|
|
|
// First attempt: the DB-level UNIQUE constraint rejects us
|
|
|
|
|
|
// because another worker already inserted the same
|
|
|
|
|
|
// email_message_id. Before throwing, insert the "raced"
|
|
|
|
|
|
// record so the catch block's lookup can find it.
|
|
|
|
|
|
const err = new Error(
|
|
|
|
|
|
"Failed to create record. UNIQUE constraint failed: index idx_orders_email_message_id",
|
|
|
|
|
|
) as Error & { status: number };
|
|
|
|
|
|
err.status = 409;
|
|
|
|
|
|
await originalCreate({
|
|
|
|
|
|
id: "order-race",
|
|
|
|
|
|
order_number: "RACE-1",
|
|
|
|
|
|
company: "comp-d6",
|
|
|
|
|
|
customer: "cust-d6",
|
|
|
|
|
|
email_message_id: "<RACE@x.com>",
|
|
|
|
|
|
...data,
|
|
|
|
|
|
});
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
return originalCreate(data);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await createOrder({
|
|
|
|
|
|
customer_id: "cust-d6",
|
|
|
|
|
|
company_id: "comp-d6",
|
|
|
|
|
|
email_message_id: "<RACE@x.com>",
|
|
|
|
|
|
items: [{ description: "x" }],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.duplicate).toBe(true);
|
|
|
|
|
|
expect(result.order.id).toBe("order-race");
|
|
|
|
|
|
expect(insertAttempts).toBe(1); // we did try to insert; the catch caught the unique violation
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
ordersColl.create = originalCreate;
|
|
|
|
|
|
pbMock.collection = originalCollection;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 23:13:46 +00:00
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
|
|
// Order split — splitOrderForContinuation. Caller passes the item IDs they
|
|
|
|
|
|
// want moved; unlisted items stay on the parent.
|
|
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
describe("splitOrderForContinuation", () => {
|
|
|
|
|
|
it("moves pending items to a new continuation order and stamps breadcrumbs", async () => {
|
|
|
|
|
|
// Seed: a parent order with three pending items.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "ord-1",
|
|
|
|
|
|
order_number: "ORD-2026-08-001",
|
|
|
|
|
|
company: "comp-1",
|
|
|
|
|
|
customer: "cust-1",
|
|
|
|
|
|
quote: "q-1",
|
|
|
|
|
|
notes: "Original notes",
|
|
|
|
|
|
rep: "Alice",
|
|
|
|
|
|
contact_name: "Mr Buyer",
|
|
|
|
|
|
quote_reference_text: "RFQ-123",
|
|
|
|
|
|
rfq_number: "RFQ-123",
|
|
|
|
|
|
});
|
|
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: `oi-${i + 1}`,
|
|
|
|
|
|
order: "ord-1",
|
|
|
|
|
|
description: `Item ${i + 1}`,
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
order_qty: 1,
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
drv_status: "unassigned",
|
|
|
|
|
|
status: "active",
|
|
|
|
|
|
selling_price: 10,
|
|
|
|
|
|
cost_price: 5,
|
|
|
|
|
|
markup: 50,
|
|
|
|
|
|
markup_percent: 50,
|
|
|
|
|
|
sku: `SKU-${i + 1}`,
|
|
|
|
|
|
unit: "EA",
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
quote_item: `qi-${i + 1}`,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result = await splitOrderForContinuation("ord-1", ["oi-1", "oi-2"]);
|
|
|
|
|
|
|
|
|
|
|
|
// Continuation order exists, inherited the parent's identity fields.
|
|
|
|
|
|
expect(result.continuation.id).toBeDefined();
|
|
|
|
|
|
expect(result.continuation.id).not.toBe("ord-1");
|
|
|
|
|
|
expect(result.continuation.company).toBe("comp-1");
|
|
|
|
|
|
expect(result.continuation.customer).toBe("cust-1");
|
|
|
|
|
|
expect(result.continuation.quote).toBe("q-1");
|
|
|
|
|
|
expect(result.continuation.rep).toBe("Alice");
|
|
|
|
|
|
expect(result.continuation.contact_name).toBe("Mr Buyer");
|
|
|
|
|
|
expect(result.continuation.quote_reference_text).toBe("RFQ-123");
|
|
|
|
|
|
expect(result.continuation.rfq_number).toBe("RFQ-123");
|
|
|
|
|
|
// Notes get the breadcrumb appended.
|
|
|
|
|
|
expect(result.continuation.notes).toMatch(/Original notes/);
|
|
|
|
|
|
expect(result.continuation.notes).toMatch(/Split from ORD-2026-08-001/);
|
|
|
|
|
|
|
|
|
|
|
|
// Two items moved, one kept.
|
|
|
|
|
|
expect(result.itemsMoved).toBe(2);
|
|
|
|
|
|
expect(result.itemsKept).toBe(1);
|
|
|
|
|
|
expect(result.itemsFailed).toEqual([]);
|
|
|
|
|
|
|
|
|
|
|
|
// Original items still belong to the parent (order relation unchanged).
|
|
|
|
|
|
const allItems = await pbMock.collection("order_items").getFullList();
|
|
|
|
|
|
const onParent = allItems.filter((it) => it.order === "ord-1");
|
|
|
|
|
|
expect(onParent.length).toBe(3);
|
|
|
|
|
|
// Moved items have the breadcrumb in their notes.
|
|
|
|
|
|
const movedO1 = onParent.find((it) => it.id === "oi-1");
|
|
|
|
|
|
const movedO2 = onParent.find((it) => it.id === "oi-2");
|
|
|
|
|
|
expect(movedO1?.notes).toMatch(/Moved to ORD-/);
|
|
|
|
|
|
expect(movedO2?.notes).toMatch(/Moved to ORD-/);
|
|
|
|
|
|
expect(movedO1?.notes).toMatch(/via order split on /);
|
|
|
|
|
|
// Unmoved item has no breadcrumb.
|
|
|
|
|
|
const keptO3 = onParent.find((it) => it.id === "oi-3");
|
|
|
|
|
|
expect(keptO3?.notes ?? "").not.toMatch(/Moved to/);
|
|
|
|
|
|
|
|
|
|
|
|
// New order gets two clones with the original fields preserved
|
|
|
|
|
|
// and workflow flags reset to defaults.
|
|
|
|
|
|
const onContinuation = allItems.filter((it) => it.order === result.continuation.id);
|
|
|
|
|
|
expect(onContinuation.length).toBe(2);
|
|
|
|
|
|
for (const clone of onContinuation) {
|
|
|
|
|
|
expect(clone.item_status).toBe("pending");
|
|
|
|
|
|
expect(clone.drv_status).toBe("unassigned");
|
|
|
|
|
|
expect(clone.status).toBe("active");
|
|
|
|
|
|
expect(clone.quote_item).toMatch(/^qi-/);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("refuses to move items in non-pending statuses, reporting them per-item", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "ord-2",
|
|
|
|
|
|
order_number: "ORD-2026-08-002",
|
|
|
|
|
|
company: "comp-1",
|
|
|
|
|
|
customer: "cust-1",
|
|
|
|
|
|
quote: "",
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-pending",
|
|
|
|
|
|
order: "ord-2",
|
|
|
|
|
|
description: "Pending",
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-po",
|
|
|
|
|
|
order: "ord-2",
|
|
|
|
|
|
description: "PO generated",
|
|
|
|
|
|
item_status: "po_generated",
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-delivered",
|
|
|
|
|
|
order: "ord-2",
|
|
|
|
|
|
description: "Delivered",
|
|
|
|
|
|
item_status: "delivered",
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await splitOrderForContinuation("ord-2", [
|
|
|
|
|
|
"oi-pending",
|
|
|
|
|
|
"oi-po",
|
|
|
|
|
|
"oi-delivered",
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// Only the pending one moves. The other two are reported in itemsFailed
|
|
|
|
|
|
// with their original status names in the message.
|
|
|
|
|
|
expect(result.itemsMoved).toBe(1);
|
|
|
|
|
|
expect(result.itemsFailed.length).toBe(2);
|
|
|
|
|
|
expect(result.itemsFailed.find((f) => f.id === "oi-po")).toMatchObject({
|
|
|
|
|
|
id: "oi-po",
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(result.itemsFailed.find((f) => f.id === "oi-delivered")?.error).toMatch(
|
|
|
|
|
|
/delivered/,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// The continuation order still exists even with partial moves.
|
|
|
|
|
|
expect(result.continuation.id).toBeDefined();
|
|
|
|
|
|
const allItems = await pbMock.collection("order_items").getFullList();
|
|
|
|
|
|
expect(allItems.filter((it) => it.order === result.continuation.id).length).toBe(1);
|
|
|
|
|
|
// The refused items are untouched on the parent (no notes breadcrumb).
|
|
|
|
|
|
expect(allItems.find((it) => it.id === "oi-po")?.notes ?? "").not.toMatch(/Moved to/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("reports items that do not belong to the parent order", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "ord-3",
|
|
|
|
|
|
order_number: "ORD-2026-08-003",
|
|
|
|
|
|
company: "comp-1",
|
|
|
|
|
|
customer: "cust-1",
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-on-ord-3",
|
|
|
|
|
|
order: "ord-3",
|
|
|
|
|
|
description: "Belongs",
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "ord-other",
|
|
|
|
|
|
order_number: "ORD-2026-08-099",
|
|
|
|
|
|
company: "comp-1",
|
|
|
|
|
|
customer: "cust-1",
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-other-order",
|
|
|
|
|
|
order: "ord-other",
|
|
|
|
|
|
description: "Foreign",
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await splitOrderForContinuation("ord-3", [
|
|
|
|
|
|
"oi-on-ord-3",
|
|
|
|
|
|
"oi-other-order",
|
|
|
|
|
|
"oi-doesnt-exist",
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.itemsMoved).toBe(1);
|
|
|
|
|
|
expect(result.itemsFailed.length).toBe(2);
|
|
|
|
|
|
expect(result.itemsFailed.find((f) => f.id === "oi-other-order")?.error).toMatch(
|
|
|
|
|
|
/does not belong to order ord-3/,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(result.itemsFailed.find((f) => f.id === "oi-doesnt-exist")?.error).toMatch(
|
|
|
|
|
|
/does not belong to order ord-3/,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("throws when the parent order does not exist", async () => {
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
splitOrderForContinuation("ghost-order", ["oi-1"]),
|
|
|
|
|
|
).rejects.toThrow(/Order not found/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("throws when itemIds is empty", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "ord-4",
|
|
|
|
|
|
order_number: "ORD-2026-08-004",
|
|
|
|
|
|
company: "comp-1",
|
|
|
|
|
|
customer: "cust-1",
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
await expect(splitOrderForContinuation("ord-4", [])).rejects.toThrow(
|
|
|
|
|
|
/itemIds must be a non-empty array/,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
|
|
// /orders dashboard read model — tab filter, counts cache, rep lookup.
|
|
|
|
|
|
//
|
|
|
|
|
|
// These tests cover the bug surface fixed in 2026-07: the old
|
|
|
|
|
|
// `getOrdersDashboardData` returned the global orders pagination
|
|
|
|
|
|
// (Page 1 of 226, 4520 total orders) regardless of which tab the
|
|
|
|
|
|
// operator selected, so "Processing (4)" badges showed empty pages
|
|
|
|
|
|
// when the four matching orders happened to fall on later pages.
|
|
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
describe("getOrderIdsForTabGroup", () => {
|
|
|
|
|
|
it("returns the distinct order ids with at least one item in the tab group", async () => {
|
|
|
|
|
|
// Three orders, each with one item. Statuses cover "new",
|
|
|
|
|
|
// "processing", and "delivered" tabs so we can verify partition
|
|
|
|
|
|
// membership.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-new",
|
|
|
|
|
|
order_number: "ORD-2025-01-001",
|
|
|
|
|
|
quote: "q1",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-processing",
|
|
|
|
|
|
order_number: "ORD-2025-01-002",
|
|
|
|
|
|
quote: "q2",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-delivered",
|
|
|
|
|
|
order_number: "ORD-2025-01-003",
|
|
|
|
|
|
quote: "q3",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-new", description: "Widget",
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-2", order: "o-processing", description: "Sprocket",
|
|
|
|
|
|
item_status: "processed",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-3", order: "o-delivered", description: "Gear",
|
|
|
|
|
|
item_status: "delivered",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const processingIds = await getOrderIdsForTabGroup(pbMock as never, "processing");
|
|
|
|
|
|
expect(processingIds.sort()).toEqual(["o-processing"]);
|
|
|
|
|
|
|
|
|
|
|
|
const newIds = await getOrderIdsForTabGroup(pbMock as never, "new");
|
|
|
|
|
|
expect(newIds.sort()).toEqual(["o-new"]);
|
|
|
|
|
|
|
|
|
|
|
|
const deliveredIds = await getOrderIdsForTabGroup(pbMock as never, "delivered");
|
|
|
|
|
|
expect(deliveredIds.sort()).toEqual(["o-delivered"]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("dedupes when an order has multiple items in the same tab", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-multi",
|
|
|
|
|
|
order_number: "ORD-2025-01-010",
|
|
|
|
|
|
quote: "q",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-a", order: "o-multi", description: "A",
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-b", order: "o-multi", description: "B",
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const ids = await getOrderIdsForTabGroup(pbMock as never, "new");
|
|
|
|
|
|
expect(ids).toEqual(["o-multi"]);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("getOrderItemStatusTabCounts", () => {
|
|
|
|
|
|
beforeEach(() => clearOrderItemStatusTabCountsCache());
|
|
|
|
|
|
|
|
|
|
|
|
it("returns the count of distinct orders per tab", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-a", order_number: "ORD-2025-01-001", quote: "qa",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-b", order_number: "ORD-2025-01-002", quote: "qb",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-a", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-2", order: "o-a", description: "y", item_status: "processed",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-3", order: "o-b", description: "z", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const counts = await getOrderItemStatusTabCounts(pbMock as never);
|
|
|
|
|
|
expect(counts.new).toBe(2);
|
|
|
|
|
|
expect(counts.processing).toBe(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("bypasses the cache when bypassCache is true", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-cache", order_number: "ORD-2025-01-001", quote: "qc",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-cache", order: "o-cache", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const first = await getOrderItemStatusTabCounts(pbMock as never);
|
|
|
|
|
|
expect(first.new).toBe(1);
|
|
|
|
|
|
|
|
|
|
|
|
// Mutate the underlying store directly — bypasses our normal
|
|
|
|
|
|
// service writes, but lets us verify the cache is being served
|
|
|
|
|
|
// if `bypassCache` is not set.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-cache-2", order_number: "ORD-2025-01-002", quote: "qc2",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-cache-2", order: "o-cache-2", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const cached = await getOrderItemStatusTabCounts(pbMock as never);
|
|
|
|
|
|
expect(cached.new).toBe(1); // unchanged — cache hit
|
|
|
|
|
|
|
|
|
|
|
|
const fresh = await getOrderItemStatusTabCounts(pbMock as never, { bypassCache: true });
|
|
|
|
|
|
expect(fresh.new).toBe(2); // refresh sees the new order
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("clearOrderItemStatusTabCountsCache forces a fresh read", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-clear", order_number: "ORD-2025-01-001", quote: "qclr",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-clear", order: "o-clear", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await getOrderItemStatusTabCounts(pbMock as never); // prime the cache
|
|
|
|
|
|
clearOrderItemStatusTabCountsCache();
|
|
|
|
|
|
const fresh = await getOrderItemStatusTabCounts(pbMock as never);
|
|
|
|
|
|
expect(fresh.new).toBe(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-24 05:09:39 +00:00
|
|
|
|
it("fires two combined getFullList calls (combined tab scan + completed time-window scan)", async () => {
|
|
|
|
|
|
// Regression guard: the pre-`completed`-tab implementation issued
|
|
|
|
|
|
// 6 parallel `getFullList` calls (one per tab), each with a
|
|
|
|
|
|
// separate filter scan. With ~18k rows in `order_items` and no
|
|
|
|
|
|
// `item_status` index, those 6 scans dominated the page-load
|
|
|
|
|
|
// budget. The current implementation collapses to:
|
|
|
|
|
|
// (a) ONE combined scan covering every tab status, used to
|
|
|
|
|
|
// bucket the partition's "primary" tab counts, plus
|
|
|
|
|
|
// (b) ONE time-windowed scan for the `completed` tab (recent
|
|
|
|
|
|
// `delivered` + `completed` items only).
|
|
|
|
|
|
// Total = 2 scans — still O(1) on the number of tabs, and each
|
|
|
|
|
|
// scan is fields-only so the wire payload stays tiny. This test
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
// intercepts the mock's `collection()` to count how many times
|
|
|
|
|
|
// `getFullList` is called on the `order_items` collection.
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o1", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-2", order: "o2", description: "x", item_status: "processed",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-3", order: "o3", description: "x", item_status: "delivered",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const originalCollection = pbMock.collection;
|
|
|
|
|
|
let getFullListCalls = 0;
|
|
|
|
|
|
pbMock.collection = ((name: string) => {
|
|
|
|
|
|
const proxy = originalCollection(name);
|
|
|
|
|
|
if (name === "order_items") {
|
|
|
|
|
|
const originalGetFullList = proxy.getFullList;
|
|
|
|
|
|
proxy.getFullList = ((...args: unknown[]) => {
|
|
|
|
|
|
getFullListCalls++;
|
|
|
|
|
|
return (originalGetFullList as (...a: unknown[]) => unknown).call(
|
|
|
|
|
|
proxy,
|
|
|
|
|
|
...args,
|
|
|
|
|
|
);
|
|
|
|
|
|
}) as typeof proxy.getFullList;
|
|
|
|
|
|
}
|
|
|
|
|
|
return proxy;
|
|
|
|
|
|
}) as typeof pbMock.collection;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await getOrderItemStatusTabCounts(pbMock as never, { bypassCache: true });
|
2026-08-24 05:09:39 +00:00
|
|
|
|
expect(getFullListCalls).toBe(2);
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
pbMock.collection = originalCollection;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-08-05 21:00:54 +00:00
|
|
|
|
|
|
|
|
|
|
it("can skip archive tabs for the main orders page", async () => {
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o1", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-2", order: "o2", description: "x", item_status: "delivered",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-3", order: "o3", description: "x", item_status: "cancelled",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const counts = await getOrderItemStatusTabCounts(pbMock as never, {
|
|
|
|
|
|
bypassCache: true,
|
|
|
|
|
|
includeArchivedTabs: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(counts.new).toBe(1);
|
|
|
|
|
|
expect(counts.delivered).toBe(0);
|
|
|
|
|
|
expect(counts.cancelled).toBe(0);
|
|
|
|
|
|
});
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("listOrdersWithItems — tab filter", () => {
|
|
|
|
|
|
it("returns only orders that have at least one item in the requested tab", async () => {
|
|
|
|
|
|
// Two orders: one with a "pending" item, one with a "processed"
|
|
|
|
|
|
// item. Filtered to "processing" should yield only the second.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-pending", order_number: "ORD-2025-01-001", quote: "qp",
|
|
|
|
|
|
created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-processed", order_number: "ORD-2025-01-002", quote: "qpr",
|
|
|
|
|
|
created: "2025-01-02T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-pending", order: "o-pending", description: "Widget",
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-processed", order: "o-processed", description: "Sprocket",
|
|
|
|
|
|
item_status: "processed",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await listOrdersWithItems({
|
|
|
|
|
|
page: 1,
|
|
|
|
|
|
perPage: 20,
|
|
|
|
|
|
tabGroup: "processing",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.items.map((i) => i.order.id)).toEqual(["o-processed"]);
|
|
|
|
|
|
expect(result.totalItems).toBe(1);
|
|
|
|
|
|
expect(result.totalPages).toBe(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns an empty list with totalPages=0 when no orders match the tab", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-only-new", order_number: "ORD-2025-01-001", quote: "qn",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-only-new", order: "o-only-new", description: "x",
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await listOrdersWithItems({
|
|
|
|
|
|
page: 1,
|
|
|
|
|
|
perPage: 20,
|
|
|
|
|
|
tabGroup: "cancelled",
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(result.items).toEqual([]);
|
|
|
|
|
|
expect(result.totalItems).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("reports totalItems from the matching set, not the page slice (pagination honesty)", async () => {
|
|
|
|
|
|
// Five orders with processed items, perPage=2. The page-1 response
|
|
|
|
|
|
// should have 2 items but totalItems=5 / totalPages=3 so the
|
|
|
|
|
|
// footer says "Page 1 of 3" — matching the operator's mental
|
|
|
|
|
|
// model of how many orders the tab actually contains.
|
|
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: `o-${i}`,
|
|
|
|
|
|
order_number: `ORD-2025-01-00${i + 1}`,
|
|
|
|
|
|
quote: `q-${i}`,
|
|
|
|
|
|
created: `2025-01-0${i + 1}T00:00:00Z`,
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: `oi-${i}`,
|
|
|
|
|
|
order: `o-${i}`,
|
|
|
|
|
|
description: "x",
|
|
|
|
|
|
item_status: "processed",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result = await listOrdersWithItems({
|
|
|
|
|
|
page: 1,
|
|
|
|
|
|
perPage: 2,
|
|
|
|
|
|
tabGroup: "processing",
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(result.items.length).toBe(2);
|
|
|
|
|
|
expect(result.totalItems).toBe(5);
|
|
|
|
|
|
expect(result.totalPages).toBe(3);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("falls back to all-orders pagination when no tab is supplied", async () => {
|
|
|
|
|
|
// Sanity check — the existing all-orders path is unchanged. PB's
|
|
|
|
|
|
// `getList(page, perPage, { sort })` returns a paginated slice;
|
|
|
|
|
|
// the test mock mirrors that with `totalItems = items.length`,
|
|
|
|
|
|
// so totalPages = 1 here regardless of the underlying set size.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-a", order_number: "ORD-2025-01-001", quote: "qa",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-b", order_number: "ORD-2025-01-002", quote: "qb",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await listOrdersWithItems({
|
|
|
|
|
|
page: 1,
|
|
|
|
|
|
perPage: 20,
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(result.items.length).toBe(2);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
fix(orders): remove broken fields param + show contact_name + REP label
# Regression: orders/quotes list lost customer/company/rep data
Last commit added a `fields` parameter to the orders and quotes
list queries to trim the wire payload. Measured on a stale test
result, looked like a 16x win. Actual: PB 0.26's `fields`
parameter is a HARD ALLOWLIST of MAIN record columns only \u2014
specifying it makes PB drop the entire `expand` payload from
the response. Result: customer, company, rep, quote names
disappeared from every order/quote card.
Verified against the live PB:
- With `fields`: expand is dropped, just relation IDs returned
- Without `fields`: expand works, full records (55-57 KB for
20 rows with deep expand)
Fix: remove the `fields` parameter from both list queries. The
slim-payload intent has to be paid at the schema level (cap the
unbounded text columns) rather than via PB query params.
The earlier '12x smaller' / '16x smaller' benchmarks were
measurements of the BROKEN response, not the working one. Real
wire sizes are unchanged from before the perf work \u2014 but the
other wins (server-side tab filter, combined status counts,
targeted users fetch, 60s counts cache) all still apply.
# Bonus: show contact_name on the order card
~76 of ~4.5k orders have `contact_name` set (the free-text
person the order is for, captured during email-intake when the
customer record is just the company). Added to the OrderCard
header between customer and rep, with a docstring explaining
the distinction from the customer relation and email_sender.
Added contact_name to the Order type and pinned the regression
with a test that asserts the orders list query does NOT pass
`fields` (so a future 'let's slim the payload' attempt
doesn't reintroduce the bug).
# REP label
The rep column on the order card now reads 'REP' (uppercase)
instead of 'rep' (lowercase). All 1312 tests pass.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:27:02 +00:00
|
|
|
|
it("does not pass a `fields` parameter on the orders list (PB 0.26 breaks expand when fields is set)", async () => {
|
|
|
|
|
|
// Regression guard: earlier I tried to limit the wire payload
|
|
|
|
|
|
// by passing a `fields` list with per-expand syntax. PB 0.26
|
|
|
|
|
|
// treats `fields` as a hard allowlist of MAIN record columns,
|
|
|
|
|
|
// and the moment you set it the `expand` payload is dropped
|
|
|
|
|
|
// from the response — so customer/company/rep/quote names
|
|
|
|
|
|
// vanish from every order card. The orders list deliberately
|
|
|
|
|
|
// omits `fields`; this test pins that behaviour so a future
|
|
|
|
|
|
// "let's slim the payload" change doesn't reintroduce the bug.
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
let capturedFields: string | undefined;
|
|
|
|
|
|
let capturedExpand: string | undefined;
|
|
|
|
|
|
const originalCollection = pbMock.collection;
|
|
|
|
|
|
pbMock.collection = ((name: string) => {
|
|
|
|
|
|
const proxy = originalCollection(name);
|
|
|
|
|
|
if (name === "orders") {
|
|
|
|
|
|
const originalGetList = proxy.getList;
|
|
|
|
|
|
proxy.getList = ((page: number, perPage: number, opts: { filter?: string; sort?: string; fields?: string; expand?: string }) => {
|
|
|
|
|
|
capturedFields = opts?.fields;
|
|
|
|
|
|
capturedExpand = opts?.expand;
|
|
|
|
|
|
return (originalGetList as (...a: unknown[]) => unknown).call(
|
|
|
|
|
|
proxy,
|
|
|
|
|
|
page,
|
|
|
|
|
|
perPage,
|
|
|
|
|
|
opts,
|
|
|
|
|
|
);
|
|
|
|
|
|
}) as typeof proxy.getList;
|
|
|
|
|
|
}
|
|
|
|
|
|
return proxy;
|
|
|
|
|
|
}) as typeof pbMock.collection;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await listOrdersWithItems({ page: 1, perPage: 20 });
|
|
|
|
|
|
|
fix(orders): remove broken fields param + show contact_name + REP label
# Regression: orders/quotes list lost customer/company/rep data
Last commit added a `fields` parameter to the orders and quotes
list queries to trim the wire payload. Measured on a stale test
result, looked like a 16x win. Actual: PB 0.26's `fields`
parameter is a HARD ALLOWLIST of MAIN record columns only \u2014
specifying it makes PB drop the entire `expand` payload from
the response. Result: customer, company, rep, quote names
disappeared from every order/quote card.
Verified against the live PB:
- With `fields`: expand is dropped, just relation IDs returned
- Without `fields`: expand works, full records (55-57 KB for
20 rows with deep expand)
Fix: remove the `fields` parameter from both list queries. The
slim-payload intent has to be paid at the schema level (cap the
unbounded text columns) rather than via PB query params.
The earlier '12x smaller' / '16x smaller' benchmarks were
measurements of the BROKEN response, not the working one. Real
wire sizes are unchanged from before the perf work \u2014 but the
other wins (server-side tab filter, combined status counts,
targeted users fetch, 60s counts cache) all still apply.
# Bonus: show contact_name on the order card
~76 of ~4.5k orders have `contact_name` set (the free-text
person the order is for, captured during email-intake when the
customer record is just the company). Added to the OrderCard
header between customer and rep, with a docstring explaining
the distinction from the customer relation and email_sender.
Added contact_name to the Order type and pinned the regression
with a test that asserts the orders list query does NOT pass
`fields` (so a future 'let's slim the payload' attempt
doesn't reintroduce the bug).
# REP label
The rep column on the order card now reads 'REP' (uppercase)
instead of 'rep' (lowercase). All 1312 tests pass.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:27:02 +00:00
|
|
|
|
// `fields` must NOT be sent — PB 0.26 drops the expand payload
|
|
|
|
|
|
// when it sees it.
|
|
|
|
|
|
expect(capturedFields).toBeUndefined();
|
|
|
|
|
|
// Expand IS sent, and includes the four relations the list view
|
|
|
|
|
|
// reads (company, customer, customer.rep, quote). Importantly
|
|
|
|
|
|
// it does NOT include `potential_quote` — the list view only
|
|
|
|
|
|
// checks the field's existence, never the expanded record.
|
|
|
|
|
|
expect(capturedExpand).toContain("company");
|
|
|
|
|
|
expect(capturedExpand).toContain("customer");
|
|
|
|
|
|
expect(capturedExpand).toContain("customer.rep");
|
|
|
|
|
|
expect(capturedExpand).toContain("quote");
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
expect(capturedExpand).not.toContain("potential_quote");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
pbMock.collection = originalCollection;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-11 23:13:46 +00:00
|
|
|
|
describe("listOrdersWithItems — search scope + per-tab match counts", () => {
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Seed one order per active tab, all sharing the token "widget" in
|
|
|
|
|
|
* their item description, plus a delivered (archived) order that also
|
|
|
|
|
|
* matches. Lets each test assert both cross-tab counting and the
|
|
|
|
|
|
* archive exclusion.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function seedCrossTabMatches() {
|
|
|
|
|
|
const rows: [string, string, string][] = [
|
|
|
|
|
|
["o-new", "pending", "2025-01-01T00:00:00Z"],
|
|
|
|
|
|
["o-proc", "processed", "2025-01-02T00:00:00Z"],
|
|
|
|
|
|
["o-pool", "collection_pool", "2025-01-03T00:00:00Z"],
|
|
|
|
|
|
["o-coll", "collected", "2025-01-04T00:00:00Z"],
|
|
|
|
|
|
["o-archived", "delivered", "2025-01-05T00:00:00Z"],
|
|
|
|
|
|
];
|
|
|
|
|
|
for (const [id, status, created] of rows) {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id,
|
|
|
|
|
|
order_number: `ORD-${id}`,
|
|
|
|
|
|
quote: `q-${id}`,
|
|
|
|
|
|
created,
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: `oi-${id}`,
|
|
|
|
|
|
order: id,
|
|
|
|
|
|
description: "Widget assembly",
|
|
|
|
|
|
item_status: status,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it("counts matches across every active tab, not just the active one", async () => {
|
|
|
|
|
|
await seedCrossTabMatches();
|
|
|
|
|
|
|
|
|
|
|
|
// Operator is standing on "new" — but the hits in processing,
|
|
|
|
|
|
// collection_pool and collected must still be counted so the tab
|
|
|
|
|
|
// strip can point at them. This is the core of "show me where it is".
|
|
|
|
|
|
const result = await listOrdersWithItems({
|
|
|
|
|
|
page: 1,
|
|
|
|
|
|
perPage: 20,
|
|
|
|
|
|
search: "widget",
|
|
|
|
|
|
tabGroup: "new",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.searchCounts).toMatchObject({
|
|
|
|
|
|
new: 1,
|
|
|
|
|
|
processing: 1,
|
|
|
|
|
|
collection_pool: 1,
|
|
|
|
|
|
collected: 1,
|
|
|
|
|
|
});
|
|
|
|
|
|
// ...while the visible list is still narrowed to the active tab.
|
|
|
|
|
|
expect(result.items.map((i) => i.order.id)).toEqual(["o-new"]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("excludes archived (delivered/cancelled) orders from search", async () => {
|
|
|
|
|
|
await seedCrossTabMatches();
|
|
|
|
|
|
|
|
|
|
|
|
const result = await listOrdersWithItems({
|
|
|
|
|
|
page: 1,
|
|
|
|
|
|
perPage: 20,
|
|
|
|
|
|
search: "widget",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// "o-archived" matches the text but is delivered-only, so it is out
|
|
|
|
|
|
// of scope — it belongs to /orders/archive.
|
|
|
|
|
|
expect(result.items.map((i) => i.order.id)).not.toContain("o-archived");
|
|
|
|
|
|
expect(result.searchCounts?.delivered ?? 0).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("is unaffected by sort direction (regression: the 500-cap coupling)", async () => {
|
|
|
|
|
|
// The old implementation fetched the first 500 orders BY SORT and
|
|
|
|
|
|
// filtered those, so an ascending sort would hide newer matches.
|
|
|
|
|
|
// Search now scopes by active-order id, so both directions agree.
|
|
|
|
|
|
await seedCrossTabMatches();
|
|
|
|
|
|
|
|
|
|
|
|
const ascending = await listOrdersWithItems({
|
|
|
|
|
|
search: "widget",
|
|
|
|
|
|
sort: "created",
|
|
|
|
|
|
});
|
|
|
|
|
|
const descending = await listOrdersWithItems({
|
|
|
|
|
|
search: "widget",
|
|
|
|
|
|
sort: "-created",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(ascending.items.map((i) => i.order.id).sort()).toEqual(
|
|
|
|
|
|
descending.items.map((i) => i.order.id).sort(),
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(ascending.searchCounts).toEqual(descending.searchCounts);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("counts an order once per tab it has items in", async () => {
|
|
|
|
|
|
// A single order straddling two tabs should appear in both counts —
|
|
|
|
|
|
// the counts are 'distinct orders per tab', not a partition.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-split", order_number: "ORD-split", quote: "q-split",
|
|
|
|
|
|
created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-split-a", order: "o-split", description: "Widget",
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-split-b", order: "o-split", description: "Widget",
|
|
|
|
|
|
item_status: "processed",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await listOrdersWithItems({ search: "widget" });
|
|
|
|
|
|
expect(result.searchCounts).toMatchObject({ new: 1, processing: 1 });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("omits searchCounts entirely when no search is active", async () => {
|
|
|
|
|
|
await seedCrossTabMatches();
|
|
|
|
|
|
const result = await listOrdersWithItems({ page: 1, perPage: 20 });
|
|
|
|
|
|
expect(result.searchCounts).toBeUndefined();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-12 06:09:36 +00:00
|
|
|
|
describe("listOrdersWithItems — tab taxonomy completeness", () => {
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Regression guard. The `ItemStatus` union and `TAB_GROUP_ITEM_STATUSES`
|
|
|
|
|
|
* must stay in lockstep — every status MUST be assigned to a tab,
|
|
|
|
|
|
* because:
|
|
|
|
|
|
* 1. Unmapped statuses are invisible to /orders (they fail the
|
|
|
|
|
|
* tab filter).
|
|
|
|
|
|
* 2. They drop out of `TAB_STATUSES` and therefore out of the
|
|
|
|
|
|
* search candidate set built by `getActiveOrderIds`.
|
|
|
|
|
|
* 3. They drop out of the tab counts query entirely.
|
|
|
|
|
|
*
|
|
|
|
|
|
* This caught the bug where 60 active items (ready_invoice,
|
|
|
|
|
|
* invoiced, delivery_note, removed) were silently missing from
|
|
|
|
|
|
* search and mis-bucketed as "new" — including order 4500704208
|
|
|
|
|
|
* (ready_invoice, expected: Collected).
|
|
|
|
|
|
*/
|
|
|
|
|
|
it("returns search hits whose item_status is mapped to a non-new tab", async () => {
|
|
|
|
|
|
// ready_invoice should land on the Collected tab now. Previously
|
|
|
|
|
|
// it was bucketed as "new" and excluded from the search candidate
|
|
|
|
|
|
// set, so the order was unfindable.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-inv",
|
|
|
|
|
|
order_number: "ORD-invoice-ready",
|
|
|
|
|
|
quote: "q-inv",
|
|
|
|
|
|
created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-inv",
|
|
|
|
|
|
order: "o-inv",
|
|
|
|
|
|
description: "Widget",
|
|
|
|
|
|
item_status: "ready_invoice",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await listOrdersWithItems({ search: "invoice" });
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.items.map((i) => i.order.id)).toEqual(["o-inv"]);
|
|
|
|
|
|
expect(result.searchCounts).toMatchObject({ collected: 1 });
|
|
|
|
|
|
expect(result.searchCounts?.new ?? 0).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("counts every item_status in the live union across the active set", async () => {
|
|
|
|
|
|
// Build one item per ItemStatus union member, confirm each one
|
|
|
|
|
|
// surfaces in `getActiveOrderIds` (the search candidate set).
|
|
|
|
|
|
const allStatuses = [
|
|
|
|
|
|
"pending", "approval_needed", "approval_rejected",
|
|
|
|
|
|
"processed", "po_generated",
|
|
|
|
|
|
"collection_pool", "assigned", "ready_delivery",
|
|
|
|
|
|
"collected", "stock_verified", "picking",
|
|
|
|
|
|
"delivered", "cancelled",
|
|
|
|
|
|
"ready_invoice", "invoiced",
|
|
|
|
|
|
"stock_booked", "with_driver", "checked_in", "completed",
|
|
|
|
|
|
"removed", "delivery_note",
|
|
|
|
|
|
];
|
|
|
|
|
|
for (const s of allStatuses) {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: `o-${s}`,
|
|
|
|
|
|
order_number: `ORD-${s}`,
|
|
|
|
|
|
quote: `q-${s}`,
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: `oi-${s}-${allStatuses.indexOf(s)}`,
|
|
|
|
|
|
order: `o-${s}`,
|
|
|
|
|
|
description: "x",
|
|
|
|
|
|
item_status: s,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
// If this fails, an ItemStatus union member has been added to
|
|
|
|
|
|
// src/lib/types.ts without a matching TAB_GROUP_ITEM_STATUSES entry
|
|
|
|
|
|
// in src/lib/item-status-ui.ts — and that item is invisible to
|
|
|
|
|
|
// /orders search and counts.
|
|
|
|
|
|
const result = await listOrdersWithItems({
|
|
|
|
|
|
search: "ORD-",
|
|
|
|
|
|
});
|
|
|
|
|
|
const foundIds = new Set(result.items.map(i => i.order.id));
|
|
|
|
|
|
// Every active-tab status should appear; delivered and cancelled
|
|
|
|
|
|
// remain excluded by design (they live on /orders/archive).
|
|
|
|
|
|
const active = allStatuses.filter(s => s !== "delivered" && s !== "cancelled");
|
|
|
|
|
|
for (const s of active) {
|
|
|
|
|
|
expect(foundIds, `item_status "${s}" should be in search results`).toContain(`o-${s}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-11 23:13:46 +00:00
|
|
|
|
describe("getOrdersDashboardData — search counts replace global counts", () => {
|
|
|
|
|
|
it("promotes per-tab match counts over the global tab counts while searching", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-hit", order_number: "ORD-hit", quote: "q-hit",
|
|
|
|
|
|
created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-hit", order: "o-hit", description: "Unobtainium",
|
|
|
|
|
|
item_status: "processed",
|
|
|
|
|
|
});
|
|
|
|
|
|
// A second order that must NOT contribute to the badges during a
|
|
|
|
|
|
// search for "unobtainium".
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-miss", order_number: "ORD-miss", quote: "q-miss",
|
|
|
|
|
|
created: "2025-01-02T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-miss", order: "o-miss", description: "Something else",
|
|
|
|
|
|
item_status: "processed",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const searched = await getOrdersDashboardData({
|
|
|
|
|
|
search: "unobtainium",
|
|
|
|
|
|
includeArchivedCounts: false,
|
|
|
|
|
|
bypassCountsCache: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
// Badge shows 1 match in processing, not the global 2 orders.
|
|
|
|
|
|
expect(searched.counts.processing).toBe(1);
|
|
|
|
|
|
|
|
|
|
|
|
const browsing = await getOrdersDashboardData({
|
|
|
|
|
|
includeArchivedCounts: false,
|
|
|
|
|
|
bypassCountsCache: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(browsing.counts.processing).toBe(2);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
describe("getOrdersDashboardData — end-to-end", () => {
|
|
|
|
|
|
beforeEach(() => clearOrderItemStatusTabCountsCache());
|
|
|
|
|
|
|
|
|
|
|
|
it("returns tab-scoped counts and items in a single response", async () => {
|
|
|
|
|
|
// Three orders: a pending one (new tab), a processed one
|
|
|
|
|
|
// (processing tab), and a delivered one.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-p", order_number: "ORD-2025-01-001", quote: "qp",
|
|
|
|
|
|
created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-pr", order_number: "ORD-2025-01-002", quote: "qpr",
|
|
|
|
|
|
created: "2025-01-02T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-d", order_number: "ORD-2025-01-003", quote: "qd",
|
|
|
|
|
|
created: "2025-01-03T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-p", order: "o-p", description: "Widget",
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-pr", order: "o-pr", description: "Sprocket",
|
|
|
|
|
|
item_status: "processed",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-d", order: "o-d", description: "Gear",
|
|
|
|
|
|
item_status: "delivered",
|
2026-08-24 05:09:39 +00:00
|
|
|
|
// The completed tab requires a recent `delivered_at` (last 30
|
|
|
|
|
|
// days) — see `completedTabFilter`. The fixture here uses
|
|
|
|
|
|
// `now` so the row qualifies regardless of when the test runs.
|
|
|
|
|
|
delivered_at: new Date().toISOString(),
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await getOrdersDashboardData({
|
|
|
|
|
|
page: 1,
|
|
|
|
|
|
perPage: 20,
|
|
|
|
|
|
tabGroup: "processing",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Counts are global (always were) — the badge still tells the truth.
|
2026-08-24 05:09:39 +00:00
|
|
|
|
// `delivered` items now bucket into the `completed` tab (the
|
|
|
|
|
|
// partition lists `completed` before `delivered` in TAB_GROUP_ORDER
|
|
|
|
|
|
// so the time-windowed slice wins as the "primary" tab). The
|
|
|
|
|
|
// archive page reads its own delivered count from
|
|
|
|
|
|
// `getOrdersArchiveData` rather than this map.
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
expect(result.counts.new).toBe(1);
|
|
|
|
|
|
expect(result.counts.processing).toBe(1);
|
2026-08-24 05:09:39 +00:00
|
|
|
|
expect(result.counts.completed).toBe(1);
|
|
|
|
|
|
expect(result.counts.delivered).toBe(0);
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
|
|
|
|
|
|
// Items are scoped to the active tab.
|
|
|
|
|
|
expect(result.items.map((i) => i.order.id)).toEqual(["o-pr"]);
|
|
|
|
|
|
expect(result.totalPages).toBe(1);
|
|
|
|
|
|
expect(result.totalItems).toBe(1);
|
|
|
|
|
|
});
|
2026-08-05 21:00:54 +00:00
|
|
|
|
|
|
|
|
|
|
it("can omit archive counts for the main orders page", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-p", order_number: "ORD-2025-01-001", quote: "qp",
|
|
|
|
|
|
created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-d", order_number: "ORD-2025-01-002", quote: "qd",
|
|
|
|
|
|
created: "2025-01-02T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-p", order: "o-p", description: "Widget",
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-d", order: "o-d", description: "Gear",
|
|
|
|
|
|
item_status: "delivered",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await getOrdersDashboardData({
|
|
|
|
|
|
page: 1,
|
|
|
|
|
|
perPage: 20,
|
|
|
|
|
|
tabGroup: "new",
|
|
|
|
|
|
includeArchivedCounts: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.counts.new).toBe(1);
|
|
|
|
|
|
expect(result.counts.delivered).toBe(0);
|
|
|
|
|
|
expect(result.counts.cancelled).toBe(0);
|
|
|
|
|
|
});
|
perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:
#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
then fetches just those orders. The footer pagination reflects
the matching set ("Page 1 of N"), not the global 4520-order
count ("Page 1 of 226"). The "Processing (4)" badge now
matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
triggers a fresh server-side query. The redundant visibleOrders
client filter is removed. The empty state now nudges the operator
to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").
#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
getFullList calls into ONE combined query, with batch: 5000.
6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
checks the field's existence).
- Added a migration script for the order_items.item_status index
(scripts/migrations/2026-08-04-...). Idempotent: the live PB
already has the index per the schema export, so the script
no-ops on most installs; documented as the recommended manual
step in case an older instance is missing it.
#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
email_message). Without limiting, the orders deep-expand was
dragging the full email body of every linked quote into the
list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
`expandField.subfield` syntax PB supports: only
`quote.quote_number`, `company.company`, `customer.customer`,
`customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
view + search predicate actually read; the supplier expand (not
rendered per-line) is dropped.
Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.
Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 16:17:59 +00:00
|
|
|
|
});
|
feat(orders): move delivered+cancelled to dedicated /orders/archive page
The 'Delivered' tab on /orders was broken \u2014 4,326 of 4,514 orders
(96%) have at least one delivered item. The old tab tried to
build a WHERE id IN (...) filter with 4,326 ids (~120 KB),
blowing past PB's ~4 KB filter cap and 400ing. This commit
moves historical orders to a dedicated page so:
- The main /orders view is fast (22-28 active orders, not 4.5k
historical)
- The archive can be honest about its pagination ('Page 1 of
217' is expected when looking for something from last month)
- The badge counts on the main page stop being dominated by
'Delivered (4,326)' / 'Cancelled (111)'
# Measured against the live PB
/api/v1/orders/archive?page=1&perPage=2 \u2014 15.2s cold, 6.5 KB
response. The 15s is the order_items.getFullList over 13,256
items (the bottleneck; PB paginates over many round-trips).
Per-row payload is the standard 5-6 KB for orders with deep
expand. Subsequent pages within the same minute would be much
faster since PB caches the order_items query.
# Service layer
- getOrdersArchiveData(params) in src/lib/server/orders-service.ts
- Returns paginated list of orders with \u22651 item in
(delivered, cancelled) status, plus per-status distinct
counts for the header totals
- Strategy: getFullList on order_items (ids + status only),
dedupe + per-status counts in one pass, chunk the order ids
by 100 (PB's URL/filter cap is between 4.5-6.7 KB) and fire
parallel getFullList queries on orders with deep expand
- Search hits order_number, company, customer, contact_name
- Sort defaults to -created, override via the sort param
- /api/v1/orders/archive thin route: page, perPage, search, sort
(no tab, no nocache \u2014 archive is one bucket, not a tab split)
# Page
- /orders/archive/page.tsx: card list with header counts
(delivered / cancelled badges), search input, pagination
footer. Click an order to open /orders/[id] in detail.
# Main /orders updates
- Delivered + Cancelled removed from the tab strip (passed
tabKeys=['new','processing','collection_pool','collected'] to
ItemStatusTabs)
- Counts on the main page now exclude delivered + cancelled
- 'Archive' button in the page header takes the operator to
/orders/archive
- Safety useEffect: if activeTab is delivered/cancelled (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in orders-service.test.ts:
- Returns orders with \u22651 delivered/cancelled item, plus counts
- Dedupe: one order with multiple matching items counts as 1
- Search by order_number and contact_name
- Empty result when no orders match
Full suite: 1321/1321 pass.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:58:43 +00:00
|
|
|
|
|
|
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
|
|
// Orders archive — read model for /orders/archive.
|
|
|
|
|
|
//
|
|
|
|
|
|
// The archive is the home for orders with at least one item in
|
|
|
|
|
|
// (delivered, cancelled) status. On the live PB that holds ~99% of
|
|
|
|
|
|
// all orders, so it gets its own paginated page rather than living
|
|
|
|
|
|
// as a tab on the main orders view. These tests pin the contract.
|
|
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
describe("getOrdersArchiveData", () => {
|
2026-08-05 21:00:54 +00:00
|
|
|
|
function makeOrdersArchivePb(options: { failOnArchiveItemScan?: boolean } = {}) {
|
|
|
|
|
|
const listRecords = (name: string) =>
|
|
|
|
|
|
Array.from((store[name] || new Map()).values()) as Array<Record<string, unknown>>;
|
|
|
|
|
|
|
|
|
|
|
|
const matchesArchiveFilter = (
|
|
|
|
|
|
order: Record<string, unknown>,
|
|
|
|
|
|
filter: string,
|
|
|
|
|
|
) => {
|
|
|
|
|
|
const orderItems = listRecords("order_items").filter(
|
|
|
|
|
|
(item) => String(item.order ?? "") === String(order.id ?? ""),
|
|
|
|
|
|
);
|
|
|
|
|
|
const hasDelivered = orderItems.some((item) => item.item_status === "delivered");
|
|
|
|
|
|
const hasCancelled = orderItems.some((item) => item.item_status === "cancelled");
|
|
|
|
|
|
const wantsDelivered = filter.includes('order_items_via_order.item_status ?= "delivered"');
|
|
|
|
|
|
const wantsCancelled = filter.includes('order_items_via_order.item_status ?= "cancelled"');
|
|
|
|
|
|
if (wantsDelivered && wantsCancelled) return hasDelivered || hasCancelled;
|
|
|
|
|
|
if (wantsDelivered) return hasDelivered;
|
|
|
|
|
|
if (wantsCancelled) return hasCancelled;
|
|
|
|
|
|
return true;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const matchesSearch = (order: Record<string, unknown>, filter: string) => {
|
|
|
|
|
|
const orderNumberMatch = filter.match(/order_number ~ "([^"]+)"/);
|
|
|
|
|
|
if (orderNumberMatch) {
|
|
|
|
|
|
return String(order.order_number ?? "")
|
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
|
.includes(orderNumberMatch[1].toLowerCase());
|
|
|
|
|
|
}
|
|
|
|
|
|
const contactMatch = filter.match(/contact_name ~ "([^"]+)"/);
|
|
|
|
|
|
if (contactMatch) {
|
|
|
|
|
|
return String(order.contact_name ?? "")
|
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
|
.includes(contactMatch[1].toLowerCase());
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
autoCancellation: () => undefined,
|
|
|
|
|
|
collection: (name: string) => {
|
|
|
|
|
|
if (name === "orders") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
async getList(
|
|
|
|
|
|
page = 1,
|
|
|
|
|
|
perPage = 25,
|
|
|
|
|
|
opts: { filter?: string; sort?: string; expand?: string } = {},
|
|
|
|
|
|
) {
|
|
|
|
|
|
let items = listRecords("orders").filter((order) =>
|
|
|
|
|
|
matchesArchiveFilter(order, opts.filter || "") &&
|
|
|
|
|
|
matchesSearch(order, opts.filter || ""),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (opts.sort === "-created") {
|
|
|
|
|
|
items = items.sort((a, b) =>
|
|
|
|
|
|
String(b.created ?? "").localeCompare(String(a.created ?? "")),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
const totalItems = items.length;
|
|
|
|
|
|
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
|
|
|
|
|
|
const start = (page - 1) * perPage;
|
|
|
|
|
|
return {
|
|
|
|
|
|
items: items.slice(start, start + perPage),
|
|
|
|
|
|
totalItems,
|
|
|
|
|
|
totalPages,
|
|
|
|
|
|
page,
|
|
|
|
|
|
perPage,
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
getFullList: pbMock.collection("orders").getFullList,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (name === "order_items" && options.failOnArchiveItemScan) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
async getFullList(opts: { filter?: string } = {}) {
|
|
|
|
|
|
if (!(opts.filter || "").includes('order = "')) {
|
|
|
|
|
|
throw new Error("unexpected full archive item scan");
|
|
|
|
|
|
}
|
|
|
|
|
|
return pbMock.collection("order_items").getFullList(opts);
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return pbMock.collection(name);
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(orders): move delivered+cancelled to dedicated /orders/archive page
The 'Delivered' tab on /orders was broken \u2014 4,326 of 4,514 orders
(96%) have at least one delivered item. The old tab tried to
build a WHERE id IN (...) filter with 4,326 ids (~120 KB),
blowing past PB's ~4 KB filter cap and 400ing. This commit
moves historical orders to a dedicated page so:
- The main /orders view is fast (22-28 active orders, not 4.5k
historical)
- The archive can be honest about its pagination ('Page 1 of
217' is expected when looking for something from last month)
- The badge counts on the main page stop being dominated by
'Delivered (4,326)' / 'Cancelled (111)'
# Measured against the live PB
/api/v1/orders/archive?page=1&perPage=2 \u2014 15.2s cold, 6.5 KB
response. The 15s is the order_items.getFullList over 13,256
items (the bottleneck; PB paginates over many round-trips).
Per-row payload is the standard 5-6 KB for orders with deep
expand. Subsequent pages within the same minute would be much
faster since PB caches the order_items query.
# Service layer
- getOrdersArchiveData(params) in src/lib/server/orders-service.ts
- Returns paginated list of orders with \u22651 item in
(delivered, cancelled) status, plus per-status distinct
counts for the header totals
- Strategy: getFullList on order_items (ids + status only),
dedupe + per-status counts in one pass, chunk the order ids
by 100 (PB's URL/filter cap is between 4.5-6.7 KB) and fire
parallel getFullList queries on orders with deep expand
- Search hits order_number, company, customer, contact_name
- Sort defaults to -created, override via the sort param
- /api/v1/orders/archive thin route: page, perPage, search, sort
(no tab, no nocache \u2014 archive is one bucket, not a tab split)
# Page
- /orders/archive/page.tsx: card list with header counts
(delivered / cancelled badges), search input, pagination
footer. Click an order to open /orders/[id] in detail.
# Main /orders updates
- Delivered + Cancelled removed from the tab strip (passed
tabKeys=['new','processing','collection_pool','collected'] to
ItemStatusTabs)
- Counts on the main page now exclude delivered + cancelled
- 'Archive' button in the page header takes the operator to
/orders/archive
- Safety useEffect: if activeTab is delivered/cancelled (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in orders-service.test.ts:
- Returns orders with \u22651 delivered/cancelled item, plus counts
- Dedupe: one order with multiple matching items counts as 1
- Search by order_number and contact_name
- Empty result when no orders match
Full suite: 1321/1321 pass.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:58:43 +00:00
|
|
|
|
it("returns orders with at least one delivered or cancelled item, plus header counts", async () => {
|
|
|
|
|
|
// 2 delivered orders, 1 cancelled, 1 active (no delivered/cancelled).
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-d1", order_number: "ORD-2025-01-001", quote: "q1",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-d2", order_number: "ORD-2025-01-002", quote: "q2",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-c1", order_number: "ORD-2025-01-003", quote: "q3",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-active", order_number: "ORD-2025-01-004", quote: "q4",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-d1", description: "x", item_status: "delivered",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-2", order: "o-d2", description: "x", item_status: "delivered",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-3", order: "o-c1", description: "x", item_status: "cancelled",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-4", order: "o-active", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-05 21:00:54 +00:00
|
|
|
|
const result = await getOrdersArchiveData(
|
|
|
|
|
|
{ page: 1, perPage: 20 },
|
|
|
|
|
|
makeOrdersArchivePb() as never,
|
|
|
|
|
|
);
|
feat(orders): move delivered+cancelled to dedicated /orders/archive page
The 'Delivered' tab on /orders was broken \u2014 4,326 of 4,514 orders
(96%) have at least one delivered item. The old tab tried to
build a WHERE id IN (...) filter with 4,326 ids (~120 KB),
blowing past PB's ~4 KB filter cap and 400ing. This commit
moves historical orders to a dedicated page so:
- The main /orders view is fast (22-28 active orders, not 4.5k
historical)
- The archive can be honest about its pagination ('Page 1 of
217' is expected when looking for something from last month)
- The badge counts on the main page stop being dominated by
'Delivered (4,326)' / 'Cancelled (111)'
# Measured against the live PB
/api/v1/orders/archive?page=1&perPage=2 \u2014 15.2s cold, 6.5 KB
response. The 15s is the order_items.getFullList over 13,256
items (the bottleneck; PB paginates over many round-trips).
Per-row payload is the standard 5-6 KB for orders with deep
expand. Subsequent pages within the same minute would be much
faster since PB caches the order_items query.
# Service layer
- getOrdersArchiveData(params) in src/lib/server/orders-service.ts
- Returns paginated list of orders with \u22651 item in
(delivered, cancelled) status, plus per-status distinct
counts for the header totals
- Strategy: getFullList on order_items (ids + status only),
dedupe + per-status counts in one pass, chunk the order ids
by 100 (PB's URL/filter cap is between 4.5-6.7 KB) and fire
parallel getFullList queries on orders with deep expand
- Search hits order_number, company, customer, contact_name
- Sort defaults to -created, override via the sort param
- /api/v1/orders/archive thin route: page, perPage, search, sort
(no tab, no nocache \u2014 archive is one bucket, not a tab split)
# Page
- /orders/archive/page.tsx: card list with header counts
(delivered / cancelled badges), search input, pagination
footer. Click an order to open /orders/[id] in detail.
# Main /orders updates
- Delivered + Cancelled removed from the tab strip (passed
tabKeys=['new','processing','collection_pool','collected'] to
ItemStatusTabs)
- Counts on the main page now exclude delivered + cancelled
- 'Archive' button in the page header takes the operator to
/orders/archive
- Safety useEffect: if activeTab is delivered/cancelled (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in orders-service.test.ts:
- Returns orders with \u22651 delivered/cancelled item, plus counts
- Dedupe: one order with multiple matching items counts as 1
- Search by order_number and contact_name
- Empty result when no orders match
Full suite: 1321/1321 pass.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:58:43 +00:00
|
|
|
|
|
|
|
|
|
|
// 3 archived orders (the active one is excluded).
|
|
|
|
|
|
expect(result.items.map((i) => i.order.id).sort()).toEqual([
|
|
|
|
|
|
"o-c1",
|
|
|
|
|
|
"o-d1",
|
|
|
|
|
|
"o-d2",
|
|
|
|
|
|
]);
|
|
|
|
|
|
expect(result.totalItems).toBe(3);
|
|
|
|
|
|
// Header counts: distinct orders per status.
|
|
|
|
|
|
expect(result.counts.delivered).toBe(2);
|
|
|
|
|
|
expect(result.counts.cancelled).toBe(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("dedupes orders that have multiple items in the matching statuses", async () => {
|
|
|
|
|
|
// An order with two delivered items should still count as 1 order.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-multi", order_number: "ORD-2025-01-001", quote: "q1",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-a", order: "o-multi", description: "A",
|
|
|
|
|
|
item_status: "delivered",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-b", order: "o-multi", description: "B",
|
|
|
|
|
|
item_status: "delivered",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-c", order: "o-multi", description: "C",
|
|
|
|
|
|
item_status: "cancelled",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-05 21:00:54 +00:00
|
|
|
|
const result = await getOrdersArchiveData(
|
|
|
|
|
|
{ page: 1, perPage: 20 },
|
|
|
|
|
|
makeOrdersArchivePb() as never,
|
|
|
|
|
|
);
|
feat(orders): move delivered+cancelled to dedicated /orders/archive page
The 'Delivered' tab on /orders was broken \u2014 4,326 of 4,514 orders
(96%) have at least one delivered item. The old tab tried to
build a WHERE id IN (...) filter with 4,326 ids (~120 KB),
blowing past PB's ~4 KB filter cap and 400ing. This commit
moves historical orders to a dedicated page so:
- The main /orders view is fast (22-28 active orders, not 4.5k
historical)
- The archive can be honest about its pagination ('Page 1 of
217' is expected when looking for something from last month)
- The badge counts on the main page stop being dominated by
'Delivered (4,326)' / 'Cancelled (111)'
# Measured against the live PB
/api/v1/orders/archive?page=1&perPage=2 \u2014 15.2s cold, 6.5 KB
response. The 15s is the order_items.getFullList over 13,256
items (the bottleneck; PB paginates over many round-trips).
Per-row payload is the standard 5-6 KB for orders with deep
expand. Subsequent pages within the same minute would be much
faster since PB caches the order_items query.
# Service layer
- getOrdersArchiveData(params) in src/lib/server/orders-service.ts
- Returns paginated list of orders with \u22651 item in
(delivered, cancelled) status, plus per-status distinct
counts for the header totals
- Strategy: getFullList on order_items (ids + status only),
dedupe + per-status counts in one pass, chunk the order ids
by 100 (PB's URL/filter cap is between 4.5-6.7 KB) and fire
parallel getFullList queries on orders with deep expand
- Search hits order_number, company, customer, contact_name
- Sort defaults to -created, override via the sort param
- /api/v1/orders/archive thin route: page, perPage, search, sort
(no tab, no nocache \u2014 archive is one bucket, not a tab split)
# Page
- /orders/archive/page.tsx: card list with header counts
(delivered / cancelled badges), search input, pagination
footer. Click an order to open /orders/[id] in detail.
# Main /orders updates
- Delivered + Cancelled removed from the tab strip (passed
tabKeys=['new','processing','collection_pool','collected'] to
ItemStatusTabs)
- Counts on the main page now exclude delivered + cancelled
- 'Archive' button in the page header takes the operator to
/orders/archive
- Safety useEffect: if activeTab is delivered/cancelled (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in orders-service.test.ts:
- Returns orders with \u22651 delivered/cancelled item, plus counts
- Dedupe: one order with multiple matching items counts as 1
- Search by order_number and contact_name
- Empty result when no orders match
Full suite: 1321/1321 pass.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:58:43 +00:00
|
|
|
|
expect(result.totalItems).toBe(1);
|
|
|
|
|
|
expect(result.counts.delivered).toBe(1);
|
|
|
|
|
|
expect(result.counts.cancelled).toBe(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("filters archive results by search (order_number, company, customer, contact)", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "ORD-2025-FINDME", quote: "q1",
|
|
|
|
|
|
contact_name: "Specific Contact",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-2", order_number: "ORD-2025-OTHER", quote: "q2",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "x", item_status: "delivered",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-2", order: "o-2", description: "x", item_status: "delivered",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Search by order_number
|
2026-08-05 21:00:54 +00:00
|
|
|
|
const byOrderNum = await getOrdersArchiveData(
|
|
|
|
|
|
{ page: 1, perPage: 20, search: "FINDME" },
|
|
|
|
|
|
makeOrdersArchivePb() as never,
|
|
|
|
|
|
);
|
feat(orders): move delivered+cancelled to dedicated /orders/archive page
The 'Delivered' tab on /orders was broken \u2014 4,326 of 4,514 orders
(96%) have at least one delivered item. The old tab tried to
build a WHERE id IN (...) filter with 4,326 ids (~120 KB),
blowing past PB's ~4 KB filter cap and 400ing. This commit
moves historical orders to a dedicated page so:
- The main /orders view is fast (22-28 active orders, not 4.5k
historical)
- The archive can be honest about its pagination ('Page 1 of
217' is expected when looking for something from last month)
- The badge counts on the main page stop being dominated by
'Delivered (4,326)' / 'Cancelled (111)'
# Measured against the live PB
/api/v1/orders/archive?page=1&perPage=2 \u2014 15.2s cold, 6.5 KB
response. The 15s is the order_items.getFullList over 13,256
items (the bottleneck; PB paginates over many round-trips).
Per-row payload is the standard 5-6 KB for orders with deep
expand. Subsequent pages within the same minute would be much
faster since PB caches the order_items query.
# Service layer
- getOrdersArchiveData(params) in src/lib/server/orders-service.ts
- Returns paginated list of orders with \u22651 item in
(delivered, cancelled) status, plus per-status distinct
counts for the header totals
- Strategy: getFullList on order_items (ids + status only),
dedupe + per-status counts in one pass, chunk the order ids
by 100 (PB's URL/filter cap is between 4.5-6.7 KB) and fire
parallel getFullList queries on orders with deep expand
- Search hits order_number, company, customer, contact_name
- Sort defaults to -created, override via the sort param
- /api/v1/orders/archive thin route: page, perPage, search, sort
(no tab, no nocache \u2014 archive is one bucket, not a tab split)
# Page
- /orders/archive/page.tsx: card list with header counts
(delivered / cancelled badges), search input, pagination
footer. Click an order to open /orders/[id] in detail.
# Main /orders updates
- Delivered + Cancelled removed from the tab strip (passed
tabKeys=['new','processing','collection_pool','collected'] to
ItemStatusTabs)
- Counts on the main page now exclude delivered + cancelled
- 'Archive' button in the page header takes the operator to
/orders/archive
- Safety useEffect: if activeTab is delivered/cancelled (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in orders-service.test.ts:
- Returns orders with \u22651 delivered/cancelled item, plus counts
- Dedupe: one order with multiple matching items counts as 1
- Search by order_number and contact_name
- Empty result when no orders match
Full suite: 1321/1321 pass.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:58:43 +00:00
|
|
|
|
expect(byOrderNum.items.map((i) => i.order.id)).toEqual(["o-1"]);
|
|
|
|
|
|
|
|
|
|
|
|
// Search by contact_name
|
2026-08-05 21:00:54 +00:00
|
|
|
|
const byContact = await getOrdersArchiveData(
|
|
|
|
|
|
{ page: 1, perPage: 20, search: "specific" },
|
|
|
|
|
|
makeOrdersArchivePb() as never,
|
|
|
|
|
|
);
|
feat(orders): move delivered+cancelled to dedicated /orders/archive page
The 'Delivered' tab on /orders was broken \u2014 4,326 of 4,514 orders
(96%) have at least one delivered item. The old tab tried to
build a WHERE id IN (...) filter with 4,326 ids (~120 KB),
blowing past PB's ~4 KB filter cap and 400ing. This commit
moves historical orders to a dedicated page so:
- The main /orders view is fast (22-28 active orders, not 4.5k
historical)
- The archive can be honest about its pagination ('Page 1 of
217' is expected when looking for something from last month)
- The badge counts on the main page stop being dominated by
'Delivered (4,326)' / 'Cancelled (111)'
# Measured against the live PB
/api/v1/orders/archive?page=1&perPage=2 \u2014 15.2s cold, 6.5 KB
response. The 15s is the order_items.getFullList over 13,256
items (the bottleneck; PB paginates over many round-trips).
Per-row payload is the standard 5-6 KB for orders with deep
expand. Subsequent pages within the same minute would be much
faster since PB caches the order_items query.
# Service layer
- getOrdersArchiveData(params) in src/lib/server/orders-service.ts
- Returns paginated list of orders with \u22651 item in
(delivered, cancelled) status, plus per-status distinct
counts for the header totals
- Strategy: getFullList on order_items (ids + status only),
dedupe + per-status counts in one pass, chunk the order ids
by 100 (PB's URL/filter cap is between 4.5-6.7 KB) and fire
parallel getFullList queries on orders with deep expand
- Search hits order_number, company, customer, contact_name
- Sort defaults to -created, override via the sort param
- /api/v1/orders/archive thin route: page, perPage, search, sort
(no tab, no nocache \u2014 archive is one bucket, not a tab split)
# Page
- /orders/archive/page.tsx: card list with header counts
(delivered / cancelled badges), search input, pagination
footer. Click an order to open /orders/[id] in detail.
# Main /orders updates
- Delivered + Cancelled removed from the tab strip (passed
tabKeys=['new','processing','collection_pool','collected'] to
ItemStatusTabs)
- Counts on the main page now exclude delivered + cancelled
- 'Archive' button in the page header takes the operator to
/orders/archive
- Safety useEffect: if activeTab is delivered/cancelled (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in orders-service.test.ts:
- Returns orders with \u22651 delivered/cancelled item, plus counts
- Dedupe: one order with multiple matching items counts as 1
- Search by order_number and contact_name
- Empty result when no orders match
Full suite: 1321/1321 pass.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:58:43 +00:00
|
|
|
|
expect(byContact.items.map((i) => i.order.id)).toEqual(["o-1"]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns an empty result when no orders have delivered/cancelled items", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-active", order_number: "ORD-2025-01-001", quote: "q1",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-active", description: "x",
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-05 21:00:54 +00:00
|
|
|
|
const result = await getOrdersArchiveData(
|
|
|
|
|
|
{ page: 1, perPage: 20 },
|
|
|
|
|
|
makeOrdersArchivePb() as never,
|
|
|
|
|
|
);
|
feat(orders): move delivered+cancelled to dedicated /orders/archive page
The 'Delivered' tab on /orders was broken \u2014 4,326 of 4,514 orders
(96%) have at least one delivered item. The old tab tried to
build a WHERE id IN (...) filter with 4,326 ids (~120 KB),
blowing past PB's ~4 KB filter cap and 400ing. This commit
moves historical orders to a dedicated page so:
- The main /orders view is fast (22-28 active orders, not 4.5k
historical)
- The archive can be honest about its pagination ('Page 1 of
217' is expected when looking for something from last month)
- The badge counts on the main page stop being dominated by
'Delivered (4,326)' / 'Cancelled (111)'
# Measured against the live PB
/api/v1/orders/archive?page=1&perPage=2 \u2014 15.2s cold, 6.5 KB
response. The 15s is the order_items.getFullList over 13,256
items (the bottleneck; PB paginates over many round-trips).
Per-row payload is the standard 5-6 KB for orders with deep
expand. Subsequent pages within the same minute would be much
faster since PB caches the order_items query.
# Service layer
- getOrdersArchiveData(params) in src/lib/server/orders-service.ts
- Returns paginated list of orders with \u22651 item in
(delivered, cancelled) status, plus per-status distinct
counts for the header totals
- Strategy: getFullList on order_items (ids + status only),
dedupe + per-status counts in one pass, chunk the order ids
by 100 (PB's URL/filter cap is between 4.5-6.7 KB) and fire
parallel getFullList queries on orders with deep expand
- Search hits order_number, company, customer, contact_name
- Sort defaults to -created, override via the sort param
- /api/v1/orders/archive thin route: page, perPage, search, sort
(no tab, no nocache \u2014 archive is one bucket, not a tab split)
# Page
- /orders/archive/page.tsx: card list with header counts
(delivered / cancelled badges), search input, pagination
footer. Click an order to open /orders/[id] in detail.
# Main /orders updates
- Delivered + Cancelled removed from the tab strip (passed
tabKeys=['new','processing','collection_pool','collected'] to
ItemStatusTabs)
- Counts on the main page now exclude delivered + cancelled
- 'Archive' button in the page header takes the operator to
/orders/archive
- Safety useEffect: if activeTab is delivered/cancelled (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in orders-service.test.ts:
- Returns orders with \u22651 delivered/cancelled item, plus counts
- Dedupe: one order with multiple matching items counts as 1
- Search by order_number and contact_name
- Empty result when no orders match
Full suite: 1321/1321 pass.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:58:43 +00:00
|
|
|
|
expect(result.items).toEqual([]);
|
|
|
|
|
|
expect(result.totalItems).toBe(0);
|
|
|
|
|
|
expect(result.counts.delivered).toBe(0);
|
|
|
|
|
|
expect(result.counts.cancelled).toBe(0);
|
|
|
|
|
|
});
|
2026-08-05 21:00:54 +00:00
|
|
|
|
|
|
|
|
|
|
it("loads the default archive page without scanning every order_item first", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-d1", order_number: "ORD-2025-01-001", quote: "q1",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-d1", description: "x", item_status: "delivered",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await getOrdersArchiveData(
|
|
|
|
|
|
{ page: 1, perPage: 20 },
|
|
|
|
|
|
makeOrdersArchivePb({ failOnArchiveItemScan: true }) as never,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.items.map((i) => i.order.id)).toEqual(["o-d1"]);
|
|
|
|
|
|
expect(result.counts.delivered).toBe(1);
|
|
|
|
|
|
});
|
feat(orders): move delivered+cancelled to dedicated /orders/archive page
The 'Delivered' tab on /orders was broken \u2014 4,326 of 4,514 orders
(96%) have at least one delivered item. The old tab tried to
build a WHERE id IN (...) filter with 4,326 ids (~120 KB),
blowing past PB's ~4 KB filter cap and 400ing. This commit
moves historical orders to a dedicated page so:
- The main /orders view is fast (22-28 active orders, not 4.5k
historical)
- The archive can be honest about its pagination ('Page 1 of
217' is expected when looking for something from last month)
- The badge counts on the main page stop being dominated by
'Delivered (4,326)' / 'Cancelled (111)'
# Measured against the live PB
/api/v1/orders/archive?page=1&perPage=2 \u2014 15.2s cold, 6.5 KB
response. The 15s is the order_items.getFullList over 13,256
items (the bottleneck; PB paginates over many round-trips).
Per-row payload is the standard 5-6 KB for orders with deep
expand. Subsequent pages within the same minute would be much
faster since PB caches the order_items query.
# Service layer
- getOrdersArchiveData(params) in src/lib/server/orders-service.ts
- Returns paginated list of orders with \u22651 item in
(delivered, cancelled) status, plus per-status distinct
counts for the header totals
- Strategy: getFullList on order_items (ids + status only),
dedupe + per-status counts in one pass, chunk the order ids
by 100 (PB's URL/filter cap is between 4.5-6.7 KB) and fire
parallel getFullList queries on orders with deep expand
- Search hits order_number, company, customer, contact_name
- Sort defaults to -created, override via the sort param
- /api/v1/orders/archive thin route: page, perPage, search, sort
(no tab, no nocache \u2014 archive is one bucket, not a tab split)
# Page
- /orders/archive/page.tsx: card list with header counts
(delivered / cancelled badges), search input, pagination
footer. Click an order to open /orders/[id] in detail.
# Main /orders updates
- Delivered + Cancelled removed from the tab strip (passed
tabKeys=['new','processing','collection_pool','collected'] to
ItemStatusTabs)
- Counts on the main page now exclude delivered + cancelled
- 'Archive' button in the page header takes the operator to
/orders/archive
- Safety useEffect: if activeTab is delivered/cancelled (e.g.
stale URL or local state), reset to 'new'
# Tests
4 new tests in orders-service.test.ts:
- Returns orders with \u22651 delivered/cancelled item, plus counts
- Dedupe: one order with multiple matching items counts as 1
- Search by order_number and contact_name
- Empty result when no orders match
Full suite: 1321/1321 pass.
Co-authored-by: Orca <help@stably.ai>
2026-08-04 17:58:43 +00:00
|
|
|
|
});
|
2026-08-13 07:02:27 +00:00
|
|
|
|
|
|
|
|
|
|
/* ============================================================================
|
|
|
|
|
|
* cancelOrder / cancelOrdersBulk / findDuplicateOrders
|
|
|
|
|
|
* ----------------------------------------------------------------------------
|
|
|
|
|
|
* Smoke tests for the soft-cancel + duplicate-detection flow added on
|
|
|
|
|
|
* 2026-08-12. Real PB verification lives in the live script in the
|
|
|
|
|
|
* scratchpad directory; these tests pin the basic contract.
|
|
|
|
|
|
* ========================================================================== */
|
|
|
|
|
|
|
|
|
|
|
|
describe("cancelOrder", () => {
|
|
|
|
|
|
it("flips every in-flight item_status to 'cancelled'", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-cancel", order_number: "ORD-cancel", quote: "qc",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-cancel", description: "a", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-2", order: "o-cancel", description: "b", item_status: "po_generated",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-3", order: "o-cancel", description: "c", item_status: "collected",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await cancelOrder("o-cancel");
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.itemsCancelled).toBe(3);
|
|
|
|
|
|
expect(result.itemsSkipped).toEqual([]);
|
|
|
|
|
|
const after = await pbMock.collection("order_items").getFullList();
|
|
|
|
|
|
expect(after.every((i) => i.item_status === "cancelled")).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("refuses to cancel an order where every item is terminal", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-shipped", order_number: "ORD-shipped", quote: "qs",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-d1", order: "o-shipped", description: "a", item_status: "delivered",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-d2", order: "o-shipped", description: "b", item_status: "delivered",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await expect(cancelOrder("o-shipped")).rejects.toThrow(/terminal state/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("partially cancels an order with mixed statuses, reporting the skipped ones", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-mixed", order_number: "ORD-mixed", quote: "qm",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-m1", order: "o-mixed", description: "a", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-m2", order: "o-mixed", description: "b", item_status: "delivered",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await cancelOrder("o-mixed");
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.itemsCancelled).toBe(1);
|
|
|
|
|
|
expect(result.itemsSkipped).toEqual([{ id: "oi-m2", item_status: "delivered" }]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("allowDelivered flag lets the bulk-cancel path skip the terminal guard", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-bulk", order_number: "ORD-bulk", quote: "qb",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-b1", order: "o-bulk", description: "a", item_status: "delivered",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-b2", order: "o-bulk", description: "b", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await cancelOrder("o-bulk", { allowDelivered: true });
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.itemsCancelled).toBe(2);
|
|
|
|
|
|
expect(result.itemsSkipped).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("cancelOrdersBulk", () => {
|
|
|
|
|
|
it("returns per-order success/failure", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-ok", order_number: "ORD-ok", quote: "qk",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-ok", order: "o-ok", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-missing", order_number: "ORD-missing", quote: "qm",
|
|
|
|
|
|
});
|
|
|
|
|
|
// (no items — empty order, will succeed with 0 cancelled)
|
|
|
|
|
|
|
|
|
|
|
|
const result = await cancelOrdersBulk(["o-ok", "o-missing", "o-doesnt-exist"]);
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.succeeded.map((r) => r.orderId).sort()).toEqual(["o-missing", "o-ok"]);
|
|
|
|
|
|
expect(result.failed).toEqual([{ orderId: "o-doesnt-exist", error: expect.stringContaining("not found") }]);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("findDuplicateOrders", () => {
|
|
|
|
|
|
it("only returns groups with ≥2 active records (the ones visible on the order board)", async () => {
|
|
|
|
|
|
// The 389020 pattern from live data: 4 records share the number,
|
|
|
|
|
|
// but only 2 are "on the board" (have items in active status).
|
2026-08-24 05:09:39 +00:00
|
|
|
|
// The other 2 are off the board — the operator never sees them
|
|
|
|
|
|
// — so the duplicates endpoint should hide them. With the new
|
|
|
|
|
|
// `completed` tab, *recent* `delivered` rows count as on the
|
|
|
|
|
|
// board (they show up on the Completed tab), so we use a row
|
|
|
|
|
|
// whose `delivered_at` is older than the 30-day window as the
|
|
|
|
|
|
// "off the board" example.
|
2026-08-13 07:02:27 +00:00
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "Widget", item_status: "po_generated",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-2", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00.100Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-2", order: "o-2", description: "Widget", item_status: "po_generated",
|
|
|
|
|
|
});
|
2026-08-24 05:09:39 +00:00
|
|
|
|
// Third copy — off the board. `delivered_at` is set years ago,
|
|
|
|
|
|
// well outside the 30-day Completed window, so it falls through
|
|
|
|
|
|
// to the archive and isn't visible to the operator.
|
2026-08-13 07:02:27 +00:00
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-3", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00.200Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-3", order: "o-3", description: "Widget", item_status: "delivered",
|
2026-08-24 05:09:39 +00:00
|
|
|
|
delivered_at: "2024-01-01T00:00:00Z",
|
2026-08-13 07:02:27 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders();
|
|
|
|
|
|
expect(groups).toHaveLength(1);
|
|
|
|
|
|
expect(groups[0].records.map((r) => r.id).sort()).toEqual(["o-1", "o-2"]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("does not surface groups where only one record is active", async () => {
|
|
|
|
|
|
// Two records share the number, but only one has items in active
|
|
|
|
|
|
// status. The off-board copy is hidden, leaving only 1 record,
|
|
|
|
|
|
// so the group doesn't qualify as a duplicate from the operator's
|
|
|
|
|
|
// perspective.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "Widget", item_status: "po_generated",
|
|
|
|
|
|
});
|
2026-08-24 05:09:39 +00:00
|
|
|
|
// Off-board copy: `delivered_at` set years ago, well outside the
|
|
|
|
|
|
// 30-day Completed window — so it lives on /orders/archive and
|
|
|
|
|
|
// the operator can't see it. (Naïvely setting `item_status =
|
|
|
|
|
|
// delivered` without a stale `delivered_at` would now show up on
|
|
|
|
|
|
// the Completed tab and count as on the board.)
|
2026-08-13 07:02:27 +00:00
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-2", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00.100Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-2", order: "o-2", description: "Widget", item_status: "delivered",
|
2026-08-24 05:09:39 +00:00
|
|
|
|
delivered_at: "2024-01-01T00:00:00Z",
|
2026-08-13 07:02:27 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders();
|
|
|
|
|
|
expect(groups).toHaveLength(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("does not surface singletons or orders with no active items", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-solo", order_number: "SOLO", quote: "q", created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
2026-08-24 05:09:39 +00:00
|
|
|
|
// Off-board: a delivered row whose `delivered_at` is well outside
|
|
|
|
|
|
// the 30-day Completed window (so it lives only on
|
|
|
|
|
|
// /orders/archive).
|
2026-08-13 07:02:27 +00:00
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-delivered", order_number: "ALSO-SOLO", quote: "q", created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-d", order: "o-delivered", description: "Widget", item_status: "delivered",
|
2026-08-24 05:09:39 +00:00
|
|
|
|
delivered_at: "2024-01-01T00:00:00Z",
|
2026-08-13 07:02:27 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders();
|
|
|
|
|
|
expect(groups).toHaveLength(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("enriches each active record with company, customer, and item statuses", async () => {
|
|
|
|
|
|
await pbMock.collection("customers").create({ id: "c-x", customer: "Cust" });
|
|
|
|
|
|
await pbMock.collection("companies").create({ id: "co-x", company: "Co" });
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "DUP", quote: "q",
|
|
|
|
|
|
customer: "c-x", company: "co-x", created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "M16x50 bolt", quantity: 400, item_status: "po_generated",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-2", order_number: "DUP", quote: "q",
|
|
|
|
|
|
customer: "c-x", company: "co-x", created: "2025-01-01T00:00:00.100Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-2", order: "o-2", description: "M16x50 bolt", quantity: 400, item_status: "invoiced",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders();
|
|
|
|
|
|
expect(groups).toHaveLength(1);
|
|
|
|
|
|
const r1 = groups[0].records.find((r) => r.id === "o-1")!;
|
|
|
|
|
|
const r2 = groups[0].records.find((r) => r.id === "o-2")!;
|
|
|
|
|
|
expect(r1.customerName).toBe("Cust");
|
|
|
|
|
|
expect(r1.companyName).toBe("Co");
|
|
|
|
|
|
expect(r1.itemStatusSummary).toEqual({ po_generated: 1 });
|
|
|
|
|
|
// The invoiced copy shows the operator how far each got — the
|
|
|
|
|
|
// "invoiced" one is the natural keeper.
|
|
|
|
|
|
expect(r2.itemStatusSummary).toEqual({ invoiced: 1 });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("sorts records by created asc so the oldest is first", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-newer", order_number: "DUP", quote: "q",
|
|
|
|
|
|
created: "2025-01-01T01:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-n", order: "o-newer", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-older", order_number: "DUP", quote: "q",
|
|
|
|
|
|
created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-o", order: "o-older", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders();
|
|
|
|
|
|
expect(groups[0].records.map((r) => r.id)).toEqual(["o-older", "o-newer"]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns groups sorted by size (largest first)", async () => {
|
2026-08-16 09:25:00 +00:00
|
|
|
|
// Use distinct quantities per order_number so the cross-number
|
|
|
|
|
|
// line-aggregate pass doesn't accidentally fire here — this test
|
|
|
|
|
|
// is about the order-number pass's sort order.
|
|
|
|
|
|
for (const [id, num, qty] of [
|
|
|
|
|
|
["o-s1", "SOLO", 1],
|
|
|
|
|
|
["o-s2", "SOLO", 1],
|
|
|
|
|
|
["o-t1", "TRIPLE", 2],
|
|
|
|
|
|
["o-t2", "TRIPLE", 2],
|
|
|
|
|
|
["o-t3", "TRIPLE", 2],
|
2026-08-13 07:02:27 +00:00
|
|
|
|
]) {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id, order_number: num, quote: "q",
|
|
|
|
|
|
created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
2026-08-16 09:25:00 +00:00
|
|
|
|
id: `oi-${id}`, order: id, description: "x", quantity: qty, item_status: "pending",
|
2026-08-13 07:02:27 +00:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders();
|
|
|
|
|
|
expect(groups.map((g) => g.orderNumber)).toEqual(["TRIPLE", "SOLO"]);
|
|
|
|
|
|
});
|
2026-08-16 09:25:00 +00:00
|
|
|
|
|
|
|
|
|
|
// ────── line-aggregate ("Same line items") pass ──────
|
|
|
|
|
|
it("flags cross-order_number line-aggregate duplicates even when descriptions differ", async () => {
|
|
|
|
|
|
// Two orders under different order_numbers, same line breakdown
|
|
|
|
|
|
// (1 line, qty 5, R100). Descriptions and created-timestamps
|
|
|
|
|
|
// differ — the kind: "line_aggregate" pass should still flag it.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-a", order_number: "ORD-A", quote: "q",
|
|
|
|
|
|
created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-a", order: "o-a", description: "M16 hex bolt 50mm grade 8.8",
|
|
|
|
|
|
quantity: 5, selling_price: 100, item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-b", order_number: "ORD-B", quote: "q",
|
|
|
|
|
|
created: "2025-01-02T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-b", order: "o-b", description: "M16 x 50 hex bolt", // different text
|
|
|
|
|
|
quantity: 5, selling_price: 100, item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders();
|
|
|
|
|
|
const aggregateGroups = groups.filter((g) => g.kind === "line_aggregate");
|
|
|
|
|
|
expect(aggregateGroups).toHaveLength(1);
|
|
|
|
|
|
expect(aggregateGroups[0].records.map((r) => r.id).sort()).toEqual(["o-a", "o-b"]);
|
|
|
|
|
|
expect(aggregateGroups[0].orderNumber).toMatch(/^1 line · 5 · R/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("does not double-list a same-number group under line-aggregate (cross-number filter)", async () => {
|
|
|
|
|
|
// Two records under the SAME order_number with matching content
|
|
|
|
|
|
// — already an order-number duplicate. The line-aggregate pass
|
|
|
|
|
|
// must NOT surface them again.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "x", quantity: 5, selling_price: 100,
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-2", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00.100Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-2", order: "o-2", description: "x", quantity: 5, selling_price: 100,
|
|
|
|
|
|
item_status: "invoiced",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders();
|
|
|
|
|
|
expect(groups).toHaveLength(1);
|
|
|
|
|
|
expect(groups[0].kind).toBe("order_number");
|
|
|
|
|
|
expect(groups[0].records.map((r) => r.id)).toEqual(["o-1", "o-2"]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("excludes orders with zero items from line-aggregate matching", async () => {
|
|
|
|
|
|
// Empty orders would all match each other on (0 lines, 0 qty, R0)
|
|
|
|
|
|
// — must not be flagged.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-empty-1", order_number: "EMPTY-A", quote: "q",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-empty-2", order_number: "EMPTY-B", quote: "q",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders();
|
|
|
|
|
|
expect(groups).toHaveLength(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("rounds sub-cent price differences so 1499.998 and 1500.002 both match 1500.00", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-x", order_number: "X", quote: "q", created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-x", order: "o-x", description: "x", quantity: 1,
|
|
|
|
|
|
selling_price: 1499.998, item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-y", order_number: "Y", quote: "q", created: "2025-01-02T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-y", order: "o-y", description: "x", quantity: 1,
|
|
|
|
|
|
selling_price: 1500.002, item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders();
|
|
|
|
|
|
expect(groups.filter((g) => g.kind === "line_aggregate")).toHaveLength(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("matches when both sides are entirely zero-priced", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-z1", order_number: "Z1", quote: "q", created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-z1", order: "o-z1", description: "x", quantity: 5,
|
|
|
|
|
|
selling_price: 0, item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-z2", order_number: "Z2", quote: "q", created: "2025-01-02T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-z2", order: "o-z2", description: "x", quantity: 5,
|
|
|
|
|
|
selling_price: 0, item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders();
|
|
|
|
|
|
expect(groups.filter((g) => g.kind === "line_aggregate")).toHaveLength(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("does not match when one side is zero-priced and the other is not", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-p1", order_number: "P1", quote: "q", created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-p1", order: "o-p1", description: "x", quantity: 1,
|
|
|
|
|
|
selling_price: 0, item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-p2", order_number: "P2", quote: "q", created: "2025-01-02T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-p2", order: "o-p2", description: "x", quantity: 1,
|
|
|
|
|
|
selling_price: 50, item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders();
|
|
|
|
|
|
expect(groups).toHaveLength(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("does not match when total quantity differs (everything else the same)", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-q1", order_number: "Q1", quote: "q", created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-q1", order: "o-q1", description: "x", quantity: 5,
|
|
|
|
|
|
selling_price: 100, item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-q2", order_number: "Q2", quote: "q", created: "2025-01-02T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-q2", order: "o-q2", description: "x", quantity: 6,
|
|
|
|
|
|
selling_price: 100, item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders();
|
|
|
|
|
|
expect(groups).toHaveLength(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("does not match when line count differs (everything else the same)", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-c1", order_number: "C1", quote: "q", created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-c1a", order: "o-c1", description: "x", quantity: 5,
|
|
|
|
|
|
selling_price: 100, item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-c1b", order: "o-c1", description: "y", quantity: 5,
|
|
|
|
|
|
selling_price: 100, item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-c2", order_number: "C2", quote: "q", created: "2025-01-02T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-c2", order: "o-c2", description: "x", quantity: 10,
|
|
|
|
|
|
selling_price: 100, item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders();
|
|
|
|
|
|
expect(groups).toHaveLength(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("computes totalSellingPrice as the sum of selling_price × quantity per line", async () => {
|
|
|
|
|
|
// 2 lines: qty 2 @ R50 = R100, qty 3 @ R100 = R300 → total R400.
|
|
|
|
|
|
// qty 5, R400. Match against another order with same aggregates.
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-m1", order_number: "M1", quote: "q", created: "2025-01-01T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-m1a", order: "o-m1", description: "a", quantity: 2, selling_price: 50,
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-m1b", order: "o-m1", description: "b", quantity: 3, selling_price: 100,
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-m2", order_number: "M2", quote: "q", created: "2025-01-02T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-m2a", order: "o-m2", description: "a", quantity: 2, selling_price: 50,
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-m2b", order: "o-m2", description: "b", quantity: 3, selling_price: 100,
|
|
|
|
|
|
item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders();
|
|
|
|
|
|
const aggs = groups.filter((g) => g.kind === "line_aggregate");
|
|
|
|
|
|
expect(aggs).toHaveLength(1);
|
|
|
|
|
|
expect(aggs[0].orderNumber).toMatch(/^2 lines · 5 · R/);
|
|
|
|
|
|
expect(aggs[0].records[0].totalQuantity).toBe(5);
|
|
|
|
|
|
expect(aggs[0].records[0].totalSellingPrice).toBeCloseTo(400, 2);
|
|
|
|
|
|
});
|
2026-08-13 07:02:27 +00:00
|
|
|
|
});
|
2026-08-21 17:18:02 +00:00
|
|
|
|
|
|
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// setOrderQuote (Hermes unblock)
|
|
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("setOrderQuote", () => {
|
|
|
|
|
|
it("links an existing order to an existing quote, returns {order, previous}", async () => {
|
|
|
|
|
|
await pbMock.collection("quotes").create({ id: "q-1" });
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "ORD-1", quote: "", created: "2026-08-21T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.previous).toEqual({ quote: "" });
|
|
|
|
|
|
expect(result.order.id).toBe("o-1");
|
|
|
|
|
|
expect(result.order.quote).toBe("q-1");
|
|
|
|
|
|
|
|
|
|
|
|
// The store reflects the write.
|
|
|
|
|
|
const stored = (store["orders"] || new Map()).get("o-1");
|
|
|
|
|
|
expect(stored?.quote).toBe("q-1");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("refuses 409 order_locked when any item is past pending", async () => {
|
|
|
|
|
|
await pbMock.collection("quotes").create({ id: "q-1" });
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "ORD-1", quote: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "x", item_status: "po_generated",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-2", order: "o-1", description: "y", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
|
|
|
|
|
|
expect.fail("should have thrown");
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
expect((err as Error & { status?: number }).status).toBe(409);
|
|
|
|
|
|
const data = (err as Error & { data?: { code?: string; offendingItemIds?: string[] } }).data;
|
|
|
|
|
|
expect(data?.code).toBe("order_locked");
|
|
|
|
|
|
expect(data?.offendingItemIds).toContain("oi-1");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Order quote must not have changed.
|
|
|
|
|
|
const stored = (store["orders"] || new Map()).get("o-1");
|
|
|
|
|
|
expect(stored?.quote).toBe("");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("refuses 409 quote_already_linked when another order has the quote", async () => {
|
|
|
|
|
|
await pbMock.collection("quotes").create({ id: "q-1" });
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-other", order_number: "ORD-OTHER", quote: "q-1",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-other", order: "o-other", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "ORD-1", quote: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
|
|
|
|
|
|
expect.fail("should have thrown");
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
expect((err as Error & { status?: number }).status).toBe(409);
|
|
|
|
|
|
const data = (err as Error & { data?: { code?: string; conflictingOrderId?: string } }).data;
|
|
|
|
|
|
expect(data?.code).toBe("quote_already_linked");
|
|
|
|
|
|
expect(data?.conflictingOrderId).toBe("o-other");
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("404 when the order doesn't exist", async () => {
|
|
|
|
|
|
await pbMock.collection("quotes").create({ id: "q-1" });
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await setOrderQuote({ orderId: "missing", quoteId: "q-1" });
|
|
|
|
|
|
expect.fail("should have thrown");
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
expect((err as Error & { status?: number }).status).toBe(404);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("404 when the quote doesn't exist", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "ORD-1", quote: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await setOrderQuote({ orderId: "o-1", quoteId: "missing" });
|
|
|
|
|
|
expect.fail("should have thrown");
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
expect((err as Error & { status?: number }).status).toBe(404);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("is idempotent when the order already points at the quote (no-op + no audit stamp)", async () => {
|
|
|
|
|
|
await pbMock.collection("quotes").create({ id: "q-1" });
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "ORD-1", quote: "q-1", notes: "original note",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await setOrderQuote({
|
|
|
|
|
|
orderId: "o-1",
|
|
|
|
|
|
quoteId: "q-1",
|
|
|
|
|
|
actor: { id: "agent-1", name: "Hermes" },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.previous).toEqual({ quote: "q-1" });
|
|
|
|
|
|
expect(result.order.quote).toBe("q-1");
|
|
|
|
|
|
|
|
|
|
|
|
// Notes must NOT be touched on idempotent no-op.
|
|
|
|
|
|
expect((store["orders"] || new Map()).get("o-1")?.notes).toBe("original note");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("stamps actor into notes when provided", async () => {
|
|
|
|
|
|
await pbMock.collection("quotes").create({ id: "q-1" });
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "ORD-1", quote: "", notes: "existing note",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await setOrderQuote({
|
|
|
|
|
|
orderId: "o-1",
|
|
|
|
|
|
quoteId: "q-1",
|
|
|
|
|
|
actor: { id: "agent-1", name: "Hermes" },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const stored = (store["orders"] || new Map()).get("o-1");
|
|
|
|
|
|
expect(stored?.quote).toBe("q-1");
|
|
|
|
|
|
expect(String(stored?.notes ?? "")).toMatch(/existing note/);
|
|
|
|
|
|
expect(String(stored?.notes ?? "")).toMatch(/Hermes linked this order to quote q-1/);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-29 12:06:10 +00:00
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// setOrderQuote line pairing (Task 5)
|
|
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("setOrderQuote line pairing", () => {
|
|
|
|
|
|
it("pairs order lines to the quote's lines after writing the hard link", async () => {
|
|
|
|
|
|
const { pairOrderLines } = await import("./order-quote-link-service");
|
2026-08-29 12:17:26 +00:00
|
|
|
|
vi.mocked(pairOrderLines).mockClear();
|
2026-08-29 12:06:10 +00:00
|
|
|
|
await pbMock.collection("quotes").create({ id: "q-1" });
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "ORD-1", quote: "", created: "2026-08-21T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "hex bolt m20", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
|
|
|
|
|
|
|
|
|
|
|
|
expect(pairOrderLines).toHaveBeenCalledWith("o-1", "q-1");
|
|
|
|
|
|
expect(result.order.quote).toBe("q-1");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("does not fail the link when pairing throws", async () => {
|
|
|
|
|
|
const { pairOrderLines } = await import("./order-quote-link-service");
|
2026-08-29 12:17:26 +00:00
|
|
|
|
vi.mocked(pairOrderLines).mockClear();
|
2026-08-29 12:06:10 +00:00
|
|
|
|
vi.mocked(pairOrderLines).mockRejectedValueOnce(new Error("PB down"));
|
|
|
|
|
|
|
|
|
|
|
|
await pbMock.collection("quotes").create({ id: "q-1" });
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "ORD-1", quote: "", created: "2026-08-21T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "hex bolt m20", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
|
|
|
|
|
|
|
|
|
|
|
|
// The link still landed despite the pairing failure.
|
|
|
|
|
|
expect(result.order.quote).toBe("q-1");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("does not pair lines when the link is refused (order_locked)", async () => {
|
|
|
|
|
|
const { pairOrderLines } = await import("./order-quote-link-service");
|
|
|
|
|
|
vi.mocked(pairOrderLines).mockClear();
|
|
|
|
|
|
|
|
|
|
|
|
await pbMock.collection("quotes").create({ id: "q-1" });
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "ORD-1", quote: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "x", item_status: "po_generated",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
|
|
|
|
|
|
expect.fail("should have thrown");
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
expect((err as Error & { status?: number }).status).toBe(409);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
expect(pairOrderLines).not.toHaveBeenCalled();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("also pairs lines on the idempotent no-op path (order already points at the quote)", async () => {
|
|
|
|
|
|
const { pairOrderLines } = await import("./order-quote-link-service");
|
|
|
|
|
|
vi.mocked(pairOrderLines).mockClear();
|
|
|
|
|
|
|
|
|
|
|
|
await pbMock.collection("quotes").create({ id: "q-1" });
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "ORD-1", quote: "q-1",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "hex bolt m20", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
|
|
|
|
|
|
|
|
|
|
|
|
// Re-running pairing on an already-linked order is safe (it never
|
|
|
|
|
|
// overwrites an existing `quote_item`) and picks up lines added
|
|
|
|
|
|
// or edited since the original link — so pairing runs here too,
|
|
|
|
|
|
// even though the hard link itself is a no-op.
|
|
|
|
|
|
expect(pairOrderLines).toHaveBeenCalledWith("o-1", "q-1");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-21 17:18:02 +00:00
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// findOrderByOrderNumber (Hermes unblock)
|
|
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("findOrderByOrderNumber", () => {
|
|
|
|
|
|
it("returns null on miss (no matching order)", async () => {
|
|
|
|
|
|
const hit = await findOrderByOrderNumber("DOES-NOT-EXIST");
|
|
|
|
|
|
expect(hit).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns the full order record on hit", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "ORD-1", quote: "q", created: "2026-08-21T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
const hit = await findOrderByOrderNumber("ORD-1");
|
|
|
|
|
|
expect(hit).not.toBeNull();
|
|
|
|
|
|
expect(hit?.id).toBe("o-1");
|
|
|
|
|
|
expect(hit?.order_number).toBe("ORD-1");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("escapes double-quotes in user input", async () => {
|
|
|
|
|
|
// Filter "order_number = \"weird\\\"value\"" — the mock's where
|
|
|
|
|
|
// only handles simple patterns, so we just confirm no crash on
|
|
|
|
|
|
// a quote-containing input.
|
|
|
|
|
|
const hit = await findOrderByOrderNumber('weird"value');
|
|
|
|
|
|
expect(hit).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// findOrderByEmailMessageId (Hermes unblock)
|
|
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("findOrderByEmailMessageId", () => {
|
|
|
|
|
|
it("returns null on miss", async () => {
|
|
|
|
|
|
const hit = await findOrderByEmailMessageId("<missing@mail.example>");
|
|
|
|
|
|
expect(hit).toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("returns the full order record on hit", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "ORD-1",
|
|
|
|
|
|
email_message_id: "<msg-1@mail.example>",
|
|
|
|
|
|
});
|
|
|
|
|
|
const hit = await findOrderByEmailMessageId("<msg-1@mail.example>");
|
|
|
|
|
|
expect(hit).not.toBeNull();
|
|
|
|
|
|
expect(hit?.id).toBe("o-1");
|
|
|
|
|
|
expect(hit?.email_message_id).toBe("<msg-1@mail.example>");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// findDuplicateOrders — email_message_id kind (Hermes unblock)
|
|
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe("findDuplicateOrders — email_message_id kind", () => {
|
|
|
|
|
|
it("flags a group of ≥2 records sharing the same email_message_id", async () => {
|
|
|
|
|
|
const sharedEmail = "<msg-shared@mail.example>";
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-a", order_number: "ORD-A", quote: "q",
|
|
|
|
|
|
email_message_id: sharedEmail, created: "2026-08-21T00:00:00Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-a", order: "o-a", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-b", order_number: "ORD-B", quote: "q",
|
|
|
|
|
|
email_message_id: sharedEmail, created: "2026-08-21T00:00:01Z",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-b", order: "o-b", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders({ kind: "email_message_id" });
|
|
|
|
|
|
expect(groups).toHaveLength(1);
|
|
|
|
|
|
expect(groups[0].kind).toBe("email_message_id");
|
|
|
|
|
|
expect(groups[0].orderNumber).toBe(sharedEmail);
|
|
|
|
|
|
expect(groups[0].records.map((r) => r.id).sort()).toEqual(["o-a", "o-b"]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("skips records with an empty email_message_id", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-empty-1", order_number: "E1", quote: "q", email_message_id: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-e1", order: "o-empty-1", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-empty-2", order_number: "E2", quote: "q", email_message_id: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-e2", order: "o-empty-2", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders({ kind: "email_message_id" });
|
|
|
|
|
|
expect(groups).toHaveLength(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("does not include email_message_id groups when kind filter is order_number", async () => {
|
|
|
|
|
|
const sharedEmail = "<msg-shared@mail.example>";
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-a", order_number: "ORD-A", quote: "q",
|
|
|
|
|
|
email_message_id: sharedEmail,
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-a", order: "o-a", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-b", order_number: "ORD-B", quote: "q",
|
|
|
|
|
|
email_message_id: sharedEmail,
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-b", order: "o-b", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const groups = await findDuplicateOrders({ kind: "order_number" });
|
|
|
|
|
|
expect(groups).toHaveLength(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-08-29 12:22:21 +00:00
|
|
|
|
|
|
|
|
|
|
describe("createOrder quote auto-detection", () => {
|
|
|
|
|
|
it("schedules a quote suggestion after the response", async () => {
|
|
|
|
|
|
const { suggestQuoteForOrder } = await import("./order-quote-link-service");
|
|
|
|
|
|
const { after } = await import("next/server");
|
|
|
|
|
|
vi.mocked(suggestQuoteForOrder).mockClear();
|
|
|
|
|
|
vi.mocked(after).mockClear();
|
|
|
|
|
|
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
|
|
|
|
|
|
await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" });
|
|
|
|
|
|
|
|
|
|
|
|
const created = await createOrder({
|
|
|
|
|
|
customer_id: "cust-x",
|
|
|
|
|
|
company_id: "comp-x",
|
|
|
|
|
|
items: [{ description: "M16 Bolt", quantity: 50 }],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(after).toHaveBeenCalled();
|
|
|
|
|
|
expect(suggestQuoteForOrder).toHaveBeenCalledWith(created.order.id);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("never fails order creation when the matcher throws", async () => {
|
2026-08-30 06:40:17 +00:00
|
|
|
|
const consoleErrorSpy = vi
|
|
|
|
|
|
.spyOn(console, "error")
|
|
|
|
|
|
.mockImplementation(() => {});
|
2026-08-29 12:22:21 +00:00
|
|
|
|
const { suggestQuoteForOrder } = await import("./order-quote-link-service");
|
|
|
|
|
|
vi.mocked(suggestQuoteForOrder).mockClear();
|
|
|
|
|
|
vi.mocked(suggestQuoteForOrder).mockRejectedValueOnce(new Error("PB down"));
|
|
|
|
|
|
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
|
|
|
|
|
|
await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" });
|
|
|
|
|
|
|
|
|
|
|
|
const result = await createOrder({
|
|
|
|
|
|
customer_id: "cust-x",
|
|
|
|
|
|
company_id: "comp-x",
|
|
|
|
|
|
items: [{ description: "M16 Bolt", quantity: 50 }],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.order.id).toBeDefined();
|
|
|
|
|
|
expect(result.items_created.length).toBe(1);
|
2026-08-30 06:40:17 +00:00
|
|
|
|
// Verifies the inner catch swallowed the matcher failure and
|
|
|
|
|
|
// logged it — without this assertion the test passes purely
|
|
|
|
|
|
// because the `after` mock detaches the promise regardless of
|
|
|
|
|
|
// what production code does.
|
|
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
|
|
|
|
expect.stringContaining("[createOrder] quote auto-detection failed for order"),
|
|
|
|
|
|
expect.stringContaining("PB down"),
|
|
|
|
|
|
);
|
|
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("falls back to an inline match when after() is unavailable", async () => {
|
|
|
|
|
|
const consoleErrorSpy = vi
|
|
|
|
|
|
.spyOn(console, "error")
|
|
|
|
|
|
.mockImplementation(() => {});
|
|
|
|
|
|
const { after } = await import("next/server");
|
|
|
|
|
|
const { suggestQuoteForOrder } = await import("./order-quote-link-service");
|
|
|
|
|
|
vi.mocked(suggestQuoteForOrder).mockClear();
|
|
|
|
|
|
vi.mocked(after).mockClear();
|
|
|
|
|
|
vi.mocked(after).mockImplementationOnce(() => {
|
|
|
|
|
|
throw new Error("`after` was called outside a request scope.");
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
|
|
|
|
|
|
await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" });
|
|
|
|
|
|
|
|
|
|
|
|
const r = await createOrder({
|
|
|
|
|
|
customer_id: "cust-x",
|
|
|
|
|
|
company_id: "comp-x",
|
|
|
|
|
|
items: [{ description: "M16 Bolt", quantity: 50 }],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(r.order.id).toBeDefined();
|
|
|
|
|
|
// Proves the catch fired AND ran the inline fallback — without
|
|
|
|
|
|
// the call assertion the test would pass on the spy alone even
|
2026-08-30 20:26:05 +00:00
|
|
|
|
// if `void runPostCreateMatchers()` was deleted.
|
2026-08-30 06:40:17 +00:00
|
|
|
|
expect(suggestQuoteForOrder).toHaveBeenCalledWith(r.order.id);
|
|
|
|
|
|
// The fallback logs its engagement so a silent switch to
|
|
|
|
|
|
// fire-and-forget is impossible to miss in production logs.
|
|
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
|
|
|
|
expect.stringContaining(
|
2026-08-30 20:26:05 +00:00
|
|
|
|
"[createOrder] after() unavailable, running post-create matchers inline",
|
2026-08-30 06:40:17 +00:00
|
|
|
|
),
|
|
|
|
|
|
expect.anything(),
|
|
|
|
|
|
);
|
|
|
|
|
|
consoleErrorSpy.mockRestore();
|
2026-08-29 12:22:21 +00:00
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-08-30 10:58:36 +00:00
|
|
|
|
|
|
|
|
|
|
describe("createOrder delivery-note auto-detection", () => {
|
|
|
|
|
|
it("also schedules a delivery-note match after the response", async () => {
|
|
|
|
|
|
const { suggestDeliveryNoteForOrder } = await import(
|
|
|
|
|
|
"./order-delivery-note-link-service"
|
|
|
|
|
|
);
|
|
|
|
|
|
vi.mocked(suggestDeliveryNoteForOrder).mockClear();
|
|
|
|
|
|
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
|
|
|
|
|
|
await pbMock.collection("companies").create({
|
|
|
|
|
|
id: "comp-x",
|
|
|
|
|
|
name: "Acme (Pty) Ltd",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const created = await createOrder({
|
|
|
|
|
|
customer_id: "cust-x",
|
|
|
|
|
|
company_id: "comp-x",
|
|
|
|
|
|
items: [{ description: "M16 Bolt", quantity: 50 }],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(suggestDeliveryNoteForOrder).toHaveBeenCalledWith(created.order.id);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("a delivery-note match failure does not prevent the quote suggestion from running", async () => {
|
|
|
|
|
|
// The point of the two independent try/catch blocks — a delivery-
|
|
|
|
|
|
// note match failure (e.g. PB down for delivery_notes collection)
|
|
|
|
|
|
// must still leave the quote suggestion able to land. The two
|
|
|
|
|
|
// matchers are independent: one failing cannot starve the other.
|
|
|
|
|
|
const { suggestDeliveryNoteForOrder } = await import(
|
|
|
|
|
|
"./order-delivery-note-link-service"
|
|
|
|
|
|
);
|
|
|
|
|
|
const { suggestQuoteForOrder } = await import("./order-quote-link-service");
|
|
|
|
|
|
vi.mocked(suggestDeliveryNoteForOrder).mockClear();
|
|
|
|
|
|
vi.mocked(suggestQuoteForOrder).mockClear();
|
|
|
|
|
|
vi.mocked(suggestDeliveryNoteForOrder).mockRejectedValueOnce(
|
|
|
|
|
|
new Error("DN lookup failed"),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
|
|
|
|
|
|
await pbMock.collection("companies").create({
|
|
|
|
|
|
id: "comp-x",
|
|
|
|
|
|
name: "Acme (Pty) Ltd",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const created = await createOrder({
|
|
|
|
|
|
customer_id: "cust-x",
|
|
|
|
|
|
company_id: "comp-x",
|
|
|
|
|
|
items: [{ description: "M16 Bolt", quantity: 50 }],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(suggestQuoteForOrder).toHaveBeenCalledWith(created.order.id);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("a quote-match failure does not prevent the delivery-note match from running", async () => {
|
|
|
|
|
|
// The reverse direction — the quote matcher throws, the delivery-
|
|
|
|
|
|
// note matcher still runs. Proves the second try/catch is its
|
|
|
|
|
|
// own scope and not a continuation of the first.
|
|
|
|
|
|
const { suggestDeliveryNoteForOrder } = await import(
|
|
|
|
|
|
"./order-delivery-note-link-service"
|
|
|
|
|
|
);
|
|
|
|
|
|
const { suggestQuoteForOrder } = await import("./order-quote-link-service");
|
|
|
|
|
|
vi.mocked(suggestDeliveryNoteForOrder).mockClear();
|
|
|
|
|
|
vi.mocked(suggestQuoteForOrder).mockClear();
|
|
|
|
|
|
vi.mocked(suggestQuoteForOrder).mockRejectedValueOnce(
|
|
|
|
|
|
new Error("quote lookup failed"),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
|
|
|
|
|
|
await pbMock.collection("companies").create({
|
|
|
|
|
|
id: "comp-x",
|
|
|
|
|
|
name: "Acme (Pty) Ltd",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const created = await createOrder({
|
|
|
|
|
|
customer_id: "cust-x",
|
|
|
|
|
|
company_id: "comp-x",
|
|
|
|
|
|
items: [{ description: "M16 Bolt", quantity: 50 }],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(suggestDeliveryNoteForOrder).toHaveBeenCalledWith(created.order.id);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-08-30 12:34:37 +00:00
|
|
|
|
|
|
|
|
|
|
describe("createOrderItemForOrder (mid-flight add)", () => {
|
|
|
|
|
|
// Task 2 of the non-invoiceable-order-extras plan — the pending
|
|
|
|
|
|
// gate that previously 409'd when siblings were po_generated is
|
|
|
|
|
|
// removed because adding a new row is independent of any in-flight
|
|
|
|
|
|
// PO's pricing/supplier reference. Three tests below cover the
|
|
|
|
|
|
// contract: (a) the gate is gone, (b) the new flag is authoritative
|
|
|
|
|
|
// for selling_price, (c) omission leaves the row billable.
|
|
|
|
|
|
|
|
|
|
|
|
it("adds a line to an order whose siblings are po_generated (was previously 409 order_locked)", async () => {
|
|
|
|
|
|
const { createOrderItemForOrder } = await import("./orders-service");
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1",
|
|
|
|
|
|
order_number: "ORD-1",
|
|
|
|
|
|
// Customer + company required by createOrder's lead row — skip
|
|
|
|
|
|
// for this targeted test by using a minimal stub.
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-existing",
|
|
|
|
|
|
order: "o-1",
|
|
|
|
|
|
description: "Bolt",
|
|
|
|
|
|
quantity: 50,
|
|
|
|
|
|
item_status: "po_generated",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await createOrderItemForOrder("o-1", {
|
|
|
|
|
|
description: "Washer",
|
|
|
|
|
|
quantity: 50,
|
|
|
|
|
|
cost_price: 5,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.failed).toEqual([]);
|
|
|
|
|
|
expect(result.created.length).toBe(1);
|
|
|
|
|
|
expect(result.created[0].description).toBe("Washer");
|
|
|
|
|
|
expect(result.orderLocked).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("exclude_from_invoice forces selling_price to 0 even when caller passes a non-zero price", async () => {
|
|
|
|
|
|
const { createOrderItemForOrder } = await import("./orders-service");
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1",
|
|
|
|
|
|
order_number: "ORD-1",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await createOrderItemForOrder("o-1", {
|
|
|
|
|
|
description: "Nut",
|
|
|
|
|
|
quantity: 50,
|
|
|
|
|
|
cost_price: 10,
|
|
|
|
|
|
markup: 50, // would compute selling_price = 15 without the flag
|
|
|
|
|
|
selling_price: 15, // would persist 15 without the flag
|
|
|
|
|
|
exclude_from_invoice: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.created[0].selling_price).toBe(0);
|
|
|
|
|
|
expect(result.created[0].markup).toBe(50); // markup stays as-is
|
|
|
|
|
|
|
|
|
|
|
|
// The flag itself persists on the secondary update — separate
|
|
|
|
|
|
// assertion because the `created[]` return shape doesn't include
|
|
|
|
|
|
// it.
|
|
|
|
|
|
const stored = (store["order_items"] as Map<string, Record<string, unknown>>).get(
|
|
|
|
|
|
result.created[0].id,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(stored?.exclude_from_invoice).toBe(true);
|
|
|
|
|
|
expect(stored?.selling_price).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("omitting exclude_from_invoice preserves the existing markup-based selling_price calculation", async () => {
|
|
|
|
|
|
const { createOrderItemForOrder } = await import("./orders-service");
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1",
|
|
|
|
|
|
order_number: "ORD-1",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await createOrderItemForOrder("o-1", {
|
|
|
|
|
|
description: "Bolt",
|
|
|
|
|
|
quantity: 50,
|
|
|
|
|
|
cost_price: 10,
|
|
|
|
|
|
markup: 25,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.created[0].selling_price).toBe(12.5); // 10 * 1.25
|
|
|
|
|
|
const stored = (store["order_items"] as Map<string, Record<string, unknown>>).get(
|
|
|
|
|
|
result.created[0].id,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(stored?.exclude_from_invoice).toBeFalsy();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-08-30 18:06:26 +00:00
|
|
|
|
|
|
|
|
|
|
describe("cancelOrderItem", () => {
|
|
|
|
|
|
let cancelOrderItem: typeof import("./orders-service").cancelOrderItem;
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
|
({ cancelOrderItem } = await import("./orders-service"));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("flips item_status to cancelled for a pending line", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({
|
|
|
|
|
|
id: "o-1", order_number: "ORD-1",
|
|
|
|
|
|
});
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "M2 flat washer", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await cancelOrderItem("o-1", "oi-1");
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.id).toBe("oi-1");
|
|
|
|
|
|
expect(result.item_status).toBe("cancelled");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("refuses 404 when the order doesn't exist", async () => {
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-orphan", order: "o-missing", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// The item belongs to o-missing; we're calling with o-1.
|
|
|
|
|
|
// Even though the item exists, the order check fires first.
|
|
|
|
|
|
try {
|
|
|
|
|
|
await cancelOrderItem("o-1", "oi-orphan");
|
|
|
|
|
|
expect.fail("should have thrown");
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
expect((err as Error & { status?: number }).status).toBe(404);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("refuses 404 when the item belongs to a different order", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1" });
|
|
|
|
|
|
await pbMock.collection("orders").create({ id: "o-2", order_number: "ORD-2" });
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Item is on o-1; we're calling with o-2.
|
|
|
|
|
|
try {
|
|
|
|
|
|
await cancelOrderItem("o-2", "oi-1");
|
|
|
|
|
|
expect.fail("should have thrown");
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
expect((err as Error & { status?: number }).status).toBe(404);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("refuses 409 when the line is already in a terminal status", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1" });
|
|
|
|
|
|
|
|
|
|
|
|
// 'invoiced' is in CANCEL_BLOCKED_ITEM_STATUSES — cancelling
|
|
|
|
|
|
// would orphan the customer invoice. Same for delivered /
|
|
|
|
|
|
// cancelled. One test per status would be overkill; pick
|
|
|
|
|
|
// 'invoiced' as the representative terminal state.
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-invoiced", order: "o-1", description: "x", item_status: "invoiced",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await cancelOrderItem("o-1", "oi-invoiced");
|
|
|
|
|
|
expect.fail("should have thrown");
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
expect((err as Error & { status?: number }).status).toBe(409);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("stamps the operator identity on notes when an actor is supplied", async () => {
|
|
|
|
|
|
await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1" });
|
|
|
|
|
|
await pbMock.collection("order_items").create({
|
|
|
|
|
|
id: "oi-1", order: "o-1", description: "x", item_status: "pending",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await cancelOrderItem("o-1", "oi-1", { actor: { id: "u-1", name: "Dave" } });
|
|
|
|
|
|
|
|
|
|
|
|
const stored = (store["order_items"] as Map<string, Record<string, unknown>>).get("oi-1");
|
|
|
|
|
|
expect(stored?.item_status).toBe("cancelled");
|
|
|
|
|
|
// The notes field is appended, not replaced — preserves any
|
|
|
|
|
|
// operator note that was already on the line.
|
|
|
|
|
|
const notes = String(stored?.notes ?? "");
|
|
|
|
|
|
expect(notes).toMatch(/cancelled by Dave/);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|