feat(delivery-notes): standardize delivery-note letterhead across PDFs

- Introduced a centralized `deliveryNoteLetterheadCode` function to ensure all delivery-note PDFs consistently use the Westcott Group letterhead, regardless of the subsidiary.
- Updated relevant API routes and services to utilize the new function, removing previous hardcoded fallbacks.
- Enhanced the `DriverStop` interface to include timestamps for better reporting capabilities.
- Added new fields to track creation timestamps for driver stops, improving data accuracy for outstanding reporting.
This commit is contained in:
Dave 2026-08-18 11:53:25 +02:00
parent bfcddb9d82
commit 2f0e911563
10 changed files with 272 additions and 16 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

View file

@ -25,6 +25,14 @@
"LOG_LEVEL": "error",
"DISABLE_CONSOLE_OUTPUT": "true"
}
},
"n8n-wfdash": {
"type": "http",
"url": "https://wfdash.n8n.apphub.co.za/mcp-server/http",
"headers": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0Zjc3MGRmMi1iZDJmLTQ0MzYtYjE1My1iMjcwMDRlZmM3YjYiLCJpc3MiOiJuOG4iLCJhdWQiOiJtY3Atc2VydmVyLWFwaSIsImp0aSI6ImI1OWFlYTdjLTRhZGEtNGVlZS04NDFiLTI0YzI3OTAzYjZmZCIsImlhdCI6MTc4NzAzNzMxMX0.qWf1lMeatXnHr3ZDxxpT5L4PuG_5orWjDaMqBVEN5eI"
},
"enabled": true
}
}
}

View file

@ -0,0 +1,76 @@
{
"name": "WFDASH — Driver Live Stop Alerter",
"active": false,
"settings": {
"executionOrder": "v1",
"timezone": "Africa/Johannesburg",
"saveDataSuccessExecution": "all",
"saveDataErrorExecution": "all",
"callerPolicy": "workflowsFromSameOwner"
},
"nodes": [
{
"parameters": {
"rule": { "interval": [{ "secondsInterval": 90 }] }
},
"id": "sched-poll",
"name": "Every 90s",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [-640, 0]
},
{
"parameters": {
"method": "GET",
"url": "=https://wfdash.apphub.co.za/api/v1/driver/stops?drv_id={{ $env.WFDASH_DRIVER_ID }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "Authorization", "value": "=Bearer {{ $env.WFDASH_AGENT_API_KEY }}" }
]
},
"options": { "timeout": 15000 }
},
"id": "fetch-stops",
"name": "Fetch Driver Stops",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [-380, 0]
},
{
"parameters": {
"mode": "runOnceForAllItems",
"language": "javaScript",
"jsCode": "const resp = $input.first().json;\nconst items = (resp && Array.isArray(resp.items)) ? resp.items : [];\n\nconst staticData = $getWorkflowStaticData('global');\nconst seen = new Set(Array.isArray(staticData.seenIds) ? staticData.seenIds : []);\nconst fresh = items.filter(i => i.id && !seen.has(i.id));\n\nstaticData.seenIds = items.map(i => i.id);\nstaticData.lastRunAt = new Date().toISOString();\n\nif (fresh.length === 0) return [];\n\nreturn fresh.map(i => ({\n json: {\n id: i.id,\n supplier: (i.supplier && i.supplier.name) || 'Unknown',\n description: i.description || '',\n quantity: i.quantity_ordered || 0,\n po: i.po_number || ''\n }\n}));"
},
"id": "diff-stops",
"name": "Detect New Stops",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-100, 0]
},
{
"parameters": {
"resource": "sms",
"operation": "send",
"from": "=whatsapp:{{ $env.TWILIO_WHATSAPP_FROM }}",
"to": "=whatsapp:{{ $env.MANAGER_WHATSAPP_NUMBER }}",
"message": "=🚚 {{ $json.supplier }} — {{ $json.description }} ({{ $json.quantity }}) {{ $json.po }}",
"options": {}
},
"id": "send-wa",
"name": "Send WhatsApp",
"type": "n8n-nodes-base.twilio",
"typeVersion": 1,
"position": [180, 0],
"credentials": {
"twilioApi": { "id": "REPLACE_WITH_TWILIO_CREDENTIAL_ID", "name": "Twilio account" }
}
}
],
"connections": {
"Every 90s": { "main": [[{ "node": "Fetch Driver Stops", "type": "main", "index": 0 }]] },
"Fetch Driver Stops": { "main": [[{ "node": "Detect New Stops", "type": "main", "index": 0 }]] },
"Detect New Stops": { "main": [[{ "node": "Send WhatsApp", "type": "main", "index": 0 }]] }
}
}

View file

@ -0,0 +1,76 @@
{
"name": "WFDASH — Driver Morning WhatsApp Summary",
"active": false,
"settings": {
"executionOrder": "v1",
"timezone": "Africa/Johannesburg",
"saveDataSuccessExecution": "all",
"saveDataErrorExecution": "all",
"callerPolicy": "workflowsFromSameOwner"
},
"nodes": [
{
"parameters": {
"rule": { "interval": [{ "hours": 7, "triggerAtMinute": 0 }] }
},
"id": "sched-morning",
"name": "Daily 07:00",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [-560, 0]
},
{
"parameters": {
"method": "GET",
"url": "=https://wfdash.apphub.co.za/api/v1/driver/stops?drv_id={{ $env.WFDASH_DRIVER_ID }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "Authorization", "value": "=Bearer {{ $env.WFDASH_AGENT_API_KEY }}" }
]
},
"options": { "timeout": 15000 }
},
"id": "fetch-stops",
"name": "Fetch Driver Stops",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [-280, 0]
},
{
"parameters": {
"mode": "runOnceForAllItems",
"language": "javaScript",
"jsCode": "const resp = $input.first().json;\nconst items = (resp && Array.isArray(resp.items)) ? resp.items : [];\nconst live = items.filter(i => i.supplier && i.supplier.name);\n\nconst groups = {};\nfor (const i of live) {\n const k = i.supplier.name.trim();\n groups[k] = (groups[k] || 0) + 1;\n}\n\nconst today = new Date().toLocaleDateString('en-ZA', { weekday: 'long', day: 'numeric', month: 'short' });\nlet body;\nconst supplierCount = Object.keys(groups).length;\nif (live.length === 0) {\n body = '*WFDASH driver briefing* — ' + today + '\\n\\nNo stops today.\\nOpen app: https://wfdash.apphub.co.za/driver-app';\n} else {\n const sorted = Object.entries(groups).sort((a, b) => b[1] - a[1]);\n const lines = sorted.map(([name, n]) => '• ' + name + ' — ' + n);\n body = '*WFDASH driver briefing* — ' + today + '\\n\\n' + live.length + ' stop' + (live.length === 1 ? '' : 's') + ' across ' + supplierCount + ' supplier' + (supplierCount === 1 ? '' : 's') + ':\\n\\n' + lines.join('\\n') + '\\n\\nOpen app: https://wfdash.apphub.co.za/driver-app';\n}\n\nreturn [{ json: { message: body } }];"
},
"id": "build-msg",
"name": "Group by Supplier",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [0, 0]
},
{
"parameters": {
"resource": "sms",
"operation": "send",
"from": "=whatsapp:{{ $env.TWILIO_WHATSAPP_FROM }}",
"to": "=whatsapp:{{ $env.MANAGER_WHATSAPP_NUMBER }}",
"message": "={{ $json.message }}",
"options": {}
},
"id": "send-wa",
"name": "Send WhatsApp (Twilio)",
"type": "n8n-nodes-base.twilio",
"typeVersion": 1,
"position": [280, 0],
"credentials": {
"twilioApi": { "id": "REPLACE_WITH_TWILIO_CREDENTIAL_ID", "name": "Twilio account" }
}
}
],
"connections": {
"Daily 07:00": { "main": [[{ "node": "Fetch Driver Stops", "type": "main", "index": 0 }]] },
"Fetch Driver Stops": { "main": [[{ "node": "Group by Supplier", "type": "main", "index": 0 }]] },
"Group by Supplier": { "main": [[{ "node": "Send WhatsApp (Twilio)", "type": "main", "index": 0 }]] }
}
}

View file

@ -11,8 +11,8 @@ import {
import { resolveDeliveryNoteLineQty } from "@/lib/pdf/delivery-note-document";
import { renderDeliveryNotePdf } from "@/lib/pdf/render";
import {
deliveryNoteLetterheadCode,
fetchLetterhead,
letterheadForCompany,
loadLetterheadLogoDataUri,
} from "@/lib/letterhead";
@ -144,7 +144,9 @@ async function handlePrint(input: { deliveryNoteNumber: string; origin: string }
// current items. This keeps older records printable. Same
// letterhead + logo lookup as the create route so the fallback
// rendering matches the saved version byte-for-byte.
const letterheadCode = letterheadForCompany(header.companyName) ?? "CNL";
// Same canonical letterhead as the create route — see
// `deliveryNoteLetterheadCode()` for the business rule.
const letterheadCode = deliveryNoteLetterheadCode();
const [letterhead, logoDataUri] = await Promise.all([
fetchLetterhead(letterheadCode),
loadLetterheadLogoDataUri(letterheadCode),

View file

@ -4,8 +4,8 @@ import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { renderDeliveryNotePdf } from "@/lib/pdf/render";
import { resolveDeliveryNoteLineQty } from "@/lib/pdf/delivery-note-document";
import {
deliveryNoteLetterheadCode,
fetchLetterhead,
letterheadForCompany,
loadLetterheadLogoDataUri,
} from "@/lib/letterhead";
@ -340,10 +340,13 @@ export async function POST(request: NextRequest) {
const first = items[0];
const order = first.expand?.order;
const companyName = order?.expand?.company?.company ?? "";
// Derive the letterhead from the company name (CNL vs
// ISHERWOOD). Falls back to CNL when the name doesn't match
// either — same default the rest of the invoicing flow uses.
const letterheadCode = letterheadForCompany(companyName) ?? "CNL";
// Every delivery-note PDF renders under the Westcott Group
// letterhead, regardless of which subsidiary the customer
// belongs to. The subsidiary name still appears in the PDF
// meta block so the printed document tells the driver which
// subsidiary the delivery is for — only the letterhead is
// always Westcott. See `deliveryNoteLetterheadCode()`.
const letterheadCode = deliveryNoteLetterheadCode();
const letterhead = await fetchLetterhead(letterheadCode);
const logoDataUri = await loadLetterheadLogoDataUri(letterheadCode);
const pdfBuffer = await renderDeliveryNotePdf({

View file

@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import {
DELIVERY_NOTE_LETTERHEAD_CODE,
deliveryNoteLetterheadCode,
letterheadForCompany,
} from "./letterhead";
/**
* Locks down the contract that delivery-note PDFs always render under
* the Westcott Group letterhead, regardless of which subsidiary the
* customer belongs to. If a future change makes the rule
* subsidiary-driven, this test is the guardrail that will fail first
* and force the discussion.
*
* `letterheadForCompany` is intentionally kept narrow (ISHERWOOD / CNL
* only) because that helper is also used by the quote / PO flows
* which have different brand rules. Delivery notes don't.
*/
describe("deliveryNoteLetterheadCode", () => {
it("is the Westcott Group letterhead", () => {
expect(deliveryNoteLetterheadCode()).toBe("WESTSCOTT GROUP OF COMPANIES");
expect(DELIVERY_NOTE_LETTERHEAD_CODE).toBe("WESTSCOTT GROUP OF COMPANIES");
});
it("returns the canonical code regardless of subsidiary name", () => {
// Every subsidiary the Westcott Group owns should still render
// under the Westcott letterhead. If anyone ever wires
// `letterheadForCompany` into the delivery-note flow, this test
// will catch it because `letterheadForCompany` returns CNL /
// ISHERWOOD for these inputs.
for (const subsidiary of [
"CNL Fasteners (Pty) Ltd",
"Isherwood Mining South Africa (Pty) Ltd",
"Westcott Test Engineering (Pty) Ltd",
"Merafong Crushers (Pty) Ltd.",
"",
]) {
const fromCompany = letterheadForCompany(subsidiary);
const fromHelper = deliveryNoteLetterheadCode();
// The helper is the source of truth for delivery notes — it
// must never equal whatever `letterheadForCompany` returns for
// a subsidiary name (CNL / ISHERWOOD are the only two it
// could match; the helper must always be the Westcott code).
expect(fromHelper).toBe("WESTSCOTT GROUP OF COMPANIES");
expect(fromHelper).not.toBe(fromCompany ?? "");
}
});
});

View file

@ -19,6 +19,31 @@ export const LETTERHEAD_CODES: readonly LetterheadCode[] = [
"WESTSCOTT GROUP OF COMPANIES",
] as const;
/**
* Canonical letterhead for ALL delivery-note PDFs, regardless of
* which subsidiary the customer belongs to. The subsidiary name
* still appears in the PDF meta block (so the printed document
* tells the driver which subsidiary the delivery is for) but the
* letterhead/header is always the Westcott Group one.
*
* Centralised here so the delivery-notes service and the invoicing
* API (which both render delivery-note PDFs) can't drift apart
* again. If the brand ever needs to change, this is the only
* constant to touch.
*/
export const DELIVERY_NOTE_LETTERHEAD_CODE: LetterheadCode =
"WESTSCOTT GROUP OF COMPANIES";
/**
* Helper that returns the canonical delivery-note letterhead code.
* Use this anywhere a delivery-note PDF is being rendered so the
* three-letter `?? "CNL"` fallbacks that used to live in the
* invoicing API routes stay gone.
*/
export function deliveryNoteLetterheadCode(): LetterheadCode {
return DELIVERY_NOTE_LETTERHEAD_CODE;
}
/**
* Pick the letterhead code that matches a given company name. The
* invoice / delivery-note flows don't always carry an explicit

View file

@ -37,9 +37,16 @@ import { generateQuoteNumberServer } from "./quotes-service";
import { renderDeliveryNotePdf } from "@/lib/pdf/render";
import { resolveDeliveryNoteLineQty } from "@/lib/pdf/delivery-note-document";
import {
DELIVERY_NOTE_LETTERHEAD_CODE,
deliveryNoteLetterheadCode,
fetchLetterhead,
loadLetterheadLogoDataUri,
} from "@/lib/letterhead";
// Re-export so existing callers that imported the constant from this
// module (`import { DELIVERY_NOTE_LETTERHEAD_CODE } from "@/lib/server/delivery-notes-service"`)
// keep working after the canonical home moved to `@/lib/letterhead`.
export { DELIVERY_NOTE_LETTERHEAD_CODE };
import type {
DeliveryNote,
DeliveryNoteStatus,
@ -80,10 +87,6 @@ const ORDERS = "orders";
*
* 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";
/**
* Lifecycle transitions for the delivery_notes.status field. The
@ -374,11 +377,11 @@ async function attachDeliveryNoteToQuote(
const companyName = dn.expand?.company?.company ?? "";
// All delivery-note PDFs render under the Westcott
// Group letterhead, regardless of which subsidiary the
// customer belongs to — see DELIVERY_NOTE_LETTERHEAD_CODE.
// customer belongs to — see deliveryNoteLetterheadCode().
// The `companyName` above still goes into the PDF meta
// block so the printed document tells the driver which
// subsidiary the delivery is for.
const letterheadCode = DELIVERY_NOTE_LETTERHEAD_CODE;
const letterheadCode = deliveryNoteLetterheadCode();
const letterhead = await fetchLetterhead(letterheadCode);
const logoDataUri = await loadLetterheadLogoDataUri(letterheadCode);
const customerName = dn.expand?.customer?.customer ?? "";
@ -945,7 +948,7 @@ export async function createAdHocDeliveryNote(
? (companyRow as unknown as { company: string }).company
: "";
const letterheadCode = DELIVERY_NOTE_LETTERHEAD_CODE;
const letterheadCode = deliveryNoteLetterheadCode();
const letterhead = await fetchLetterhead(letterheadCode);
const logoDataUri = await loadLetterheadLogoDataUri(letterheadCode);
@ -1066,11 +1069,11 @@ export async function regenerateDeliveryNotePdf(
// All delivery-note PDFs render under the Westcott
// Group letterhead, regardless of which subsidiary the
// customer belongs to — see DELIVERY_NOTE_LETTERHEAD_CODE.
// customer belongs to — see deliveryNoteLetterheadCode().
// The `companyName` above still goes into the PDF meta
// block so the printed document tells the driver which
// subsidiary the delivery is for.
const letterheadCode = DELIVERY_NOTE_LETTERHEAD_CODE;
const letterheadCode = deliveryNoteLetterheadCode();
const [letterhead, logoDataUri] = await Promise.all([
fetchLetterhead(letterheadCode),
loadLetterheadLogoDataUri(letterheadCode),

View file

@ -83,6 +83,16 @@ export interface DriverStop {
drv_id: string | null;
/** ISO timestamp of the most recent assignment (date-only on stock side). */
assigned_at: string | null;
/** ISO timestamp of when the underlying PB row was created. Useful
* for "outstanding N days" reporting from the driver app / n8n. */
created_at: string | null;
/** ISO timestamp of when the parent order / purchase-order was
* created in WFDASH. This is the canonical "this thing came into
* the system on day X" timestamp — distinct from `assigned_at`
* (when a driver was put on it) and `created_at` (when the line
* item was added, which can differ if the line was split). Use
* this for "outstanding N days" reporting. */
outstanding_since: string | null;
/** Parent PO number, formatted for display. */
po_number: string;
/** Parent order id (delivery only — `null` for pickups). */
@ -305,6 +315,8 @@ function normaliseDelivery(r: DeliveryRow): DriverStop {
status: (r.item_status ?? "collection_pool") as DriverStopStatus,
drv_id: r.drv_id || null,
assigned_at: r.assigned_at || null,
created_at: r.created || null,
outstanding_since: order?.created || null,
po_number: r.expand?.purchase_order?.po_number || "",
order_id: order?.id || null,
customer_name: customer?.customer || null,
@ -340,6 +352,8 @@ function normalisePickup(r: PickupRow): DriverStop {
status: (r.status ?? "collection_pool") as DriverStopStatus,
drv_id: r.drv_id || null,
assigned_at: r.assigned_at || null,
created_at: r.created || null,
outstanding_since: r.expand?.purchase?.created || null,
po_number: r.expand?.purchase?.purchase_number || "",
order_id: null,
customer_name: null,