feat(stock-quick-draft): types + searchStockItemsForQuickDraft
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
14badb8aee
commit
eb64f03db6
2 changed files with 347 additions and 0 deletions
215
src/lib/server/stock-quick-draft-service.test.ts
Normal file
215
src/lib/server/stock-quick-draft-service.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
132
src/lib/server/stock-quick-draft-service.ts
Normal file
132
src/lib/server/stock-quick-draft-service.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
Loading…
Reference in a new issue