src/app/api/v1/sprint/attachment-counts/route.ts
- Chunk itemIds into batches of 80 before building the PocketBase filter;
the live cap was hit at ~115 IDs × 15 chars and surfaced as a
generic Next 500 ("Something went wrong while processing your
request.") that hid the real cause. 7 chunks for the sprint
page's max of 500 items.
src/app/layout.tsx
- Add metadataBase derived from NEXT_PUBLIC_SITE_URL /
NEXT_PUBLIC_POCKETBASE_URL. Silences the per-page
"metadataBase not set" warning during dev renders and gives
OG / Twitter cards a stable canonical base.
src/app/api/v1/{quotes,order-items,purchase-orders,stock,
stock-purchases,users,sprint,driver,ai-insights}/route.ts (NEW)
- Add the missing top-level collection routes the agent API
needs. seven are straight passthroughs to their PB collection;
/sprint proxies to quote_items with optional claimStatus and
category query params; /driver lists PB users (no role field
in PB so callers narrow with ?filter=); /ai-insights returns
a roll-up count + most-recent regenerate timestamp across the
four collections that carry ai_* JSON fields.
src/app/api/v1/collection-pool/{route.ts,route.test.ts},
src/app/(app)/collection-pool/{page.tsx,page.test.tsx,
view-model.ts,view-model.test.ts,components.tsx} (DELETED)
- Drop the collection_pool feature entirely: PB has no
collection_pool collection on the live instance
(pb:migrate:orders-v2 was never run), the codebase comment
in purchase-orders-service.ts says "now-removed", and the
sidebar entrypoint has been migrated to /sprint + /deliveries.
The replacement operational status is the `item_status =
"collection_pool"` enum value on order_items, which stays.
src/lib/server/orders-service.ts (+ test), src/lib/pb-helpers.ts,
src/components/sidebar.tsx, src/components/ui/assign-driver-dialog.tsx,
src/app/(app)/collection-map/sidebar.tsx,
src/app/api/mcp/route.ts (+ test) (MODIFIED)
- Remove listCollectionPool + getCollectionPool +
updateCollectionPoolStatus, the /collection-pool sidebar entry,
the deep-link in assign-driver-dialog and collection-map
sidebar, and the collection_pool.list MCP tool (the tool,
handler, and test mock).
scripts/migrations/2026-07-26-fix-quote-counter-and-reassign-numbers.ts (NEW)
- One-shot migration script that:
1. Reads every quote, sorts by created ASC.
2. Reassigns clean Q202607001 … Q202607{N} over the whole
table (including the e+22 garbage and the legacy
Qnnnnnn rows).
3. Sets quote_counters.(year=2026, month=7).last_seq = N
so the next /api/v1/quotes/next-number returns a clean,
atomic, unique number.
Default mode = dry-run; pass --apply to write.
Already executed against the live cloud PB during this session
(verified: 200/200 rows updated, counter at 200, next-number
returns Q202607201, Q202607202, Q202607203 on consecutive calls).
AGENTS.md (MODIFIED)
- Add data-layer-discipline block matching the style of the
three existing blocks. Documents which layer is legacy (pb-helpers)
vs current (lib/server/*-service.ts via /api/v1/*), gives the
migration order (customers → orders → quotes → suppliers →
companies → rest), and includes the grep one-liner future AI
agents should run before extending pb-helpers.
Not in this commit (your WIP, untouched):
src/app/(app)/{dashboard,delivery-notes,layout,orders/[id],
quotes/[id],quotes/approvals,quotes,sprint}/page.tsx,
src/app/(auth)/login/page.tsx, src/components/charts/*,
src/components/{company-form,customer-form,quote-email-card,
sprint-item-pricing-modal,supplier-form,theme-bootstrap,
theme-picker}*.{tsx,test.tsx}, src/app/api/invoicing/delivery-note/*
src/app/api/v1/quotes/[id]/pdf/route.ts, src/app/globals.css,
src/lib/{item-status-ui,pdf/quote-document,quote-status*,themes,
types}.ts, src/lib/server/{order-item-status*,quotes-service}.ts,
src/styles/themes.css, APP_CAPABILITIES.md, README.md,
docs/SHIPPED.md, scripts/smoke/screenshot-pages.mjs,
and the new untracked files under docs/, scripts/migrations/
2026-07-19…25, src/app/{(app)/deliveries,(app)/delivery-notes,
api/v1/ai-insights/rerun,api/v1/deliveries,api/v1/delivery-notes,
api/v1/quotes/[id]/partial-approve,api/v1/quotes/[id]/split},
src/components/{ai-insights-panel,ai-insights-renderer,
ai-item-history-trigger,brand-stripe,delivery-notes,ui/stock-item-search,
ui/duplicate-warning,ui/duplicate-warning.test}.tsx, supplier-form.test.tsx,
src/lib/{pb-helpers-duplicate.test,server/deliveries-service*,
server/delivery-notes-service*}, public/wfdash-logo*.png,
opencode.jsonc.
451 lines
15 KiB
TypeScript
451 lines
15 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
|
|
// --- PocketBase mock ---------------------------------------------------------
|
|
// 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}`;
|
|
|
|
const getOrCreateMap = (name: string): StoreMap => {
|
|
if (!store[name]) store[name] = new Map();
|
|
return store[name];
|
|
};
|
|
|
|
const where = (name: string, filter: string): Record<string, unknown>[] => {
|
|
const items = Array.from(getOrCreateMap(name).values());
|
|
if (!filter || !filter.trim()) return items;
|
|
const clauses = filter.split("&&").map((c) => c.trim());
|
|
return items.filter((item) =>
|
|
clauses.every((clause) => {
|
|
const m = clause.match(/^(\w+)\s*=\s*"?([^"]+)"?$/);
|
|
if (!m) return true;
|
|
const [ field, raw] = m;
|
|
const value = raw.replace(/^"|"$/g, "");
|
|
if (field === "year" || field === "month") {
|
|
return Number(item[field]) === Number(value);
|
|
}
|
|
return String(item[field] ?? "") === value;
|
|
}),
|
|
);
|
|
};
|
|
|
|
const pbMock = {
|
|
collection: (name: string) => ({
|
|
async getList(
|
|
_page = 1,
|
|
_perPage = 25,
|
|
opts: { filter?: string; sort?: string } = {},
|
|
) {
|
|
const items = where(name, opts.filter || "");
|
|
if (opts.sort) {
|
|
const sign = opts.sort.startsWith("-") ? -1 : 1;
|
|
const field = opts.sort.replace(/^-/, "");
|
|
items.sort((a, b) => {
|
|
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;
|
|
});
|
|
}
|
|
return { items, totalItems: items.length, page: 1, perPage: _perPage };
|
|
},
|
|
async getFullList() {
|
|
return where(name, "");
|
|
},
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
const item = {
|
|
id: nextId(),
|
|
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 };
|
|
},
|
|
}),
|
|
};
|
|
|
|
const clearStore = () => {
|
|
for (const key of Object.keys(store)) delete store[key];
|
|
seq = 0;
|
|
};
|
|
|
|
return { store, pbMock, clearStore };
|
|
});
|
|
|
|
vi.mock("pocketbase", () => {
|
|
return {
|
|
default: vi.fn().mockImplementation(() => pbMock),
|
|
};
|
|
});
|
|
|
|
vi.mock("./pocketbase-admin", () => ({
|
|
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
|
|
}));
|
|
|
|
import {
|
|
createOrderFromQuote,
|
|
getOrderItemsCollectionName,
|
|
listCollectionPool,
|
|
} from "./orders-service";
|
|
|
|
beforeEach(() => {
|
|
clearStore();
|
|
});
|
|
|
|
// --- 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 () => {
|
|
// 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",
|
|
});
|
|
|
|
const result = await createOrderFromQuote("quote-abc");
|
|
expect(result.already_exists).toBe(true);
|
|
expect(result.order.id).toBe("order-1");
|
|
expect(result.created_items).toBe(0);
|
|
});
|
|
|
|
it("throws when the quote has no items", async () => {
|
|
await pbMock.collection("quotes").create({
|
|
id: "quote-empty",
|
|
company: "comp-1",
|
|
customer: "cust-1",
|
|
notes: "",
|
|
});
|
|
// No quote_items rows → service must throw.
|
|
await expect(createOrderFromQuote("quote-empty")).rejects.toThrow(
|
|
/no items to convert/,
|
|
);
|
|
});
|
|
|
|
it("creates an order, copies every quote_item, and sets the right defaults", async () => {
|
|
// Seed the dependencies in the mock store.
|
|
await pbMock.collection("quotes").create({
|
|
id: "quote-99",
|
|
company: "comp-7",
|
|
customer: "cust-3",
|
|
notes: "Rush",
|
|
});
|
|
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: "",
|
|
});
|
|
|
|
const result = await createOrderFromQuote("quote-99");
|
|
|
|
expect(result.already_exists).toBe(false);
|
|
expect(result.created_items).toBe(2);
|
|
|
|
// 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({
|
|
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",
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
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({
|
|
id: "quote-seq",
|
|
company: "c",
|
|
customer: null,
|
|
notes: "",
|
|
});
|
|
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: "",
|
|
});
|
|
|
|
await createOrderFromQuote("quote-seq");
|
|
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);
|
|
});
|
|
|
|
it("writes to the collection named by ORDER_ITEMS_COLLECTION env var", async () => {
|
|
process.env.ORDER_ITEMS_COLLECTION = "order_items_v2";
|
|
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: "",
|
|
});
|
|
|
|
const result = await createOrderFromQuote("q");
|
|
expect(result.order_items_collection).toBe("order_items_v2");
|
|
|
|
const v2Rows = await pbMock.collection("order_items_v2").getFullList();
|
|
expect(v2Rows.length).toBe(1);
|
|
expect(v2Rows[0].order).toBeDefined();
|
|
|
|
delete process.env.ORDER_ITEMS_COLLECTION;
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|