+
}
+) {
+ try {
+ const { id } = await params;
+ const body = (await req.json().catch(() => ({}))) as {
+ setById?: string;
+ setByName?: string;
+ note?: string;
+ };
+ const updated = await cancelDeliveryNote(id, {
+ setById: typeof body.setById === "string" ? body.setById : null,
+ setByName: typeof body.setByName === "string" ? body.setByName : null,
+ note: typeof body.note === "string" ? body.note : null,
+ });
+ return NextResponse.json(updated);
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : "Failed to cancel delivery note";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/v1/delivery-notes/[id]/mark-complete/route.ts b/src/app/api/v1/delivery-notes/[id]/mark-complete/route.ts
new file mode 100644
index 0000000..ebd3876
--- /dev/null
+++ b/src/app/api/v1/delivery-notes/[id]/mark-complete/route.ts
@@ -0,0 +1,19 @@
+import { NextRequest, NextResponse } from "next/server";
+import { markDeliveryNoteComplete } from "@/lib/server/delivery-notes-service";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function POST(
+ _req: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params;
+ const updated = await markDeliveryNoteComplete(id);
+ return NextResponse.json(updated);
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : "Failed to mark delivery note as complete";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
diff --git a/src/lib/server/delivery-notes-service.ts b/src/lib/server/delivery-notes-service.ts
index bc2271a..8a46553 100644
--- a/src/lib/server/delivery-notes-service.ts
+++ b/src/lib/server/delivery-notes-service.ts
@@ -498,6 +498,88 @@ export async function markDeliveryNoteInvoiced(
});
}
+/**
+ * Mark the delivery note as "completed". Operator signal that
+ * the work has wrapped up and the items are no longer in flight.
+ * Allowed from "invoiced" only — once cancelled you have to
+ * revert to draft first.
+ *
+ * Side effects:
+ * - delivery_notes.status flips "invoiced" → "completed".
+ */
+export async function markDeliveryNoteComplete(deliveryNoteId: string): Promise {
+ const pb = await getAdminPocketBase();
+
+ const dn = await pb.collection(DELIVERY_NOTES).getOne(deliveryNoteId);
+ if (dn.status !== "invoiced") {
+ throw new Error(
+ `Delivery note must be in "invoiced" status to mark complete (current: "${dn.status}").`,
+ );
+ }
+ assertTransition("invoiced", "completed");
+
+ return pb.collection(DELIVERY_NOTES).update(dn.id, {
+ status: "completed",
+ });
+}
+
+export interface CancelDeliveryNoteInput {
+ setById?: string | null;
+ setByName?: string | null;
+ note?: string | null;
+}
+
+/**
+ * Cancel a delivery note. Items are flipped back to "completed"
+ * (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.
+ *
+ * Allowed from draft / sent / po_received / invoiced. From
+ * "completed" you must not cancel — terminal. From "cancelled"
+ * it's a no-op.
+ */
+export async function cancelDeliveryNote(
+ deliveryNoteId: string,
+ input: CancelDeliveryNoteInput = {},
+): Promise {
+ const pb = await getAdminPocketBase();
+
+ const dn = await pb.collection(DELIVERY_NOTES).getOne(deliveryNoteId);
+ const from = dn.status;
+ if (from === "cancelled") {
+ return dn;
+ }
+ if (from === "completed") {
+ throw new Error(
+ `Cannot cancel a delivery note in "completed" status.`,
+ );
+ }
+ assertTransition(from, "cancelled");
+
+ const trimmedNote = (input.note ?? "").trim();
+ const setById = input.setById ?? "";
+ const setByName = input.setByName ?? "";
+ const setAt = new Date().toISOString();
+ const banner = `Delivery note CANCELLED by ${setByName || setById || "unknown"} on ${setAt}${trimmedNote ? ` — ${trimmedNote}` : ""}`;
+
+ const items = await pb.collection(ORDER_ITEMS).getFullList({
+ filter: `delivery_note_number = "${dn.delivery_note_number}"`,
+ });
+ await Promise.all(
+ items.map((it) =>
+ pb.collection(ORDER_ITEMS).update(it.id, {
+ item_status: "completed",
+ notes: it.notes ? `${it.notes}\n${banner}` : banner,
+ }),
+ ),
+ );
+
+ return pb.collection(DELIVERY_NOTES).update(dn.id, {
+ status: "cancelled",
+ });
+}
+
// ─────────────────────────────────────────────────────────────────
// Ad-hoc (Path B) creation
// ─────────────────────────────────────────────────────────────────