WFDASH-WEBAPP/src/lib/server/delivery-notes-service.ts

2131 lines
80 KiB
TypeScript
Raw Normal View History

/**
* Server-side delivery-notes service.
*
* Admin-scoped writes for the three actions that drive the
* delivery_note → quote → invoice lifecycle:
*
* 1. createQuoteFromDeliveryNote
* Draft → Sent
* Reads the delivery note + its order_items, creates a new
* Quote with status="delivery_note", copies each order_item
* into a new quote_item, links the new quote back to the
* delivery note, and flips the delivery note's status to
* "sent" (we now have a quote ready to send with the DN).
*
* 2. setDeliveryNoteCustomerPo
* Sent → po_received
* Stamps the customer's PO on the delivery note and flips
* the status. The operator types the PO after the customer
* sends it back with their PO number.
*
* 3. markDeliveryNoteInvoiced
* po_received → invoiced
* Flips the order_items from item_status="delivery_note" to
* item_status="ready_invoice" so they show up in the
* Invoicing page ready to invoice. Updates the delivery
* note's status to "invoiced". The actual invoice creation
* happens through the normal Invoicing flow.
*
* Why a service module instead of inlining in the routes:
* - The "create quote + items + flip status" sequence is 3+ PB
* writes; the route stays small + the logic is testable.
* - Other callers (cron, future bulk tools) can reuse these.
*/
import { getAdminPocketBase } from "./pocketbase-admin";
import { generateQuoteNumberServer } from "./quotes-service";
import { renderDeliveryNotePdf } from "@/lib/pdf/render";
import { compressBuffer } from "@/lib/server/attachment-compressor";
fix(orders): bill the customer quantity, not the supplier order_qty `order_items.order_qty` is the SUPPLIER-facing quantity — what prints on the PO. Five call sites read it as the customer quantity, which is a money bug in both directions: - supplier MOQ (customer 4, we buy 10) → invoiced 10, over-billing 6 - shorted line (customer 100, supplier covers 40) → invoiced 40, under-billing 60 until the operator splits the remainder The invoicing screen never fetched `quantity` at all (zero occurrences in page.tsx), so it could not bill the customer quantity even in principle. Added to the PB field projection, which is load-bearing: drop it and PB returns undefined and every line silently falls back to order_qty. A page-level test now fails if the projection drifts. `maxSurplusFor` measured the surplus as `collected - order_qty`, which on an MOQ line is always 0 — the overflow was mathematically unbookable and vanished from inventory by construction, not by operator error. Four call sites built delivery-note lines by hand; two passed `quantity` and two forgot it, so the same DN printed the customer's 4 via the service layer and the supplier's 10 via the API routes. All four now share `buildDeliveryNoteLine`, so they cannot diverge again. `order_qty` had no doc comment at all next to an 11-line one on `quantity` — that absence is why five subsystems guessed differently. Now documented, with a new `src/lib/order-item-qty.ts` holding `customerQty` / `supplierQty` as the single definition. driver-stops.ts and collection-confirm/po-card.tsx read `order_qty` CORRECTLY (they check what the supplier delivered against what we ordered from them) and carry comments saying so, so the next cleanup doesn't "fix" them into breaking. 23 new tests. Full suite: no regressions — the 15 failures are identical before and after (verified by stashing and re-running). Audit: docs/superpowers/specs/2026-08-30-quantity-handling-audit.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:28:33 +00:00
import { buildDeliveryNoteLine } from "@/lib/pdf/delivery-note-document";
fix(delivery-note): signed copy invisible, and a bigger QR in the corner TWO bugs, one of which was hiding the other. 1. "No signed copy on file" for a note that had just been signed, scanned and uploaded. The upload was never the problem — the READ path was. `delivery_notes.signed_copy` is a PB file field with `maxSelect: 1`: we WRITE it as an array (`signed_copy: [file]`), but PocketBase RETURNS A BARE STRING when exactly one file is attached. Verified on the live record after a real upload: typeof : string Array.isArray : false value : "image_ar52u2hjfq.jpg" value[0] : "i" <-- indexing yields a CHARACTER Every read site assumed `string[]`, and not one of them threw: - signed-copy GET: `Array.isArray(x) ? x[0] : null` returned null for notes that demonstrably had a copy — the reported error - delivery-note detail tooltip: `signed_copy[0]` rendered the first LETTER of the filename - delivery-notes-service: treated every signed copy as absent, so the quote went out with the UNSIGNED pdf attached - deliveries-service: reported an empty filename - deliveries page: `.join(", ")` on a string — this one THREW, on a page people open daily Adds `src/lib/file-fields.ts` (firstFileName / fileNames / hasFile) and routes all of them through it. The type was the root of the wrong assumptions, so it is now `string | string[]` with a comment pointing at the helper. 2. QR 28pt -> 52pt, moved to the top-right corner. Measured by rasterising the real PDF at 200 dpi and decoding: it now reads at 1.24 px/module, where the old size needed ~2.5 — roughly double the distance and angle tolerance. It could not simply go at top:40: the logo is an absolute 160x100pt block in the same corner, so the QR landed on top of it. That is not only ugly, it breaks the QR's quiet zone — logo ink inside the light margin makes the code refuse to decode. The logo now steps left (right: 112 -> 106, width 160 -> 132) to give the QR the true corner, and the company name is width-bounded ONLY when a logo is present. The Westscott letterhead has no matching .svg in public/letterhead/, so in practice the name still gets the full width and stays on one line. Nine tests pin the file-field shapes, including one that asserts the old `.join(", ")` call really does throw on a string, so the string case cannot be quietly dropped again.
2026-10-04 15:00:30 +00:00
import { firstFileName } from "@/lib/file-fields";
import {
DELIVERY_NOTE_LETTERHEAD_CODE,
deliveryNoteLetterheadCode,
fetchLetterhead,
loadLetterheadLogoDataUri,
} from "@/lib/letterhead";
// Re-export so existing callers that imported the constant from this
// module (`import { DELIVERY_NOTE_LETTERHEAD_CODE } from "@/lib/server/delivery-notes-service"`)
// keep working after the canonical home moved to `@/lib/letterhead`.
export { DELIVERY_NOTE_LETTERHEAD_CODE };
import type {
DeliveryNote,
DeliveryNoteStatus,
OrderItem,
Quote,
QuoteItem,
} from "@/lib/types";
const DELIVERY_NOTES = "delivery_notes";
const ORDER_ITEMS = "order_items";
const QUOTES = "quotes";
const QUOTE_ITEMS = "quote_items";
const QUOTE_ATTACHMENTS = "quote_attachments";
const CUSTOMERS = "customers";
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
/**
* Lifecycle transitions for the delivery_notes.status field. The
* service methods enforce these — they reject any transition that
* isn't on the list, so the route layer can pass the requested
* status and trust that the service won't silently corrupt state.
*/
const ALLOWED_TRANSITIONS: Record<DeliveryNoteStatus, DeliveryNoteStatus[]> = {
draft: ["sent", "loaded", "cancelled"],
sent: ["po_received", "loaded", "cancelled", "draft"],
loaded: ["po_received", "sent", "cancelled"],
po_received: ["invoiced", "cancelled"],
invoiced: ["completed", "cancelled"],
completed: [],
cancelled: ["draft"],
};
/** Throws a typed error the route can surface verbatim to the user. */
function rejectTransition(
from: DeliveryNoteStatus,
to: DeliveryNoteStatus,
): never {
const allowed = ALLOWED_TRANSITIONS[from] ?? [];
const list = allowed.length > 0 ? allowed.join(", ") : "none";
throw new Error(
`Cannot transition delivery note from "${from}" to "${to}" (allowed: ${list}).`,
);
}
function assertTransition(
from: DeliveryNoteStatus,
to: DeliveryNoteStatus,
): void {
const allowed = ALLOWED_TRANSITIONS[from] ?? [];
if (!allowed.includes(to)) rejectTransition(from, to);
}
/**
* The result of a Create-Quote action. The route hands this back
* to the client so the UI can navigate to the new quote.
*/
export interface CreateQuoteFromDeliveryNoteResult {
id: string;
quote_number: string;
}
/**
* Create a Quote that prices the items in this delivery note.
*
* Reads:
* - delivery_notes[id] — the header (customer, company)
* - order_items where delivery_note_number = dn.delivery_note_number
*
* Writes:
* - quotes.create (status="delivery_note", delivery_note=dn.id)
* - quote_items.create (one per order_item, description / qty /
* supplier / cost_price / selling_price copied)
* - delivery_notes.update (status: "draft" → "sent")
*
* Pricing rule (per the user's brief):
* - If order_item.selling_price is set, copy it to the quote_item.
* The operator confirms on the quote detail page.
* - If order_item.selling_price is null, copy cost_price so the
* operator can see what they paid and decide a margin. The
* quote_item.pricing_source is set to "manual" so the UI shows
* a "needs pricing" badge.
*
* Idempotency: a delivery note should only have ONE quote linked
* (status="delivery_note" and delivery_note=dn.id). If one already
* exists, the route surfaces a 409 — the operator can re-open the
* existing quote instead of creating a duplicate.
*/
export async function createQuoteFromDeliveryNote(
deliveryNoteId: string,
): Promise<CreateQuoteFromDeliveryNoteResult> {
const pb = await getAdminPocketBase();
const dn = await pb.collection(DELIVERY_NOTES).getOne<DeliveryNote>(deliveryNoteId, {
expand: "customer,company,order",
});
if (dn.status !== "draft") {
throw new Error(
`Delivery note must be in "draft" status to create a quote (current: "${dn.status}").`,
);
}
// Reject if a quote already exists for this delivery note. The
// status filter is a belt-and-braces check — the operator could
// in theory have transitioned the previous quote to "sent" or
// beyond, in which case the relationship is no longer "active".
// We look for the *primary* quote (status="delivery_note" with
// delivery_note=dn.id) — a quote that has been sent and is now
// in flight doesn't count.
const existing = await pb.collection(QUOTES).getList<Quote>(1, 1, {
filter: `delivery_note = "${deliveryNoteId}" && status = "delivery_note"`,
});
if (existing.items.length > 0) {
const q = existing.items[0];
throw new Error(
`A delivery-note quote already exists for this delivery note (${q.quote_number}). Open it instead of creating a duplicate.`,
);
}
// Fetch the order_items that this delivery note groups together.
// No expand here — we only need the fields listed below.
const items = await pb.collection(ORDER_ITEMS).getFullList<OrderItem>({
filter: `delivery_note_number = "${dn.delivery_note_number}"`,
});
if (items.length === 0) {
throw new Error(
`No order items found with delivery_note_number = "${dn.delivery_note_number}". Cannot create an empty quote.`,
);
}
const quoteNumber = await generateQuoteNumberServer();
const now = new Date().toISOString();
// Create the quote header.
const quoteCreate: Record<string, unknown> = {
quote_number: quoteNumber,
status: "delivery_note",
delivery_note: dn.id,
// customer/company are required on the Quote collection schema.
// The delivery note has both, so we just copy through.
customer: dn.customer ?? "",
company: dn.company ?? "",
quote_date: now,
validity_days: 30,
// Stamp the delivery note number on the quote as the RFQ
// reference. The DN IS the request for pricing here — it's
// the document that the office is responding to with the
// quote. Operators see the DN number on the quote's RFQ line
// and know instantly "this came from a delivery note, not
// from a customer email".
rfq_number: dn.delivery_note_number,
description:
// `??` only falls back on null/undefined, so an empty
// `dn.notes` would pass through as an empty description.
// `||` also catches empty strings, which is what we want —
// an empty notes value should trigger the default.
dn.notes || `Delivery note ${dn.delivery_note_number} pricing`,
};
const quote = await pb.collection(QUOTES).create<Quote>(quoteCreate);
// Create one quote_item per order_item. We deliberately DON'T
// touch order_item status here — the items stay at
// item_status="delivery_note" until the operator clicks
// "Mark Invoiced". The quote and the order_items are two views
// of the same physical goods; flipping order_items here would
// break the Invoiced-tab count before any invoice exists.
for (const it of items) {
const itemCreate: Record<string, unknown> = {
quote: quote.id,
quote_number: quoteNumber,
description: it.description ?? "",
quantity: Number(it.quantity ?? 1),
// Copy the supplier so the operator can see the original
// supplier on the quote detail page even if the order
// item's supplier is later reassigned.
supplier: it.supplier ?? null,
// Costs always carry over — the operator needs to see what
// the office paid to set a margin.
cost_price: Number(it.cost_price ?? 0),
// Selling price is best-effort: copy if set, otherwise 0
// and pricing_source="manual" so the UI flags it.
selling_price: Number(it.selling_price ?? 0),
notes: it.notes ?? "",
sku: it.sku ?? "",
pricing_source: it.selling_price ? it.pricing_source ?? "" : "manual",
};
await pb.collection(QUOTE_ITEMS).create<QuoteItem>(itemCreate);
}
// v3-C: also attach the delivery note to the new quote so
// the email-send modal can include it. The email-send
// modal already lets the operator check which
// quote_attachments to include in the outbound email, so
// by uploading the DN as kind="delivery_note" the operator
// can include "the delivery note" alongside "the quote PDF"
// in one click.
//
// v3-E.2 — signed-copy preference: if the operator has
// uploaded a signed copy to `delivery_notes.signed_copy`,
// we attach THAT (it's what the customer actually signed).
// Otherwise we re-render the unsigned PDF from the order
// items. Both versions are useful; signed is the right
// default when present.
//
// Best-effort: a failure here doesn't roll back the quote.
// The quote + items are the source of truth; the attachment
// is a convenience for the email-send step. If it fails, the
// operator can re-run the attach from the detail page.
try {
await attachDeliveryNoteToQuote(
quote.id,
quote.quote_number,
dn,
items,
);
} catch (attachErr) {
// Surface to the operator via console; the route layer
// doesn't need to fail the whole create-quote flow over a
// PDF render hiccup. The detail page can detect the
// missing attachment and offer to re-run.
console.error(
"[delivery-notes-service] delivery-note attach failed:",
attachErr,
);
}
// Flip the delivery note status: draft → sent (we now have a
// quote ready to send with the DN). Use the assert helper so a
// future schema-driven status rename can't silently no-op.
assertTransition("draft", "sent");
await pb.collection(DELIVERY_NOTES).update(dn.id, { status: "sent" });
return { id: quote.id, quote_number: quote.quote_number };
}
/**
* Attach the delivery note PDF (or signed copy if uploaded) to
* the new quote as a `quote_attachments` row of
* kind="delivery_note".
*
* The signed-copy preference:
* - If `dn.signed_copy` is set (operator uploaded a signed
* PDF / scan), we attach THAT. It's what the customer
* actually signed — the right thing to email back to them.
* - Otherwise we re-render the unsigned PDF from the order
* items (same render path the legacy delivery-note route
* uses, just inlined here so we don't need a network hop).
*
* What this gives the operator:
* - The email-send modal already shows quote_attachments
* with checkboxes. With this attachment on the quote, the
* operator can include both "the delivery note" and
* "the quote PDF" in the customer email in one send.
* - The quote detail page can also show the DN as a
* downloadable file alongside the quote's own PDF.
*
* Why this is one function: both code paths share the same
* downstream "upload to quote_attachments as kind=delivery_note"
* step. Splitting them would duplicate the upload + DB write
* for no real benefit — the only thing that differs is where
* the bytes come from (signed copy vs re-rendered).
*/
async function attachDeliveryNoteToQuote(
quoteId: string,
quoteNumber: string,
dn: DeliveryNote,
items: OrderItem[],
): Promise<void> {
const pb = await getAdminPocketBase();
// Path 1: signed copy already on the record. Download the
// bytes from PB's file endpoint and re-upload to the quote
// attachment (PB file fields are record-scoped, can't be
// shared by reference).
fix(delivery-note): signed copy invisible, and a bigger QR in the corner TWO bugs, one of which was hiding the other. 1. "No signed copy on file" for a note that had just been signed, scanned and uploaded. The upload was never the problem — the READ path was. `delivery_notes.signed_copy` is a PB file field with `maxSelect: 1`: we WRITE it as an array (`signed_copy: [file]`), but PocketBase RETURNS A BARE STRING when exactly one file is attached. Verified on the live record after a real upload: typeof : string Array.isArray : false value : "image_ar52u2hjfq.jpg" value[0] : "i" <-- indexing yields a CHARACTER Every read site assumed `string[]`, and not one of them threw: - signed-copy GET: `Array.isArray(x) ? x[0] : null` returned null for notes that demonstrably had a copy — the reported error - delivery-note detail tooltip: `signed_copy[0]` rendered the first LETTER of the filename - delivery-notes-service: treated every signed copy as absent, so the quote went out with the UNSIGNED pdf attached - deliveries-service: reported an empty filename - deliveries page: `.join(", ")` on a string — this one THREW, on a page people open daily Adds `src/lib/file-fields.ts` (firstFileName / fileNames / hasFile) and routes all of them through it. The type was the root of the wrong assumptions, so it is now `string | string[]` with a comment pointing at the helper. 2. QR 28pt -> 52pt, moved to the top-right corner. Measured by rasterising the real PDF at 200 dpi and decoding: it now reads at 1.24 px/module, where the old size needed ~2.5 — roughly double the distance and angle tolerance. It could not simply go at top:40: the logo is an absolute 160x100pt block in the same corner, so the QR landed on top of it. That is not only ugly, it breaks the QR's quiet zone — logo ink inside the light margin makes the code refuse to decode. The logo now steps left (right: 112 -> 106, width 160 -> 132) to give the QR the true corner, and the company name is width-bounded ONLY when a logo is present. The Westscott letterhead has no matching .svg in public/letterhead/, so in practice the name still gets the full width and stays on one line. Nine tests pin the file-field shapes, including one that asserts the old `.join(", ")` call really does throw on a string, so the string case cannot be quietly dropped again.
2026-10-04 15:00:30 +00:00
// maxSelect:1 field — PB hands back a bare string for one file, so
// the old Array.isArray check silently treated every signed copy as
// absent and the quote went out with the unsigned PDF attached.
const signedCopyName = firstFileName(dn.signed_copy);
let filename: string;
let blob: Blob;
let contentType: string;
let sizeBytes: number;
if (signedCopyName) {
// Fetch the bytes from PB. The URL is the public file
// endpoint; we use the admin client to bypass the
// per-user auth header.
const fileUrl = pb.files.getURL(dn, signedCopyName);
const res = await fetch(fileUrl);
if (!res.ok) {
throw new Error(
`Failed to fetch signed copy from PB (HTTP ${res.status})`,
);
}
const arrayBuffer = await res.arrayBuffer();
filename = signedCopyName;
contentType = res.headers.get("content-type") || "application/pdf";
sizeBytes = arrayBuffer.byteLength;
blob = new Blob([new Uint8Array(arrayBuffer)], { type: contentType });
} else {
// Path 2: re-render the unsigned PDF from the order items.
const companyName = dn.expand?.company?.company ?? "";
// All delivery-note PDFs render under the Westcott
// Group letterhead, regardless of which subsidiary the
// customer belongs to — see deliveryNoteLetterheadCode().
// 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 = deliveryNoteLetterheadCode();
const letterhead = await fetchLetterhead(letterheadCode);
const logoDataUri = await loadLetterheadLogoDataUri(letterheadCode);
const customerName = dn.expand?.customer?.customer ?? "";
const orderNumber = dn.expand?.order?.order_number ?? "";
const pdfBuffer = await renderDeliveryNotePdf({
header: {
deliveryNoteNumber: dn.delivery_note_number,
dateIso:
dn.delivery_date ||
new Date(dn.created || Date.now()).toISOString().slice(0, 10),
orderNumber,
companyName,
customerName,
},
fix(orders): bill the customer quantity, not the supplier order_qty `order_items.order_qty` is the SUPPLIER-facing quantity — what prints on the PO. Five call sites read it as the customer quantity, which is a money bug in both directions: - supplier MOQ (customer 4, we buy 10) → invoiced 10, over-billing 6 - shorted line (customer 100, supplier covers 40) → invoiced 40, under-billing 60 until the operator splits the remainder The invoicing screen never fetched `quantity` at all (zero occurrences in page.tsx), so it could not bill the customer quantity even in principle. Added to the PB field projection, which is load-bearing: drop it and PB returns undefined and every line silently falls back to order_qty. A page-level test now fails if the projection drifts. `maxSurplusFor` measured the surplus as `collected - order_qty`, which on an MOQ line is always 0 — the overflow was mathematically unbookable and vanished from inventory by construction, not by operator error. Four call sites built delivery-note lines by hand; two passed `quantity` and two forgot it, so the same DN printed the customer's 4 via the service layer and the supplier's 10 via the API routes. All four now share `buildDeliveryNoteLine`, so they cannot diverge again. `order_qty` had no doc comment at all next to an 11-line one on `quantity` — that absence is why five subsystems guessed differently. Now documented, with a new `src/lib/order-item-qty.ts` holding `customerQty` / `supplierQty` as the single definition. driver-stops.ts and collection-confirm/po-card.tsx read `order_qty` CORRECTLY (they check what the supplier delivered against what we ordered from them) and carry comments saying so, so the next cleanup doesn't "fix" them into breaking. 23 new tests. Full suite: no regressions — the 15 failures are identical before and after (verified by stashing and re-running). Audit: docs/superpowers/specs/2026-08-30-quantity-handling-audit.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:28:33 +00:00
lines: items.map(buildDeliveryNoteLine),
letterhead,
logoDataUri,
});
filename = `${dn.delivery_note_number}.pdf`;
contentType = "application/pdf";
sizeBytes = pdfBuffer.byteLength;
blob = new Blob([new Uint8Array(pdfBuffer)], { type: contentType });
}
const form = new FormData();
form.append("file", blob, filename);
form.append("quote", quoteId);
form.append("filename", filename);
form.append("content_type", contentType);
form.append("size_bytes", String(sizeBytes));
form.append("kind", "delivery_note");
// Mark as outbound: the email-send UI distinguishes
// operator-uploaded attachments from inbound RFQ attachments.
form.append("direction", "outbound");
await pb.collection(QUOTE_ATTACHMENTS).create(form);
// `quoteNumber` is useful for log context if this ever
// needs to surface an error; reference it so the no-
// unused-vars lint is happy.
void quoteNumber;
}
/**
* Stamp the customer's PO on the delivery note and flip status
* from "sent" to "po_received". The operator triggers this after
* the customer sends their PO back with the quote.
*
* Idempotency: setting the same PO twice is a no-op (PB returns
* the same record). Setting a *different* PO on an already-
* po_received record is allowed — customers sometimes correct
* their PO mid-flight.
*/
export async function setDeliveryNoteCustomerPo(
deliveryNoteId: string,
customerPo: string,
): Promise<DeliveryNote> {
const pb = await getAdminPocketBase();
const trimmed = (customerPo ?? "").trim();
if (!trimmed) {
throw new Error("Customer PO cannot be empty.");
}
const dn = await pb.collection(DELIVERY_NOTES).getOne<DeliveryNote>(deliveryNoteId);
// Allow the operator to set the PO again on a po_received /
// invoiced / completed record (customer may have sent a
// correction). Only "sent" → "po_received" is an automatic
// status flip; later transitions are explicit operator actions.
if (dn.status === "sent") {
assertTransition("sent", "po_received");
return pb.collection(DELIVERY_NOTES).update<DeliveryNote>(dn.id, {
customer_po: trimmed,
status: "po_received",
});
}
if (dn.status === "po_received" || dn.status === "invoiced" || dn.status === "completed") {
return pb.collection(DELIVERY_NOTES).update<DeliveryNote>(dn.id, {
customer_po: trimmed,
});
}
// draft → would skip the "sent" step (we never sent a quote)
// cancelled → pointless
throw new Error(
`Cannot set customer PO on a delivery note in status "${dn.status}". The note must be sent first.`,
);
}
/**
* Shape accepted by `setDeliveryNoteOrder`. Mirrors the actor
* pattern used by `setOrderQuote` and the rest of the
* agent-facing services.
*/
export interface SetDeliveryNoteOrderInput {
deliveryNoteId: string;
orderId: string;
actor?: { id: string; name: string };
}
/**
* Stamp or replace the parent `order` relation on a delivery note.
*
* Background: `delivery_notes.order` is a relation to `orders` but
* has never had a setter in the code — only the `createQuoteFrom-
* DeliveryNote` / `createAdHocDeliveryNote` writes populate it
* (the latter leaves it null on purpose). Hermes needs to link a
* delivery note to an order mid-email-intake when one is found in
* an inbound RFQ email — without this setter the agent can't
* associate a discovered DN with the order it originated from.
*
* Refusal semantics:
* - 404 if either the delivery note or the order doesn't exist.
* - 409 if the delivery note is in `invoiced` or `completed`
* status. Changing the matched order late would orphan the
* invoice line items that were booked off this DN's items,
* and on a `completed` DN the work has been closed out —
* re-matching would rewrite history. Operator must clone the
* DN into a new one and re-link from there if they really
* need to change the matched order late in the lifecycle.
*
* Idempotency: setting the same order on a DN that already points
* at it is a no-op (PB update is a no-op when the value doesn't
* change). Setting a different order is the normal use case.
*
* Actor stamping: when `actor` is provided, append a single-line
* audit entry onto the DN's free-text `notes` field so the
* WFDASH UI surfaces which changes came from the agent. The
* `notes` field on `delivery_notes` is free-text (not a JSON
* feed like `order_items.notes`).
*
* Customer/company backfill: `delivery_notes.customer` /
* `.company` are meant to mirror the linked order's customer /
* company (see the header-create block in the legacy
* `/api/invoicing/delivery-note` route). A DN created while those
* relations failed to resolve (e.g. the 2026-08-30 → 2026-09-07
* `fields`-strips-`expand` regression — see git history on that
* route) ends up with `customer`/`company`/`order` all blank. When
* this setter links the order, it also fills `customer`/`company`
* from that order IF the DN's own values are currently blank —
* never overwrites an operator-set value. This makes re-linking
* the order via this one call enough to fully backfill an affected
* DN, instead of needing a second, nonexistent setter.
*/
export async function setDeliveryNoteOrder(
input: SetDeliveryNoteOrderInput,
): Promise<{ deliveryNote: DeliveryNote; previous: { order: string } }> {
const pb = await getAdminPocketBase();
const deliveryNoteId = String(input?.deliveryNoteId ?? "").trim();
const orderId = String(input?.orderId ?? "").trim();
if (!deliveryNoteId) {
const err = new Error("deliveryNoteId is required");
(err as Error & { status?: number }).status = 400;
throw err;
}
if (!orderId) {
const err = new Error("orderId is required");
(err as Error & { status?: number }).status = 400;
throw err;
}
// 404 if the DN doesn't exist.
const dn = await pb
.collection(DELIVERY_NOTES)
.getOne<DeliveryNote>(deliveryNoteId)
.catch(() => null);
if (!dn) {
const err = new Error(`Delivery note ${deliveryNoteId} not found`);
(err as Error & { status?: number }).status = 404;
throw err;
}
// 404 if the order doesn't exist. PB would 400 on a bad FK id,
// but a 404 from getOne is a cleaner signal for the caller. The
// record is kept (not discarded) — its `customer`/`company`
// relation ids feed the backfill below.
let order: { customer?: string; company?: string } | null = null;
try {
order = await pb
.collection(ORDERS)
.getOne<{ customer?: string; company?: string }>(orderId);
} catch {
const err = new Error(`Order ${orderId} not found`);
(err as Error & { status?: number }).status = 404;
throw err;
}
// 409 if the DN is too far through the lifecycle. Changing the
// matched order at this point would orphan invoice lines that
// were booked off the DN's order_items (those items are
// identified by `delivery_note_number`, not by `order`, but the
// DN's UI surfaces the order the items came from — swapping the
// order after invoicing would render the invoiced lines
// disconnected from the customer's order document).
if (dn.status === "invoiced" || dn.status === "completed") {
const err = new Error(
`Cannot change the matched order on a delivery note in status "${dn.status}". ` +
`Clone the note into a new one and re-link from there.`,
);
(err as Error & { status?: number }).status = 409;
(err as Error & { data?: unknown }).data = {
code: "delivery_note_locked",
status: dn.status,
};
throw err;
}
const previous: { order: string } = { order: dn.order || "" };
// Backfill customer/company from the order — only when the DN's
// own value is currently blank. See the docstring above. Computed
// up front so it also applies on the idempotent (order-unchanged)
// path below: a DN can already point at the right order yet still
// be missing customer/company (exactly the shape the 2026-08-30 →
// 2026-09-07 `fields`-strips-`expand` bug left behind), and a
// second call with the same order id is the natural way an
// operator/agent re-runs this setter to fix that up.
const backfill: Record<string, unknown> = {};
if (!dn.customer && order?.customer) {
backfill.customer = order.customer;
}
if (!dn.company && order?.company) {
backfill.company = order.company;
}
// Idempotent on `order`: don't re-write it, don't re-stamp notes —
// but still apply the customer/company backfill if there's one to
// apply, since that's a pure data-quality fix, not a "change".
if (previous.order === orderId) {
if (Object.keys(backfill).length > 0) {
await pb.collection(DELIVERY_NOTES).update<DeliveryNote>(deliveryNoteId, backfill);
}
const fresh = await pb
.collection(DELIVERY_NOTES)
.getOne<DeliveryNote>(deliveryNoteId);
return { deliveryNote: fresh, previous };
}
// Optional actor stamp: append a single-line audit entry onto
// the DN's free-text notes field. We always also update the
// `order` relation; the stamp is just an audit breadcrumb.
const update: Record<string, unknown> = { order: orderId, ...backfill };
if (input.actor?.id && input.actor?.name) {
const stamp = `[${new Date().toISOString()}] ${input.actor.name} linked this delivery note to order ${orderId}`;
const existingNotes = (dn.notes ?? "").toString();
update.notes = existingNotes ? `${existingNotes}
${stamp}` : stamp;
}
await pb.collection(DELIVERY_NOTES).update<DeliveryNote>(deliveryNoteId, update);
const updated = await pb
.collection(DELIVERY_NOTES)
.getOne<DeliveryNote>(deliveryNoteId);
return { deliveryNote: updated, previous };
}
/**
* Mark the delivery note as "invoiced". This is the operator's
* signal that the invoice has been created (through the normal
* Invoicing page). It does NOT create the invoice itself — that
* happens through the existing Invoicing flow against the items
* with item_status="ready_invoice".
*
* What it does:
* 1. Flip every order_item with this delivery_note_number from
* item_status="delivery_note" → item_status="ready_invoice".
* After this, the items appear in the Invoicing page ready
* to be invoiced.
* 2. Flip delivery_notes.status from "po_received" → "invoiced".
*
* The "completed" transition (invoiced → completed) is left to
* the operator to do manually, since "completed" depends on
* whether the invoice has been *paid* — that's an external
* signal, not something the office tracks inside WFDASH.
*/
export async function markDeliveryNoteInvoiced(
deliveryNoteId: string,
): Promise<DeliveryNote> {
const pb = await getAdminPocketBase();
const dn = await pb.collection(DELIVERY_NOTES).getOne<DeliveryNote>(deliveryNoteId);
if (dn.status !== "po_received") {
throw new Error(
`Delivery note must be in "po_received" status to mark invoiced (current: "${dn.status}").`,
);
}
// Belt-and-braces: PB should reject "invoiced" if it isn't in
// the enum, but the explicit assert surfaces a clearer error if
// a future migration drops the value.
assertTransition("po_received", "invoiced");
// Flip the order_items to "ready_invoice" so they show up in
// the Invoicing page. Done as a parallel fan-out for the
// common case of a small batch. For very large batches the
// existing Invoicing flow batches PATCHes the same way.
const items = await pb.collection(ORDER_ITEMS).getFullList<OrderItem>({
filter: `delivery_note_number = "${dn.delivery_note_number}"`,
});
if (items.length === 0) {
throw new Error(
`No order items found with delivery_note_number = "${dn.delivery_note_number}". Cannot mark as invoiced.`,
);
}
await Promise.all(
items.map((it) =>
pb.collection(ORDER_ITEMS).update(it.id, {
item_status: "ready_invoice",
}),
),
);
return pb.collection(DELIVERY_NOTES).update<DeliveryNote>(dn.id, {
status: "invoiced",
});
}
/**
* Mark the delivery note as "completed". Operator signal that
* the work has wrapped up and the items are no longer in flight.
* Allowed from "invoiced" only — once cancelled you have to
* revert to draft first.
*
* Side effects:
* - delivery_notes.status flips "invoiced" → "completed".
*/
export async function markDeliveryNoteComplete(deliveryNoteId: string): Promise<DeliveryNote> {
const pb = await getAdminPocketBase();
const dn = await pb.collection(DELIVERY_NOTES).getOne<DeliveryNote>(deliveryNoteId);
if (dn.status !== "invoiced") {
throw new Error(
`Delivery note must be in "invoiced" status to mark complete (current: "${dn.status}").`,
);
}
assertTransition("invoiced", "completed");
return pb.collection(DELIVERY_NOTES).update<DeliveryNote>(dn.id, {
status: "completed",
});
}
export interface MarkDeliveryNoteCompletedByInvoiceInput {
/**
* The QuickBooks invoice number / id returned by the n8n
* `wfdash-quickbooks-invoice-creator` webhook. Stamped on the
* DN's `notes` field so an auditor can find the DN from the
* invoice (and vice-versa) later.
*/
invoiceNumber: string;
/**
* Operator display name (the one who clicked "Invoice via
* QuickBooks"). Stamped alongside the invoice number for
* accountability.
*/
sentByName?: string | null;
/**
* Optional ISO timestamp. Defaults to `new Date().toISOString()`
* server-side. Useful in tests for deterministic assertions.
*/
sentAtIso?: string;
}
/**
* Auto-close a delivery note after a successful QuickBooks invoice
* creation. Used by the `/api/v1/delivery-notes/[id]/invoice`
* endpoint — the page-level "Invoice via QuickBooks" button calls
* this after the n8n webhook returns 2xx, so the operator doesn't
* have to click "Mark complete" as a second step.
*
* Same eligibility as `markDeliveryNoteComplete`:
* - DN must currently be in "invoiced" status.
* - Allowed transition: invoiced → completed.
*
* Audit trail: a single banner is appended to `delivery_notes.notes`
* (with a leading newline separator if notes was non-empty) of the
* form:
*
* Auto-closed via QuickBooks invoice <num> by <name> on <iso>
*
* The banner mirrors the convention used by `cancelDeliveryNote`
* (single-line, leading newline separator, no leading/trailing
* whitespace). Operators can grep notes for "QuickBooks" to find
* every DN that was auto-closed by this flow.
*
* Why we don't reuse `markDeliveryNoteComplete` then PATCH notes
* separately: a half-applied state (status=completed, no banner)
* would still pass existing tests and look correct in the UI but
* lose the audit trail. Doing both fields in a single update keeps
* the audit trail atomic with the status flip.
*/
export async function markDeliveryNoteCompletedByInvoice(
deliveryNoteId: string,
input: MarkDeliveryNoteCompletedByInvoiceInput,
): Promise<DeliveryNote> {
const pb = await getAdminPocketBase();
const dn = await pb.collection(DELIVERY_NOTES).getOne<DeliveryNote>(deliveryNoteId);
// Operator signal: invoice and close from ANY non-terminal stage.
// The "Invoice via QuickBooks" button on the detail page is shown
// on every active DN (draft / sent / po_received / invoiced). The
// only statuses we refuse are the terminal ones — `completed`
// (already done) and `cancelled` (don't resurrect a cancelled DN).
if (dn.status === "completed" || dn.status === "cancelled") {
throw new Error(
`Delivery note in "${dn.status}" status cannot be auto-closed via invoice.`,
);
}
// Bypass the standard `ALLOWED_TRANSITIONS` graph (which only
// allows `invoiced → completed`). The QB flow is the operator's
// explicit "fast-forward" — they picked the customer, they
// confirmed the order number, they hit Send. The webhook's
// writeback is what flips the items to `invoiced`; the DN can
// skip straight to `completed` because the audit banner on
// `notes` records the invoice number that was created.
// We intentionally do NOT touch `ALLOWED_TRANSITIONS` itself —
// the manual "Mark complete" button still uses the standard
// transition. The fast-forward only applies to this entrypoint.
const invoiceNumber = input.invoiceNumber.trim();
if (!invoiceNumber) {
throw new Error("invoiceNumber is required to auto-close the delivery note.");
}
const sentByName = (input.sentByName ?? "").trim();
const sentAt = input.sentAtIso ?? new Date().toISOString();
const banner = sentByName
? `Auto-closed via QuickBooks invoice ${invoiceNumber} by ${sentByName} on ${sentAt}`
: `Auto-closed via QuickBooks invoice ${invoiceNumber} on ${sentAt}`;
const existingNotes = (dn.notes ?? "").trimEnd();
const mergedNotes = existingNotes
? `${existingNotes}\n${banner}`
: banner;
return pb.collection(DELIVERY_NOTES).update<DeliveryNote>(dn.id, {
status: "completed",
notes: mergedNotes,
});
}
export interface CancelDeliveryNoteInput {
setById?: string | null;
setByName?: string | null;
note?: string | null;
}
/**
* Cancel a delivery note. Items are flipped to "cancelled"
* (no longer "delivery_note") so they fall off the Invoicing
* "ready for delivery note" list, with a note stamping who
* cancelled it, when, and why. The DN header itself also goes
* to "cancelled" — both ends match.
*
* Why "cancelled" on the order_items, not "completed": the
* `order_items.item_status` select field on PB does not include
* "completed" (only the `delivery_notes.status` field does — the
* mark-invoiced flow on the DN uses it as the terminal state).
* We mirror the DN's own "cancelled" status on the items so the
* order_items enum is satisfied without needing a PB admin
* schema change, and so the audit trail reads consistently
* (cancelled DN → cancelled items).
*
* Allowed from draft / sent / po_received / invoiced. From
* "completed" you must not cancel — terminal. From "cancelled"
* it's a no-op.
*/
export async function cancelDeliveryNote(
deliveryNoteId: string,
input: CancelDeliveryNoteInput = {},
): Promise<DeliveryNote> {
const pb = await getAdminPocketBase();
const dn = await pb.collection(DELIVERY_NOTES).getOne<DeliveryNote>(deliveryNoteId);
const from = dn.status;
if (from === "cancelled") {
return dn;
}
if (from === "completed") {
throw new Error(
`Cannot cancel a delivery note in "completed" status.`,
);
}
assertTransition(from, "cancelled");
const trimmedNote = (input.note ?? "").trim();
const setById = input.setById ?? "";
const setByName = input.setByName ?? "";
const setAt = new Date().toISOString();
const banner = `Delivery note CANCELLED by ${setByName || setById || "unknown"} on ${setAt}${trimmedNote ? ` — ${trimmedNote}` : ""}`;
const items = await pb.collection(ORDER_ITEMS).getFullList<OrderItem>({
filter: `delivery_note_number = "${dn.delivery_note_number}"`,
});
await Promise.all(
items.map((it) =>
pb.collection(ORDER_ITEMS).update(it.id, {
item_status: "cancelled",
notes: it.notes ? `${it.notes}\n${banner}` : banner,
}),
),
);
return pb.collection(DELIVERY_NOTES).update<DeliveryNote>(dn.id, {
status: "cancelled",
});
}
// ─────────────────────────────────────────────────────────────────
// Ad-hoc (Path B) creation
// ─────────────────────────────────────────────────────────────────
/**
* Generate the next delivery note number for `now`. Format
* `DN{YY}{MM}{DD}{NN}` with a 2-digit per-day sequence. Counts the
* most-recent matching record (queries `order_items` because
* that's where the DN number is stamped on every line — querying
* the `delivery_notes` header would miss records where the header
* create failed and only the order_items stamps landed) and
* increments; concurrent requests may collide — accepted as a
* known limitation for v1.
*
* The mirror of this function lives in
* `src/app/api/invoicing/delivery-note/route.ts` (the legacy
* "mark as delivery note" route). Any change to the format here
* must be mirrored there too, or the two code paths will produce
* different numbers for the same day.
*/
export async function generateDeliveryNoteNumberServer(
now: Date = new Date(),
): Promise<string> {
const pb = await getAdminPocketBase();
const yy = String(now.getFullYear()).slice(-2);
const mm = String(now.getMonth() + 1).padStart(2, "0");
const dd = String(now.getDate()).padStart(2, "0");
const prefix = `DN${yy}${mm}${dd}`;
try {
const existing = await pb
.collection(ORDER_ITEMS)
.getList<OrderItem>(1, 1, {
filter: `delivery_note_number ~ "${prefix}"`,
sort: "-delivery_note_number",
});
let seq = 1;
if (existing.items.length > 0) {
const last = existing.items[0].delivery_note_number ?? "";
const lastSeq = parseInt(last.slice(prefix.length), 10);
if (Number.isFinite(lastSeq) && lastSeq > 0) {
seq = lastSeq + 1;
}
}
return `${prefix}${String(seq).padStart(2, "0")}`;
} catch {
// If the `delivery_note_number` field doesn't exist on
// order_items yet, fall back to a random suffix so the
// operation still completes. The operator can rename in PB
// admin if needed.
const fallback = Math.floor(Math.random() * 90 + 10);
return `${prefix}${fallback}`;
}
}
feat(pos): raise a system delivery note on an account sale An account sale at the till used to produce a delivery-note-shaped slip that was rendered entirely on the device and printed on the 80mm thermal printer. Nothing reached the server, so the paperwork the client signed had no record behind it. Cash / card / EFT are untouched and still get the thermal slip. An account sale now creates a real `delivery_notes` record. The DN is raised server-side INSIDE `checkoutSale` rather than by a follow-up call from the till, because the offline queue (`flushQueue`) re-posts `/checkout` and nothing else — a client-side second call would be silently dropped for every sale taken while the network was down, which is exactly when a till is flakiest. `pos_sales.delivery_note` is the idempotency anchor that makes it exactly-once: on a replay it hands back the existing DN instead of making a second one, and a sale whose first attempt left the field blank retries. It self-heals. Deliberately non-fatal: if the DN can't be created the sale still succeeds. The money and the stock movement are real by then; failing the checkout would leave the drawer disagreeing with the stock ledger. The till is told instead. Also in here: - DN number collision retry. The generator reads the highest existing number and adds one, which is not atomic, while PB holds a unique index on it. The POS adds a second concurrent writer, so two creates could collide and 500 on a benign condition. - GET /api/v1/pos/sales now expands customer, customer.company and delivery_note so history.html can rebuild the DN card on a reprint. The nested customer.company matters — customer.company is itself a relation, so without it the card printed a raw PB id where the name belongs. - "Print slip" is hidden for account sales; that tender produces a delivery note, not a slip. New GET /api/v1/pos/delivery-note/[number]/pdf streams the A4 original behind assertPosAuth, so the till's bearer key stays its only credential. It does not point at the CRM's unauthenticated invoicing route. Verified end to end against a live note: DN26100401, correct company, both lines with pricing, PDF attached, stock decremented once, and a replay of the same client_ref returned the same DN without a duplicate.
2026-10-04 11:15:29 +00:00
/**
* Did this PocketBase create fail because the delivery note number
* was already taken?
*
* PB surfaces a unique-index violation as a 400 with a per-field
* `data` map, so the reliable check is "the error names our field"
* rather than matching a message string that varies by SDK version.
* The message is checked too as a fallback for the older shape where
* the field map is absent.
*
* Kept narrow on purpose: anything that isn't recognisably a number
* collision (a bad customer id, a PB outage) must propagate, or the
* retry loop would swallow a real failure and retry it five times.
*/
function isDeliveryNoteNumberCollision(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const e = err as {
message?: unknown;
status?: unknown;
response?: { status?: unknown; data?: unknown };
data?: { [field: string]: { code?: string; message?: string } };
};
// PB's unique-violation code.
if (e.data?.delivery_note_number?.code === "validation_invalid_unique") return true;
const message = typeof e.message === "string" ? e.message : "";
return /delivery_note_number/i.test(message) && /unique|already exists|violat/i.test(message);
}
/**
* The shape of one line in the ad-hoc "create delivery note"
* form. The customer + qty are required; supplier + prices are
* optional (operator fills them in on the quote detail page if
* they're known at counter time, or leaves them for the
* catalogue-match step).
*/
export interface AdHocDeliveryNoteItemInput {
description: string;
quantity: number;
supplier?: string | null;
cost_price?: number | null;
selling_price?: number | null;
sku?: string | null;
notes?: string | null;
}
/**
* The full ad-hoc creation payload. The customer is required
* (we need somewhere to ship the items to); company is optional
* (we can derive from customer, or leave blank for unknown
* billing); the items list must have at least one entry.
*/
export interface AdHocDeliveryNoteInput {
customer: string;
company?: string | null;
notes?: string | null;
items: AdHocDeliveryNoteItemInput[];
/**
* PB user id of the operator who triggered the create. Used
* to stamp `created_by` on the delivery_notes header so the
* detail page can show "Created by X" in the audit panel.
*/
createdBy?: string | null;
}
export interface AdHocDeliveryNoteResult {
id: string;
delivery_note_number: string;
}
/**
* Create a delivery note from scratch — Path B (counter pickup).
* No supplier order, no previous quote, no prior history. The
* operator types in what the customer took off the shelf and
* the system creates:
*
* - one `delivery_notes` header (status="draft")
* - one `order_items` row per input item, stamped with the
* new DN number and `order: null` (no parent order — ad-hoc)
* - the DN PDF rendered + uploaded to every item's
* `order_item_attachements` field, so the operator can hit
* "Print" / "Download" immediately and `/api/invoicing/delivery-note-pdf`
* finds it. This mirrors what Path A (`/api/invoicing/delivery-note`)
* does after the status flip.
*
* The items are stamped with `item_status: "delivery_note"`
* immediately (the delivery-note lifecycle for ad-hoc items
* starts here; the operator doesn't need a separate "mark as
* delivery note" step like Path A does).
*
* Errors:
* - missing customer
* - empty items list
* - item with no description
* - item with quantity <= 0
*/
export async function createAdHocDeliveryNote(
input: AdHocDeliveryNoteInput,
): Promise<AdHocDeliveryNoteResult> {
const pb = await getAdminPocketBase();
// ── 1. Validate input ────────────────────────────────────────
if (!input.customer) {
throw new Error("Customer is required for an ad-hoc delivery note.");
}
if (!Array.isArray(input.items) || input.items.length === 0) {
throw new Error(
"At least one item is required for an ad-hoc delivery note.",
);
}
for (let i = 0; i < input.items.length; i++) {
const it = input.items[i];
if (!it.description || !it.description.trim()) {
throw new Error(`Item #${i + 1} is missing a description.`);
}
const qty = Number(it.quantity);
if (!Number.isFinite(qty) || qty <= 0) {
throw new Error(`Item #${i + 1} ("${it.description}") has invalid quantity: ${it.quantity}.`);
}
}
// ── 2. Generate the DN number ─────────────────────────────────
feat(pos): raise a system delivery note on an account sale An account sale at the till used to produce a delivery-note-shaped slip that was rendered entirely on the device and printed on the 80mm thermal printer. Nothing reached the server, so the paperwork the client signed had no record behind it. Cash / card / EFT are untouched and still get the thermal slip. An account sale now creates a real `delivery_notes` record. The DN is raised server-side INSIDE `checkoutSale` rather than by a follow-up call from the till, because the offline queue (`flushQueue`) re-posts `/checkout` and nothing else — a client-side second call would be silently dropped for every sale taken while the network was down, which is exactly when a till is flakiest. `pos_sales.delivery_note` is the idempotency anchor that makes it exactly-once: on a replay it hands back the existing DN instead of making a second one, and a sale whose first attempt left the field blank retries. It self-heals. Deliberately non-fatal: if the DN can't be created the sale still succeeds. The money and the stock movement are real by then; failing the checkout would leave the drawer disagreeing with the stock ledger. The till is told instead. Also in here: - DN number collision retry. The generator reads the highest existing number and adds one, which is not atomic, while PB holds a unique index on it. The POS adds a second concurrent writer, so two creates could collide and 500 on a benign condition. - GET /api/v1/pos/sales now expands customer, customer.company and delivery_note so history.html can rebuild the DN card on a reprint. The nested customer.company matters — customer.company is itself a relation, so without it the card printed a raw PB id where the name belongs. - "Print slip" is hidden for account sales; that tender produces a delivery note, not a slip. New GET /api/v1/pos/delivery-note/[number]/pdf streams the A4 original behind assertPosAuth, so the till's bearer key stays its only credential. It does not point at the CRM's unauthenticated invoicing route. Verified end to end against a live note: DN26100401, correct company, both lines with pricing, PDF attached, stock decremented once, and a replay of the same client_ref returned the same DN without a duplicate.
2026-10-04 11:15:29 +00:00
let deliveryNoteNumber = await generateDeliveryNoteNumberServer();
// ── 3. Create the delivery_notes header ──────────────────────
// The route layer is responsible for knowing the auth'd
// user; the service just trusts `createdBy` (which is null
// in tests / cron scenarios).
feat(pos): raise a system delivery note on an account sale An account sale at the till used to produce a delivery-note-shaped slip that was rendered entirely on the device and printed on the 80mm thermal printer. Nothing reached the server, so the paperwork the client signed had no record behind it. Cash / card / EFT are untouched and still get the thermal slip. An account sale now creates a real `delivery_notes` record. The DN is raised server-side INSIDE `checkoutSale` rather than by a follow-up call from the till, because the offline queue (`flushQueue`) re-posts `/checkout` and nothing else — a client-side second call would be silently dropped for every sale taken while the network was down, which is exactly when a till is flakiest. `pos_sales.delivery_note` is the idempotency anchor that makes it exactly-once: on a replay it hands back the existing DN instead of making a second one, and a sale whose first attempt left the field blank retries. It self-heals. Deliberately non-fatal: if the DN can't be created the sale still succeeds. The money and the stock movement are real by then; failing the checkout would leave the drawer disagreeing with the stock ledger. The till is told instead. Also in here: - DN number collision retry. The generator reads the highest existing number and adds one, which is not atomic, while PB holds a unique index on it. The POS adds a second concurrent writer, so two creates could collide and 500 on a benign condition. - GET /api/v1/pos/sales now expands customer, customer.company and delivery_note so history.html can rebuild the DN card on a reprint. The nested customer.company matters — customer.company is itself a relation, so without it the card printed a raw PB id where the name belongs. - "Print slip" is hidden for account sales; that tender produces a delivery note, not a slip. New GET /api/v1/pos/delivery-note/[number]/pdf streams the A4 original behind assertPosAuth, so the till's bearer key stays its only credential. It does not point at the CRM's unauthenticated invoicing route. Verified end to end against a live note: DN26100401, correct company, both lines with pricing, PDF attached, stock decremented once, and a replay of the same client_ref returned the same DN without a duplicate.
2026-10-04 11:15:29 +00:00
//
// Retried on a number collision. `generateDeliveryNoteNumberServer`
// reads the highest existing number and adds one — that read-then-
// write is not atomic, and PB holds a UNIQUE index on
// `delivery_notes.delivery_note_number`. Two creates racing (the
// POS now writes here too, alongside the CRM dialog) can therefore
// pick the same number, and without this loop the loser gets a bare
// 500 from the route for what is really a benign, self-healing
// condition. PB reports this as a validation failure on the field.
let header: DeliveryNote;
for (let attempt = 1; ; attempt++) {
try {
header = await pb.collection(DELIVERY_NOTES).create<DeliveryNote>({
delivery_note_number: deliveryNoteNumber,
customer: input.customer,
company: input.company ?? null,
order: null, // ad-hoc — no parent order
status: "draft",
notes: input.notes ?? "",
created_by: input.createdBy ?? null,
});
break;
} catch (err) {
const isCollision =
attempt < 5 &&
isDeliveryNoteNumberCollision(err);
if (!isCollision) throw err;
console.warn(
`[createAdHocDeliveryNote] ${deliveryNoteNumber} already taken — regenerating (attempt ${attempt + 1})`,
);
deliveryNoteNumber = await generateDeliveryNoteNumberServer();
}
}
// ── 4. Create the order_items rows ────────────────────────────
// Each item is ad-hoc (no parent order) and stamped with the
// DN number + the delivery-note status. We do this in parallel
// since the items are independent writes. The created rows are
// captured so step 5 can attach the PDF to each one.
const createdItems = await Promise.all(
input.items.map((it) =>
pb.collection(ORDER_ITEMS).create<OrderItem>({
order: null,
delivery_note_number: deliveryNoteNumber,
description: it.description.trim(),
quantity: Number(it.quantity),
supplier: it.supplier ?? null,
cost_price: Number(it.cost_price ?? 0),
selling_price:
it.selling_price != null ? Number(it.selling_price) : null,
sku: it.sku ?? "",
notes: it.notes ?? "",
// Ad-hoc items start the DN lifecycle here. The Path A
// flow does a separate "mark as delivery note" step in
// Invoicing; for Path B that step is implicit.
item_status: "delivery_note",
// The other workflow fields are not relevant for an
// ad-hoc DN — leave them at PB defaults.
}),
),
);
// ── 5. Render + upload the DN PDF ─────────────────────────────
// Mirrors `/api/invoicing/delivery-note` step 2: render the PDF
// and attach it to every item's `order_item_attachements` field
// so the print / download buttons on the detail page can find it.
// Without this step, the user lands on the detail page, clicks
// Download, and gets "No saved PDF for this delivery note" because
// no item has anything in the attachments field.
//
// The PDF needs a company name (for the letterhead) and a customer
// name (for the meta block). For ad-hoc DNs there's no parent
// order to derive either from, so we resolve them via the
// relations on the header. Failures here are non-fatal — the
// header + items are already saved; the operator can hit the
// print route which falls back to regenerating on the fly.
try {
const [customerRow, companyRow] = await Promise.all([
pb.collection(CUSTOMERS).getOne(input.customer).catch(() => null),
input.company
? pb.collection(COMPANIES).getOne(input.company).catch(() => null)
: Promise.resolve(null),
]);
const customerName =
typeof (customerRow as { customer?: unknown } | null)?.customer === "string"
? (customerRow as unknown as { customer: string }).customer
: "";
const companyName =
typeof (companyRow as { company?: unknown } | null)?.company === "string"
? (companyRow as unknown as { company: string }).company
: "";
const letterheadCode = deliveryNoteLetterheadCode();
const letterhead = await fetchLetterhead(letterheadCode);
const logoDataUri = await loadLetterheadLogoDataUri(letterheadCode);
const pdfBuffer = await renderDeliveryNotePdf({
header: {
deliveryNoteNumber,
dateIso: new Date().toISOString().slice(0, 10),
orderNumber: "", // ad-hoc — no parent order
companyName,
customerName,
},
fix(orders): bill the customer quantity, not the supplier order_qty `order_items.order_qty` is the SUPPLIER-facing quantity — what prints on the PO. Five call sites read it as the customer quantity, which is a money bug in both directions: - supplier MOQ (customer 4, we buy 10) → invoiced 10, over-billing 6 - shorted line (customer 100, supplier covers 40) → invoiced 40, under-billing 60 until the operator splits the remainder The invoicing screen never fetched `quantity` at all (zero occurrences in page.tsx), so it could not bill the customer quantity even in principle. Added to the PB field projection, which is load-bearing: drop it and PB returns undefined and every line silently falls back to order_qty. A page-level test now fails if the projection drifts. `maxSurplusFor` measured the surplus as `collected - order_qty`, which on an MOQ line is always 0 — the overflow was mathematically unbookable and vanished from inventory by construction, not by operator error. Four call sites built delivery-note lines by hand; two passed `quantity` and two forgot it, so the same DN printed the customer's 4 via the service layer and the supplier's 10 via the API routes. All four now share `buildDeliveryNoteLine`, so they cannot diverge again. `order_qty` had no doc comment at all next to an 11-line one on `quantity` — that absence is why five subsystems guessed differently. Now documented, with a new `src/lib/order-item-qty.ts` holding `customerQty` / `supplierQty` as the single definition. driver-stops.ts and collection-confirm/po-card.tsx read `order_qty` CORRECTLY (they check what the supplier delivered against what we ordered from them) and carry comments saying so, so the next cleanup doesn't "fix" them into breaking. 23 new tests. Full suite: no regressions — the 15 failures are identical before and after (verified by stashing and re-running). Audit: docs/superpowers/specs/2026-08-30-quantity-handling-audit.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:28:33 +00:00
lines: createdItems.map(buildDeliveryNoteLine),
letterhead,
logoDataUri,
});
const filename = `${deliveryNoteNumber}.pdf`;
// Compress the generated PDF once outside the per-item loop —
// every created item gets the same compressed bytes, and PB's
// multi-file field preserves them as a stable token from day
// one (see `attachment-compressor.ts` for the URL-churn
// rationale). The filename is unchanged; only the bytes shrink.
const { buffer: compressedPdfBuffer } = await compressBuffer(
pdfBuffer,
filename,
"application/pdf",
);
const blob = new Blob([new Uint8Array(compressedPdfBuffer)], {
type: "application/pdf",
});
await Promise.all(
createdItems.map(async (it) => {
const form = new FormData();
form.append("order_item_attachements", blob, filename);
await pb.collection(ORDER_ITEMS).update(it.id, form);
}),
);
} catch (pdfErr) {
// Non-fatal — surface in the server log so the operator can
// regenerate the PDF via the print route's fallback (which
// re-renders from the order_items rows when no saved copy is
// present), but don't fail the whole create.
console.error(
"[createAdHocDeliveryNote] PDF save failed (non-fatal):",
pdfErr,
);
}
return {
id: header.id,
delivery_note_number: deliveryNoteNumber,
};
}
/**
* Regenerate (and re-save) the DN PDF for an existing delivery note.
*
* Re-renders the PDF from the current `order_items` rows + the
* header relations, then uploads the bytes to each item's
* `order_item_attachements` field so the print / download /
* preview-PDF endpoints can serve it.
*
* Why this exists: the older `createAdHocDeliveryNote` flow
* created header + items but never saved a PDF, so any DN
* created before the save-on-create fix landed (e.g. `DN26081307`
* from the 2026-08-13 morning session) gets a `404 No saved PDF`
* from `/api/invoicing/delivery-note-pdf`. The button on the
* detail page that calls this lets the operator regenerate the
* PDF on demand. After it succeeds, the saved copy takes over
* and the regular endpoints serve it.
*
* Works for any DN — old or new. If the operator has edited
* items (e.g. re-priced a line) since the original PDF was
* saved, this picks up the latest state.
*
* Errors:
* - 404 (route layer) — DN id not found
* - 500 — render or upload failure
*/
export async function regenerateDeliveryNotePdf(
deliveryNoteId: string,
): Promise<{ pdfSaved: true; filename: string }> {
const pb = await getAdminPocketBase();
const dn = await pb
.collection(DELIVERY_NOTES)
.getOne<DeliveryNote>(deliveryNoteId);
const deliveryNoteNumber = dn.delivery_note_number;
// Pull the items for this DN. Same expand pattern as the print
// route's fallback render — we need order.customer / company
// for the PDF meta block, and the items themselves for the
// line rows. Ad-hoc DNs (no parent order) work fine here:
// `expand.order` will be undefined and we fall back to the
// header's customer / company relations.
const items = await pb
.collection(ORDER_ITEMS)
.getFullList<OrderItem>({
filter: `delivery_note_number = "${deliveryNoteNumber}"`,
expand: "purchase_order.supplier,order.company,order.customer",
});
if (items.length === 0) {
throw new Error(
`No order items found for delivery note ${deliveryNoteNumber}.`,
);
}
// Resolve customer / company names. Prefer the order's expand
// (Path A — items have a parent order), fall back to the
// header's customer / company relations (Path B — ad-hoc).
const firstOrder = items[0].expand?.order;
const customerName =
firstOrder?.expand?.customer?.customer ??
(await resolveRelationName(pb, dn.customer, CUSTOMERS, "customer")) ??
"";
const companyName =
firstOrder?.expand?.company?.company ??
(await resolveRelationName(pb, dn.company, COMPANIES, "company")) ??
"";
// All delivery-note PDFs render under the Westcott
// Group letterhead, regardless of which subsidiary the
// customer belongs to — see deliveryNoteLetterheadCode().
// 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 = deliveryNoteLetterheadCode();
const [letterhead, logoDataUri] = await Promise.all([
fetchLetterhead(letterheadCode),
loadLetterheadLogoDataUri(letterheadCode),
]);
const pdfBuffer = await renderDeliveryNotePdf({
header: {
deliveryNoteNumber,
dateIso:
dn.delivery_date ||
new Date(dn.created || Date.now()).toISOString().slice(0, 10),
orderNumber: firstOrder?.order_number ?? "",
companyName,
customerName,
},
fix(orders): bill the customer quantity, not the supplier order_qty `order_items.order_qty` is the SUPPLIER-facing quantity — what prints on the PO. Five call sites read it as the customer quantity, which is a money bug in both directions: - supplier MOQ (customer 4, we buy 10) → invoiced 10, over-billing 6 - shorted line (customer 100, supplier covers 40) → invoiced 40, under-billing 60 until the operator splits the remainder The invoicing screen never fetched `quantity` at all (zero occurrences in page.tsx), so it could not bill the customer quantity even in principle. Added to the PB field projection, which is load-bearing: drop it and PB returns undefined and every line silently falls back to order_qty. A page-level test now fails if the projection drifts. `maxSurplusFor` measured the surplus as `collected - order_qty`, which on an MOQ line is always 0 — the overflow was mathematically unbookable and vanished from inventory by construction, not by operator error. Four call sites built delivery-note lines by hand; two passed `quantity` and two forgot it, so the same DN printed the customer's 4 via the service layer and the supplier's 10 via the API routes. All four now share `buildDeliveryNoteLine`, so they cannot diverge again. `order_qty` had no doc comment at all next to an 11-line one on `quantity` — that absence is why five subsystems guessed differently. Now documented, with a new `src/lib/order-item-qty.ts` holding `customerQty` / `supplierQty` as the single definition. driver-stops.ts and collection-confirm/po-card.tsx read `order_qty` CORRECTLY (they check what the supplier delivered against what we ordered from them) and carry comments saying so, so the next cleanup doesn't "fix" them into breaking. 23 new tests. Full suite: no regressions — the 15 failures are identical before and after (verified by stashing and re-running). Audit: docs/superpowers/specs/2026-08-30-quantity-handling-audit.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:28:33 +00:00
lines: items.map(buildDeliveryNoteLine),
letterhead,
logoDataUri,
});
// Same upload pattern as `/api/invoicing/delivery-note` step 2:
// PB's file field PATCH REPLACES the existing list with whatever
// we send, so a re-regenerate cleanly overwrites the previous
// copy. Same filename → the retrieval endpoint (which looks for
// a file named `{deliveryNoteNumber}.pdf` on the first item)
// just picks up the new bytes.
const filename = `${deliveryNoteNumber}.pdf`;
// Compress the re-rendered PDF once outside the per-item loop.
// A regenerate is the operator fixing a price/line error and
// pushing a new revision — compressing before upload means the
// new revision's token is stable from the moment it's written,
// and operators don't churn the URL every time they touch a
// line item. See `attachment-compressor.ts` for the rationale.
const { buffer: compressedPdfBuffer } = await compressBuffer(
pdfBuffer,
filename,
"application/pdf",
);
const blob = new Blob([new Uint8Array(compressedPdfBuffer)], {
type: "application/pdf",
});
await Promise.all(
items.map(async (it) => {
const form = new FormData();
form.append("order_item_attachements", blob, filename);
await pb.collection(ORDER_ITEMS).update(it.id, form);
}),
);
return { pdfSaved: true, filename };
}
/**
* Look up a single relation's display field. Used by
* `regenerateDeliveryNotePdf` to fall back to the header's
* customer / company relations when the items don't have a
* parent order (ad-hoc / Path B delivery notes).
*
* Returns null when the relation is unset, the id is missing,
* or the lookup fails. The caller treats null as "blank" and
* the PDF meta block renders an empty cell.
*/
async function resolveRelationName(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
relationId: string | null | undefined,
collection: string,
field: string,
): Promise<string | null> {
if (!relationId) return null;
try {
const row = (await pb.collection(collection).getOne(relationId)) as {
[k: string]: unknown;
};
const value = row[field];
return typeof value === "string" && value.length > 0 ? value : null;
} catch {
return null;
}
}
/**
* Input for `combineDeliveryNotes`. The operator picks 2+ DNs
* from the list page; the service picks the oldest as the
* survivor (so the printed number the customer has already
* seen stays stable), moves every other DN's items onto the
* survivor, and cancels the others.
*
* Attaching the merged result to an order is optional. Two
* ways to do it (mutually exclusive):
* - `orderNumber` (string) — looked up against `orders.order_number`.
* Used by the operator typing the number they know. If the
* PB schema's `order_number` is non-unique (currently the
* case on this collection — see OrderCandidatesError below),
* the lookup can match multiple orders and the operator
* has to disambiguate via the second step.
* - `orderId` (PB id) — used after disambiguation, or when the
* picker UI provides the id directly. Bypasses the
* string lookup entirely; the service fetches the exact
* order row by id.
*
* `note` is appended to the survivor's notes ("Merged from
* DN…") and to each cancelled DN's notes for the audit trail.
*/
export interface CombineDeliveryNotesInput {
deliveryNoteIds: string[];
orderNumber?: string | null;
orderId?: string | null;
note?: string | null;
}
export interface CombineDeliveryNotesResult {
survivor: { id: string; delivery_note_number: string };
cancelled: Array<{ id: string; delivery_note_number: string }>;
itemsMoved: number;
pdfRegenerated: boolean;
}
/**
* One row in the "pick which order to attach" picker that
* surfaces when the operator's typed `orderNumber` matched
* more than one order. Carries just enough info for the
* operator to make a confident choice: PB id (used as the
* retry body's `orderId`), customer / company display
* names, and the order's created timestamp.
*/
export interface OrderCandidate {
id: string;
order_number: string;
customer_name: string | null;
company_name: string | null;
created: string;
}
/**
* Thrown by `combineDeliveryNotes` when the operator's typed
* `orderNumber` matched 2+ orders. The `orders` collection
* does NOT have a unique index on `order_number` (verified
* 2026-08-13 against live PB) — the same string can legitimately
* appear on multiple orders because the field is the customer's
* own PO / quote reference, not a unique system identifier.
*
* The route layer catches this and returns a 409 with the
* candidates attached, so the page modal can render a picker
* that lets the operator disambiguate by customer / company /
* created date. The retry path uses the picked candidate's id
* via `CombineDeliveryNotesInput.orderId` (no second string
* lookup — the operator's typed value is replaced by the
* specific id they picked).
*/
export class OrderCandidatesError extends Error {
public readonly candidates: OrderCandidate[];
public readonly orderNumber: string;
constructor(orderNumber: string, candidates: OrderCandidate[]) {
super(
`Order number "${orderNumber}" matched ${candidates.length} orders — pick the right one.`,
);
this.name = "OrderCandidatesError";
this.candidates = candidates;
this.orderNumber = orderNumber;
}
}
const COMBINABLE_STATUSES: DeliveryNoteStatus[] = ["draft", "sent"];
/**
* Combine N delivery notes into the oldest of the bunch.
*
* Workflow:
* 1. Fetch every selected DN header.
* 2. Reject if any is not in `draft` or `sent` (the user
* asked to gate combine on those — once a DN has a
* customer PO on it or an invoice was generated, the
* merge would invalidate the audit trail).
* 3. Sort by `created` ascending; the first one survives.
* 4. If the operator supplied `orderNumber`, re-fetch that
* order and stamp its id on the survivor header (+ the
* customer / company relations for completeness).
* 5. For each non-survivor DN:
* - re-stamp every item's `delivery_note_number` to the
* survivor's number (and its `order` to the new order
* id when `orderNumber` was supplied);
* - flip the DN header to status="cancelled" with a
* "Merged into <survivor number>" note appended to the
* existing notes field.
* 6. Regenerate the survivor's PDF so it lists every line
* (including the just-moved ones). Returns `pdfRegenerated:
* false` on render failure — the merge still committed,
* the operator can hit Regenerate on the detail page.
*
* The merge is *not* transactional: a failure midway leaves
* the data in a partially-merged state. We log every step so
* the operator (or an admin) can see where it stopped. Each
* item update and each header update is wrapped in its own
* try/catch so one bad row doesn't abort the rest of the
* batch.
*/
export async function combineDeliveryNotes(
input: CombineDeliveryNotesInput,
): Promise<CombineDeliveryNotesResult> {
const pb = await getAdminPocketBase();
const ids = Array.from(
new Set(
(input.deliveryNoteIds ?? [])
.filter((id): id is string => typeof id === "string" && id.length > 0),
),
);
if (ids.length < 2) {
throw new Error("Combine needs at least 2 distinct delivery notes.");
}
// 1. Fetch every header (one round trip).
const headers = await pb.collection(DELIVERY_NOTES).getFullList<DeliveryNote>({
filter: ids.map((id) => `id = "${id}"`).join(" || "),
});
if (headers.length !== ids.length) {
const found = new Set(headers.map((h) => h.id));
const missing = ids.filter((id) => !found.has(id));
throw new Error(`Delivery note(s) not found: ${missing.join(", ")}`);
}
// 2. Status guard.
for (const h of headers) {
if (!COMBINABLE_STATUSES.includes(h.status)) {
throw new Error(
`Delivery note ${h.delivery_note_number} is "${h.status}" — only draft or sent DNs can be combined.`,
);
}
}
// 3. Oldest survives.
headers.sort(
(a, b) =>
new Date(a.created || 0).getTime() -
new Date(b.created || 0).getTime(),
);
const survivor = headers[0];
const cancelled = headers.slice(1);
// 4. Resolve the order to attach. Two input paths:
// a) `orderId` (PB id) — operator already disambiguated,
// fetched straight by id, no string lookup. Used after
// the picker from the duplicate-match flow.
// b) `orderNumber` (string) — looked up against
// `orders.order_number`. If exactly one match, use it.
// If 0, throw "not found". If 2+, throw
// OrderCandidatesError with the candidate list so the
// modal can show a picker.
// c) Neither — leave the survivor's order unset (ad-hoc
// combined DN, no parent order).
let orderId: string | null = null;
let orderCustomerId: string | null = null;
let orderCompanyId: string | null = null;
if (input.orderId && input.orderId.trim().length > 0) {
try {
const order = await pb.collection(ORDERS).getOne<{
id: string;
order_number: string;
customer?: string;
company?: string;
}>(input.orderId.trim());
orderId = order.id;
orderCustomerId = order.customer ?? null;
orderCompanyId = order.company ?? null;
} catch (err) {
// The orderId we got from the picker was stale (deleted
// between the picker fetch and the retry). Surface a
// clear message — the operator just needs to redo the
// picker.
if (err instanceof Error) throw err;
throw new Error(String(err));
}
} else if (input.orderNumber && input.orderNumber.trim().length > 0) {
const orderNumber = input.orderNumber.trim();
try {
const orderRows = await pb.collection(ORDERS).getFullList<{
id: string;
order_number: string;
customer?: string;
company?: string;
created?: string;
}>({
filter: `order_number = "${orderNumber.replace(/"/g, '\\"')}"`,
});
if (orderRows.length === 0) {
throw new Error(
`Order "${orderNumber}" not found — leave the field blank to combine without linking an order.`,
);
}
if (orderRows.length > 1) {
// Non-unique match — surface the candidates so the
// operator can disambiguate via the picker on the
// modal. We batch one fetch of customer + company
// names so the picker rows show the right label
// instead of just the relation id.
const customerIds = Array.from(
new Set(
orderRows
.map((o) => o.customer)
.filter((c): c is string => typeof c === "string" && c.length > 0),
),
);
const companyIds = Array.from(
new Set(
orderRows
.map((o) => o.company)
.filter((c): c is string => typeof c === "string" && c.length > 0),
),
);
const [customerRows, companyRows] = await Promise.all([
customerIds.length > 0
? pb.collection("customers").getFullList<{
id: string;
customer?: string;
}>({
filter:
customerIds.map((id) => `id = "${id}"`).join(" || ") ||
"id = \"\"",
})
: Promise.resolve([] as Array<{ id: string; customer?: string }>),
companyIds.length > 0
? pb.collection("companies").getFullList<{
id: string;
company?: string;
}>({
filter:
companyIds.map((id) => `id = "${id}"`).join(" || ") ||
"id = \"\"",
})
: Promise.resolve([] as Array<{ id: string; company?: string }>),
]);
const customerById = new Map(
customerRows.map((c) => [c.id, c.customer ?? null]),
);
const companyById = new Map(
companyRows.map((c) => [c.id, c.company ?? null]),
);
const candidates: OrderCandidate[] = orderRows.map((o) => ({
id: o.id,
order_number: o.order_number,
customer_name: o.customer
? customerById.get(o.customer) ?? null
: null,
company_name: o.company
? companyById.get(o.company) ?? null
: null,
created: o.created ?? "",
}));
throw new OrderCandidatesError(orderNumber, candidates);
}
const order = orderRows[0];
orderId = order.id;
orderCustomerId = order.customer ?? null;
orderCompanyId = order.company ?? null;
} catch (err) {
// OrderCandidatesError is intentional — re-throw so the
// route layer can map it to a structured 409. Other
// errors bubble up as plain 500s.
if (err instanceof OrderCandidatesError) throw err;
if (err instanceof Error) throw err;
throw new Error(String(err));
}
}
// 5a. Patch the survivor header. When the operator linked
// an order, we override the customer / company to the
// order's relations so the merged DN points at exactly
// one customer + company record (the assumption being
// that all the items being merged were for the same
// physical order).
try {
const survivorPatch: Record<string, unknown> = {};
if (orderId) {
survivorPatch.order = orderId;
if (orderCustomerId) survivorPatch.customer = orderCustomerId;
if (orderCompanyId) survivorPatch.company = orderCompanyId;
}
const mergedFromNumbers = cancelled.map((h) => h.delivery_note_number);
const existing = survivor.notes ?? "";
const appendNote =
mergedFromNumbers.length > 0
? `${existing ? `${existing}\n` : ""}Merged from ${mergedFromNumbers.join(", ")}${input.note ? ` — ${input.note}` : ""}`
: `${existing ? `${existing}\n` : ""}${input.note ?? ""}`;
survivorPatch.notes = appendNote;
await pb.collection(DELIVERY_NOTES).update(survivor.id, survivorPatch);
} catch (err) {
throw new Error(
`Failed to update survivor DN ${survivor.delivery_note_number}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
// 5b. Move items from each cancelled DN onto the survivor.
// One fetch per cancelled DN (their items are filtered
// by their own DN number), then a per-item update with
// the survivor's DN number + the optional new order id.
let itemsMoved = 0;
for (const dead of cancelled) {
let deadItems: OrderItem[] = [];
try {
deadItems = await pb.collection(ORDER_ITEMS).getFullList<OrderItem>({
filter: `delivery_note_number = "${dead.delivery_note_number}"`,
});
} catch (err) {
console.error(
`[combineDeliveryNotes] failed to fetch items for ${dead.delivery_note_number}:`,
err,
);
// Continue — the operator can move the items by hand if
// this fetch blew up. We don't abort the whole merge.
continue;
}
for (const it of deadItems) {
try {
const patch: Record<string, unknown> = {
delivery_note_number: survivor.delivery_note_number,
};
if (orderId) patch.order = orderId;
await pb.collection(ORDER_ITEMS).update(it.id, patch);
itemsMoved += 1;
} catch (err) {
console.error(
`[combineDeliveryNotes] failed to move item ${it.id} from ${dead.delivery_note_number} → ${survivor.delivery_note_number}:`,
err,
);
}
}
// Cancel the source header.
try {
const existingDeadNote = dead.notes ?? "";
const mergedIntoNote = `${existingDeadNote ? `${existingDeadNote}\n` : ""}Merged into ${survivor.delivery_note_number}${input.note ? ` — ${input.note}` : ""}`;
await pb.collection(DELIVERY_NOTES).update(dead.id, {
status: "cancelled",
notes: mergedIntoNote,
});
} catch (err) {
console.error(
`[combineDeliveryNotes] failed to cancel ${dead.delivery_note_number}:`,
err,
);
}
}
// 6. Regenerate the survivor's PDF so the merged line list
// is reflected in the saved copy. Same pattern as the
// regenerate-PDF button — best-effort, log on failure.
let pdfRegenerated = false;
try {
await regenerateDeliveryNotePdf(survivor.id);
pdfRegenerated = true;
} catch (err) {
console.error(
`[combineDeliveryNotes] PDF regen failed for ${survivor.delivery_note_number}:`,
err,
);
}
return {
survivor: {
id: survivor.id,
delivery_note_number: survivor.delivery_note_number,
},
cancelled: cancelled.map((h) => ({
id: h.id,
delivery_note_number: h.delivery_note_number,
})),
itemsMoved,
pdfRegenerated,
};
}
/**
* Update the `quantity` field on a single order_item that
* belongs to a delivery note.
*
* Two validations before the write:
* 1. The item resolves and has a `delivery_note_number`.
* Items without a DN aren't editable through this path —
* they're plain order items and the operator should
* edit them via the order page instead.
* 2. The parent DN's status is `draft` or `sent`. Once a DN
* has a customer PO or an invoice, editing a qty changes
* what's actually being delivered under a number the
* customer already has on file. Block + point the
* operator at the right flow.
*
* Bounds: `quantity > 0` only. No upper bound — top-ups
* (qty > order_qty) are allowed because the operator might
* have under-ordered and is now handing the customer the
* full remaining stock. The detail page shows order_qty
* alongside the editable qty so the operator can see the
* original order size at a glance.
*
* On success, triggers a PDF regeneration for the parent DN
* so the printed copy matches the new qty. Auto-regen is the
* user's chosen UX (every save re-renders).
*/
export async function updateDeliveryNoteItemQuantity(input: {
itemId: string;
quantity: number;
}): Promise<{
item: { id: string; quantity: number; delivery_note_number: string };
pdfRegenerated: boolean;
}> {
const pb = await getAdminPocketBase();
const qty = Number(input.quantity);
if (!Number.isFinite(qty) || qty <= 0) {
throw new Error(`Quantity must be a positive number (got ${input.quantity}).`);
}
// Fetch + guard 1: must belong to a DN.
const item = await pb
.collection(ORDER_ITEMS)
.getOne<OrderItem>(input.itemId);
if (!item.delivery_note_number) {
throw new Error(
`Item ${input.itemId} isn't on a delivery note — edit it from the order page.`,
);
}
// Fetch + guard 2: parent DN must be draft or sent.
const dnRows = await pb
.collection(DELIVERY_NOTES)
.getFullList<DeliveryNote>({
filter: `delivery_note_number = "${item.delivery_note_number}"`,
});
if (dnRows.length === 0) {
throw new Error(
`Delivery note ${item.delivery_note_number} not found for item ${input.itemId}.`,
);
}
// If the same DN number is somehow on multiple headers
// (shouldn't happen — it's unique-indexed), refuse rather
// than pick.
if (dnRows.length > 1) {
throw new Error(
`Multiple delivery_notes rows match "${item.delivery_note_number}" — fix in PB admin before editing qtys.`,
);
}
const dn = dnRows[0];
if (!COMBINABLE_STATUSES.includes(dn.status)) {
throw new Error(
`Cannot edit qty on a "${dn.status}" delivery note — only draft or sent DNs are editable.`,
);
}
// Apply the update.
await pb.collection(ORDER_ITEMS).update(input.itemId, { quantity: qty });
// Auto-regenerate the parent DN's PDF per UX decision. Same
// best-effort pattern as the merge — log on failure but
// don't surface to the caller (the qty edit succeeded, the
// operator can hit Regenerate to retry the PDF).
let pdfRegenerated = false;
try {
await regenerateDeliveryNotePdf(dn.id);
pdfRegenerated = true;
} catch (err) {
console.error(
`[updateDeliveryNoteItemQuantity] PDF regen failed for ${dn.delivery_note_number}:`,
err,
);
}
return {
item: {
id: item.id,
quantity: qty,
delivery_note_number: item.delivery_note_number,
},
pdfRegenerated,
};
}
feat(quotes): split attachments into internal vs outbound with operator upload UI Splits the quote attachment model along the seam that actually matters: 'operator-only references' (drawings the operator consulted, supplier emails they filed away) vs 'customer-facing files' (supplier quotes, drawings, NDAs that need to ride along on the email). Schema (PB): new `internal_attachments` + `outbound_attachments` fields on the `quotes` collection, maxSelect 10 each. Existing `attachments` field kept for back-compat but is no longer the source of truth for outbound emails. API: - GET/POST/DELETE /api/v1/quotes/[id]/internal-attachments[/{filename}] - GET/POST/DELETE /api/v1/quotes/[id]/outbound-attachments[/{filename}] - POST /api/v1/quotes/[id]/attachments/upload kept for legacy `attachments` MCP: `quotes.send_email` description now correctly states `selectedAttachmentIds` are PB file tokens from `outbound_attachments` (supplier quotes, drawings, NDAs), not IDs of the legacy `attachments` collection. Frontend: `ProcessOutboundAttachmentsCard` component lets the operator upload/remove customer-facing files during the process page; approval + process pages now read the new fields. Outbound Send workflow (deployed as `PIEAkPYYYZLGl6Pj`): - Old workflow (id `WF_Email_Send_SMTP`) and stale repo file (`WFDASH — Quote Outbound Send.json`) replaced. - New revision intersects `quotes.outbound_attachments` with `email_outbound_attachment_ids`, downloads each from `/api/files/quotes/<id>/<token>`, and emits `options.attachments: [{name, content, type}]` consumed by `Send Email (SMTP)` (PO Send pattern). - Failure path flipped from `send_failed` to `mail_failed` so `retryQuoteEmail` can find failed sends (it only accepts the latter). Previously failed sends were stranded. Email Monitor (v5) workflow fixes: - Reply-branch auth: any authenticated request now passes through; pre-fix bug rejected branches that the IMAP trigger hadn't seen yet. - Reply detection: bulletproof subject regex (/Q-?\d{4}-?\d{2}-?\d{3}/) accepts both legacy `Q-2026-09-246` and current `Q202609246` formats; canonicalised digits-only for PB `~` substring match. Delivery notes: new pricing endpoint at /api/v1/delivery-notes/items/[id]/pricing + service-layer methods. Snapshots kept for forensics: PRE-FIX-REPLY-{BRANCH-AUTH,DETECTION}.json and push-payload.baseline.json are the previously-deployed workflow shapes, retained so we can diff/rollback if regression hits. Verified: - PUT workflow PIEAkPYYYZLGl6Pj -> 200, active, 25 nodes - quotes-service.test.ts: 117 pass (4 pre-existing failures unchanged) - mcp/route.test.ts: 118 pass (2 pre-existing failures unchanged) - node --check on new Code node JS passes Docs: docs/quotes-outbound-attachments-deploy.md covers the deploy procedure; docs/api-mcp-layer.md description sync.
2026-09-09 11:43:56 +00:00
/**
* Statuses where editing pricing / qty on the parent delivery
* note's items is permitted. The original qty-only endpoint
* gates on `draft | sent` (the "combine" gate); pricing edits
* need a wider gate because the operator is often asked to
* fix a typo on a `completed` DN weeks after the customer paid
* the invoice (no combine action involved, no risk of merging
* a closed DN). Cancelled is excluded so we don't resurrect
* lines that were deliberately wound back.
*/
const PRICING_EDITABLE_STATUSES: DeliveryNoteStatus[] = [
"draft",
"sent",
"loaded",
"po_received",
"invoiced",
"completed",
];
/**
* The result of an Edit-pricing action. Same shape as the
* qty-only endpoint so the route layer can return one
* `{item, pdfRegenerated}` payload across both edit paths.
*/
export interface UpdateDeliveryNoteItemPricingResult {
item: {
id: string;
delivery_note_number: string;
quantity: number | null;
cost_price: number;
selling_price: number | null;
};
pdfRegenerated: boolean;
}
export interface UpdateDeliveryNoteItemPricingInput {
itemId: string;
cost_price?: number;
selling_price?: number | null;
quantity?: number;
}
/**
* Edit pricing (and optionally quantity) on a single order_item
* that belongs to a delivery note. Used by the Items table's
* Edit mode on the delivery-note detail page.
*
* Differs from `updateDeliveryNoteItemQuantity` in two ways:
* 1. **Wider permission gate** — allows editing on any non-
* cancelled status (draft → completed). The qty-only
* path is locked to draft/sent; this one is the escape
* hatch for fixing a typo on a closed DN.
* 2. **Multi-field** — accepts `cost_price` and/or
* `selling_price` (and optionally `quantity`). At least
* one editable field must be supplied; a no-op call with
* no fields is rejected with 400.
*
* Validation:
* - `cost_price`: finite number >= 0. Zero is a valid value
* (e.g. free giveaway / internal transfer); negative is not.
* - `selling_price`: finite number >= 0, OR `null` (clears
* the price — leaves the line unpriced so it surfaces as
* "needs pricing" downstream).
* - `quantity`: finite number > 0 (matches the qty-only
* endpoint's contract — we don't allow zero/negative).
*
* Bounds for editing-on-completed: there is no upper bound on
* cost or selling price, and no top-up cap on quantity. The
* operator is the source of truth on a closed DN — they may be
* correcting a customer dispute, an invoice re-issue, or a
* supplier credit memo. We log the change in the order_item
* `updated` timestamp; full audit trail lives in PB.
*
* On success: PATCH the order_item, then auto-regenerate the
* parent DN's PDF (best-effort — same pattern as the qty
* endpoint; PDF failure doesn't fail the write, the operator
* can hit Regenerate on the detail page to retry).
*/
export async function updateDeliveryNoteItemPricing(
input: UpdateDeliveryNoteItemPricingInput,
): Promise<UpdateDeliveryNoteItemPricingResult> {
const pb = await getAdminPocketBase();
// Field-presence check. Accept only defined + finite inputs;
// an explicit `null` for selling_price is preserved (clears
// the price) but an undefined / missing field is just left
// alone on the row.
const hasCost = input.cost_price !== undefined;
const hasSelling = input.selling_price !== undefined;
const hasQty = input.quantity !== undefined;
if (!hasCost && !hasSelling && !hasQty) {
throw new Error(
"At least one of cost_price, selling_price, or quantity must be provided.",
);
}
// Per-field validation. Done before any DB I/O so a bad
// request doesn't waste a round trip.
if (hasCost) {
const cp = Number(input.cost_price);
if (!Number.isFinite(cp) || cp < 0) {
throw new Error(
`cost_price must be a non-negative number (got ${String(input.cost_price)}).`,
);
}
}
if (hasSelling && input.selling_price !== null) {
const sp = Number(input.selling_price);
if (!Number.isFinite(sp) || sp < 0) {
throw new Error(
`selling_price must be a non-negative number or null (got ${String(input.selling_price)}).`,
);
}
}
if (hasQty) {
const qty = Number(input.quantity);
if (!Number.isFinite(qty) || qty <= 0) {
throw new Error(
`quantity must be a positive number (got ${String(input.quantity)}).`,
);
}
}
// Fetch + guard 1: must belong to a DN.
const item = await pb
.collection(ORDER_ITEMS)
.getOne<OrderItem>(input.itemId);
if (!item.delivery_note_number) {
throw new Error(
`Item ${input.itemId} isn't on a delivery note — edit it from the order page.`,
);
}
// Fetch + guard 2: parent DN must be in a pricing-editable
// status (any non-cancelled stage). Same multi-header guard
// as the qty endpoint — refuse rather than pick if the
// delivery_note_number resolves to >1 DN header.
const dnRows = await pb
.collection(DELIVERY_NOTES)
.getFullList<DeliveryNote>({
filter: `delivery_note_number = "${item.delivery_note_number}"`,
});
if (dnRows.length === 0) {
throw new Error(
`Delivery note ${item.delivery_note_number} not found for item ${input.itemId}.`,
);
}
if (dnRows.length > 1) {
throw new Error(
`Multiple delivery_notes rows match "${item.delivery_note_number}" — fix in PB admin before editing pricing.`,
);
}
const dn = dnRows[0];
if (!PRICING_EDITABLE_STATUSES.includes(dn.status)) {
throw new Error(
`Cannot edit pricing on a "${dn.status}" delivery note — only draft, sent, loaded, po_received, invoiced, or completed DNs are editable.`,
);
}
// Build the patch payload from the fields the caller actually
// provided. `undefined` keys are dropped so we don't
// accidentally blank out a column when the operator only
// touched one of the three. Includes `pricing_source`
// (string) so we can stamp the audit tag below.
const patch: Record<string, number | string | null> = {};
if (hasCost) patch.cost_price = Number(input.cost_price);
if (hasSelling) {
// Explicit `null` clears the price; a finite number writes it.
patch.selling_price =
input.selling_price === null ? null : Number(input.selling_price);
}
if (hasQty) patch.quantity = Number(input.quantity);
// Stamp the audit tag on every pricing change. Operator-
// typed values are tagged "manual" so downstream surfaces
// (process page, sprint rows) can flag it for re-review if
// they need to.
if (hasCost || hasSelling) {
patch.pricing_source = "manual";
}
await pb.collection(ORDER_ITEMS).update(input.itemId, patch);
// Auto-regenerate the parent DN's PDF per UX decision (every
// save re-renders the printed copy). Best-effort: a PDF
// failure does not surface to the caller; the pricing edit
// succeeded, the operator can hit Regenerate on the detail
// page to retry the PDF render.
let pdfRegenerated = false;
try {
await regenerateDeliveryNotePdf(dn.id);
pdfRegenerated = true;
} catch (err) {
console.error(
`[updateDeliveryNoteItemPricing] PDF regen failed for ${dn.delivery_note_number}:`,
err,
);
}
return {
item: {
id: item.id,
delivery_note_number: item.delivery_note_number,
quantity:
hasQty ? Number(input.quantity) : item.quantity ?? null,
cost_price: hasCost ? Number(input.cost_price) : item.cost_price,
selling_price: hasSelling
? input.selling_price === null
? null
: Number(input.selling_price)
: item.selling_price ?? null,
},
pdfRegenerated,
};
}
export interface DeliveryNoteDetail {
deliveryNote: DeliveryNote;
/** order_items grouped under this note by `delivery_note_number`. */
items: OrderItem[];
/** Most recent quote pricing this note, or null. */
quote: Quote | null;
}
/**
* Read model for the delivery-note detail page. Throws a 404-tagged
* error when the note doesn't exist.
*/
export async function getDeliveryNoteDetail(deliveryNoteId: string): Promise<DeliveryNoteDetail> {
const pb = await getAdminPocketBase();
let deliveryNote: DeliveryNote;
try {
deliveryNote = await pb.collection(DELIVERY_NOTES).getOne<DeliveryNote>(deliveryNoteId, {
expand: "order,company,customer,created_by",
});
} catch {
const err = new Error(`Delivery note ${deliveryNoteId} not found`);
(err as Error & { status?: number }).status = 404;
throw err;
}
const esc = (v: string) => v.replace(/"/g, '\\"');
const [items, quotes] = await Promise.all([
deliveryNote.delivery_note_number
? pb.collection(ORDER_ITEMS).getFullList<OrderItem>({
filter: `delivery_note_number = "${esc(deliveryNote.delivery_note_number)}"`,
expand: "supplier",
})
: Promise.resolve([] as OrderItem[]),
pb.collection(QUOTES).getList<Quote>(1, 1, {
filter: `delivery_note = "${esc(deliveryNote.id)}"`,
sort: "-created",
expand: "customer,company",
}),
]);
return { deliveryNote, items, quote: quotes.items[0] ?? null };
}