feat(stock-quick-draft): getStockItemSupplierHistory

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-30 21:50:04 +02:00
parent eb64f03db6
commit 3eef60f34c
2 changed files with 255 additions and 5 deletions

View file

@ -24,7 +24,7 @@ const { store, pbMock, clearStore } = vi.hoisted(() => {
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*(.+)$/);
const m = clause.match(/^(\w+)\s*(=|!=|~|>=?|<=?)\s*(.+)$/);
if (!m) return true;
const [, field, op, rawValue] = m;
let value: string | number | boolean;
@ -47,6 +47,8 @@ const { store, pbMock, clearStore } = vi.hoisted(() => {
actual.toLowerCase().includes(value.toLowerCase())
);
}
if (op === ">") return Number(actual) > Number(value);
if (op === "<") return Number(actual) < Number(value);
return true;
});
}),
@ -58,7 +60,7 @@ const { store, pbMock, clearStore } = vi.hoisted(() => {
async getList(
_page = 1,
perPage = 25,
opts: { filter?: string; sort?: string } = {},
opts: { filter?: string; sort?: string; expand?: string } = {},
) {
let items = where(name, opts.filter || "");
if (opts.sort) {
@ -75,10 +77,28 @@ const { store, pbMock, clearStore } = vi.hoisted(() => {
});
}
const sliced = items.slice(0, perPage);
if (opts.expand) {
const expandFields = opts.expand.split(",").map((f) => f.trim());
for (const item of sliced) {
const expandObj: Record<string, unknown> = {};
for (const field of expandFields) {
const relatedId = item[field];
if (relatedId === undefined || relatedId === null) continue;
for (const collName of Object.keys(store)) {
const related = store[collName].get(String(relatedId));
if (related) {
expandObj[field] = related;
break;
}
}
}
item.expand = expandObj;
}
}
return { items: sliced, totalItems: items.length, page: 1, perPage };
},
async getFullList() {
return where(name, "");
async getFullList(opts: { filter?: string } = {}) {
return where(name, opts.filter || "");
},
async getOne(id: string) {
const item = getOrCreateMap(name).get(id);
@ -139,7 +159,10 @@ vi.mock("./pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
}));
import { searchStockItemsForQuickDraft } from "./stock-quick-draft-service";
import {
getStockItemSupplierHistory,
searchStockItemsForQuickDraft,
} from "./stock-quick-draft-service";
beforeEach(() => clearStore());
@ -212,4 +235,139 @@ describe("searchStockItemsForQuickDraft", () => {
unit: "ea",
});
});
});
describe("getStockItemSupplierHistory", () => {
it("returns empty when the item has no purchase history", async () => {
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
name: "115mm Cutting Disc",
is_active: true,
});
const history = await getStockItemSupplierHistory(pbMock as never, "item-1");
expect(history).toEqual([]);
});
it("aggregates by supplier: lastCost, lastDate, timesBought", async () => {
const supplierId = (
await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
).id;
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
name: "115mm Cutting Disc",
is_active: true,
});
const purchase1 = await pbMock.collection("stock_purchases").create({
supplier: supplierId,
status: "received",
purchase_number: "PO26070101STK",
created: "2026-06-10T08:00:00.000Z",
});
const purchase2 = await pbMock.collection("stock_purchases").create({
supplier: supplierId,
status: "received",
purchase_number: "PO26071401STK",
created: "2026-07-14T08:00:00.000Z",
});
await pbMock.collection("stock_purchase_items").create({
purchase: purchase1.id,
stock_item: "item-1",
cost_price: 18.5,
quantity: 10,
quantity_received: 10,
});
await pbMock.collection("stock_purchase_items").create({
purchase: purchase2.id,
stock_item: "item-1",
cost_price: 19.0,
quantity: 20,
quantity_received: 20,
});
const history = await getStockItemSupplierHistory(pbMock as never, "item-1");
expect(history).toHaveLength(1);
expect(history[0]).toEqual({
supplierId,
supplierName: "Steel & Pipe Co",
lastCost: 19.0,
lastDate: "2026-07-14T08:00:00.000Z",
timesBought: 2,
});
});
it("returns one entry per supplier, sorted by lastDate desc", async () => {
const supA = (
await pbMock.collection("suppliers").create({ suppliername: "Supplier A" })
).id;
const supB = (
await pbMock.collection("suppliers").create({ suppliername: "Supplier B" })
).id;
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
is_active: true,
});
const purchaseA = await pbMock.collection("stock_purchases").create({
supplier: supA,
status: "received",
purchase_number: "PO26070101STK",
created: "2026-06-10T08:00:00.000Z",
});
const purchaseB = await pbMock.collection("stock_purchases").create({
supplier: supB,
status: "received",
purchase_number: "PO26071501STK",
created: "2026-07-15T08:00:00.000Z",
});
await pbMock.collection("stock_purchase_items").create({
purchase: purchaseA.id,
stock_item: "item-1",
cost_price: 18.5,
quantity_received: 10,
});
await pbMock.collection("stock_purchase_items").create({
purchase: purchaseB.id,
stock_item: "item-1",
cost_price: 21.0,
quantity_received: 5,
});
const history = await getStockItemSupplierHistory(pbMock as never, "item-1");
expect(history).toHaveLength(2);
expect(history[0].supplierId).toBe(supB);
expect(history[1].supplierId).toBe(supA);
});
it("only counts lines with quantity_received > 0", async () => {
const supA = (
await pbMock.collection("suppliers").create({ suppliername: "Supplier A" })
).id;
await pbMock.collection("stock_items").create({
id: "item-1",
sku: "STK-CD-115",
is_active: true,
});
const purchase = await pbMock.collection("stock_purchases").create({
supplier: supA,
status: "draft",
purchase_number: "PO26070101STK",
created: "2026-07-01T08:00:00.000Z",
});
await pbMock.collection("stock_purchase_items").create({
purchase: purchase.id,
stock_item: "item-1",
cost_price: 18.5,
quantity_received: 0,
});
const history = await getStockItemSupplierHistory(pbMock as never, "item-1");
expect(history).toEqual([]);
});
});

View file

@ -129,4 +129,96 @@ export async function searchStockItemsForQuickDraft(
description: String(row.description ?? ""),
unit: row.unit ? String(row.unit) : undefined,
}));
}
/* ============================================================================
* getStockItemSupplierHistory
* ========================================================================== */
/**
* Returns the per-supplier purchase history for a `stock_item`.
* Reads `stock_purchase_items` joined through `stock_purchases` so
* we can aggregate by supplier. Filters to lines that have actually
* been received (`quantity_received > 0`) so drafts don't pollute
* the "we bought this from X before" list.
*
* The supplier is expanded via `purchase.supplier`; if the
* `stock_purchases.supplier` row no longer exists in `suppliers`,
* the line is silently skipped (we can't show a name for it).
*/
export async function getStockItemSupplierHistory(
pb: StockQuickDraftPb,
stockItemId: string,
): Promise<SupplierHistoryEntry[]> {
const rows = await pb.collection(STOCK_PURCHASE_ITEMS).getFullList({
filter: `stock_item = "${stockItemId}" && quantity_received > 0`,
});
const purchaseIds = Array.from(
new Set(
rows
.map((r) => (r.purchase ? String(r.purchase) : null))
.filter((id): id is string => id !== null),
),
);
if (purchaseIds.length === 0) return [];
// Fetch purchases in one call (with supplier expanded). We use
// a single getList rather than N getOnes — cheaper on PB.
const purchasesResult = await pb
.collection(STOCK_PURCHASES)
.getList(1, purchaseIds.length, {
filter: purchaseIds.map((id) => `id = "${id}"`).join(" || "),
expand: "supplier",
});
const purchaseById = new Map<string, Record<string, unknown>>();
for (const p of purchasesResult.items) purchaseById.set(String(p.id), p);
type Agg = {
supplierId: string;
supplierName: string;
lastCost: number;
lastDate: string;
timesBought: number;
};
const bySupplier = new Map<string, Agg>();
for (const row of rows) {
const purchaseId = row.purchase ? String(row.purchase) : null;
if (!purchaseId) continue;
const purchase = purchaseById.get(purchaseId);
if (!purchase) continue;
const expand = purchase.expand as
| { supplier?: { id: string; suppliername: string } }
| undefined;
const supplier = expand?.supplier;
if (!supplier) continue;
const supplierId = String(supplier.id);
const cost = typeof row.cost_price === "number" ? row.cost_price : 0;
const created = String(purchase.created ?? "");
const existing = bySupplier.get(supplierId);
if (!existing) {
bySupplier.set(supplierId, {
supplierId,
supplierName: String(supplier.suppliername ?? ""),
lastCost: cost,
lastDate: created,
timesBought: 1,
});
} else {
existing.timesBought += 1;
if (created > existing.lastDate) {
existing.lastDate = created;
existing.lastCost = cost;
}
}
}
return Array.from(bySupplier.values()).sort((a, b) =>
a.lastDate < b.lastDate ? 1 : a.lastDate > b.lastDate ? -1 : 0,
);
}