feat(stock-quick-draft): types + searchStockItemsForQuickDraft

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-30 21:38:58 +02:00
parent 14badb8aee
commit eb64f03db6
2 changed files with 347 additions and 0 deletions

View file

@ -0,0 +1,215 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
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;
// Split on &&, then for each conjunct split on ||. Parens are
// grouping-only and can be stripped — none of the filters used
// here have nested groups mixing ANDs and ORs.
const conjuncts = filter.split("&&").map((c) => c.trim());
return items.filter((item) =>
conjuncts.every((conjunct) => {
const disjuncts = conjunct.split("||").map((d) => d.trim());
return disjuncts.some((d) => {
const clause = d.replace(/^\(/, "").replace(/\)$/, "").trim();
const m = clause.match(/^(\w+)\s*(=|!=|~)\s*(.+)$/);
if (!m) return true;
const [, field, op, rawValue] = m;
let value: string | number | boolean;
if (rawValue.startsWith('"') && rawValue.endsWith('"')) {
value = rawValue.slice(1, -1);
} else if (rawValue === "true") {
value = true;
} else if (rawValue === "false") {
value = false;
} else {
value = Number(rawValue);
}
const actual = item[field];
if (op === "=") return actual == value;
if (op === "!=") return actual != value;
if (op === "~") {
return (
typeof actual === "string" &&
typeof value === "string" &&
actual.toLowerCase().includes(value.toLowerCase())
);
}
return true;
});
}),
);
};
const pbMock = {
collection: (name: string) => ({
async getList(
_page = 1,
perPage = 25,
opts: { filter?: string; sort?: string } = {},
) {
let items = where(name, opts.filter || "");
if (opts.sort) {
const desc = opts.sort.startsWith("-");
const field = desc ? opts.sort.slice(1) : opts.sort;
items = [...items].sort((a, b) => {
const av = a[field] as number | string | undefined;
const bv = b[field] as number | string | undefined;
if (av === bv) return 0;
if (av === undefined) return 1;
if (bv === undefined) return -1;
const cmp = av < bv ? -1 : 1;
return desc ? -cmp : cmp;
});
}
const sliced = items.slice(0, perPage);
return { items: sliced, totalItems: items.length, page: 1, perPage };
},
async getFullList() {
return where(name, "");
},
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>) {
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)) {
existing[key] = value;
}
existing.updated = new Date().toISOString();
return { ...existing };
},
async delete(id: string) {
const map = getOrCreateMap(name);
if (!map.has(id)) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
map.delete(id);
return { id };
},
}),
};
const clearStore = () => {
for (const key of Object.keys(store)) delete store[key];
seq = 0;
};
return { store, pbMock, clearStore };
});
vi.mock("pocketbase", () => ({ default: vi.fn().mockImplementation(() => pbMock) }));
vi.mock("./pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
}));
import { searchStockItemsForQuickDraft } from "./stock-quick-draft-service";
beforeEach(() => clearStore());
describe("searchStockItemsForQuickDraft", () => {
it("returns active stock_items whose sku or name matches the query", async () => {
await pbMock.collection("stock_items").create({
sku: "STK-CD-115",
name: "115mm Cutting Disc",
is_active: true,
});
await pbMock.collection("stock_items").create({
sku: "STK-WR-6",
name: "Wire rope 6mm",
is_active: true,
});
await pbMock.collection("stock_items").create({
sku: "STK-CD-230",
name: "230mm Cutting Disc",
is_active: true,
});
const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10);
expect(results).toHaveLength(2);
expect(results.map((r) => r.sku).sort()).toEqual(["STK-CD-115", "STK-CD-230"]);
});
it("excludes inactive items", async () => {
await pbMock.collection("stock_items").create({
sku: "STK-CD-115",
name: "115mm Cutting Disc",
is_active: false,
});
const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10);
expect(results).toEqual([]);
});
it("clamps limit to a max of 25", async () => {
for (let i = 0; i < 30; i++) {
await pbMock.collection("stock_items").create({
sku: `STK-X-${i}`,
name: `Cutting disc variant ${i}`,
is_active: true,
});
}
const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 100);
expect(results).toHaveLength(25);
});
it("returns narrow projection fields", async () => {
const created = await pbMock.collection("stock_items").create({
sku: "STK-CD-115",
name: "115mm Cutting Disc",
description: "For angle grinder",
unit: "ea",
is_active: true,
});
const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10);
expect(results[0]).toEqual({
id: created.id,
sku: "STK-CD-115",
name: "115mm Cutting Disc",
description: "For angle grinder",
unit: "ea",
});
});
});

View file

@ -0,0 +1,132 @@
/**
* Stock Quick-Draft service — backs the /stock-orders modal that lets
* a buyer add items to draft stock_purchases in an item-first rhythm.
*
* Reuses `generateStockPurchaseNumber` from `stock-purchases-service.ts`
* for the find-or-create open-draft step; no schema changes, no new
* statuses. Admin PB only (consistent with the other service modules).
*/
import type PocketBase from "pocketbase";
import { getAdminPocketBase } from "./pocketbase-admin";
import { generateStockPurchaseNumber } from "./stock-purchases-service";
const STOCK_ITEMS = "stock_items";
const STOCK_PURCHASES = "stock_purchases";
const STOCK_PURCHASE_ITEMS = "stock_purchase_items";
const SEARCH_LIMIT_MAX = 25;
const SEARCH_LIMIT_DEFAULT = 10;
/* ============================================================================
* Minimal PB client shape this module needs.
*
* The route passes in the real `getAdminPocketBase()`; the unit test
* passes in a tiny mock. Only the methods we actually call are typed.
* ========================================================================== */
export interface StockQuickDraftPb {
collection: (name: string) => {
getList: (
page?: number,
perPage?: number,
opts?: { filter?: string; sort?: string; expand?: string }
) => Promise<{
items: Array<Record<string, unknown>>;
totalItems: number;
page: number;
perPage: number;
totalPages: number;
}>;
getFullList: (opts?: { filter?: string }) => Promise<Array<Record<string, unknown>>>;
getOne: (id: string, opts?: { expand?: string }) => Promise<Record<string, unknown>>;
create: (data: Record<string, unknown>) => Promise<Record<string, unknown>>;
update: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown>>;
delete: (id: string) => Promise<Record<string, unknown>>;
};
}
/* ============================================================================
* Public types
* ========================================================================== */
export interface StockItemSearchResult {
id: string;
sku: string;
name: string;
description: string;
unit?: string;
}
export interface SupplierHistoryEntry {
supplierId: string;
supplierName: string;
lastCost: number;
lastDate: string;
timesBought: number;
}
export interface AddQuickDraftLineNewStockItem {
name: string;
sku: string;
description?: string;
unit?: string;
}
export interface AddQuickDraftLineInput {
supplierId: string;
stockItemId?: string;
quantity: number;
costPrice: number;
newStockItem?: AddQuickDraftLineNewStockItem;
}
export type AddQuickDraftLineResult =
| { ok: true; purchaseId: string; purchaseNumber: string; lineId: string; createdDraft: boolean }
| { ok: false; reason: string; message: string };
export type RemoveQuickDraftLineResult =
| { ok: true }
| { ok: false; reason: string; message: string };
/* ============================================================================
* Helpers
* ========================================================================== */
async function resolvePb(pb?: StockQuickDraftPb): Promise<StockQuickDraftPb> {
if (pb) return pb;
return (await getAdminPocketBase()) as unknown as StockQuickDraftPb;
}
/* ============================================================================
* searchStockItemsForQuickDraft
* ========================================================================== */
/**
* Typeahead search over `stock_items`. Matches `sku` OR `name` with
* PocketBase's `~` (contains, case-insensitive). Excludes inactive
* items. Limit defaults to 10, clamped to a max of 25.
*
* Returns a narrow projection — only the fields the modal needs.
*/
export async function searchStockItemsForQuickDraft(
pb: StockQuickDraftPb,
q: string,
limit?: number,
): Promise<StockItemSearchResult[]> {
const safeLimit = Math.min(Math.max(limit ?? SEARCH_LIMIT_DEFAULT, 1), SEARCH_LIMIT_MAX);
const escaped = q.replace(/"/g, '\\"');
const filter = `is_active=true && (sku ~ "${escaped}" || name ~ "${escaped}")`;
const result = await pb.collection(STOCK_ITEMS).getList(1, safeLimit, {
filter,
sort: "name",
});
return result.items.map((row) => ({
id: String(row.id),
sku: String(row.sku ?? ""),
name: String(row.name ?? ""),
description: String(row.description ?? ""),
unit: row.unit ? String(row.unit) : undefined,
}));
}