feat(invoicing, dn): add surplus tracking, fix qty, update branding
- Fix resolveInvoiceLineDefaults to prioritize customer order quantity over supplier-collected quantity, ensuring invoices reflect what the customer actually ordered - Add surplusQty utility function to calculate supplier MOQ overflow that should be booked to stock via /api/v1/stock/surplus-book - Add surplus stock indicators in order and delivery note modals with descriptive tooltips explaining the surplus amount - Expand supported letterhead codes to include WESTSCOTT GROUP, and configure all delivery notes to use the Westcott Group letterhead globally per business branding rules - Improve error handling and logging in the delivery notes API POST route to include PocketBase error details and request context for easier debugging - Add comprehensive unit tests for the updated invoice line logic and new surplusQty function to prevent regressions
This commit is contained in:
parent
8a224a7a24
commit
dce9bfb04a
6 changed files with 172 additions and 21 deletions
|
|
@ -29,6 +29,7 @@ import {
|
|||
matchesSearch,
|
||||
resolveInvoicePdfUrl,
|
||||
resolveInvoiceLineDefaults,
|
||||
surplusQty,
|
||||
type InvoiceLineOverride,
|
||||
type InvoicingRow,
|
||||
} from "./view-model";
|
||||
|
|
@ -837,6 +838,7 @@ function SingleOrderQbModal({
|
|||
const priceId = `invoice-price-${row.id}`;
|
||||
const included = includedRowIds.has(row.id);
|
||||
const label = itemLabel(row);
|
||||
const surplus = surplusQty(row);
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
|
|
@ -875,7 +877,17 @@ function SingleOrderQbModal({
|
|||
</div>
|
||||
</div>
|
||||
<label className="space-y-1 text-sm" htmlFor={qtyId}>
|
||||
<span>Qty</span>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span>Qty</span>
|
||||
{surplus > 0 ? (
|
||||
<span
|
||||
className="rounded-full border border-info/40 bg-info/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-info"
|
||||
title={`Supplier delivered ${row.qtyCollected}, customer ordered ${row.orderQty}. ${surplus} goes to stock — book via /api/v1/stock/surplus-book.`}
|
||||
>
|
||||
+{surplus} to stock
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<input
|
||||
id={qtyId}
|
||||
type="number"
|
||||
|
|
@ -1093,6 +1105,7 @@ function SingleDeliveryNoteModal({
|
|||
const priceId = `dn-price-${row.id}`;
|
||||
const included = includedRowIds.has(row.id);
|
||||
const label = itemLabel(row);
|
||||
const surplus = surplusQty(row);
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
|
|
@ -1131,7 +1144,17 @@ function SingleDeliveryNoteModal({
|
|||
</div>
|
||||
</div>
|
||||
<label className="space-y-1 text-sm" htmlFor={qtyId}>
|
||||
<span>Qty</span>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span>Qty</span>
|
||||
{surplus > 0 ? (
|
||||
<span
|
||||
className="rounded-full border border-info/40 bg-info/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-info"
|
||||
title={`Supplier delivered ${row.qtyCollected}, customer ordered ${row.orderQty}. ${surplus} will be booked to stock via /api/v1/stock/surplus-book after delivery.`}
|
||||
>
|
||||
+{surplus} to stock
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<input
|
||||
id={qtyId}
|
||||
type="number"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ import {
|
|||
isGroupReadyToPrint,
|
||||
matchesSearch,
|
||||
pickClosestCustomer,
|
||||
resolveInvoiceLineDefaults,
|
||||
resolveInvoicePdfUrl,
|
||||
surplusQty,
|
||||
type InvoicingRow,
|
||||
type InvoicingStatus,
|
||||
} from "./view-model";
|
||||
|
|
@ -474,3 +476,54 @@ describe("resolveInvoicePdfUrl", () => {
|
|||
})).toBe("https://pb.example.com/api/files/pbc_order_items/row-1/invoice_a.pdf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveInvoiceLineDefaults", () => {
|
||||
it("uses the customer order qty, not the supplier-collected qty (MOQ overflow scenario)", () => {
|
||||
// Customer ordered 4, supplier MOQ forced an order of 10, supplier
|
||||
// delivered all 10. The invoice must reflect 4 — only the customer
|
||||
// qty goes on the QuickBooks payload. The remaining 6 is routed to
|
||||
// stock via /api/v1/stock/surplus-book (see surplusQty below).
|
||||
const row = makeRow({ orderQty: 4, qtyCollected: 10 });
|
||||
expect(resolveInvoiceLineDefaults(row).qty).toBe(4);
|
||||
});
|
||||
|
||||
it("falls back to qtyCollected when there is no customer orderQty (legacy rows)", () => {
|
||||
const row = makeRow({ orderQty: null, qtyCollected: 6 });
|
||||
expect(resolveInvoiceLineDefaults(row).qty).toBe(6);
|
||||
});
|
||||
|
||||
it("defaults to 1 when neither qty is recorded", () => {
|
||||
const row = makeRow({ orderQty: null, qtyCollected: null });
|
||||
expect(resolveInvoiceLineDefaults(row).qty).toBe(1);
|
||||
});
|
||||
|
||||
it("uses orderQty even when it equals qtyCollected (no overflow)", () => {
|
||||
const row = makeRow({ orderQty: 4, qtyCollected: 4 });
|
||||
expect(resolveInvoiceLineDefaults(row).qty).toBe(4);
|
||||
});
|
||||
|
||||
it("uses orderQty when it exceeds qtyCollected (short delivery — never invoices the surplus)", () => {
|
||||
const row = makeRow({ orderQty: 10, qtyCollected: 4 });
|
||||
expect(resolveInvoiceLineDefaults(row).qty).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("surplusQty", () => {
|
||||
it("returns the supplier-MOQ overflow (collected - ordered)", () => {
|
||||
expect(surplusQty(makeRow({ orderQty: 4, qtyCollected: 10 }))).toBe(6);
|
||||
});
|
||||
|
||||
it("returns 0 when collected equals ordered (no overflow)", () => {
|
||||
expect(surplusQty(makeRow({ orderQty: 4, qtyCollected: 4 }))).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when short delivery (collected < ordered)", () => {
|
||||
expect(surplusQty(makeRow({ orderQty: 10, qtyCollected: 4 }))).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when either side is missing", () => {
|
||||
expect(surplusQty(makeRow({ orderQty: null, qtyCollected: 10 }))).toBe(0);
|
||||
expect(surplusQty(makeRow({ orderQty: 4, qtyCollected: null }))).toBe(0);
|
||||
expect(surplusQty(makeRow({ orderQty: null, qtyCollected: null }))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -358,16 +358,36 @@ export type InvoiceLineOverride = {
|
|||
};
|
||||
|
||||
export function resolveInvoiceLineDefaults(row: InvoicingRow): { qty: number; price: number } {
|
||||
// The invoice qty must reflect what the CUSTOMER ordered (`orderQty`),
|
||||
// never what was `qtyCollected` from the supplier. When a supplier
|
||||
// MOQ forces us to buy more than the customer wanted (e.g. ordered 4,
|
||||
// received 10), the operator routes the surplus into stock via
|
||||
// /api/v1/stock/surplus-book — only the customer-facing 4 goes on the
|
||||
// invoice / delivery note. Fall back to `qtyCollected` only when no
|
||||
// explicit customer qty was ever recorded (legacy rows / quotes not
|
||||
// yet promoted into a real order).
|
||||
const qty =
|
||||
typeof row.qtyCollected === "number"
|
||||
? row.qtyCollected
|
||||
: typeof row.orderQty === "number"
|
||||
? row.orderQty
|
||||
typeof row.orderQty === "number"
|
||||
? row.orderQty
|
||||
: typeof row.qtyCollected === "number"
|
||||
? row.qtyCollected
|
||||
: 1;
|
||||
const price = typeof row.sellingPrice === "number" ? row.sellingPrice : 0;
|
||||
return { qty, price };
|
||||
}
|
||||
|
||||
/**
|
||||
* How many units of `row` were collected from the supplier that do NOT
|
||||
* belong on the customer's invoice — i.e. the supplier-MOQ overflow
|
||||
* that should be booked into stock. Returns 0 when there's no
|
||||
* headroom (collected ≤ ordered) or when either side is unknown.
|
||||
*/
|
||||
export function surplusQty(row: InvoicingRow): number {
|
||||
if (typeof row.qtyCollected !== "number") return 0;
|
||||
if (typeof row.orderQty !== "number") return 0;
|
||||
return Math.max(0, row.qtyCollected - row.orderQty);
|
||||
}
|
||||
|
||||
export function buildQbInvoicePayload(input: {
|
||||
orderNumber: string;
|
||||
billingCompany: string;
|
||||
|
|
|
|||
|
|
@ -123,10 +123,24 @@ export async function POST(req: NextRequest) {
|
|||
});
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to create ad-hoc delivery note";
|
||||
const e = err as {
|
||||
message?: string;
|
||||
response?: { data?: unknown; status?: number };
|
||||
data?: unknown;
|
||||
};
|
||||
const pbDetail = e.response?.data ?? e.data;
|
||||
const baseMessage =
|
||||
err instanceof Error ? err.message : "Failed to create ad-hoc delivery note";
|
||||
const message = pbDetail
|
||||
? `${baseMessage} | ${JSON.stringify(pbDetail).slice(0, 500)}`
|
||||
: baseMessage;
|
||||
console.error("[delivery-notes.create]", {
|
||||
message: baseMessage,
|
||||
pbDetail,
|
||||
createdByProvided: !!createdBy,
|
||||
customer: body.customer,
|
||||
itemCount: body.items?.length,
|
||||
});
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,15 +6,18 @@ import type { BaseRecord } from "./types";
|
|||
/**
|
||||
* Letterhead — server-side read model for a row in `letterhead_settings`.
|
||||
*
|
||||
* Each company (CNL, ISHERWOOD) has exactly one row (enforced by the
|
||||
* `idx_letterhead_settings_one_per_company` unique index). The PDF
|
||||
* generator fetches this row fresh on every render so edits in the
|
||||
* PB admin UI (logo swap, bank detail fix, terms tweak, accent color
|
||||
* change) propagate to the next quote without a code deploy.
|
||||
* Each company / brand has exactly one row. The PDF generator fetches
|
||||
* this row fresh on every render so edits in the PB admin UI (logo
|
||||
* swap, bank detail fix, terms tweak, accent color change) propagate
|
||||
* to the next quote / DN / PO without a code deploy.
|
||||
*/
|
||||
export type LetterheadCode = "CNL" | "ISHERWOOD";
|
||||
export type LetterheadCode = "CNL" | "ISHERWOOD" | "WESTSCOTT GROUP OF COMPANIES";
|
||||
|
||||
export const LETTERHEAD_CODES: readonly LetterheadCode[] = ["CNL", "ISHERWOOD"] as const;
|
||||
export const LETTERHEAD_CODES: readonly LetterheadCode[] = [
|
||||
"CNL",
|
||||
"ISHERWOOD",
|
||||
"WESTSCOTT GROUP OF COMPANIES",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Pick the letterhead code that matches a given company name. The
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ import { renderDeliveryNotePdf } from "@/lib/pdf/render";
|
|||
import { resolveDeliveryNoteLineQty } from "@/lib/pdf/delivery-note-document";
|
||||
import {
|
||||
fetchLetterhead,
|
||||
letterheadForCompany,
|
||||
loadLetterheadLogoDataUri,
|
||||
} from "@/lib/letterhead";
|
||||
import type {
|
||||
|
|
@ -59,6 +58,33 @@ const COMPANIES = "companies";
|
|||
const SUPPLIERS = "suppliers";
|
||||
const ORDERS = "orders";
|
||||
|
||||
/**
|
||||
* The letterhead every delivery-note PDF renders under.
|
||||
*
|
||||
* Hardcoded by business decision: the Westcott group owns
|
||||
* multiple subsidiaries (CNL, ISHERWOOD, Test Engineering,
|
||||
* etc.) but the printed DN is a Westcott-Group-branded
|
||||
* document regardless of which subsidiary the customer
|
||||
* belongs to. The actual letterhead content (logo, address,
|
||||
* banking, accent colour) lives in `letterhead_settings` —
|
||||
* this constant is the *lookup* into that table, not the
|
||||
* content itself.
|
||||
*
|
||||
* `fetchLetterhead` runs a case-insensitive substring match
|
||||
* on `letterhead_settings.display_name` against this value,
|
||||
* so the matching row in PB must have a display_name that
|
||||
* contains this string (verbatim or as a substring). The row
|
||||
* the user added in PB (id `5wezvypn6fy47p9`) has
|
||||
* display_name `WESTSCOTT GROUP OF COMPANIES` which matches
|
||||
* cleanly.
|
||||
*
|
||||
* If you want to swap the brand (e.g. rebrand to a new parent
|
||||
* company), update the PB row and change this string in
|
||||
* lockstep. The PDF will pick up the new content from PB
|
||||
* automatically without any code change beyond the constant.
|
||||
*/
|
||||
const DELIVERY_NOTE_LETTERHEAD_CODE = "WESTSCOTT GROUP OF COMPANIES";
|
||||
|
||||
/**
|
||||
* Lifecycle transitions for the delivery_notes.status field. The
|
||||
* service methods enforce these — they reject any transition that
|
||||
|
|
@ -345,7 +371,13 @@ async function attachDeliveryNoteToQuote(
|
|||
} else {
|
||||
// Path 2: re-render the unsigned PDF from the order items.
|
||||
const companyName = dn.expand?.company?.company ?? "";
|
||||
const letterheadCode = letterheadForCompany(companyName) ?? "CNL";
|
||||
// All delivery-note PDFs render under the Westcott
|
||||
// Group letterhead, regardless of which subsidiary the
|
||||
// customer belongs to — see DELIVERY_NOTE_LETTERHEAD_CODE.
|
||||
// The `companyName` above still goes into the PDF meta
|
||||
// block so the printed document tells the driver which
|
||||
// subsidiary the delivery is for.
|
||||
const letterheadCode = DELIVERY_NOTE_LETTERHEAD_CODE;
|
||||
const letterhead = await fetchLetterhead(letterheadCode);
|
||||
const logoDataUri = await loadLetterheadLogoDataUri(letterheadCode);
|
||||
const customerName = dn.expand?.customer?.customer ?? "";
|
||||
|
|
@ -813,7 +845,7 @@ export async function createAdHocDeliveryNote(
|
|||
? (companyRow as unknown as { company: string }).company
|
||||
: "";
|
||||
|
||||
const letterheadCode = letterheadForCompany(companyName) ?? "CNL";
|
||||
const letterheadCode = DELIVERY_NOTE_LETTERHEAD_CODE;
|
||||
const letterhead = await fetchLetterhead(letterheadCode);
|
||||
const logoDataUri = await loadLetterheadLogoDataUri(letterheadCode);
|
||||
|
||||
|
|
@ -932,7 +964,13 @@ export async function regenerateDeliveryNotePdf(
|
|||
(await resolveRelationName(pb, dn.company, COMPANIES, "company")) ??
|
||||
"";
|
||||
|
||||
const letterheadCode = letterheadForCompany(companyName) ?? "CNL";
|
||||
// All delivery-note PDFs render under the Westcott
|
||||
// Group letterhead, regardless of which subsidiary the
|
||||
// customer belongs to — see DELIVERY_NOTE_LETTERHEAD_CODE.
|
||||
// The `companyName` above still goes into the PDF meta
|
||||
// block so the printed document tells the driver which
|
||||
// subsidiary the delivery is for.
|
||||
const letterheadCode = DELIVERY_NOTE_LETTERHEAD_CODE;
|
||||
const [letterhead, logoDataUri] = await Promise.all([
|
||||
fetchLetterhead(letterheadCode),
|
||||
loadLetterheadLogoDataUri(letterheadCode),
|
||||
|
|
|
|||
Loading…
Reference in a new issue