fix(stock-purchase-send): render auto-PDF in-process instead of HTTP self-call
The send-email route's auto-PDF block did an internal \`fetch(/api/v1/stock-purchases/[id]/pdf)\` to render a PDF when the operator hit Send on a stock PO without one. Cloudflare's loopback filter rejects that self-call with a network-level error before Next.js sees it, surfacing as 'fetch failed' in the modal (status rolled to draft, route returned 500 with code 'pdf_failed'). Extract the PDF generation into \`generateStockPurchasePdf\` on the service layer so the send-email route can render the PDF in-process. Same pattern as the customer-PO flow's \`generatePurchaseOrderPdf\`. The route stays a thin shim that maps \`StockPurchasePdfValidationError\` to 422. Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
86534f738a
commit
91d8363380
4 changed files with 490 additions and 204 deletions
|
|
@ -1,16 +1,10 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
import {
|
||||
fetchLetterhead,
|
||||
loadLetterheadLogoDataUri,
|
||||
} from "@/lib/letterhead";
|
||||
import { renderStockPurchasePdf } from "@/lib/pdf/render";
|
||||
import type {
|
||||
StockPurchase,
|
||||
StockPurchaseItem,
|
||||
Supplier,
|
||||
PurchaseOrderDocumentEntry,
|
||||
} from "@/lib/types";
|
||||
generateStockPurchasePdf,
|
||||
StockPurchasePdfValidationError,
|
||||
} from "@/lib/server/stock-purchases-service";
|
||||
import type { StockPurchase, PurchaseOrderDocumentEntry } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Stock Purchase Order PDF API — `POST /api/v1/stock-purchases/[id]/pdf`
|
||||
|
|
@ -18,26 +12,12 @@ import type {
|
|||
* Body: `{ notes?: string }` — optional free-text reason, required on
|
||||
* regenerate (v≥2) so the audit trail explains why the PDF changed.
|
||||
*
|
||||
* Mirrors the customer-PO PDF route
|
||||
* (`src/app/api/v1/purchase-orders/[id]/pdf/route.ts`) line-for-line
|
||||
* except:
|
||||
* - reads from `stock_purchases` + `stock_purchase_items`
|
||||
* - renders `StockPurchaseDocument` (no customer/order context)
|
||||
* - drops the resulting file into `stock_purchases.documents`
|
||||
*
|
||||
* Flow:
|
||||
* 1. Validate PO exists + has supplier (422 with code)
|
||||
* 2. Load lines linked to this PO (`stock_purchase_items.purchase = id`)
|
||||
* 3. Fetch the hard-coded CNL letterhead (stock POs are always
|
||||
* issued by CNL — same as customer POs)
|
||||
* 4. Inline CNL logo as base64 SVG data URI (same @react-pdf workaround)
|
||||
* 5. Compute subtotal (locked snapshot for storage)
|
||||
* 6. Compute next version = max(documents_history.version) + 1
|
||||
* 7. Render PDF in-process via @react-pdf (Node runtime)
|
||||
* 8. Append the new PDF to `stock_purchases.documents` (file, multi)
|
||||
* AND append a metadata entry to `stock_purchases.documents_history`
|
||||
* in a single PB update.
|
||||
* 9. Return { pdfId, version, filename, downloadUrl, total }
|
||||
* Thin shim — the actual PDF rendering lives in
|
||||
* `generateStockPurchasePdf` (`src/lib/server/stock-purchases-service.ts`).
|
||||
* That same function is called from the send-email route's auto-PDF
|
||||
* block so we don't HTTP self-call (Cloudflare rejects the loopback
|
||||
* with a network-level error, surfacing as "fetch failed" to the
|
||||
* operator).
|
||||
*
|
||||
* `GET /api/v1/stock-purchases/[id]/pdf?version=N`
|
||||
*
|
||||
|
|
@ -54,7 +34,11 @@ export const dynamic = "force-dynamic";
|
|||
|
||||
type ValidationErrorBody = {
|
||||
error: string;
|
||||
code: "missing_relations" | "no_items" | "no_letterhead";
|
||||
code:
|
||||
| "missing_relations"
|
||||
| "no_items"
|
||||
| "no_letterhead"
|
||||
| "purchase_not_found";
|
||||
details?: {
|
||||
purchaseId?: string;
|
||||
supplierId?: string;
|
||||
|
|
@ -80,153 +64,23 @@ export async function POST(
|
|||
}
|
||||
|
||||
try {
|
||||
const pb = await getAdminPocketBase();
|
||||
|
||||
// ─── 1. PO + supplier (all expanded server-side) ───
|
||||
const purchase = await pb
|
||||
.collection("stock_purchases")
|
||||
.getOne<StockPurchase>(purchaseId, { expand: "supplier" });
|
||||
|
||||
const supplier = purchase.expand?.supplier as Supplier | undefined;
|
||||
if (!supplier) {
|
||||
const body: ValidationErrorBody = {
|
||||
error:
|
||||
"Stock purchase is missing the supplier relation. Cannot render PDF.",
|
||||
code: "missing_relations",
|
||||
details: {
|
||||
purchaseId: purchase.id,
|
||||
supplierId: purchase.supplier || undefined,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(body, { status: 422 });
|
||||
}
|
||||
|
||||
// ─── 2. Lines linked to this PO ───
|
||||
const items = await pb
|
||||
.collection("stock_purchase_items")
|
||||
.getFullList<StockPurchaseItem>({
|
||||
filter: `purchase = "${purchaseId}"`,
|
||||
sort: "created",
|
||||
expand: "stock_item",
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
const body: ValidationErrorBody = {
|
||||
error:
|
||||
"This stock PO has no line items attached. Add lines on the " +
|
||||
"stock-order detail page before generating a PDF.",
|
||||
code: "no_items",
|
||||
details: { purchaseId: purchase.id },
|
||||
};
|
||||
return NextResponse.json(body, { status: 422 });
|
||||
}
|
||||
|
||||
// ─── 3. Letterhead — always CNL ───
|
||||
let letterhead;
|
||||
try {
|
||||
letterhead = await fetchLetterhead("CNL");
|
||||
} catch (err) {
|
||||
const body: ValidationErrorBody = {
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "CNL letterhead not found in letterhead_settings.",
|
||||
code: "no_letterhead",
|
||||
details: { purchaseId: purchase.id },
|
||||
};
|
||||
return NextResponse.json(body, { status: 422 });
|
||||
}
|
||||
|
||||
// ─── 4. Logo ───
|
||||
const logoDataUri = await loadLetterheadLogoDataUri("CNL");
|
||||
|
||||
// ─── 5. Compute totals (locked snapshot for storage) ───
|
||||
const subtotal = Math.round(
|
||||
items.reduce(
|
||||
(s, it) =>
|
||||
s + (Number(it.quantity) || 0) * (Number(it.cost_price) || 0),
|
||||
0
|
||||
) * 100
|
||||
) / 100;
|
||||
|
||||
// ─── 6. Next version number ───
|
||||
const history: PurchaseOrderDocumentEntry[] = purchase.documents_history ?? [];
|
||||
const nextVersion =
|
||||
history.length > 0
|
||||
? Math.max(...history.map((h) => h.version)) + 1
|
||||
: 1;
|
||||
|
||||
// Mark every previously-"current" history entry as "superseded".
|
||||
const newHistoryBase: PurchaseOrderDocumentEntry[] = history.map((h) =>
|
||||
h.status === "current" ? { ...h, status: "superseded" } : h
|
||||
);
|
||||
|
||||
// ─── 7. Render PDF ───
|
||||
const generatedAtIso = new Date().toISOString();
|
||||
const pdfBuffer = await renderStockPurchasePdf({
|
||||
purchase,
|
||||
items,
|
||||
supplier,
|
||||
letterhead,
|
||||
logoDataUri,
|
||||
generatedAtIso,
|
||||
totalAmount: subtotal,
|
||||
const result = await generateStockPurchasePdf(purchaseId, {
|
||||
notes: body.notes,
|
||||
generatedBy: body.generatedBy,
|
||||
});
|
||||
|
||||
// ─── 8. Append PDF + history entry to stock_purchases.documents ───
|
||||
const safePo = String(purchase.purchase_number || purchaseId).replace(
|
||||
/[^\w.-]/g,
|
||||
"_"
|
||||
);
|
||||
const filename = `STK-PO-CNL-${safePo}-v${nextVersion}.pdf`;
|
||||
|
||||
// PB file fields accept an array of File/Blob. Append to the
|
||||
// existing array so every regen is preserved.
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
"documents",
|
||||
new Blob([new Uint8Array(pdfBuffer)], { type: "application/pdf" }),
|
||||
filename
|
||||
);
|
||||
const updated = await pb
|
||||
.collection("stock_purchases")
|
||||
.update<{ documents: string[] | string }>(purchaseId, formData);
|
||||
const docsRaw = updated?.documents ?? [];
|
||||
const docs = Array.isArray(docsRaw) ? docsRaw : [docsRaw];
|
||||
const newToken = docs[docs.length - 1] ?? "";
|
||||
|
||||
const newHistoryEntry: PurchaseOrderDocumentEntry = {
|
||||
version: nextVersion,
|
||||
file: filename,
|
||||
token: newToken,
|
||||
generatedAt: generatedAtIso,
|
||||
generatedBy: body.generatedBy || "",
|
||||
reason: (body.notes || "").slice(0, 500),
|
||||
status: "current",
|
||||
};
|
||||
|
||||
await pb.collection("stock_purchases").update(purchaseId, {
|
||||
documents_history: [...newHistoryBase, newHistoryEntry],
|
||||
});
|
||||
|
||||
// ─── 9. Persist total_amount (locked snapshot). Status is NOT
|
||||
// touched here — the send flow owns the draft → sending →
|
||||
// sent state machine. Stock PO receive is also separate.
|
||||
await pb.collection("stock_purchases").update(purchaseId, {
|
||||
total_amount: subtotal,
|
||||
});
|
||||
|
||||
// ─── 10. Response ───
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
pdfId: purchaseId,
|
||||
version: nextVersion,
|
||||
filename,
|
||||
downloadUrl: `/api/v1/files/stock_purchases/${purchaseId}/${filename}`,
|
||||
total: subtotal,
|
||||
generatedAt: generatedAtIso,
|
||||
...result,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof StockPurchasePdfValidationError) {
|
||||
const responseBody: ValidationErrorBody = {
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
details: err.details,
|
||||
};
|
||||
return NextResponse.json(responseBody, { status: 422 });
|
||||
}
|
||||
console.error("[stock-po-pdf] render failed:", err);
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
sendStockPurchaseEmail,
|
||||
generateStockPurchasePdf,
|
||||
StockPurchasePdfValidationError,
|
||||
type SendStockPurchaseEmailPayload,
|
||||
} from "@/lib/server/stock-purchases-service";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
|
|
@ -105,38 +107,18 @@ export async function POST(
|
|||
// version already exists, we use the latest. If the operator
|
||||
// edits the costs and re-sends, the next "Send" creates a new
|
||||
// version on top of the old one.
|
||||
//
|
||||
// Calls the rendering logic in-process via
|
||||
// `generateStockPurchasePdf` rather than HTTP self-calling
|
||||
// `/api/v1/stock-purchases/[id]/pdf`. The self-call was rejected
|
||||
// by Cloudflare's loopback filter as a network-level error
|
||||
// (surfaced to the operator as "fetch failed" in the modal).
|
||||
if (
|
||||
!result.purchase.documents ||
|
||||
(Array.isArray(result.purchase.documents) && result.purchase.documents.length === 0)
|
||||
) {
|
||||
try {
|
||||
const pdfRes = await fetch(
|
||||
new URL(`/api/v1/stock-purchases/${purchaseId}/pdf`, req.nextUrl.origin).toString(),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { cookie: req.headers.get("cookie") ?? "" },
|
||||
// Body is optional; the route accepts an empty object.
|
||||
}
|
||||
);
|
||||
if (!pdfRes.ok) {
|
||||
// PDF generation failed — roll the status back to draft so
|
||||
// the operator can retry. The email wasn't sent yet.
|
||||
const reason = `PDF generation failed: ${pdfRes.status}`;
|
||||
const rolled = (await adminPb
|
||||
.collection("stock_purchases")
|
||||
.update(purchaseId, {
|
||||
status: "draft",
|
||||
email_error: `[pdf] ${reason}`,
|
||||
})
|
||||
.catch(() => result.purchase)) as typeof result.purchase;
|
||||
return NextResponse.json(
|
||||
{
|
||||
purchase: rolled,
|
||||
failure: { code: "pdf_failed", message: reason },
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
await generateStockPurchasePdf(purchaseId, {});
|
||||
// Re-read the purchase to pick up the freshly-stored
|
||||
// documents + documents_history.
|
||||
const fresh = (await adminPb
|
||||
|
|
@ -145,7 +127,11 @@ export async function POST(
|
|||
result.purchase = fresh;
|
||||
} catch (pdfErr) {
|
||||
const reason =
|
||||
pdfErr instanceof Error ? pdfErr.message : "PDF generation threw";
|
||||
pdfErr instanceof StockPurchasePdfValidationError
|
||||
? pdfErr.message
|
||||
: pdfErr instanceof Error
|
||||
? pdfErr.message
|
||||
: "PDF generation threw";
|
||||
const rolled = (await adminPb
|
||||
.collection("stock_purchases")
|
||||
.update(purchaseId, {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ const { store, pbMock, clearStore, setNow, now } = vi.hoisted(() => {
|
|||
}
|
||||
return items[0];
|
||||
},
|
||||
async getOne(id: string) {
|
||||
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 };
|
||||
|
|
@ -126,7 +126,7 @@ const { store, pbMock, clearStore, setNow, now } = vi.hoisted(() => {
|
|||
getOrCreateMap(name).set(id, item);
|
||||
return item;
|
||||
},
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
async update(id: string, data: Record<string, unknown> | FormData) {
|
||||
const map = getOrCreateMap(name);
|
||||
const existing = map.get(id);
|
||||
if (!existing) {
|
||||
|
|
@ -134,7 +134,34 @@ const { store, pbMock, clearStore, setNow, now } = vi.hoisted(() => {
|
|||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// 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);
|
||||
|
|
@ -171,9 +198,31 @@ 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,
|
||||
|
|
@ -902,3 +951,177 @@ describe("recordStockPurchaseSendOutcome", () => {
|
|||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,9 +47,15 @@ import type {
|
|||
StockPurchaseItem,
|
||||
StockPurchaseStatus,
|
||||
Supplier,
|
||||
PurchaseOrderDocumentEntry,
|
||||
} from "@/lib/types";
|
||||
import { buildDefaultPoEmailBody } from "./po-email-template";
|
||||
import { getAdminPocketBase } from "./pocketbase-admin";
|
||||
import {
|
||||
fetchLetterhead,
|
||||
loadLetterheadLogoDataUri,
|
||||
} from "@/lib/letterhead";
|
||||
import { renderStockPurchasePdf } from "@/lib/pdf/render";
|
||||
|
||||
// Re-export the types from `@/lib/types` so the route handlers
|
||||
// don't need a second import. The types are the source of truth
|
||||
|
|
@ -1110,3 +1116,220 @@ export async function recordStockPurchaseSendOutcome(
|
|||
.collection(STOCK_PURCHASES_COLL)
|
||||
.update(input.purchaseId, patch)) as StockPurchase;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* PDF generation (extracted from the /pdf route so the send-email
|
||||
* route can render server-side without HTTP self-calling — see
|
||||
* src/app/api/v1/stock-purchases/[id]/send-email/route.ts for context)
|
||||
* ========================================================================== */
|
||||
|
||||
/**
|
||||
* Validation errors thrown by `generateStockPurchasePdf`. Mirrors
|
||||
* the customer-PO `PurchaseOrderPdfValidationError` shape so the
|
||||
* route handlers can map these to HTTP 422 with a consistent body.
|
||||
*/
|
||||
export class StockPurchasePdfValidationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code:
|
||||
| "missing_relations"
|
||||
| "no_items"
|
||||
| "no_letterhead"
|
||||
| "purchase_not_found",
|
||||
public readonly details?: {
|
||||
purchaseId?: string;
|
||||
supplierId?: string;
|
||||
},
|
||||
) {
|
||||
super(message);
|
||||
this.name = "StockPurchasePdfValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenerateStockPurchasePdfInput {
|
||||
/** Free-text reason written to `documents_history.reason`. */
|
||||
notes?: string;
|
||||
/** Operator id/name for the audit trail. Optional. */
|
||||
generatedBy?: string;
|
||||
}
|
||||
|
||||
export interface GenerateStockPurchasePdfResult {
|
||||
pdfId: string;
|
||||
version: number;
|
||||
filename: string;
|
||||
downloadUrl: string;
|
||||
total: number;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side render of a Stock Purchase Order PDF and append it to
|
||||
* `stock_purchases.documents` (file) + `documents_history` (json).
|
||||
*
|
||||
* Shared by the explicit `/api/v1/stock-purchases/[id]/pdf` route
|
||||
* (operator clicks "Regenerate PDF") and the send-email route's
|
||||
* auto-PDF block (when the operator clicks Send on a PO that has
|
||||
* no document yet).
|
||||
*
|
||||
* The previous send-email flow tried to invoke this via
|
||||
* `fetch(\`/api/v1/stock-purchases/.../pdf\`)` — a self-call over
|
||||
* HTTPS. Cloudflare (or any reverse proxy that filters loopback
|
||||
* traffic) rejects that with a network-level error before the
|
||||
* request reaches Next.js, surfacing as "fetch failed" to the
|
||||
* operator. Calling the rendering logic in-process bypasses the
|
||||
* network round-trip entirely.
|
||||
*
|
||||
* What it does NOT do: flip the PO to `sent`. The PDF lifecycle is
|
||||
* decoupled from the email lifecycle — this matches the customer-PO
|
||||
* behaviour adopted in the generatePurchaseOrderPdf refactor.
|
||||
*
|
||||
* Throws `StockPurchasePdfValidationError` for user-actionable
|
||||
* preconditions (missing relations, no items, missing letterhead)
|
||||
* so the caller can map them to HTTP 422. Other errors propagate.
|
||||
*/
|
||||
export async function generateStockPurchasePdf(
|
||||
purchaseId: string,
|
||||
input: GenerateStockPurchasePdfInput = {},
|
||||
): Promise<GenerateStockPurchasePdfResult> {
|
||||
const pb = await getAdminPocketBase();
|
||||
|
||||
// 1. PO + supplier (expanded server-side)
|
||||
let purchase: StockPurchase;
|
||||
try {
|
||||
purchase = await pb
|
||||
.collection(STOCK_PURCHASES_COLL)
|
||||
.getOne<StockPurchase>(purchaseId, { expand: "supplier" });
|
||||
} catch {
|
||||
throw new StockPurchasePdfValidationError(
|
||||
"Stock purchase does not exist.",
|
||||
"purchase_not_found",
|
||||
{ purchaseId },
|
||||
);
|
||||
}
|
||||
|
||||
const supplier = purchase.expand?.supplier as Supplier | undefined;
|
||||
if (!supplier) {
|
||||
throw new StockPurchasePdfValidationError(
|
||||
"Stock purchase is missing the supplier relation. Cannot render PDF.",
|
||||
"missing_relations",
|
||||
{
|
||||
purchaseId: purchase.id,
|
||||
supplierId: purchase.supplier || undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Items linked to this PO
|
||||
const items = await pb
|
||||
.collection(STOCK_PURCHASE_ITEMS_COLL)
|
||||
.getFullList<StockPurchaseItem>({
|
||||
filter: `purchase = "${purchaseId}"`,
|
||||
sort: "created",
|
||||
expand: "stock_item",
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
throw new StockPurchasePdfValidationError(
|
||||
"This stock PO has no line items attached. Add lines on the " +
|
||||
"stock-order detail page before generating a PDF.",
|
||||
"no_items",
|
||||
{ purchaseId: purchase.id },
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Letterhead — always CNL
|
||||
let letterhead;
|
||||
try {
|
||||
letterhead = await fetchLetterhead("CNL");
|
||||
} catch (err) {
|
||||
throw new StockPurchasePdfValidationError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "CNL letterhead not found in letterhead_settings.",
|
||||
"no_letterhead",
|
||||
{ purchaseId: purchase.id },
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Logo
|
||||
const logoDataUri = await loadLetterheadLogoDataUri("CNL");
|
||||
|
||||
// 5. Compute totals (locked snapshot for storage)
|
||||
const subtotal =
|
||||
Math.round(
|
||||
items.reduce(
|
||||
(s, it) =>
|
||||
s + (Number(it.quantity) || 0) * (Number(it.cost_price) || 0),
|
||||
0,
|
||||
) * 100,
|
||||
) / 100;
|
||||
|
||||
// 6. Next version number
|
||||
const history: PurchaseOrderDocumentEntry[] = purchase.documents_history ?? [];
|
||||
const nextVersion =
|
||||
history.length > 0
|
||||
? Math.max(...history.map((h) => h.version)) + 1
|
||||
: 1;
|
||||
|
||||
// 7. Render PDF
|
||||
const generatedAtIso = new Date().toISOString();
|
||||
const pdfBuffer = await renderStockPurchasePdf({
|
||||
purchase,
|
||||
items,
|
||||
supplier,
|
||||
letterhead,
|
||||
logoDataUri,
|
||||
generatedAtIso,
|
||||
totalAmount: subtotal,
|
||||
});
|
||||
|
||||
// 8. Append PDF to stock_purchases.documents (multipart)
|
||||
const safePo = String(purchase.purchase_number || purchaseId).replace(
|
||||
/[^\w.-]/g,
|
||||
"_",
|
||||
);
|
||||
const filename = `STK-PO-CNL-${safePo}-v${nextVersion}.pdf`;
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
"documents",
|
||||
new Blob([new Uint8Array(pdfBuffer)], { type: "application/pdf" }),
|
||||
filename,
|
||||
);
|
||||
const updated = await pb
|
||||
.collection(STOCK_PURCHASES_COLL)
|
||||
.update<{ documents: string[] | string }>(purchaseId, formData);
|
||||
const docsRaw = updated?.documents ?? [];
|
||||
const docs = Array.isArray(docsRaw) ? docsRaw : [docsRaw];
|
||||
const newToken = docs[docs.length - 1] ?? "";
|
||||
|
||||
// 9. Append history entry — mark previous "current" as "superseded"
|
||||
const newHistoryBase: PurchaseOrderDocumentEntry[] = history.map((h) =>
|
||||
h.status === "current" ? { ...h, status: "superseded" } : h,
|
||||
);
|
||||
const newHistoryEntry: PurchaseOrderDocumentEntry = {
|
||||
version: nextVersion,
|
||||
file: filename,
|
||||
token: newToken,
|
||||
generatedAt: generatedAtIso,
|
||||
generatedBy: input.generatedBy || "",
|
||||
reason: (input.notes || "").slice(0, 500),
|
||||
status: "current",
|
||||
};
|
||||
await pb.collection(STOCK_PURCHASES_COLL).update(purchaseId, {
|
||||
documents_history: [...newHistoryBase, newHistoryEntry],
|
||||
});
|
||||
|
||||
// 10. Persist total_amount (status stays as-is — see function JSDoc)
|
||||
await pb.collection(STOCK_PURCHASES_COLL).update(purchaseId, {
|
||||
total_amount: subtotal,
|
||||
});
|
||||
|
||||
return {
|
||||
pdfId: purchaseId,
|
||||
version: nextVersion,
|
||||
filename,
|
||||
downloadUrl: `/api/v1/files/stock_purchases/${purchaseId}/${filename}`,
|
||||
total: subtotal,
|
||||
generatedAt: generatedAtIso,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue