- Added an audit trail feature that logs every create, update, and delete action on delivery notes and orders, capturing detailed changes per field. - Introduced the `AuditTimeline` component to display the history of changes for delivery notes and orders, enhancing traceability. - Updated the `page.tsx` files for delivery notes and orders to fetch and display audit logs, ensuring users can view the history of modifications. - Enhanced the `package.json` with a new migration script for setting up the audit log functionality. This feature improves accountability and transparency in the management of delivery notes and orders.
824 lines
No EOL
30 KiB
TypeScript
824 lines
No EOL
30 KiB
TypeScript
import { getAdminPocketBase } from "./pocketbase-admin";
|
|
import { compressBuffer } from "@/lib/server/attachment-compressor";
|
|
import { buildDefaultPoEmailBody } from "./po-email-template";
|
|
import { fetchLetterhead, loadLetterheadLogoDataUri } from "@/lib/letterhead";
|
|
import { renderPurchaseOrderPdf } from "@/lib/pdf/render";
|
|
import type {
|
|
Order,
|
|
OrderItem,
|
|
PurchaseOrder,
|
|
PurchaseOrderDocumentEntry,
|
|
Supplier,
|
|
} from "@/lib/types";
|
|
|
|
/**
|
|
* Server-side Purchase Order pipeline used by the API route and the n8n
|
|
* workflow that does the actual SMTP send.
|
|
*
|
|
* Mirrors the pattern established by `quotes-service.ts` — pre-send
|
|
* validation + status flip happens here synchronously; the SMTP send
|
|
* itself runs in n8n, fired either by:
|
|
* - the operator clicking Send PO (this route), or
|
|
* - the scheduled cron (n8n polls `purchase_orders` where status="draft"
|
|
* AND created < now-15min, fires the same flow).
|
|
*
|
|
* Status lifecycle: draft → sending → sent
|
|
* └→ mail_failed (retryable)
|
|
* └→ cancelled (terminal)
|
|
*/
|
|
|
|
const PO_COLLECTION = "purchase_orders";
|
|
const SUPPLIERS_COLLECTION = "suppliers";
|
|
const ORDER_ITEMS_COLLECTION = "order_items";
|
|
|
|
/**
|
|
* Statuses the operator is allowed to send from.
|
|
*
|
|
* `sent` is intentionally included: it lets the operator resend a PO
|
|
* after a successful send (wrong address, didn't reach the supplier,
|
|
* supplier asked for it again). The route and modal label the action
|
|
* "Resend" for both `sent` and `mail_failed` so the operator doesn't
|
|
* have to wonder which state the PO is in.
|
|
*
|
|
* `cancelled` and `sending` are NOT sendable:
|
|
* - `cancelled` is terminal — the operator actively decided to
|
|
* kill this PO. Letting them send from there would silently undo
|
|
* that decision. If they change their mind they have to flip
|
|
* status manually.
|
|
* - `sending` means an n8n run is in flight; double-clicking Send
|
|
* while one is in flight would race the operator's first send
|
|
* to completion (and potentially send the same email twice).
|
|
* The UI shows a "Sending…" disabled button in that state.
|
|
*/
|
|
const SENDABLE_STATUSES = new Set(["draft", "mail_failed", "sent"]);
|
|
|
|
/** Pretty loose email check — SMTP will reject truly malformed addresses. */
|
|
function looksLikeEmail(s: string): boolean {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
|
|
}
|
|
|
|
export interface SendPurchaseOrderEmailPayload {
|
|
/** Optional override for the primary To: address. Defaults to supplier.email. */
|
|
to?: string;
|
|
cc: string;
|
|
bcc: string;
|
|
subject: string;
|
|
message: string;
|
|
}
|
|
|
|
export interface SendPurchaseOrderEmailFailure {
|
|
code:
|
|
| "no_recipient"
|
|
| "invalid_recipient"
|
|
| "no_pdf"
|
|
| "no_line_items"
|
|
| "invalid_status"
|
|
| "purchase_order_not_found"
|
|
| "supplier_not_found"
|
|
| "supplier_no_email";
|
|
message: string;
|
|
}
|
|
|
|
export interface SendPurchaseOrderEmailResult {
|
|
purchaseOrder: PurchaseOrder;
|
|
/** Null on success; populated when pre-send validation rejected the send. */
|
|
failure: SendPurchaseOrderEmailFailure | null;
|
|
/**
|
|
* True when the operator typed an email that didn't match the
|
|
* supplier's stored email and we updated supplier.email to match.
|
|
* Front-end uses this to surface a "Updated the supplier email to
|
|
* match" confirmation in the toast.
|
|
*/
|
|
supplierEmailUpdated?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Pre-send validation + status flip for a Purchase Order outbound email.
|
|
*
|
|
* The flow mirrors `sendQuoteEmail` exactly:
|
|
* 1. Load the PO + supplier (separately, getOne doesn't expand).
|
|
* 2. Validate status (must be draft or mail_failed).
|
|
* 3. Validate supplier email is real (not empty, looks like an email).
|
|
* 4. Validate operator's CC/BCC entries.
|
|
* 5. Validate PO has at least one line item linked — without lines the
|
|
* email body would be empty and the supplier would have no idea
|
|
* what to deliver.
|
|
* 6. Flip status to `sending`, persist composition, clear previous
|
|
* error. The actual SMTP send runs in n8n.
|
|
*
|
|
* IMPORTANT: we do NOT generate the RFC 5322 Message-ID here. n8n
|
|
* generates it at SMTP time and writes it back on success/failure via
|
|
* `recordPurchaseOrderSendOutcome`. This keeps the Message-ID
|
|
* guarantee (unique per message) in the sender's hands.
|
|
*/
|
|
export async function sendPurchaseOrderEmail(
|
|
poId: string,
|
|
payload: SendPurchaseOrderEmailPayload
|
|
): Promise<SendPurchaseOrderEmailResult> {
|
|
const pb = await getAdminPocketBase();
|
|
|
|
// ── 1. Load PO + supplier ─────────────────────────────────────────
|
|
let po: PurchaseOrder;
|
|
try {
|
|
po = await pb.collection(PO_COLLECTION).getOne<PurchaseOrder>(poId);
|
|
} catch {
|
|
return {
|
|
purchaseOrder: null as unknown as PurchaseOrder,
|
|
failure: {
|
|
code: "purchase_order_not_found",
|
|
message: "Purchase Order does not exist.",
|
|
},
|
|
};
|
|
}
|
|
|
|
let supplier: Supplier | null = null;
|
|
if (po.supplier) {
|
|
try {
|
|
supplier = (await pb
|
|
.collection(SUPPLIERS_COLLECTION)
|
|
.getOne(po.supplier)) as Supplier;
|
|
} catch {
|
|
return {
|
|
purchaseOrder: po,
|
|
failure: {
|
|
code: "supplier_not_found",
|
|
message: "Supplier record not found for this PO.",
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
// ── 2. Validate status ────────────────────────────────────────────
|
|
if (!SENDABLE_STATUSES.has(po.status)) {
|
|
return markPreSendFailure(pb, po, {
|
|
code: "invalid_status",
|
|
message: `Cannot send from status "${po.status}". Allowed: ${[...SENDABLE_STATUSES].join(", ")}.`,
|
|
});
|
|
}
|
|
|
|
// ── 3. Validate supplier email ────────────────────────────────────
|
|
if (!supplier) {
|
|
return markPreSendFailure(pb, po, {
|
|
code: "supplier_not_found",
|
|
message: "This PO has no supplier attached.",
|
|
});
|
|
}
|
|
const primary = (payload.to || supplier.email || "").trim();
|
|
if (!looksLikeEmail(primary)) {
|
|
return markPreSendFailure(pb, po, {
|
|
code: "supplier_no_email",
|
|
message:
|
|
"No valid recipient email on this PO (supplier.email is empty and no override was provided).",
|
|
});
|
|
}
|
|
|
|
// ── 4. Validate CC / BCC entries ──────────────────────────────────
|
|
for (const list of [payload.cc, payload.bcc]) {
|
|
for (const raw of (list || "").split(",")) {
|
|
const v = raw.trim();
|
|
if (v && !looksLikeEmail(v)) {
|
|
return markPreSendFailure(pb, po, {
|
|
code: "invalid_recipient",
|
|
message: `"${v}" doesn't look like an email address.`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 5. Validate PO has at least one linked line item ──────────────
|
|
const linkedItems = await pb
|
|
.collection(ORDER_ITEMS_COLLECTION)
|
|
.getList(1, 1, {
|
|
filter: `purchase_order = "${poId}"`,
|
|
});
|
|
if (linkedItems.totalItems === 0) {
|
|
return markPreSendFailure(pb, po, {
|
|
code: "no_line_items",
|
|
message:
|
|
"This PO has no line items attached. Re-link items on the order detail page before sending.",
|
|
});
|
|
}
|
|
|
|
// ── 5b. Validate the PO has a generated PDF attached. ─────────────
|
|
// The route fires the n8n webhook which downloads the PO PDF
|
|
// from PB by filename. If neither `documents` nor
|
|
// `documents_history` resolves to a usable filename, the
|
|
// download 404s and the workflow surfaces to the operator as
|
|
// "Webhook returned HTTP 500". Reject the send here instead
|
|
// — clearer error, no wasted webhook round-trip.
|
|
//
|
|
// The fallback chain matches `send-email/route.ts`'s
|
|
// `latestFilename` computation so the service and the route
|
|
// agree on what counts as "the latest PDF". Mirrors the
|
|
// service-layer → route-layer duplication pattern already
|
|
// used in stock-purchases + dispatch.
|
|
//
|
|
// `documents_history` can contain legacy `[null]` entries
|
|
// (PO was created before the array was filled in); filter
|
|
// those out before calling `.find()` / accessing `.file` —
|
|
// otherwise the whole send throws on a malformed row.
|
|
const documentsField = po.documents;
|
|
const documentsList: string[] = Array.isArray(documentsField)
|
|
? documentsField
|
|
: typeof documentsField === "string" && documentsField
|
|
? [documentsField]
|
|
: [];
|
|
const documentsHistory = (po.documents_history ?? []).filter(
|
|
(h): h is NonNullable<typeof h> => h != null,
|
|
);
|
|
const latestFilename =
|
|
documentsList[documentsList.length - 1] ??
|
|
(documentsHistory.find((h) => h.status === "current") ??
|
|
documentsHistory[documentsHistory.length - 1])?.file ??
|
|
"";
|
|
if (!latestFilename) {
|
|
return markPreSendFailure(pb, po, {
|
|
code: "no_pdf",
|
|
message:
|
|
"This PO has no generated PDF attached. Open the order detail page and regenerate the document before sending.",
|
|
});
|
|
}
|
|
|
|
// ── 5b. Generic body default ───────────────────────────────
|
|
// When the operator leaves the message blank, substitute a
|
|
// professional supplier-personalised template. SMTP relays
|
|
// commonly strip attachments from multipart/alternative bodies
|
|
// that have no body section (RFC 2046 violation), so an empty
|
|
// body is also the direct cause of "no PDF in inbox" reports.
|
|
//
|
|
// IMPORTANT: the default body deliberately omits ANY
|
|
// end-customer context (company name, customer rep, customer's
|
|
// PO reference). If the supplier knows who the order is for,
|
|
// they'll start going direct — that's the entire margin model.
|
|
// Operators who need to add specifics can still type their
|
|
// own message in the modal; we only fill the blank.
|
|
//
|
|
// We pass a minimal context: PO number, supplier name, and
|
|
// the line count (useful for the supplier's receiving check).
|
|
// ── 6. Persist composition + flip status to `sending`.
|
|
// We DO flip status synchronously so the operator's button hides
|
|
// immediately (no "click 50 times" bug) AND so the cron pass can
|
|
// distinguish "manually sent + awaiting n8n" from "still draft".
|
|
// The webhook pickup is still immediate (fired by the route right
|
|
// after this returns); if it misses, the cron pass picks up any
|
|
// `status="sending"` PO older than 15min and re-fires.
|
|
//
|
|
// `email_payload.to` stores the operator's override. Empty
|
|
// string = "fall back to supplier.email" (the actual send-time
|
|
// resolution happens in n8n, which reads `email_payload.to` or
|
|
// falls through to `expand.supplier.email`).
|
|
const lineCount = linkedItems.totalItems;
|
|
const defaultBody = buildDefaultPoEmailBody({
|
|
poNumber: po.po_number || po.id,
|
|
supplierName: supplier.suppliername || "",
|
|
lineCount,
|
|
});
|
|
const operatorMessage = (payload.message || "").trim();
|
|
|
|
// When the operator types their own message, that's what the supplier
|
|
// sees — both HTML and plain-text forms are produced by wrapping the
|
|
// operator's text in a minimal paragraph so it renders cleanly either
|
|
// way. When the operator leaves it blank, fall through to the default
|
|
// builder's HTML/plain pair.
|
|
const operatorHtml = operatorMessage
|
|
? `<p style="margin:0 0 16px 0;font-size:14px;color:#111111;line-height:1.55;">${operatorMessage
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/\n/g, "<br>")}</p>`
|
|
: "";
|
|
const body = operatorMessage
|
|
? { text: operatorMessage, html: operatorHtml }
|
|
: defaultBody;
|
|
|
|
const payload_json = {
|
|
to: (payload.to || "").trim(),
|
|
subject: payload.subject || `Purchase Order ${po.po_number || po.id}`,
|
|
body: body.html,
|
|
bodyText: body.text,
|
|
cc: payload.cc || "",
|
|
bcc: payload.bcc || "",
|
|
sentMessageId: po.email_payload?.sentMessageId,
|
|
sentAt: po.email_payload?.sentAt,
|
|
supplierReplyMessageId: po.email_payload?.supplierReplyMessageId,
|
|
supplierReplyPreview: po.email_payload?.supplierReplyPreview,
|
|
};
|
|
|
|
const updated = (await pb.collection(PO_COLLECTION).update(poId, {
|
|
status: "sending",
|
|
email_error: "",
|
|
email_payload: payload_json,
|
|
})) as PurchaseOrder;
|
|
|
|
// ── 7. Supplier email auto-update.
|
|
// If the operator typed a NEW email address that doesn't match
|
|
// supplier.email, persist it to the supplier record so the next
|
|
// PO to this supplier prefills correctly. Only writes when:
|
|
// - the override is non-empty (empty = "use supplier default")
|
|
// - the override is a valid email
|
|
// - the override differs from the supplier's current email
|
|
// We never blank out a supplier.email — empty overrides are
|
|
// honoured at send-time but never overwrite the source of truth.
|
|
let supplierEmailUpdated = false;
|
|
const override = (payload.to || "").trim();
|
|
if (
|
|
override &&
|
|
looksLikeEmail(override) &&
|
|
override !== (supplier.email || "").trim()
|
|
) {
|
|
try {
|
|
await pb.collection(SUPPLIERS_COLLECTION).update(supplier.id, {
|
|
email: override,
|
|
});
|
|
supplierEmailUpdated = true;
|
|
} catch (supErr) {
|
|
// Non-fatal — the send itself succeeds. The supplier.email update
|
|
// is a convenience for future POs; if it fails (PB permission,
|
|
// concurrent edit), we just log and move on.
|
|
console.warn(
|
|
`[po-send-email] failed to persist supplier.email update for ${supplier.id}:`,
|
|
supErr
|
|
);
|
|
}
|
|
}
|
|
|
|
return { purchaseOrder: updated, failure: null, supplierEmailUpdated };
|
|
}
|
|
|
|
/**
|
|
* Retry an outbound send after a `mail_failed`. Same as
|
|
* `sendPurchaseOrderEmail` but reuses the previously persisted
|
|
* composition (`email_payload`) without re-reading the modal.
|
|
*/
|
|
export async function retryPurchaseOrderEmail(
|
|
poId: string
|
|
): Promise<SendPurchaseOrderEmailResult> {
|
|
const pb = await getAdminPocketBase();
|
|
|
|
let po: PurchaseOrder;
|
|
try {
|
|
po = await pb.collection(PO_COLLECTION).getOne<PurchaseOrder>(poId);
|
|
} catch {
|
|
return {
|
|
purchaseOrder: null as unknown as PurchaseOrder,
|
|
failure: {
|
|
code: "purchase_order_not_found",
|
|
message: "Purchase Order does not exist.",
|
|
},
|
|
};
|
|
}
|
|
|
|
if (po.status !== "mail_failed") {
|
|
return markPreSendFailure(pb, po, {
|
|
code: "invalid_status",
|
|
message: `Cannot retry from status "${po.status}". Only mail_failed POs can be retried.`,
|
|
});
|
|
}
|
|
|
|
// Retry is fire-and-forget: clear the previous error, persist a
|
|
// "retry requested" hint into the payload, return the PO. n8n's
|
|
// webhook handler does the actual state machine (status flip +
|
|
// SMTP send + outcome write-back).
|
|
const updated = (await pb.collection(PO_COLLECTION).update(poId, {
|
|
email_error: "",
|
|
})) as PurchaseOrder;
|
|
|
|
return { purchaseOrder: updated, failure: null };
|
|
}
|
|
|
|
/**
|
|
* Synchronously flip a PO to `mail_failed` + write the failure reason.
|
|
* Used when pre-send validation rejects the send — we never reach n8n
|
|
* on this path, so the failure must be recorded here.
|
|
*/
|
|
async function markPreSendFailure(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
po: PurchaseOrder,
|
|
failure: SendPurchaseOrderEmailFailure
|
|
): Promise<SendPurchaseOrderEmailResult> {
|
|
const updated = (await pb.collection(PO_COLLECTION).update(po.id, {
|
|
status: "mail_failed",
|
|
email_error: `[pre-send] ${failure.message}`,
|
|
})) as PurchaseOrder;
|
|
return { purchaseOrder: updated, failure };
|
|
}
|
|
|
|
/**
|
|
* Records the *outcome* of an SMTP send attempt. Called by the n8n
|
|
* `Purchase Order Outbound Send` workflow (or any other worker that's
|
|
* been handed the send).
|
|
*
|
|
* `outcome = "success"` flips status to `sent` + stamps delivery fields.
|
|
* It deliberately does NOT touch linked `order_items.item_status` — the
|
|
* operator marks items ready-to-collect manually from the order page /
|
|
* stock-orders page after the supplier confirms, which then drops them
|
|
* off the buyer-tasks PO Sent tab. Auto-flipping on send would hide the
|
|
* item before the supplier has even confirmed.
|
|
*
|
|
* `outcome = "failure"` flips to `mail_failed` + stamps error.
|
|
*
|
|
* This is intentionally separate from `sendPurchaseOrderEmail` so the
|
|
* n8n path doesn't have to replay validation — by the time n8n runs,
|
|
* pre-send checks already passed.
|
|
*/
|
|
export interface RecordPoSendOutcomeInput {
|
|
poId: string;
|
|
outcome: "success" | "failure";
|
|
/** SMTP-issued Message-ID. Stored on success AND failure. */
|
|
messageId?: string;
|
|
/** Failure reason (SMTP response text). Required when outcome = "failure". */
|
|
errorMessage?: string;
|
|
}
|
|
|
|
export async function recordPurchaseOrderSendOutcome(
|
|
input: RecordPoSendOutcomeInput
|
|
): Promise<PurchaseOrder> {
|
|
const pb = await getAdminPocketBase();
|
|
|
|
if (input.outcome === "success") {
|
|
const sentAt = new Date().toISOString();
|
|
const po = await pb.collection(PO_COLLECTION).getOne<PurchaseOrder>(input.poId);
|
|
const payload = {
|
|
...(po.email_payload || {}),
|
|
sentAt,
|
|
sentMessageId: input.messageId || po.email_payload?.sentMessageId,
|
|
};
|
|
const patch: Record<string, unknown> = {
|
|
status: "sent",
|
|
email_error: "",
|
|
email_payload: payload,
|
|
};
|
|
if (input.messageId) patch.email_message_id = input.messageId;
|
|
|
|
const updated = (await pb.collection(PO_COLLECTION).update(
|
|
input.poId,
|
|
patch
|
|
)) as PurchaseOrder;
|
|
|
|
// Read the linked order_items to keep the back-link on the PO in
|
|
// sync. `order_items.purchase_order` is the canonical direction;
|
|
// `purchase_orders.order_item` is the mirror that surfaces in the
|
|
// PB admin UI and lets any future PO-side render read the items
|
|
// without an extra query.
|
|
//
|
|
// We deliberately do NOT touch `item_status` here. Previously this
|
|
// function auto-flipped linked items from `po_generated` to
|
|
// `collection_pool` on send, which dropped them off buyer-tasks
|
|
// immediately — before the supplier had even confirmed. The
|
|
// operator now marks items ready-to-collect from the order page /
|
|
// stock-orders page after the supplier commits, and only that
|
|
// explicit flip removes them from the buyer-tasks PO Sent tab.
|
|
//
|
|
// The filter intentionally doesn't restrict by `item_status` —
|
|
// we want every item linked to this PO regardless of where it is
|
|
// in the lifecycle, so the back-link array stays accurate.
|
|
const linked = await pb.collection(ORDER_ITEMS_COLLECTION).getFullList({
|
|
filter: `purchase_order = "${input.poId}"`,
|
|
fields: "id",
|
|
});
|
|
|
|
await pb.collection(PO_COLLECTION).update(input.poId, {
|
|
order_item: linked.map((it) => it.id),
|
|
});
|
|
|
|
return updated;
|
|
}
|
|
|
|
// outcome = "failure"
|
|
const reason = (input.errorMessage || "Unknown SMTP failure").slice(0, 990);
|
|
const patch: Record<string, unknown> = {
|
|
status: "mail_failed",
|
|
email_error: `[smtp] ${reason}`,
|
|
};
|
|
if (input.messageId) patch.email_message_id = input.messageId;
|
|
return (await pb.collection(PO_COLLECTION).update(input.poId, patch)) as PurchaseOrder;
|
|
}
|
|
|
|
/**
|
|
* Scan for POs in `status="draft"` older than `olderThanMs`. Used by
|
|
* the n8n cron workflow (`WFDASH — Purchase Order Send (Cron).js`)
|
|
* which fires every 15 minutes and dispatches each candidate.
|
|
*
|
|
* The cron doesn't read from PocketBase directly — it asks the
|
|
* dashboard to do the scan, so the only credentials it needs are
|
|
* AGENT_API_KEY (Bearer) + DASHBOARD_URL. This sidesteps the
|
|
* `N8N_BLOCK_ENV_ACCESS_IN_NODE=true` problem in our docker-compose
|
|
* (Code nodes can't read $env.* at runtime).
|
|
*
|
|
* Returns the list (possibly empty) plus a `now` timestamp the
|
|
* caller can echo back in logs.
|
|
*/
|
|
export interface StalePurchaseOrder {
|
|
id: string;
|
|
poNumber: string;
|
|
supplierName: string;
|
|
supplierEmail: string;
|
|
ageMinutes: number;
|
|
}
|
|
|
|
export interface FindStalePurchaseOrdersResult {
|
|
items: StalePurchaseOrder[];
|
|
scanned: number;
|
|
now: string;
|
|
olderThanMs: number;
|
|
}
|
|
|
|
export async function findStalePurchaseOrders(
|
|
olderThanMs: number = 15 * 60 * 1000,
|
|
limit: number = 50,
|
|
): Promise<FindStalePurchaseOrdersResult> {
|
|
const pb = await getAdminPocketBase();
|
|
const now = new Date();
|
|
const cutoff = new Date(now.getTime() - olderThanMs).toISOString();
|
|
const list = await pb.collection(PO_COLLECTION).getList<{
|
|
id: string;
|
|
po_number: string;
|
|
supplier: string;
|
|
expand?: { supplier?: { suppliername?: string; email?: string } };
|
|
created: string;
|
|
}>(1, limit, {
|
|
filter: `status="draft" && created<"${cutoff}"`,
|
|
sort: "created",
|
|
fields: "id,po_number,supplier,created",
|
|
expand: "supplier",
|
|
});
|
|
const items: StalePurchaseOrder[] = list.items.map((p) => {
|
|
const ageMs = now.getTime() - new Date(p.created).getTime();
|
|
return {
|
|
id: p.id,
|
|
poNumber: p.po_number || "",
|
|
supplierName: p.expand?.supplier?.suppliername || "",
|
|
supplierEmail: p.expand?.supplier?.email || "",
|
|
ageMinutes: Math.round(ageMs / 60000),
|
|
};
|
|
});
|
|
return {
|
|
items,
|
|
scanned: list.totalItems,
|
|
now: now.toISOString(),
|
|
olderThanMs,
|
|
};
|
|
}
|
|
|
|
/* ============================================================================
|
|
* PDF generation
|
|
* ========================================================================= */
|
|
|
|
/** Validation error from generatePurchaseOrderPdf — caller maps to HTTP 422. */
|
|
export class PurchaseOrderPdfValidationError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly code:
|
|
| "missing_relations"
|
|
| "no_items"
|
|
| "no_letterhead"
|
|
| "purchase_order_not_found",
|
|
public readonly details?: {
|
|
poId?: string;
|
|
orderId?: string;
|
|
supplierId?: string;
|
|
},
|
|
) {
|
|
super(message);
|
|
this.name = "PurchaseOrderPdfValidationError";
|
|
}
|
|
}
|
|
|
|
export interface GeneratePurchaseOrderPdfInput {
|
|
/** Free-text reason written to `documents_history.reason`. v1 ignores it. */
|
|
notes?: string;
|
|
/** Operator id/name for the audit trail. Optional. */
|
|
generatedBy?: string;
|
|
}
|
|
|
|
export interface GeneratePurchaseOrderPdfResult {
|
|
pdfId: string;
|
|
version: number;
|
|
filename: string;
|
|
downloadUrl: string;
|
|
total: number;
|
|
generatedAt: string;
|
|
}
|
|
|
|
/**
|
|
* Server-side render of a Purchase Order PDF and append it to
|
|
* `purchase_orders.documents` (file) + `documents_history` (json).
|
|
*
|
|
* Shared by the explicit `/api/v1/purchase-orders/[id]/pdf` route
|
|
* (operator clicks "Regenerate PDF") and the buyer-tasks auto-PDF
|
|
* path (`generatePoForGroup` calls this right after creating the
|
|
* draft PO record so the operator's "Generate PO" click produces a
|
|
* file ready to attach on Send).
|
|
*
|
|
* What it does NOT do: flip the PO to `sent`. The PDF lifecycle is
|
|
* decoupled from the email lifecycle — this used to flip to "sent"
|
|
* as a legacy shortcut, but caused the send-inbox/outbox state to
|
|
* drift when operators regenerated a PDF without intending to send.
|
|
* The PO stays in `draft` until the operator explicitly hits Send
|
|
* (or the cron fires).
|
|
*
|
|
* Status transitions:
|
|
* - v1 generation from a brand-new PO: status left alone (already
|
|
* `draft` per the process page).
|
|
* - v≥2 (regenerate): if the PO was previously `sent` or
|
|
* `mail_failed`, flip status back to `draft` and clear
|
|
* `email_error`. This is what the operator wants when they
|
|
* regenerate after a bad send — they want to re-send. The
|
|
* previous send's `email_message_id` + `email_payload.sentAt`
|
|
* are preserved as the audit trail.
|
|
* - `cancelled` is intentionally NOT touched — an explicit cancel
|
|
* is an operator decision ("this PO is dead"); resurrecting it
|
|
* on regenerate would silently undo that. The operator must
|
|
* flip status manually if they want to re-send after a cancel.
|
|
*
|
|
* Throws `PurchaseOrderPdfValidationError` 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 generatePurchaseOrderPdf(
|
|
poId: string,
|
|
input: GeneratePurchaseOrderPdfInput = {},
|
|
): Promise<GeneratePurchaseOrderPdfResult> {
|
|
const pb = await getAdminPocketBase();
|
|
|
|
// 1. PO + order + supplier (all expanded server-side)
|
|
let po: PurchaseOrder;
|
|
try {
|
|
po = await pb.collection(PO_COLLECTION).getOne<PurchaseOrder>(poId, {
|
|
expand: "order,supplier",
|
|
});
|
|
} catch {
|
|
throw new PurchaseOrderPdfValidationError(
|
|
"Purchase order does not exist.",
|
|
"purchase_order_not_found",
|
|
{ poId },
|
|
);
|
|
}
|
|
|
|
const order = po.expand?.order as Order | undefined;
|
|
const supplier = po.expand?.supplier as Supplier | undefined;
|
|
if (!order || !supplier) {
|
|
throw new PurchaseOrderPdfValidationError(
|
|
"Purchase order is missing the order or supplier relation. Cannot render PDF.",
|
|
"missing_relations",
|
|
{
|
|
poId: po.id,
|
|
orderId: po.order || undefined,
|
|
supplierId: po.supplier || undefined,
|
|
},
|
|
);
|
|
}
|
|
|
|
// 2. Items linked to this PO
|
|
const items = await pb
|
|
.collection(ORDER_ITEMS_COLLECTION)
|
|
.getFullList<OrderItem>({
|
|
filter: `purchase_order = "${poId}"`,
|
|
sort: "created",
|
|
});
|
|
|
|
if (items.length === 0) {
|
|
throw new PurchaseOrderPdfValidationError(
|
|
"This PO has no line items attached. Re-create the PO from the process page " +
|
|
"before generating a PDF.",
|
|
"no_items",
|
|
{ poId: po.id },
|
|
);
|
|
}
|
|
|
|
// 3. Letterhead — always CNL
|
|
let letterhead;
|
|
try {
|
|
letterhead = await fetchLetterhead("CNL");
|
|
} catch (err) {
|
|
throw new PurchaseOrderPdfValidationError(
|
|
err instanceof Error
|
|
? err.message
|
|
: "CNL letterhead not found in letterhead_settings.",
|
|
"no_letterhead",
|
|
{ poId: po.id, orderId: order.id },
|
|
);
|
|
}
|
|
|
|
// 4. Logo
|
|
const logoDataUri = await loadLetterheadLogoDataUri("CNL");
|
|
|
|
// 5. Compute totals (locked snapshot for storage). Uses the
|
|
// supplier-facing `order_qty` so shorted lines don't accidentally
|
|
// inflate the total — mirrors the per-line Qty column in the PDF.
|
|
const subtotal =
|
|
Math.round(
|
|
items.reduce(
|
|
(s, it) =>
|
|
s +
|
|
(Number(it.order_qty ?? it.quantity) || 0) *
|
|
(Number(it.cost_price) || 0),
|
|
0,
|
|
) * 100,
|
|
) / 100;
|
|
|
|
// 6. Next version number
|
|
const history: PurchaseOrderDocumentEntry[] = po.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 renderPurchaseOrderPdf({
|
|
purchaseOrder: po,
|
|
items,
|
|
order,
|
|
supplier,
|
|
letterhead,
|
|
logoDataUri,
|
|
generatedAtIso,
|
|
totalAmount: subtotal,
|
|
});
|
|
|
|
// 8. Append PDF to purchase_orders.documents (multipart)
|
|
const safePo = String(po.po_number || poId).replace(/[^\w.-]/g, "_");
|
|
const filename = `PO-CNL-${safePo}-v${nextVersion}.pdf`;
|
|
// Compress the generated PDF before upload so the new revision's
|
|
// PB token is stable from day one. PO PDFs are typically
|
|
// already-optimised (they're rendered with the letterhead PDF
|
|
// embedded), but `gs /ebook` still reliably shaves 10-30% off
|
|
// and we want the protective layer here the same as everywhere
|
|
// else. See `attachment-compressor.ts` for the rationale.
|
|
const { buffer: compressedPdfBuffer } = await compressBuffer(
|
|
pdfBuffer,
|
|
filename,
|
|
"application/pdf",
|
|
);
|
|
const formData = new FormData();
|
|
formData.append(
|
|
"documents",
|
|
new Blob([new Uint8Array(compressedPdfBuffer)], { type: "application/pdf" }),
|
|
filename,
|
|
);
|
|
const updated = await pb.collection(PO_COLLECTION).update<{
|
|
documents: string[] | string;
|
|
}>(poId, 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(PO_COLLECTION).update(poId, {
|
|
documents_history: [...newHistoryBase, newHistoryEntry],
|
|
});
|
|
|
|
// 10. Persist total_amount. On v≥2 (regenerate) we ALSO reset the
|
|
// status to `draft` and clear `email_error` so the operator can
|
|
// re-send the new version. v1 generation from a brand-new PO
|
|
// leaves status alone (it's already `draft` per the process page).
|
|
//
|
|
// Why reset on regenerate but not on first generate? After the
|
|
// first send (status="sent" or "mail_failed"), the operator
|
|
// regenerates specifically because they want to send a new
|
|
// version — and the existing "Send" button only renders for
|
|
// `draft` / `mail_failed` (`sent` POs had no way to resend
|
|
// before this change). Resetting to `draft` makes the Send button
|
|
// reappear AND clears the stale `email_error` from the prior
|
|
// failure so it doesn't show in the toast on the new send.
|
|
//
|
|
// We deliberately preserve `email_message_id` and
|
|
// `email_payload.sentAt` — those are the audit trail of the
|
|
// previous send (what went out, when, with which Message-ID).
|
|
// Wiping them would lose the record of "v1 was sent to
|
|
// supplier@example.com on 2026-08-01, then regenerated as v2".
|
|
//
|
|
// `cancelled` POs are an explicit operator decision ("this PO
|
|
// is dead, don't send it") — a regenerate should NOT silently
|
|
// resurrect them. The operator must flip status back to
|
|
// `draft` manually if they want to re-send after a cancel.
|
|
const finalStatus = po.status;
|
|
const resetFields: Record<string, unknown> = {
|
|
total_amount: subtotal,
|
|
};
|
|
if (nextVersion >= 2 && finalStatus !== "cancelled") {
|
|
resetFields.status = "draft";
|
|
resetFields.email_error = "";
|
|
}
|
|
await pb.collection(PO_COLLECTION).update(poId, resetFields);
|
|
|
|
return {
|
|
pdfId: poId,
|
|
version: nextVersion,
|
|
filename,
|
|
downloadUrl: `/api/v1/files/purchase_orders/${poId}/${filename}`,
|
|
total: subtotal,
|
|
generatedAt: generatedAtIso,
|
|
};
|
|
} |