WFDASH-WEBAPP/src/app/api/invoicing/delivery-note-print/route.ts
Dave 2f0e911563 feat(delivery-notes): standardize delivery-note letterhead across PDFs
- Introduced a centralized `deliveryNoteLetterheadCode` function to ensure all delivery-note PDFs consistently use the Westcott Group letterhead, regardless of the subsidiary.
- Updated relevant API routes and services to utilize the new function, removing previous hardcoded fallbacks.
- Enhanced the `DriverStop` interface to include timestamps for better reporting capabilities.
- Added new fields to track creation timestamps for driver stops, improving data accuracy for outstanding reporting.
2026-08-18 11:53:25 +02:00

230 lines
8.4 KiB
TypeScript

import React from "react";
import { NextRequest, NextResponse } from "next/server";
import { pdf, type DocumentProps } from "@react-pdf/renderer";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { buildPrintBundle } from "@/app/(app)/invoicing/print-bundle";
import {
PickingSlipPdf,
computePickingSlipLine,
} from "@/app/(app)/invoicing/picking-slip";
import { resolveDeliveryNoteLineQty } from "@/lib/pdf/delivery-note-document";
import { renderDeliveryNotePdf } from "@/lib/pdf/render";
import {
deliveryNoteLetterheadCode,
fetchLetterhead,
loadLetterheadLogoDataUri,
} from "@/lib/letterhead";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type OrderItemRecord = {
id: string;
description?: string | null;
delivery_note_number?: string;
order_item_details?: unknown;
order_item_attachements?: unknown;
order_qty?: number | null;
qty_collected?: number | null;
expand?: {
order?: {
id: string;
order_number?: string | null;
expand?: {
company?: { company?: string | null };
customer?: { customer?: string | null };
};
};
};
};
function readStringField(value: unknown, key: string): string {
if (!value || typeof value !== "object") return "";
const candidate = (value as Record<string, unknown>)[key];
return typeof candidate === "string" ? candidate : "";
}
function readDateField(value: unknown, key: string): string {
const raw = readStringField(value, key);
// Accept either full ISO timestamps (slice to YYYY-MM-DD) or
// pre-formatted calendar dates. Both are valid; we just need the
// date portion for the printed document.
if (raw.length >= 10) return raw.slice(0, 10);
return raw;
}
/**
* The DN PDF is uploaded to each item's `order_item_attachements`
* (multi-file) field at create time, with the operator-facing name
* `{deliveryNoteNumber}.pdf`. PB stores the file with a normalised
* token (e.g. `order_item_attachements_abc123.pdf`) — find the
* token on the first item that has a matching stored name.
*
* Returns the stored token (not the user-facing name) so the caller
* can build a `/api/files/order_items/{id}/{token}` URL to fetch the
* bytes back. Returns `null` if no item has the saved PDF — the
* caller falls back to regenerating.
*/
function findSavedDeliveryNoteAttachment(
items: Array<{ order_item_attachements?: unknown }>,
): string | null {
for (const item of items) {
const raw = item.order_item_attachements;
if (typeof raw === "string") return raw;
if (Array.isArray(raw) && typeof raw[0] === "string") return raw[0];
}
return null;
}
async function handlePrint(input: { deliveryNoteNumber: string; origin: string }) {
const deliveryNoteNumber = input.deliveryNoteNumber.trim();
if (!deliveryNoteNumber) {
return NextResponse.json({ error: "Missing deliveryNoteNumber" }, { status: 400 });
}
try {
const pb = await getAdminPocketBase();
const escapedDeliveryNoteNumber = deliveryNoteNumber.replace(/"/g, '\\"');
// Fetch by the canonical top-level PB field. Falls back to the
// JSON mirror for records created before the field was added.
const items = await pb.collection("order_items").getFullList<OrderItemRecord>({
filter: `delivery_note_number = "${escapedDeliveryNoteNumber}" && item_status = "delivery_note"`,
sort: "created",
expand: "order.company,order.customer",
});
if (!items || items.length === 0) {
return NextResponse.json(
{ error: "Delivery note not found" },
{ status: 404 },
);
}
const first = items[0];
const order = first.expand?.order;
const header = {
deliveryNoteNumber,
date:
readDateField(first.order_item_details, "deliveryNoteDate") ||
readStringField(first.order_item_details, "date") ||
new Date().toISOString().slice(0, 10),
orderNumber: order?.order_number ?? "",
companyName: order?.expand?.company?.company ?? "",
customerName: order?.expand?.customer?.customer ?? "",
};
// Lines: same shape as the picking slip so the operator sees
// consistent data across the two documents in the print bundle.
const lines = items.map((it) => ({
id: it.id,
description: (it.description ?? "").trim() || "(no description)",
qty: resolveDeliveryNoteLineQty({
orderQty: it.order_qty,
qtyCollected: it.qty_collected,
}),
}));
// Try the saved DN PDF first (uploaded at create time to each
// item's `order_item_attachements` field). Falls back to
// regenerating on the fly when no saved copy is present (e.g.
// older records created before the save-on-create flow shipped).
let docBytes: Uint8Array | null = null;
const savedToken = findSavedDeliveryNoteAttachment(items);
if (savedToken) {
const first = items[0];
const base = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
const fileUrl = `${base}/api/files/order_items/${first.id}/${encodeURIComponent(savedToken)}`;
const fileRes = await fetch(fileUrl);
if (fileRes.ok) {
docBytes = new Uint8Array(await fileRes.arrayBuffer());
}
}
if (!docBytes) {
// No saved PDF (or PB file fetch failed) — regenerate from the
// current items. This keeps older records printable. Same
// letterhead + logo lookup as the create route so the fallback
// rendering matches the saved version byte-for-byte.
// Same canonical letterhead as the create route — see
// `deliveryNoteLetterheadCode()` for the business rule.
const letterheadCode = deliveryNoteLetterheadCode();
const [letterhead, logoDataUri] = await Promise.all([
fetchLetterhead(letterheadCode),
loadLetterheadLogoDataUri(letterheadCode),
]);
const pdfBuffer = await renderDeliveryNotePdf({
header: { ...header, dateIso: header.date },
lines,
letterhead,
logoDataUri,
});
docBytes = new Uint8Array(pdfBuffer);
}
// Render the picking slip with the DN number in the header.
const slipHeader = {
orderNumber: header.orderNumber,
companyName: header.companyName,
customerName: header.customerName,
deliveryNoteNumber,
date: header.date,
};
const slipLines = items.map((it) => {
const ordered = typeof it.order_qty === "number" ? it.order_qty : 0;
const collected = typeof it.qty_collected === "number" ? it.qty_collected : 0;
const pickQty = Math.max(Math.min(collected, ordered), 0);
return {
kind: "delivery_note" as const,
line: computePickingSlipLine({
id: it.id,
description: (it.description ?? "").trim() || "(no description)",
supplierName: "",
ordered,
collected,
pickQty,
}),
};
});
const slipDoc = PickingSlipPdf({ header: slipHeader, lines: slipLines });
const slipInstance = pdf(slipDoc as unknown as React.ReactElement<DocumentProps>);
const slipString = await slipInstance.toString();
const slipBytes = new Uint8Array(Buffer.from(slipString, "binary"));
// Same bundle shape as the invoice print: duplicate the DN so the
// operator gets an office copy + customer copy in one print, then
// append the picking slip.
const combined = await buildPrintBundle(docBytes, slipBytes);
const out = Buffer.from(combined);
return new NextResponse(out, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${deliveryNoteNumber}-bundle.pdf"`,
"Cache-Control": "private, no-store",
},
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const deliveryNoteNumber = url.searchParams.get("deliveryNoteNumber") ?? "";
return handlePrint({ deliveryNoteNumber, origin: url.origin });
}
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const deliveryNoteNumber =
body && typeof body === "object"
? String((body as { deliveryNoteNumber?: unknown }).deliveryNoteNumber ?? "")
: "";
return handlePrint({ deliveryNoteNumber, origin: new URL(request.url).origin });
}