feat(stock-items): listStockItemsForStocktake service helper
Returns active stock items sorted by name with supplier expanded, for the new Stocktake PDF download endpoint. Distinct from the dashboard's low-stock query: stocktake wants every SKU alphabetically, not the under-stocked ones first. Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
e75f0d6157
commit
88678cea49
2 changed files with 111 additions and 0 deletions
69
src/lib/server/stock-items-service.test.ts
Normal file
69
src/lib/server/stock-items-service.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
// --- PocketBase mock ---------------------------------------------------------
|
||||
//
|
||||
// Hoisted mock so the service module can call `pb.collection(...)`.
|
||||
// We seed an in-memory map of `stock_items` rows.
|
||||
|
||||
const items = new Map<string, Record<string, unknown>>();
|
||||
|
||||
const pb = {
|
||||
collection: (id: string) => ({
|
||||
getFullList: async (opts: { filter?: string; sort?: string; expand?: string } = {}) => {
|
||||
if (id !== "stock_items") throw new Error(`unexpected collection: ${id}`);
|
||||
let rows = Array.from(items.values());
|
||||
if (opts.filter?.includes("is_active = true")) {
|
||||
rows = rows.filter((r) => r.is_active === true);
|
||||
}
|
||||
if (opts.sort) {
|
||||
const dir = opts.sort.startsWith("-") ? -1 : 1;
|
||||
const key = opts.sort.replace(/^-/, "");
|
||||
rows = [...rows].sort((a, b) => {
|
||||
const av = String(a[key] ?? "");
|
||||
const bv = String(b[key] ?? "");
|
||||
return dir * av.localeCompare(bv);
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
||||
getAdminPocketBase: async () => pb,
|
||||
}));
|
||||
|
||||
import { listStockItemsForStocktake } from "./stock-items-service";
|
||||
|
||||
function seed(id: string, fields: Record<string, unknown>) {
|
||||
items.set(id, { id, ...fields });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
items.clear();
|
||||
});
|
||||
|
||||
// --- Tests -------------------------------------------------------------------
|
||||
|
||||
describe("listStockItemsForStocktake", () => {
|
||||
it("returns active stock items sorted by name with supplier expanded", async () => {
|
||||
seed("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: true, supplier: "sup-1" });
|
||||
seed("si-b", { name: "Bracket", sku: "BRK-1", location: "A2", unit: "ea", current_qty: 20, is_active: true, supplier: "sup-1" });
|
||||
|
||||
const result = await listStockItemsForStocktake(pb as never);
|
||||
expect(result.map((r) => r.name)).toEqual(["Bracket", "Widget"]);
|
||||
});
|
||||
|
||||
it("returns an empty array when there are no active stock items", async () => {
|
||||
seed("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: false, supplier: "sup-1" });
|
||||
const result = await listStockItemsForStocktake(pb as never);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("excludes items with is_active = false", async () => {
|
||||
seed("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: true, supplier: "sup-1" });
|
||||
seed("si-b", { name: "Bracket", sku: "BRK-1", location: "A2", unit: "ea", current_qty: 20, is_active: false, supplier: "sup-1" });
|
||||
const result = await listStockItemsForStocktake(pb as never);
|
||||
expect(result.map((r) => r.name)).toEqual(["Widget"]);
|
||||
});
|
||||
});
|
||||
42
src/lib/server/stock-items-service.ts
Normal file
42
src/lib/server/stock-items-service.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { StockItem, Supplier } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* PB client type kept loose so both the real `PocketBase` instance
|
||||
* and the test stub satisfy the contract. The service only uses
|
||||
* `pb.collection("stock_items").getFullList(...)`.
|
||||
*/
|
||||
export type StockItemsPb = {
|
||||
collection: (id: string) => {
|
||||
getFullList: <T = unknown>(opts?: Record<string, unknown>) => Promise<T[]>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch every active stock item sorted by name, with the supplier
|
||||
* relation pre-expanded. The "Stocktake PDF" download endpoint
|
||||
* uses this to render the printable stocktake list.
|
||||
*
|
||||
* Differs from the inline `stock_items` reads on the dashboard
|
||||
* page (which filter by `is_active = true` AND sort by `current_qty`)
|
||||
* because a stocktake wants all SKUs alphabetically, not the
|
||||
* low-stock ones first.
|
||||
*
|
||||
* No pagination — the stocktake is always the whole list. If the
|
||||
* catalogue grows past `getFullList`'s default page size, PB will
|
||||
* fetch subsequent pages transparently. The PDF renderer downstream
|
||||
* is the only consumer; if it becomes a bottleneck, this is the
|
||||
* place to swap in a paginated stream.
|
||||
*/
|
||||
export async function listStockItemsForStocktake(
|
||||
pb: StockItemsPb
|
||||
): Promise<StockItem[]> {
|
||||
return (await pb.collection("stock_items").getFullList<StockItem>({
|
||||
filter: "is_active = true",
|
||||
sort: "name",
|
||||
expand: "supplier",
|
||||
})) as StockItem[];
|
||||
}
|
||||
|
||||
// Re-export the type so the route can alias it without poking
|
||||
// into `@/lib/types` directly.
|
||||
export type { StockItem, Supplier };
|
||||
Loading…
Reference in a new issue