2026-07-27 06:21:20 +00:00
|
|
|
/**
|
|
|
|
|
* 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";
|
2026-08-13 08:59:22 +00:00
|
|
|
import { resolveDeliveryNoteLineQty } from "@/lib/pdf/delivery-note-document";
|
2026-07-27 06:21:20 +00:00
|
|
|
import {
|
|
|
|
|
fetchLetterhead,
|
|
|
|
|
loadLetterheadLogoDataUri,
|
|
|
|
|
} from "@/lib/letterhead";
|
|
|
|
|
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";
|
2026-08-13 09:24:05 +00:00
|
|
|
const ORDERS = "orders";
|
2026-07-27 06:21:20 +00:00
|
|
|
|
2026-08-13 11:02:37 +00:00
|
|
|
/**
|
|
|
|
|
* The letterhead every delivery-note PDF renders under.
|
|
|
|
|
*
|
|
|
|
|
* Hardcoded by business decision: the Westcott group owns
|
|
|
|
|
* multiple subsidiaries (CNL, ISHERWOOD, Test Engineering,
|
|
|
|
|
* etc.) but the printed DN is a Westcott-Group-branded
|
|
|
|
|
* document regardless of which subsidiary the customer
|
|
|
|
|
* belongs to. The actual letterhead content (logo, address,
|
|
|
|
|
* banking, accent colour) lives in `letterhead_settings` —
|
|
|
|
|
* this constant is the *lookup* into that table, not the
|
|
|
|
|
* content itself.
|
|
|
|
|
*
|
|
|
|
|
* `fetchLetterhead` runs a case-insensitive substring match
|
|
|
|
|
* on `letterhead_settings.display_name` against this value,
|
|
|
|
|
* so the matching row in PB must have a display_name that
|
|
|
|
|
* contains this string (verbatim or as a substring). The row
|
|
|
|
|
* the user added in PB (id `5wezvypn6fy47p9`) has
|
|
|
|
|
* display_name `WESTSCOTT GROUP OF COMPANIES` which matches
|
|
|
|
|
* cleanly.
|
|
|
|
|
*
|
|
|
|
|
* If you want to swap the brand (e.g. rebrand to a new parent
|
|
|
|
|
* company), update the PB row and change this string in
|
|
|
|
|
* lockstep. The PDF will pick up the new content from PB
|
|
|
|
|
* automatically without any code change beyond the constant.
|
|
|
|
|
*/
|
|
|
|
|
const DELIVERY_NOTE_LETTERHEAD_CODE = "WESTSCOTT GROUP OF COMPANIES";
|
|
|
|
|
|
2026-07-27 06:21:20 +00:00
|
|
|
/**
|
|
|
|
|
* 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", "cancelled"],
|
|
|
|
|
sent: ["po_received", "cancelled", "draft"],
|
|
|
|
|
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).
|
|
|
|
|
const signedCopyName = Array.isArray(dn.signed_copy)
|
|
|
|
|
? dn.signed_copy[0]
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
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 ?? "";
|
2026-08-13 11:02:37 +00:00
|
|
|
// All delivery-note PDFs render under the Westcott
|
|
|
|
|
// Group letterhead, regardless of which subsidiary the
|
|
|
|
|
// customer belongs to — see DELIVERY_NOTE_LETTERHEAD_CODE.
|
|
|
|
|
// The `companyName` above still goes into the PDF meta
|
|
|
|
|
// block so the printed document tells the driver which
|
|
|
|
|
// subsidiary the delivery is for.
|
|
|
|
|
const letterheadCode = DELIVERY_NOTE_LETTERHEAD_CODE;
|
2026-07-27 06:21:20 +00:00
|
|
|
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((it) => ({
|
|
|
|
|
id: it.id,
|
|
|
|
|
description: (it.description ?? "").trim() || "(no description)",
|
|
|
|
|
qty: Number(it.quantity ?? it.order_qty ?? 1) || 1,
|
|
|
|
|
})),
|
|
|
|
|
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.`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 06:55:56 +00:00
|
|
|
/**
|
|
|
|
|
* 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",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 20:24:08 +00:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 06:55:56 +00:00
|
|
|
export interface CancelDeliveryNoteInput {
|
|
|
|
|
setById?: string | null;
|
|
|
|
|
setByName?: string | null;
|
|
|
|
|
note?: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-12 13:36:31 +00:00
|
|
|
* Cancel a delivery note. Items are flipped to "cancelled"
|
2026-08-12 06:55:56 +00:00
|
|
|
* (no longer "delivery_note") so they fall off the Invoicing
|
|
|
|
|
* "ready for delivery note" list, with a note stamping who
|
2026-08-12 13:36:31 +00:00
|
|
|
* 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).
|
2026-08-12 06:55:56 +00:00
|
|
|
*
|
|
|
|
|
* 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, {
|
2026-08-12 13:36:31 +00:00
|
|
|
item_status: "cancelled",
|
2026-08-12 06:55:56 +00:00
|
|
|
notes: it.notes ? `${it.notes}\n${banner}` : banner,
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return pb.collection(DELIVERY_NOTES).update<DeliveryNote>(dn.id, {
|
|
|
|
|
status: "cancelled",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 06:21:20 +00:00
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
|
// 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}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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)
|
2026-08-13 08:59:22 +00:00
|
|
|
* - 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.
|
2026-07-27 06:21:20 +00:00
|
|
|
*
|
|
|
|
|
* 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 ─────────────────────────────────
|
|
|
|
|
const 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).
|
|
|
|
|
const 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,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── 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
|
2026-08-13 08:59:22 +00:00
|
|
|
// 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(
|
2026-07-27 06:21:20 +00:00
|
|
|
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.
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
2026-08-13 08:59:22 +00:00
|
|
|
// ── 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"
|
2026-08-13 09:24:05 +00:00
|
|
|
? (customerRow as unknown as { customer: string }).customer
|
2026-08-13 08:59:22 +00:00
|
|
|
: "";
|
|
|
|
|
const companyName =
|
|
|
|
|
typeof (companyRow as { company?: unknown } | null)?.company === "string"
|
2026-08-13 09:24:05 +00:00
|
|
|
? (companyRow as unknown as { company: string }).company
|
2026-08-13 08:59:22 +00:00
|
|
|
: "";
|
|
|
|
|
|
2026-08-13 11:02:37 +00:00
|
|
|
const letterheadCode = DELIVERY_NOTE_LETTERHEAD_CODE;
|
2026-08-13 08:59:22 +00:00
|
|
|
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((it) => ({
|
|
|
|
|
id: it.id,
|
|
|
|
|
description: (it.description ?? "").trim() || "(no description)",
|
|
|
|
|
qty: resolveDeliveryNoteLineQty({
|
2026-08-13 10:00:51 +00:00
|
|
|
quantity: it.quantity,
|
2026-08-13 08:59:22 +00:00
|
|
|
orderQty: it.order_qty,
|
|
|
|
|
qtyCollected: it.qty_collected,
|
|
|
|
|
}),
|
|
|
|
|
})),
|
|
|
|
|
letterhead,
|
|
|
|
|
logoDataUri,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const filename = `${deliveryNoteNumber}.pdf`;
|
|
|
|
|
const blob = new Blob([new Uint8Array(pdfBuffer)], {
|
|
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 06:21:20 +00:00
|
|
|
return {
|
|
|
|
|
id: header.id,
|
|
|
|
|
delivery_note_number: deliveryNoteNumber,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-08-13 08:59:22 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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")) ??
|
|
|
|
|
"";
|
|
|
|
|
|
2026-08-13 11:02:37 +00:00
|
|
|
// All delivery-note PDFs render under the Westcott
|
|
|
|
|
// Group letterhead, regardless of which subsidiary the
|
|
|
|
|
// customer belongs to — see DELIVERY_NOTE_LETTERHEAD_CODE.
|
|
|
|
|
// The `companyName` above still goes into the PDF meta
|
|
|
|
|
// block so the printed document tells the driver which
|
|
|
|
|
// subsidiary the delivery is for.
|
|
|
|
|
const letterheadCode = DELIVERY_NOTE_LETTERHEAD_CODE;
|
2026-08-13 08:59:22 +00:00
|
|
|
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((it) => ({
|
|
|
|
|
id: it.id,
|
|
|
|
|
description: (it.description ?? "").trim() || "(no description)",
|
|
|
|
|
qty: resolveDeliveryNoteLineQty({
|
2026-08-13 10:00:51 +00:00
|
|
|
quantity: it.quantity,
|
2026-08-13 08:59:22 +00:00
|
|
|
orderQty: it.order_qty,
|
|
|
|
|
qtyCollected: it.qty_collected,
|
|
|
|
|
}),
|
|
|
|
|
})),
|
|
|
|
|
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`;
|
|
|
|
|
const blob = new Blob([new Uint8Array(pdfBuffer)], {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-13 09:24:05 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*
|
2026-08-13 10:00:51 +00:00
|
|
|
* 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.
|
2026-08-13 09:24:05 +00:00
|
|
|
*
|
|
|
|
|
* `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;
|
2026-08-13 10:00:51 +00:00
|
|
|
orderId?: string | null;
|
2026-08-13 09:24:05 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 10:00:51 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 09:24:05 +00:00
|
|
|
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);
|
|
|
|
|
|
2026-08-13 10:00:51 +00:00
|
|
|
// 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).
|
2026-08-13 09:24:05 +00:00
|
|
|
let orderId: string | null = null;
|
|
|
|
|
let orderCustomerId: string | null = null;
|
|
|
|
|
let orderCompanyId: string | null = null;
|
2026-08-13 10:00:51 +00:00
|
|
|
|
|
|
|
|
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();
|
2026-08-13 09:24:05 +00:00
|
|
|
try {
|
|
|
|
|
const orderRows = await pb.collection(ORDERS).getFullList<{
|
|
|
|
|
id: string;
|
|
|
|
|
order_number: string;
|
|
|
|
|
customer?: string;
|
|
|
|
|
company?: string;
|
2026-08-13 10:00:51 +00:00
|
|
|
created?: string;
|
2026-08-13 09:24:05 +00:00
|
|
|
}>({
|
2026-08-13 10:00:51 +00:00
|
|
|
filter: `order_number = "${orderNumber.replace(/"/g, '\\"')}"`,
|
2026-08-13 09:24:05 +00:00
|
|
|
});
|
|
|
|
|
if (orderRows.length === 0) {
|
|
|
|
|
throw new Error(
|
2026-08-13 10:00:51 +00:00
|
|
|
`Order "${orderNumber}" not found — leave the field blank to combine without linking an order.`,
|
2026-08-13 09:24:05 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (orderRows.length > 1) {
|
2026-08-13 10:00:51 +00:00
|
|
|
// 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]),
|
2026-08-13 09:24:05 +00:00
|
|
|
);
|
2026-08-13 10:00:51 +00:00
|
|
|
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);
|
2026-08-13 09:24:05 +00:00
|
|
|
}
|
|
|
|
|
const order = orderRows[0];
|
|
|
|
|
orderId = order.id;
|
|
|
|
|
orderCustomerId = order.customer ?? null;
|
|
|
|
|
orderCompanyId = order.company ?? null;
|
|
|
|
|
} catch (err) {
|
2026-08-13 10:00:51 +00:00
|
|
|
// 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;
|
2026-08-13 09:24:05 +00:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|