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:
SJCoetz 2026-08-03 16:04:46 +02:00
parent e75f0d6157
commit 88678cea49
2 changed files with 111 additions and 0 deletions

View 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"]);
});
});

View 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 };