/** * 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"; import { buildDeliveryNoteLine } from "@/lib/pdf/delivery-note-document"; 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 = { 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 { const pb = await getAdminPocketBase(); const dn = await pb.collection(DELIVERY_NOTES).getOne(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(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({ 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 = { 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(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 = { 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(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 { 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). // 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, }, 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 { 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(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(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(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(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 = {}; 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(deliveryNoteId, backfill); } const fresh = await pb .collection(DELIVERY_NOTES) .getOne(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 = { 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(deliveryNoteId, update); const updated = await pb .collection(DELIVERY_NOTES) .getOne(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 { const pb = await getAdminPocketBase(); const dn = await pb.collection(DELIVERY_NOTES).getOne(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({ 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(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 { const pb = await getAdminPocketBase(); const dn = await pb.collection(DELIVERY_NOTES).getOne(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(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 by on * * 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 { const pb = await getAdminPocketBase(); const dn = await pb.collection(DELIVERY_NOTES).getOne(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(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 { const pb = await getAdminPocketBase(); const dn = await pb.collection(DELIVERY_NOTES).getOne(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({ 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(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 { 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(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}`; } } /** * 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 { 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 ───────────────────────────────── 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). // // 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({ 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({ 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, }, 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(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({ 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, }, 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>, relationId: string | null | undefined, collection: string, field: string, ): Promise { 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 " 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 { 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({ 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 = {}; 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({ 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 = { 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(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({ 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, }; } /** * 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 { 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(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({ 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 = {}; 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 { const pb = await getAdminPocketBase(); let deliveryNote: DeliveryNote; try { deliveryNote = await pb.collection(DELIVERY_NOTES).getOne(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({ filter: `delivery_note_number = "${esc(deliveryNote.delivery_note_number)}"`, expand: "supplier", }) : Promise.resolve([] as OrderItem[]), pb.collection(QUOTES).getList(1, 1, { filter: `delivery_note = "${esc(deliveryNote.id)}"`, sort: "-created", expand: "customer,company", }), ]); return { deliveryNote, items, quote: quotes.items[0] ?? null }; }