feat(stock-quick-draft): getStockItemSupplierHistory
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
eb64f03db6
commit
3eef60f34c
2 changed files with 255 additions and 5 deletions
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue