Update the cancel delivery note dialog description to correctly state items are marked as cancelled Fix PocketBase expand field whitelisting for delivery items to avoid missing related entity data Change order item status to "cancelled" instead of "completed" when cancelling a delivery note, with updated JSDoc explaining the choice of status Update the buyer tasks PO sent filter and its regression test to cover all current ItemStatus values
782 lines
29 KiB
TypeScript
782 lines
29 KiB
TypeScript
/**
|
|
* 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 {
|
|
fetchLetterhead,
|
|
letterheadForCompany,
|
|
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";
|
|
|
|
/**
|
|
* 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 ?? "";
|
|
const letterheadCode = letterheadForCompany(companyName) ?? "CNL";
|
|
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",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mark the delivery note as "completed". Operator signal that
|
|
* the work has wrapped up and the items are no longer in flight.
|
|
* Allowed from "invoiced" only — once cancelled you have to
|
|
* revert to draft first.
|
|
*
|
|
* Side effects:
|
|
* - delivery_notes.status flips "invoiced" → "completed".
|
|
*/
|
|
export async function markDeliveryNoteComplete(deliveryNoteId: string): Promise<DeliveryNote> {
|
|
const pb = await getAdminPocketBase();
|
|
|
|
const dn = await pb.collection(DELIVERY_NOTES).getOne<DeliveryNote>(deliveryNoteId);
|
|
if (dn.status !== "invoiced") {
|
|
throw new Error(
|
|
`Delivery note must be in "invoiced" status to mark complete (current: "${dn.status}").`,
|
|
);
|
|
}
|
|
assertTransition("invoiced", "completed");
|
|
|
|
return pb.collection(DELIVERY_NOTES).update<DeliveryNote>(dn.id, {
|
|
status: "completed",
|
|
});
|
|
}
|
|
|
|
export interface CancelDeliveryNoteInput {
|
|
setById?: string | null;
|
|
setByName?: string | null;
|
|
note?: string | null;
|
|
}
|
|
|
|
/**
|
|
* Cancel a delivery note. Items are flipped to "cancelled"
|
|
* (no longer "delivery_note") so they fall off the Invoicing
|
|
* "ready for delivery note" list, with a note stamping who
|
|
* cancelled it, when, and why. The DN header itself also goes
|
|
* to "cancelled" — both ends match.
|
|
*
|
|
* Why "cancelled" on the order_items, not "completed": the
|
|
* `order_items.item_status` select field on PB does not include
|
|
* "completed" (only the `delivery_notes.status` field does — the
|
|
* mark-invoiced flow on the DN uses it as the terminal state).
|
|
* We mirror the DN's own "cancelled" status on the items so the
|
|
* order_items enum is satisfied without needing a PB admin
|
|
* schema change, and so the audit trail reads consistently
|
|
* (cancelled DN → cancelled items).
|
|
*
|
|
* Allowed from draft / sent / po_received / invoiced. From
|
|
* "completed" you must not cancel — terminal. From "cancelled"
|
|
* it's a no-op.
|
|
*/
|
|
export async function cancelDeliveryNote(
|
|
deliveryNoteId: string,
|
|
input: CancelDeliveryNoteInput = {},
|
|
): Promise<DeliveryNote> {
|
|
const pb = await getAdminPocketBase();
|
|
|
|
const dn = await pb.collection(DELIVERY_NOTES).getOne<DeliveryNote>(deliveryNoteId);
|
|
const from = dn.status;
|
|
if (from === "cancelled") {
|
|
return dn;
|
|
}
|
|
if (from === "completed") {
|
|
throw new Error(
|
|
`Cannot cancel a delivery note in "completed" status.`,
|
|
);
|
|
}
|
|
assertTransition(from, "cancelled");
|
|
|
|
const trimmedNote = (input.note ?? "").trim();
|
|
const setById = input.setById ?? "";
|
|
const setByName = input.setByName ?? "";
|
|
const setAt = new Date().toISOString();
|
|
const banner = `Delivery note CANCELLED by ${setByName || setById || "unknown"} on ${setAt}${trimmedNote ? ` — ${trimmedNote}` : ""}`;
|
|
|
|
const items = await pb.collection(ORDER_ITEMS).getFullList<OrderItem>({
|
|
filter: `delivery_note_number = "${dn.delivery_note_number}"`,
|
|
});
|
|
await Promise.all(
|
|
items.map((it) =>
|
|
pb.collection(ORDER_ITEMS).update(it.id, {
|
|
item_status: "cancelled",
|
|
notes: it.notes ? `${it.notes}\n${banner}` : banner,
|
|
}),
|
|
),
|
|
);
|
|
|
|
return pb.collection(DELIVERY_NOTES).update<DeliveryNote>(dn.id, {
|
|
status: "cancelled",
|
|
});
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Ad-hoc (Path B) creation
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Generate the next delivery note number for `now`. Format
|
|
* `DN{YY}{MM}{DD}{NN}` with a 2-digit per-day sequence. Counts the
|
|
* most-recent matching record (queries `order_items` because
|
|
* that's where the DN number is stamped on every line — querying
|
|
* the `delivery_notes` header would miss records where the header
|
|
* create failed and only the order_items stamps landed) and
|
|
* increments; concurrent requests may collide — accepted as a
|
|
* known limitation for v1.
|
|
*
|
|
* The mirror of this function lives in
|
|
* `src/app/api/invoicing/delivery-note/route.ts` (the legacy
|
|
* "mark as delivery note" route). Any change to the format here
|
|
* must be mirrored there too, or the two code paths will produce
|
|
* different numbers for the same day.
|
|
*/
|
|
export async function generateDeliveryNoteNumberServer(
|
|
now: Date = new Date(),
|
|
): Promise<string> {
|
|
const pb = await getAdminPocketBase();
|
|
const yy = String(now.getFullYear()).slice(-2);
|
|
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
|
const dd = String(now.getDate()).padStart(2, "0");
|
|
const prefix = `DN${yy}${mm}${dd}`;
|
|
try {
|
|
const existing = await pb
|
|
.collection(ORDER_ITEMS)
|
|
.getList<OrderItem>(1, 1, {
|
|
filter: `delivery_note_number ~ "${prefix}"`,
|
|
sort: "-delivery_note_number",
|
|
});
|
|
let seq = 1;
|
|
if (existing.items.length > 0) {
|
|
const last = existing.items[0].delivery_note_number ?? "";
|
|
const lastSeq = parseInt(last.slice(prefix.length), 10);
|
|
if (Number.isFinite(lastSeq) && lastSeq > 0) {
|
|
seq = lastSeq + 1;
|
|
}
|
|
}
|
|
return `${prefix}${String(seq).padStart(2, "0")}`;
|
|
} catch {
|
|
// If the `delivery_note_number` field doesn't exist on
|
|
// order_items yet, fall back to a random suffix so the
|
|
// operation still completes. The operator can rename in PB
|
|
// admin if needed.
|
|
const fallback = Math.floor(Math.random() * 90 + 10);
|
|
return `${prefix}${fallback}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 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
|
|
// since the items are independent writes.
|
|
await Promise.all(
|
|
input.items.map((it) =>
|
|
pb.collection(ORDER_ITEMS).create<OrderItem>({
|
|
order: null,
|
|
delivery_note_number: deliveryNoteNumber,
|
|
description: it.description.trim(),
|
|
quantity: Number(it.quantity),
|
|
supplier: it.supplier ?? null,
|
|
cost_price: Number(it.cost_price ?? 0),
|
|
selling_price:
|
|
it.selling_price != null ? Number(it.selling_price) : null,
|
|
sku: it.sku ?? "",
|
|
notes: it.notes ?? "",
|
|
// Ad-hoc items start the DN lifecycle here. The Path A
|
|
// flow does a separate "mark as delivery note" step in
|
|
// Invoicing; for Path B that step is implicit.
|
|
item_status: "delivery_note",
|
|
// The other workflow fields are not relevant for an
|
|
// ad-hoc DN — leave them at PB defaults.
|
|
}),
|
|
),
|
|
);
|
|
|
|
return {
|
|
id: header.id,
|
|
delivery_note_number: deliveryNoteNumber,
|
|
};
|
|
}
|