WFDASH-WEBAPP/src/lib/order-invoices.ts
Dave 7555196166 feat(audit): implement audit trail for delivery notes and orders
- 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.
2026-09-30 01:48:44 +02:00

120 lines
4.2 KiB
TypeScript

/**
* Customer invoice numbers for an order, derived from its line items.
*
* An order has no invoice column of its own — invoicing happens per
* `order_items` row, and a partial pickup can put one order across
* several invoices. The number lives in one of two places depending on
* which flow raised the invoice:
*
* `order_item_details.invoice.number` — written by the Invoicing page
* (QuickBooks webhook). Carries the
* QB link, date and who set it.
* `customer_invoice_number` — written by
* `reconcileOrderItemInvoicedStatus`.
*
* `order_item_details` wins when both are set because it carries the QB
* metadata. Safe to import from client components.
*
* Careful: collection-confirm ALSO writes `order_item_details.invoice`,
* with the SUPPLIER's invoice number ("Confirmed for invoicing — IN-…"),
* before the customer is billed. QuickBooks' write-back later replaces
* it. Only an `invoice` blob carrying QuickBooks fields (`qbInvoiceId`,
* `qbInvoiceLink` or `billingCompany`) is a customer invoice.
*/
export type InvoiceBearingItem = {
id?: string;
customer_invoice_number?: string | null;
invoiced_date?: string | null;
order_item_details?: string | Record<string, unknown> | null;
};
export type OrderInvoiceSummary = {
number: string;
/** YYYY-MM-DD (or full ISO) when known, else null. */
date: string | null;
qbInvoiceLink: string | null;
billingCompany: string | null;
setByName: string | null;
/** How many of the order's lines went out on this invoice. */
lineCount: number;
};
type InvoiceDetails = {
number?: unknown;
date?: unknown;
qbInvoiceId?: unknown;
qbInvoiceLink?: unknown;
billingCompany?: unknown;
setByName?: unknown;
};
function str(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function readInvoiceDetails(
details: InvoiceBearingItem["order_item_details"],
): InvoiceDetails | null {
let parsed: unknown = details;
if (typeof details === "string") {
if (!details.trim()) return null;
try {
parsed = JSON.parse(details);
} catch {
return null;
}
}
if (!parsed || typeof parsed !== "object") return null;
const invoice = (parsed as { invoice?: unknown }).invoice;
if (!invoice || typeof invoice !== "object") return null;
const blob = invoice as InvoiceDetails;
// Supplier invoice from collection-confirm — not a customer invoice.
const fromQuickBooks = !!(str(blob.qbInvoiceId) || str(blob.qbInvoiceLink) || str(blob.billingCompany));
return fromQuickBooks ? blob : null;
}
/** The invoice number a single line went out on, or null. */
export function readItemInvoiceNumber(item: InvoiceBearingItem): string | null {
return (
str(readInvoiceDetails(item.order_item_details)?.number) ??
str(item.customer_invoice_number)
);
}
/**
* Distinct invoices across an order's lines, oldest first. Lines with no
* invoice number are ignored, so an order that isn't invoiced yet returns
* an empty array.
*/
export function summarizeOrderInvoices(
items: readonly InvoiceBearingItem[],
): OrderInvoiceSummary[] {
const byNumber = new Map<string, OrderInvoiceSummary>();
for (const item of items) {
const details = readInvoiceDetails(item.order_item_details);
const number = str(details?.number) ?? str(item.customer_invoice_number);
if (!number) continue;
const existing = byNumber.get(number);
if (existing) {
existing.lineCount += 1;
existing.date ??= str(details?.date) ?? str(item.invoiced_date);
existing.qbInvoiceLink ??= str(details?.qbInvoiceLink);
existing.billingCompany ??= str(details?.billingCompany);
existing.setByName ??= str(details?.setByName);
continue;
}
byNumber.set(number, {
number,
date: str(details?.date) ?? str(item.invoiced_date),
qbInvoiceLink: str(details?.qbInvoiceLink),
billingCompany: str(details?.billingCompany),
setByName: str(details?.setByName),
lineCount: 1,
});
}
return [...byNumber.values()].sort((a, b) =>
(a.date ?? "9999").localeCompare(b.date ?? "9999") ||
a.number.localeCompare(b.number),
);
}