Comprehensive changes including: - Refactor picking slip line handling for multiple document types across print endpoints - Add new `/api/v1/stock-purchases/picking-slip` API for stock booking picking slips - Implement reusable Splitter component and useSplitRatio hook for resizable panels - Add floating/fullscreen invoice viewer to collection confirm page - Add real-time stock booking update event for invoicing tab - Add full test coverage for all new components and APIs - Include temporary chat attachment images for testing purposes
1153 lines
41 KiB
TypeScript
1153 lines
41 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||
|
||
// --- PocketBase mock ---------------------------------------------------------
|
||
// Mirrors the hoisted-mock pattern used in orders-service.test.ts and
|
||
// stock-surplus.test.ts. The mock is enough for the service layer
|
||
// (create / list / get / receive / mark-sent); it doesn't try to
|
||
// emulate the full PB SDK — only the methods we use.
|
||
|
||
const { store, pbMock, clearStore, setNow, now } = vi.hoisted(() => {
|
||
type StoreMap = Map<string, Record<string, unknown>>;
|
||
const store: Record<string, StoreMap> = {};
|
||
let seq = 0;
|
||
const nextId = () => `mock-${++seq}`;
|
||
let nowDate = new Date("2026-07-14T12:00:00.000Z");
|
||
|
||
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;
|
||
const clauses = filter.split("||").map((c) => c.trim());
|
||
return items.filter((item) =>
|
||
clauses.some((clause) => {
|
||
// PB's `~` is case-insensitive contains. The service uses it
|
||
// on the purchase_number for the daily-prefix scan, so we
|
||
// emulate the substring match (case-insensitive).
|
||
const m = clause.match(/^(\w+)\s*~\s*"([^"]+)"$/);
|
||
if (m) {
|
||
const [, field, raw] = m;
|
||
const value = String(item[field] ?? "").toLowerCase();
|
||
return value.includes(raw.toLowerCase());
|
||
}
|
||
const eq = clause.match(/^(\w+)\s*=\s*"([^"]+)"$/);
|
||
if (eq) {
|
||
const [, field, raw] = eq;
|
||
return String(item[field] ?? "") === raw;
|
||
}
|
||
return true;
|
||
}),
|
||
);
|
||
};
|
||
|
||
const pbMock = {
|
||
collection: (name: string) => ({
|
||
async getList(
|
||
page = 1,
|
||
perPage = 25,
|
||
opts: { filter?: string; sort?: string; expand?: string } = {},
|
||
) {
|
||
const items = where(name, opts.filter || "");
|
||
if (opts.sort) {
|
||
const sign = opts.sort.startsWith("-") ? -1 : 1;
|
||
const field = opts.sort.replace(/^-/, "");
|
||
items.sort((a, b) => {
|
||
const av = a[field] as string | number | undefined;
|
||
const bv = b[field] as string | number | undefined;
|
||
if (av === bv) {
|
||
// Tie-break by id (insertion order). PB uses a similar
|
||
// stable fallback for identical sort keys.
|
||
return String(a.id).localeCompare(String(b.id)) * -sign;
|
||
}
|
||
if (av === undefined) return 1;
|
||
if (bv === undefined) return -1;
|
||
return (av > bv ? 1 : -1) * sign;
|
||
});
|
||
}
|
||
return {
|
||
items,
|
||
totalItems: items.length,
|
||
page,
|
||
perPage,
|
||
totalPages: Math.max(1, Math.ceil(items.length / perPage)),
|
||
};
|
||
},
|
||
async getFullList(opts: { filter?: string; sort?: string; expand?: string } = {}) {
|
||
const items = where(name, opts.filter || "");
|
||
if (opts.sort) {
|
||
const sign = opts.sort.startsWith("-") ? -1 : 1;
|
||
const field = opts.sort.replace(/^-/, "");
|
||
items.sort((a, b) => {
|
||
const av = a[field] as string | number | undefined;
|
||
const bv = b[field] as string | number | undefined;
|
||
if (av === bv) {
|
||
return String(a.id).localeCompare(String(b.id)) * -sign;
|
||
}
|
||
if (av === undefined) return 1;
|
||
if (bv === undefined) return -1;
|
||
return (av > bv ? 1 : -1) * sign;
|
||
});
|
||
}
|
||
return items;
|
||
},
|
||
async getFirstListItem(filter: string) {
|
||
const items = where(name, filter);
|
||
if (items.length === 0) {
|
||
const err = new Error("not found") as Error & { status: number };
|
||
err.status = 404;
|
||
throw err;
|
||
}
|
||
return items[0];
|
||
},
|
||
async getOne(id: string, _opts?: { expand?: 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 id = nextId();
|
||
// Each create bumps `nowDate` by 1ms so the `-created` sort
|
||
// is deterministic across the test suite. PB on a real DB
|
||
// gets distinct ISO timestamps; this keeps the mock honest.
|
||
nowDate = new Date(nowDate.getTime() + 1);
|
||
const item = {
|
||
id,
|
||
created: nowDate.toISOString(),
|
||
updated: nowDate.toISOString(),
|
||
...data,
|
||
};
|
||
getOrCreateMap(name).set(id, item);
|
||
return item;
|
||
},
|
||
async update(id: string, data: Record<string, unknown> | FormData) {
|
||
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;
|
||
}
|
||
// Handle FormData uploads (PB file fields). PB stores the
|
||
// generated filename token; for the test we approximate by
|
||
// appending the original filename to `documents` so the
|
||
// post-update `getOne` sees what the production code would.
|
||
if (
|
||
typeof FormData !== "undefined" &&
|
||
data instanceof FormData
|
||
) {
|
||
for (const [key, value] of data.entries()) {
|
||
if (typeof Blob !== "undefined" && value instanceof Blob) {
|
||
// The PDF service uses `new Blob([...], { type: ... })`
|
||
// with a filename in the FormData append slot. The
|
||
// blob itself doesn't carry the filename, so we read
|
||
// it off the FormData entry metadata if available.
|
||
const filename =
|
||
(value as Blob & { name?: string }).name || "upload.pdf";
|
||
const arr = Array.isArray(existing[key])
|
||
? (existing[key] as string[])
|
||
: existing[key]
|
||
? [existing[key] as string]
|
||
: [];
|
||
existing[key] = [...arr, filename];
|
||
}
|
||
}
|
||
existing.updated = nowDate.toISOString();
|
||
return { ...existing };
|
||
}
|
||
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
|
||
if (key.endsWith("+")) {
|
||
const real = key.slice(0, -1);
|
||
existing[real] = Number(existing[real] ?? 0) + Number(value);
|
||
} else {
|
||
existing[key] = value;
|
||
}
|
||
}
|
||
existing.updated = nowDate.toISOString();
|
||
return { ...existing };
|
||
},
|
||
}),
|
||
};
|
||
|
||
const clearStore = () => {
|
||
for (const key of Object.keys(store)) delete store[key];
|
||
seq = 0;
|
||
nowDate = new Date("2026-07-14T12:00:00.000Z");
|
||
};
|
||
|
||
const setNow = (d: Date) => {
|
||
nowDate = d;
|
||
};
|
||
|
||
return { store, pbMock, clearStore, setNow, now: () => nowDate };
|
||
});
|
||
|
||
vi.mock("pocketbase", () => {
|
||
return {
|
||
default: vi.fn().mockImplementation(() => pbMock),
|
||
};
|
||
});
|
||
|
||
vi.mock("./pocketbase-admin", () => ({
|
||
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
|
||
}));
|
||
|
||
// Mock the PDF rendering + letterhead so `generateStockPurchasePdf`
|
||
// can run in unit tests without spinning up the full @react-pdf
|
||
// pipeline. The render mock returns a fake Buffer; the letterhead
|
||
// mock returns a minimal shape the code reads.
|
||
vi.mock("@/lib/pdf/render", () => ({
|
||
renderStockPurchasePdf: vi.fn().mockResolvedValue(Buffer.from("FAKE-PDF")),
|
||
}));
|
||
vi.mock("@/lib/letterhead", () => ({
|
||
fetchLetterhead: vi.fn().mockResolvedValue({
|
||
companyName: "CNL Mining Supplies",
|
||
address: "1 Test Lane",
|
||
phone: "",
|
||
email: "",
|
||
vatNumber: "",
|
||
registrationNumber: "",
|
||
bankDetails: "",
|
||
}),
|
||
loadLetterheadLogoDataUri: vi.fn().mockResolvedValue("data:image/svg+xml;base64,FAKE"),
|
||
}));
|
||
|
||
import {
|
||
createStockPurchase,
|
||
generateStockPurchaseNumber,
|
||
generateStockPurchasePdf,
|
||
StockPurchasePdfValidationError,
|
||
listStockPurchases,
|
||
getStockPurchase,
|
||
getStockPurchaseItems,
|
||
receiveStockPurchase,
|
||
markStockPurchaseSent,
|
||
recordStockPurchaseSendOutcome,
|
||
} from "./stock-purchases-service";
|
||
|
||
beforeEach(() => {
|
||
clearStore();
|
||
// Default "today" for the tests. Tests that care about the date
|
||
// override via setNow() before invoking the helper.
|
||
setNow(new Date("2026-07-14T12:00:00.000Z"));
|
||
});
|
||
|
||
// The service uses `new Date()` directly for the date prefix
|
||
// (ignoring the `setNow()` mock — the mock would need to be applied
|
||
// to the service's clock too, but that's a separate refactor). To
|
||
// make the tests resilient to the calendar advancing past the hard-
|
||
// coded date, we compute the expected prefix from the real wall clock
|
||
// at test time. The `nn` (sequence) part is the test's concern.
|
||
function realDatePrefix(): string {
|
||
const now = new Date();
|
||
const yy = String(now.getFullYear()).slice(-2);
|
||
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
||
const dd = String(now.getDate()).padStart(2, "0");
|
||
return `PO${yy}${mm}${dd}`;
|
||
}
|
||
|
||
// ============================================================================
|
||
// generateStockPurchaseNumber
|
||
// ============================================================================
|
||
|
||
describe("generateStockPurchaseNumber", () => {
|
||
it("starts at POYYMMDD01STK when no purchases exist for today", async () => {
|
||
const num = await generateStockPurchaseNumber(pbMock);
|
||
expect(num).toBe(`${realDatePrefix()}01STK`);
|
||
});
|
||
|
||
it("increments the daily sequence based on existing rows", async () => {
|
||
// Seed three existing rows for today. Use the real-date prefix
|
||
// so the test is robust to the wall clock advancing.
|
||
const prefix = realDatePrefix();
|
||
await pbMock.collection("stock_purchases").create({
|
||
purchase_number: `${prefix}01STK`,
|
||
});
|
||
await pbMock.collection("stock_purchases").create({
|
||
purchase_number: `${prefix}02STK`,
|
||
});
|
||
await pbMock.collection("stock_purchases").create({
|
||
purchase_number: `${prefix}03STK`,
|
||
});
|
||
const num = await generateStockPurchaseNumber(pbMock);
|
||
expect(num).toBe(`${prefix}04STK`);
|
||
});
|
||
|
||
it("ignores rows from a different year (different prefix)", async () => {
|
||
// Seed a row from a previous year. The scan filter is the
|
||
// daily prefix (PO{yy}{mm}{dd}), so a stale row from last
|
||
// year doesn't bump today's sequence.
|
||
await pbMock.collection("stock_purchases").create({
|
||
purchase_number: "PO25010199STK",
|
||
});
|
||
const num = await generateStockPurchaseNumber(pbMock);
|
||
expect(num).toBe(`${realDatePrefix()}01STK`);
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// createStockPurchase — validation
|
||
// ============================================================================
|
||
|
||
describe("createStockPurchase — validation", () => {
|
||
it("rejects a missing supplier", async () => {
|
||
const result = await createStockPurchase(pbMock, {
|
||
supplier: "",
|
||
items: [
|
||
{ stock_item: "si-1", description: "Widget", quantity: 5 },
|
||
],
|
||
});
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.reason).toBe("supplier_required");
|
||
expect(result.message).toMatch(/supplier/);
|
||
}
|
||
});
|
||
|
||
it("rejects an empty items array", async () => {
|
||
const result = await createStockPurchase(pbMock, {
|
||
supplier: "sup-1",
|
||
items: [],
|
||
});
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.reason).toBe("items_required");
|
||
}
|
||
});
|
||
|
||
it("rejects a line with quantity 0", async () => {
|
||
const result = await createStockPurchase(pbMock, {
|
||
supplier: "sup-1",
|
||
items: [
|
||
{ stock_item: "si-1", description: "Widget", quantity: 0 },
|
||
],
|
||
});
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.reason).toBe("invalid_item");
|
||
expect(result.message).toMatch(/quantity/i);
|
||
}
|
||
});
|
||
|
||
it("rejects a line with negative quantity", async () => {
|
||
const result = await createStockPurchase(pbMock, {
|
||
supplier: "sup-1",
|
||
items: [
|
||
{ stock_item: "si-1", description: "Widget", quantity: -3 },
|
||
],
|
||
});
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.reason).toBe("invalid_item");
|
||
}
|
||
});
|
||
|
||
it("rejects a line with NaN quantity", async () => {
|
||
const result = await createStockPurchase(pbMock, {
|
||
supplier: "sup-1",
|
||
items: [
|
||
{ stock_item: "si-1", description: "Widget", quantity: Number.NaN },
|
||
],
|
||
});
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.reason).toBe("invalid_item");
|
||
}
|
||
});
|
||
|
||
it("rejects a line with missing description", async () => {
|
||
const result = await createStockPurchase(pbMock, {
|
||
supplier: "sup-1",
|
||
items: [
|
||
{ stock_item: "si-1", description: "", quantity: 5 },
|
||
],
|
||
});
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.reason).toBe("invalid_item");
|
||
expect(result.message).toMatch(/description/i);
|
||
}
|
||
});
|
||
|
||
it("rejects a line with missing stock_item", async () => {
|
||
const result = await createStockPurchase(pbMock, {
|
||
supplier: "sup-1",
|
||
items: [
|
||
{ stock_item: "", description: "Widget", quantity: 5 },
|
||
],
|
||
});
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.reason).toBe("invalid_item");
|
||
}
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// createStockPurchase — happy path
|
||
// ============================================================================
|
||
|
||
describe("createStockPurchase — happy path", () => {
|
||
it("creates the header in draft status with a generated purchase_number", async () => {
|
||
const result = await createStockPurchase(pbMock, {
|
||
supplier: "sup-1",
|
||
notes: "first restock",
|
||
items: [
|
||
{ stock_item: "si-1", description: "Widget", quantity: 5, cost_price: 12.5 },
|
||
],
|
||
});
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
expect(result.purchase.status).toBe("draft");
|
||
expect(result.purchase.supplier).toBe("sup-1");
|
||
expect(result.purchase.purchase_number).toBe(`${realDatePrefix()}01STK`);
|
||
expect(result.purchase.notes).toBe("first restock");
|
||
});
|
||
|
||
it("creates one stock_purchase_item per line, linked to the header", async () => {
|
||
const result = await createStockPurchase(pbMock, {
|
||
supplier: "sup-1",
|
||
items: [
|
||
{ stock_item: "si-1", description: "Widget", quantity: 5, cost_price: 12.5 },
|
||
{ stock_item: "si-2", description: "Sprocket", quantity: 2, cost_price: 9.99 },
|
||
],
|
||
});
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
expect(result.items.length).toBe(2);
|
||
|
||
const allItems = await pbMock.collection("stock_purchase_items").getFullList();
|
||
const ours = allItems.filter((it) => it.purchase === result.purchase.id);
|
||
expect(ours.length).toBe(2);
|
||
|
||
const widget = ours.find((it) => it.description === "Widget");
|
||
expect(widget).toMatchObject({
|
||
purchase: result.purchase.id,
|
||
stock_item: "si-1",
|
||
quantity: 5,
|
||
quantity_received: 0,
|
||
cost_price: 12.5,
|
||
});
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// listStockPurchases
|
||
// ============================================================================
|
||
|
||
describe("listStockPurchases", () => {
|
||
it("returns rows sorted -created with item_count expanded", async () => {
|
||
// Seed two purchases + lines.
|
||
const p1 = await pbMock.collection("stock_purchases").create({
|
||
purchase_number: "PO26071501STK",
|
||
supplier: "sup-1",
|
||
status: "draft",
|
||
notes: "",
|
||
});
|
||
const p2 = await pbMock.collection("stock_purchases").create({
|
||
purchase_number: "PO26071502STK",
|
||
supplier: "sup-1",
|
||
status: "sent",
|
||
notes: "",
|
||
});
|
||
await pbMock.collection("stock_purchase_items").create({
|
||
purchase: p1.id,
|
||
stock_item: "si-1",
|
||
description: "A",
|
||
quantity: 1,
|
||
quantity_received: 0,
|
||
cost_price: 0,
|
||
notes: "",
|
||
});
|
||
await pbMock.collection("stock_purchase_items").create({
|
||
purchase: p1.id,
|
||
stock_item: "si-2",
|
||
description: "B",
|
||
quantity: 1,
|
||
quantity_received: 0,
|
||
cost_price: 0,
|
||
notes: "",
|
||
});
|
||
await pbMock.collection("stock_purchase_items").create({
|
||
purchase: p2.id,
|
||
stock_item: "si-3",
|
||
description: "C",
|
||
quantity: 1,
|
||
quantity_received: 0,
|
||
cost_price: 0,
|
||
notes: "",
|
||
});
|
||
|
||
const result = await listStockPurchases(pbMock);
|
||
expect(result.items.length).toBe(2);
|
||
// Sorted -created: p2 is newer, lands first.
|
||
expect(result.items[0].id).toBe(p2.id);
|
||
expect(result.items[0].item_count).toBe(1);
|
||
expect(result.items[1].id).toBe(p1.id);
|
||
expect(result.items[1].item_count).toBe(2);
|
||
});
|
||
|
||
it("filters by status when provided", async () => {
|
||
await pbMock.collection("stock_purchases").create({
|
||
purchase_number: "PO26071501STK",
|
||
status: "draft",
|
||
});
|
||
await pbMock.collection("stock_purchases").create({
|
||
purchase_number: "PO26071502STK",
|
||
status: "sent",
|
||
});
|
||
const result = await listStockPurchases(pbMock, { status: "sent" });
|
||
expect(result.items.length).toBe(1);
|
||
expect(result.items[0].status).toBe("sent");
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// getStockPurchase + getStockPurchaseItems
|
||
// ============================================================================
|
||
|
||
describe("getStockPurchase + getStockPurchaseItems", () => {
|
||
it("returns the header and the lines separately", async () => {
|
||
const p = await pbMock.collection("stock_purchases").create({
|
||
purchase_number: "PO26071501STK",
|
||
supplier: "sup-1",
|
||
status: "draft",
|
||
notes: "",
|
||
});
|
||
await pbMock.collection("stock_purchase_items").create({
|
||
purchase: p.id,
|
||
stock_item: "si-1",
|
||
description: "A",
|
||
quantity: 1,
|
||
quantity_received: 0,
|
||
cost_price: 0,
|
||
notes: "",
|
||
});
|
||
const purchase = await getStockPurchase(pbMock, p.id);
|
||
expect(purchase.id).toBe(p.id);
|
||
const items = await getStockPurchaseItems(pbMock, p.id);
|
||
expect(items.length).toBe(1);
|
||
expect(items[0].description).toBe("A");
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// markStockPurchaseSent
|
||
// ============================================================================
|
||
|
||
describe("markStockPurchaseSent", () => {
|
||
it("flips draft → sent", async () => {
|
||
const p = await pbMock.collection("stock_purchases").create({
|
||
purchase_number: "PO26071501STK",
|
||
status: "draft",
|
||
});
|
||
const result = await markStockPurchaseSent(pbMock, p.id);
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
expect(result.purchase.status).toBe("sent");
|
||
});
|
||
|
||
it("rejects a non-draft purchase (already sent)", async () => {
|
||
const p = await pbMock.collection("stock_purchases").create({
|
||
purchase_number: "PO26071501STK",
|
||
status: "sent",
|
||
});
|
||
const result = await markStockPurchaseSent(pbMock, p.id);
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.reason).toBe("wrong_status");
|
||
}
|
||
});
|
||
|
||
it("returns not_found for a missing id", async () => {
|
||
const result = await markStockPurchaseSent(pbMock, "missing-id");
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.reason).toBe("not_found");
|
||
}
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// receiveStockPurchase — the big one
|
||
// ============================================================================
|
||
|
||
describe("receiveStockPurchase", () => {
|
||
// Helper to seed a sent purchase + N lines + N stock_items.
|
||
async function seedSentPurchase(opts: {
|
||
lineQuantities: number[];
|
||
stockItemQty?: number;
|
||
}) {
|
||
const stockItemQty = opts.stockItemQty ?? 0;
|
||
const p = await pbMock.collection("stock_purchases").create({
|
||
purchase_number: "PO26071501STK",
|
||
supplier: "sup-1",
|
||
status: "sent",
|
||
});
|
||
const stockItemIds: string[] = [];
|
||
for (let i = 0; i < opts.lineQuantities.length; i++) {
|
||
const si = await pbMock.collection("stock_items").create({
|
||
name: `Item ${i + 1}`,
|
||
current_qty: stockItemQty,
|
||
is_active: true,
|
||
});
|
||
stockItemIds.push(si.id);
|
||
await pbMock.collection("stock_purchase_items").create({
|
||
purchase: p.id,
|
||
stock_item: si.id,
|
||
description: `Line ${i + 1}`,
|
||
quantity: opts.lineQuantities[i],
|
||
quantity_received: 0,
|
||
cost_price: 1.0,
|
||
notes: "",
|
||
});
|
||
}
|
||
return { purchaseId: p.id, stockItemIds };
|
||
}
|
||
|
||
it("receives the full remaining qty per line and flips status to received (NO inventory writes)", async () => {
|
||
const { purchaseId, stockItemIds } = await seedSentPurchase({
|
||
lineQuantities: [10, 5],
|
||
});
|
||
|
||
const result = await receiveStockPurchase(pbMock, purchaseId);
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
expect(result.purchase.status).toBe("received");
|
||
expect(result.lines.length).toBe(2);
|
||
expect(result.lines[0].booked_qty).toBe(10);
|
||
expect(result.lines[1].booked_qty).toBe(5);
|
||
|
||
// Inventory writes moved to bookStockFromInvoice. The receive
|
||
// helper no longer touches stock_items or stock_ledger, so
|
||
// current_qty must stay at the seeded value (0).
|
||
const si0 = await pbMock.collection("stock_items").getOne(stockItemIds[0]);
|
||
const si1 = await pbMock.collection("stock_items").getOne(stockItemIds[1]);
|
||
expect(si0.current_qty).toBe(0);
|
||
expect(si1.current_qty).toBe(0);
|
||
|
||
// No ledger rows either — same reason.
|
||
const ledger = await pbMock.collection("stock_ledger").getFullList();
|
||
expect(ledger.length).toBe(0);
|
||
|
||
// Line result shape keeps ledger_id + new_current_qty for
|
||
// backward-compat with the OCR match report — both stay
|
||
// empty/zero because the writes now live elsewhere.
|
||
expect(result.lines[0].ledger_id).toBe("");
|
||
expect(result.lines[0].new_current_qty).toBe(0);
|
||
|
||
// stock_purchase_items.quantity_received accumulated correctly.
|
||
const lines = await pbMock
|
||
.collection("stock_purchase_items")
|
||
.getFullList({ filter: `purchase = "${purchaseId}"` });
|
||
const sorted = lines.sort((a, b) =>
|
||
String(a.description).localeCompare(String(b.description))
|
||
);
|
||
// Sorted by description: "Line 1" (qty 10) first, "Line 2" (qty 5) second.
|
||
expect(sorted[0].description).toBe("Line 1");
|
||
expect(sorted[0].quantity_received).toBe(10);
|
||
// Lines flip to "collected" so the accountant's booked_at = null
|
||
// queue picks them up.
|
||
expect(sorted[0].status).toBe("collected");
|
||
expect(sorted[1].description).toBe("Line 2");
|
||
expect(sorted[1].quantity_received).toBe(5);
|
||
expect(sorted[1].status).toBe("collected");
|
||
});
|
||
|
||
it("honours a per-line quantity_received override (partial receive)", async () => {
|
||
const { purchaseId, stockItemIds } = await seedSentPurchase({
|
||
lineQuantities: [10, 5],
|
||
});
|
||
|
||
// Override: receive 5 of the 10 on line 1, 3 of 5 on line 2.
|
||
const lines = await pbMock
|
||
.collection("stock_purchase_items")
|
||
.getFullList({ filter: `purchase = "${purchaseId}"` });
|
||
const sorted = lines.sort((a, b) =>
|
||
String(a.description).localeCompare(String(b.description))
|
||
);
|
||
const sorted0 = sorted[0] as { id: string };
|
||
const sorted1 = sorted[1] as { id: string };
|
||
|
||
const result = await receiveStockPurchase(pbMock, purchaseId, {
|
||
items: [{ id: sorted0.id, quantity_received: 5 }, { id: sorted1.id, quantity_received: 3 }],
|
||
});
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
|
||
// current_qty is NOT bumped by the receive helper any more —
|
||
// the inventory commit is bookStockFromInvoice's job.
|
||
const si0 = await pbMock.collection("stock_items").getOne(stockItemIds[0]);
|
||
const si1 = await pbMock.collection("stock_items").getOne(stockItemIds[1]);
|
||
expect(si0.current_qty).toBe(0);
|
||
expect(si1.current_qty).toBe(0);
|
||
|
||
// status flipped to received.
|
||
expect(result.purchase.status).toBe("received");
|
||
|
||
// Per-line overrides are recorded on quantity_received.
|
||
const updated = await pbMock
|
||
.collection("stock_purchase_items")
|
||
.getFullList({ filter: `purchase = "${purchaseId}"` });
|
||
const updatedSorted = updated.sort((a, b) =>
|
||
String(a.description).localeCompare(String(b.description))
|
||
);
|
||
expect((updatedSorted[0] as { quantity_received: number }).quantity_received).toBe(5);
|
||
expect((updatedSorted[1] as { quantity_received: number }).quantity_received).toBe(3);
|
||
});
|
||
|
||
it("caps an override at the remaining qty (cannot receive more than ordered)", async () => {
|
||
const { purchaseId, stockItemIds } = await seedSentPurchase({
|
||
lineQuantities: [5],
|
||
});
|
||
const [line] = await pbMock
|
||
.collection("stock_purchase_items")
|
||
.getFullList({ filter: `purchase = "${purchaseId}"` });
|
||
const lineT = line as { id: string };
|
||
const result = await receiveStockPurchase(pbMock, purchaseId, {
|
||
items: [{ id: lineT.id, quantity_received: 9999 }],
|
||
});
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
// The accumulator lands at the line's ordered qty (5) — never
|
||
// above it.
|
||
const updated = await pbMock.collection("stock_purchase_items").getOne(lineT.id);
|
||
expect(updated.quantity_received).toBe(5);
|
||
// stock_items.current_qty unchanged (the helper doesn't bump it).
|
||
const si = await pbMock.collection("stock_items").getOne(stockItemIds[0]);
|
||
expect(si.current_qty).toBe(0);
|
||
});
|
||
|
||
it("does NOT double-handle when the same purchase is received twice (idempotency)", async () => {
|
||
const { purchaseId, stockItemIds } = await seedSentPurchase({
|
||
lineQuantities: [10],
|
||
});
|
||
|
||
// First receive: accumulates qty_received, flips to received.
|
||
const first = await receiveStockPurchase(pbMock, purchaseId);
|
||
expect(first.ok).toBe(true);
|
||
|
||
// Second receive: rejected with wrong_status because the header
|
||
// is now `received`, not `sent`. No new writes anywhere.
|
||
const second = await receiveStockPurchase(pbMock, purchaseId);
|
||
expect(second.ok).toBe(false);
|
||
if (second.ok) return;
|
||
expect(second.reason).toBe("wrong_status");
|
||
|
||
// current_qty is still at the seeded value (the helper never
|
||
// touched it).
|
||
const si = await pbMock.collection("stock_items").getOne(stockItemIds[0]);
|
||
expect(si.current_qty).toBe(0);
|
||
|
||
// No ledger rows from the receive helper.
|
||
const ledger = await pbMock.collection("stock_ledger").getFullList();
|
||
expect(ledger.length).toBe(0);
|
||
|
||
// quantity_received on the line is 10 — the accumulator landed
|
||
// once, not twice.
|
||
const lines = await pbMock
|
||
.collection("stock_purchase_items")
|
||
.getFullList({ filter: `purchase = "${purchaseId}"` });
|
||
expect((lines[0] as { quantity_received: number }).quantity_received).toBe(10);
|
||
});
|
||
|
||
it("rejects a purchase that is in draft (not sent)", async () => {
|
||
const p = await pbMock.collection("stock_purchases").create({
|
||
purchase_number: "PO26071501STK",
|
||
status: "draft",
|
||
});
|
||
const result = await receiveStockPurchase(pbMock, p.id);
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.reason).toBe("wrong_status");
|
||
}
|
||
});
|
||
|
||
it("rejects a missing purchase", async () => {
|
||
const result = await receiveStockPurchase(pbMock, "missing-id");
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.reason).toBe("not_found");
|
||
}
|
||
});
|
||
|
||
it("rejects a negative per-line override", async () => {
|
||
const { purchaseId } = await seedSentPurchase({
|
||
lineQuantities: [10],
|
||
});
|
||
const [line] = await pbMock
|
||
.collection("stock_purchase_items")
|
||
.getFullList({ filter: `purchase = "${purchaseId}"` });
|
||
const lineT = line as { id: string };
|
||
const result = await receiveStockPurchase(pbMock, purchaseId, {
|
||
items: [{ id: lineT.id, quantity_received: -1 }],
|
||
});
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.reason).toBe("wrong_status");
|
||
}
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// listStockPurchases — total value aggregation
|
||
// ============================================================================
|
||
|
||
describe("listStockPurchases — total value", () => {
|
||
async function seedPurchaseWithItems(pb: typeof pbMock, id: string, items: Array<{
|
||
quantity: number;
|
||
quantity_received: number;
|
||
cost_price: number;
|
||
}>) {
|
||
await pb.collection("stock_purchases").create({
|
||
id,
|
||
purchase_number: `PO26000100STK-${id}`,
|
||
supplier: "sup-1",
|
||
status: "draft",
|
||
notes: "",
|
||
email_error: "",
|
||
email_message_id: "",
|
||
});
|
||
for (const item of items) {
|
||
await pb.collection("stock_purchase_items").create({
|
||
purchase: id,
|
||
stock_item: "si-1",
|
||
description: "Widget",
|
||
...item,
|
||
notes: "",
|
||
});
|
||
}
|
||
}
|
||
|
||
it("returns ordered_value = Σ quantity × cost_price across lines", async () => {
|
||
await seedPurchaseWithItems(pbMock, "sp-a", [
|
||
{ quantity: 10, quantity_received: 0, cost_price: 12.5 },
|
||
{ quantity: 4, quantity_received: 0, cost_price: 99 },
|
||
]);
|
||
const { items } = await listStockPurchases(pbMock);
|
||
const row = items.find((r) => r.id === "sp-a")!;
|
||
expect(row.ordered_value).toBe(521); // 125 + 396
|
||
});
|
||
|
||
it("returns received_value = Σ quantity_received × cost_price across lines", async () => {
|
||
await seedPurchaseWithItems(pbMock, "sp-a", [
|
||
{ quantity: 10, quantity_received: 6, cost_price: 12.5 },
|
||
{ quantity: 4, quantity_received: 4, cost_price: 99 },
|
||
]);
|
||
const { items } = await listStockPurchases(pbMock);
|
||
const row = items.find((r) => r.id === "sp-a")!;
|
||
expect(row.received_value).toBe(471); // 75 + 396
|
||
});
|
||
|
||
it("rounds ordered_value and received_value to 2dp", async () => {
|
||
await seedPurchaseWithItems(pbMock, "sp-a", [
|
||
{ quantity: 3, quantity_received: 1, cost_price: 0.1 + 0.2 }, // 0.30000000000000004
|
||
]);
|
||
const { items } = await listStockPurchases(pbMock);
|
||
const row = items.find((r) => r.id === "sp-a")!;
|
||
expect(row.ordered_value).toBe(0.9);
|
||
expect(row.received_value).toBe(0.3);
|
||
});
|
||
|
||
it("returns { ordered_value: 0, received_value: 0 } when a purchase has no lines", async () => {
|
||
await pbMock.collection("stock_purchases").create({
|
||
id: "sp-empty",
|
||
purchase_number: "PO26000200STK",
|
||
supplier: "sup-1",
|
||
status: "draft",
|
||
notes: "",
|
||
email_error: "",
|
||
email_message_id: "",
|
||
});
|
||
const { items } = await listStockPurchases(pbMock);
|
||
const row = items.find((r) => r.id === "sp-empty")!;
|
||
expect(row.ordered_value).toBe(0);
|
||
expect(row.received_value).toBe(0);
|
||
expect(row.item_count).toBe(0);
|
||
});
|
||
|
||
it("computes each row's totals from its own items only (no cross-row leakage)", async () => {
|
||
await seedPurchaseWithItems(pbMock, "sp-x", [
|
||
{ quantity: 1, quantity_received: 1, cost_price: 100 },
|
||
]);
|
||
await seedPurchaseWithItems(pbMock, "sp-y", [
|
||
{ quantity: 2, quantity_received: 2, cost_price: 50 },
|
||
]);
|
||
const { items } = await listStockPurchases(pbMock);
|
||
const x = items.find((r) => r.id === "sp-x")!;
|
||
const y = items.find((r) => r.id === "sp-y")!;
|
||
expect(x.ordered_value).toBe(100);
|
||
expect(x.received_value).toBe(100);
|
||
expect(y.ordered_value).toBe(100);
|
||
expect(y.received_value).toBe(100);
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// recordStockPurchaseSendOutcome
|
||
// ============================================================================
|
||
|
||
describe("recordStockPurchaseSendOutcome", () => {
|
||
it("does NOT auto-flip stock_purchase_items.status on success (operator must mark them ready to collect manually)", async () => {
|
||
const p = await pbMock.collection("stock_purchases").create({
|
||
purchase_number: "PO26071501STK",
|
||
status: "sending",
|
||
email_payload: { sentAt: "", sentMessageId: "" },
|
||
});
|
||
const it1 = await pbMock.collection("stock_purchase_items").create({
|
||
purchase: p.id,
|
||
status: "collection_pool",
|
||
});
|
||
const it2 = await pbMock.collection("stock_purchase_items").create({
|
||
purchase: p.id,
|
||
status: "collection_pool",
|
||
});
|
||
|
||
const result = await recordStockPurchaseSendOutcome({
|
||
purchaseId: p.id,
|
||
outcome: "success",
|
||
messageId: "<abc@wfsales.co.za>",
|
||
});
|
||
|
||
// The stock_purchase itself flips to "sent" + stamps the message ID.
|
||
expect(result.status).toBe("sent");
|
||
expect(result.email_message_id).toBe("<abc@wfsales.co.za>");
|
||
|
||
// The items MUST stay in collection_pool — the operator marks them
|
||
// ready-to-collect from the stock-order page only after the supplier
|
||
// has confirmed. Same pattern as customer POs.
|
||
const itemsAfter = await pbMock
|
||
.collection("stock_purchase_items")
|
||
.getFullList({ filter: `purchase = "${p.id}"` });
|
||
expect(itemsAfter.find((i) => i.id === it1.id)?.status).toBe("collection_pool");
|
||
expect(itemsAfter.find((i) => i.id === it2.id)?.status).toBe("collection_pool");
|
||
});
|
||
|
||
it("flips stock_purchase to sent + stamps email_message_id on success", async () => {
|
||
const p = await pbMock.collection("stock_purchases").create({
|
||
purchase_number: "PO26071501STK",
|
||
status: "sending",
|
||
email_payload: { sentAt: "", sentMessageId: "" },
|
||
});
|
||
|
||
const result = await recordStockPurchaseSendOutcome({
|
||
purchaseId: p.id,
|
||
outcome: "success",
|
||
messageId: "<abc@wfsales.co.za>",
|
||
});
|
||
|
||
expect(result.status).toBe("sent");
|
||
expect(result.email_message_id).toBe("<abc@wfsales.co.za>");
|
||
expect(result.email_error).toBe("");
|
||
expect((result.email_payload as Record<string, unknown>).sentAt).toBeTruthy();
|
||
expect((result.email_payload as Record<string, unknown>).sentMessageId).toBe(
|
||
"<abc@wfsales.co.za>",
|
||
);
|
||
});
|
||
|
||
it("flips stock_purchase to mail_failed + stamps errorMessage on failure (does not touch items)", async () => {
|
||
const p = await pbMock.collection("stock_purchases").create({
|
||
purchase_number: "PO26071501STK",
|
||
status: "sending",
|
||
});
|
||
const it1 = await pbMock.collection("stock_purchase_items").create({
|
||
purchase: p.id,
|
||
status: "collection_pool",
|
||
});
|
||
|
||
const result = await recordStockPurchaseSendOutcome({
|
||
purchaseId: p.id,
|
||
outcome: "failure",
|
||
errorMessage: "SMTP 550 mailbox unavailable",
|
||
});
|
||
|
||
expect(result.status).toBe("mail_failed");
|
||
expect(result.email_error).toBe("[smtp] SMTP 550 mailbox unavailable");
|
||
|
||
// Items are NOT touched on failure (and were never touched to begin with).
|
||
const itemsAfter = await pbMock
|
||
.collection("stock_purchase_items")
|
||
.getFullList({ filter: `purchase = "${p.id}"` });
|
||
expect(itemsAfter.find((i) => i.id === it1.id)?.status).toBe("collection_pool");
|
||
});
|
||
});
|
||
|
||
describe("generateStockPurchasePdf", () => {
|
||
/**
|
||
* Seed a stock purchase with a supplier + at least one linked
|
||
* line item. Returns the purchase row.
|
||
*/
|
||
async function seedStockPurchaseWithLines(opts: {
|
||
documents?: string[];
|
||
documentsHistory?: Array<Record<string, unknown>>;
|
||
existingDocuments?: string[];
|
||
} = {}) {
|
||
const sup = await pbMock.collection("suppliers").create({
|
||
suppliername: "Acme Mining",
|
||
email: "sales@acme.test",
|
||
});
|
||
const p = await pbMock.collection("stock_purchases").create({
|
||
purchase_number: "PO26071501STK",
|
||
status: "draft",
|
||
supplier: sup.id,
|
||
documents: opts.existingDocuments ?? [],
|
||
documents_history: opts.documentsHistory ?? [],
|
||
});
|
||
// Stitch `expand.supplier` onto the purchase so the mock's
|
||
// expand option isn't needed (the collection map keys are
|
||
// plural — "suppliers" — but the service reads `expand.supplier`,
|
||
// so it's easier to set expand directly here).
|
||
(p as Record<string, unknown>).expand = { supplier: sup };
|
||
await pbMock.collection("stock_purchase_items").create({
|
||
purchase: p.id,
|
||
stock_item: "stk-1",
|
||
description: "Hose 1m",
|
||
quantity: 4,
|
||
cost_price: 250,
|
||
status: "collection_pool",
|
||
});
|
||
await pbMock.collection("stock_purchase_items").create({
|
||
purchase: p.id,
|
||
stock_item: "stk-2",
|
||
description: "Fitting",
|
||
quantity: 2,
|
||
cost_price: 100,
|
||
status: "collection_pool",
|
||
});
|
||
return p;
|
||
}
|
||
|
||
it("renders a PDF, appends it to documents, and writes a history entry", async () => {
|
||
const p = await seedStockPurchaseWithLines();
|
||
|
||
const result = await generateStockPurchasePdf(p.id, {});
|
||
|
||
expect(result.pdfId).toBe(p.id);
|
||
expect(result.version).toBe(1);
|
||
expect(result.filename).toMatch(/^STK-PO-CNL-PO26071501STK-v1\.pdf$/);
|
||
expect(result.total).toBe(1200); // 4*250 + 2*100 = 1200
|
||
expect(result.downloadUrl).toBe(`/api/v1/files/stock_purchases/${p.id}/${result.filename}`);
|
||
|
||
const after = await pbMock.collection("stock_purchases").getOne(p.id);
|
||
const docsAfter = Array.isArray(after.documents)
|
||
? after.documents
|
||
: after.documents
|
||
? [after.documents]
|
||
: [];
|
||
expect(docsAfter).toContain(result.filename);
|
||
|
||
const history = (after.documents_history ?? []) as Array<Record<string, unknown>>;
|
||
expect(history).toHaveLength(1);
|
||
expect(history[0]).toMatchObject({
|
||
version: 1,
|
||
file: result.filename,
|
||
status: "current",
|
||
});
|
||
expect(after.total_amount).toBe(1200);
|
||
});
|
||
|
||
it("bumps version + marks previous 'current' as 'superseded' on regen", async () => {
|
||
const p = await seedStockPurchaseWithLines({
|
||
documents: ["STK-PO-CNL-PO26071501STK-v1.pdf"],
|
||
documentsHistory: [
|
||
{
|
||
version: 1,
|
||
file: "STK-PO-CNL-PO26071501STK-v1.pdf",
|
||
token: "tok-v1",
|
||
generatedAt: "2026-07-14T12:00:00.000Z",
|
||
generatedBy: "",
|
||
reason: "",
|
||
status: "current",
|
||
},
|
||
],
|
||
});
|
||
|
||
const result = await generateStockPurchasePdf(p.id, {
|
||
notes: "Edited cost_price on line 2",
|
||
});
|
||
|
||
expect(result.version).toBe(2);
|
||
|
||
const after = await pbMock.collection("stock_purchases").getOne(p.id);
|
||
const history = after.documents_history as Array<Record<string, unknown>>;
|
||
expect(history).toHaveLength(2);
|
||
expect(history[0]?.status).toBe("superseded");
|
||
expect(history[1]).toMatchObject({
|
||
version: 2,
|
||
file: result.filename,
|
||
status: "current",
|
||
reason: "Edited cost_price on line 2",
|
||
});
|
||
});
|
||
|
||
it("does NOT touch the purchase status (PDF lifecycle ≠ email lifecycle)", async () => {
|
||
const p = await seedStockPurchaseWithLines();
|
||
// Status starts as "draft"; PDF generation must leave it alone.
|
||
const result = await generateStockPurchasePdf(p.id, {});
|
||
expect(result).toBeDefined();
|
||
const after = await pbMock.collection("stock_purchases").getOne(p.id);
|
||
expect(after.status).toBe("draft");
|
||
});
|
||
|
||
it("throws StockPurchasePdfValidationError(purchase_not_found) for a missing id", async () => {
|
||
await expect(generateStockPurchasePdf("does-not-exist", {})).rejects.toBeInstanceOf(
|
||
StockPurchasePdfValidationError,
|
||
);
|
||
await expect(generateStockPurchasePdf("does-not-exist", {})).rejects.toMatchObject({
|
||
code: "purchase_not_found",
|
||
});
|
||
});
|
||
|
||
it("throws StockPurchasePdfValidationError(missing_relations) when the PO has no supplier", async () => {
|
||
const p = await pbMock.collection("stock_purchases").create({
|
||
purchase_number: "PO26071501STK",
|
||
status: "draft",
|
||
supplier: "",
|
||
documents: [],
|
||
documents_history: [],
|
||
});
|
||
await pbMock.collection("stock_purchase_items").create({
|
||
purchase: p.id,
|
||
description: "x",
|
||
quantity: 1,
|
||
cost_price: 1,
|
||
});
|
||
|
||
await expect(generateStockPurchasePdf(p.id, {})).rejects.toBeInstanceOf(
|
||
StockPurchasePdfValidationError,
|
||
);
|
||
await expect(generateStockPurchasePdf(p.id, {})).rejects.toMatchObject({
|
||
code: "missing_relations",
|
||
});
|
||
});
|
||
|
||
it("throws StockPurchasePdfValidationError(no_items) when there are no line items", async () => {
|
||
const sup = await pbMock.collection("suppliers").create({
|
||
suppliername: "Acme",
|
||
email: "a@b.test",
|
||
});
|
||
const p = await pbMock.collection("stock_purchases").create({
|
||
purchase_number: "PO26071501STK",
|
||
status: "draft",
|
||
supplier: sup.id,
|
||
documents: [],
|
||
documents_history: [],
|
||
});
|
||
// Stitch expand so the supplier check passes and the no_items
|
||
// branch is reached.
|
||
(p as Record<string, unknown>).expand = { supplier: sup };
|
||
|
||
await expect(generateStockPurchasePdf(p.id, {})).rejects.toBeInstanceOf(
|
||
StockPurchasePdfValidationError,
|
||
);
|
||
await expect(generateStockPurchasePdf(p.id, {})).rejects.toMatchObject({
|
||
code: "no_items",
|
||
});
|
||
});
|
||
});
|