2026-07-14 15:35:36 +00:00
|
|
|
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 {
|
2026-08-18 09:53:25 +00:00
|
|
|
deliveryNoteLetterheadCode,
|
2026-07-14 15:35:36 +00:00
|
|
|
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",
|
2026-08-11 23:13:46 +00:00
|
|
|
expand: "order.company,order.customer",
|
2026-07-14 15:35:36 +00:00
|
|
|
});
|
|
|
|
|
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.
|
2026-08-18 09:53:25 +00:00
|
|
|
// Same canonical letterhead as the create route — see
|
|
|
|
|
// `deliveryNoteLetterheadCode()` for the business rule.
|
|
|
|
|
const letterheadCode = deliveryNoteLetterheadCode();
|
2026-07-14 15:35:36 +00:00
|
|
|
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);
|
2026-08-09 21:16:55 +00:00
|
|
|
return {
|
|
|
|
|
kind: "delivery_note" as const,
|
|
|
|
|
line: computePickingSlipLine({
|
|
|
|
|
id: it.id,
|
|
|
|
|
description: (it.description ?? "").trim() || "(no description)",
|
2026-08-11 23:42:19 +00:00
|
|
|
supplierName: "",
|
2026-08-09 21:16:55 +00:00
|
|
|
ordered,
|
|
|
|
|
collected,
|
|
|
|
|
pickQty,
|
|
|
|
|
}),
|
|
|
|
|
};
|
2026-07-14 15:35:36 +00:00
|
|
|
});
|
|
|
|
|
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 });
|
|
|
|
|
}
|