diff --git a/.audit-screenshots/fix-09-new-quote-qty-22.png b/.audit-screenshots/fix-09-new-quote-qty-22.png new file mode 100644 index 0000000..856a17e Binary files /dev/null and b/.audit-screenshots/fix-09-new-quote-qty-22.png differ diff --git a/.kilo/kilo.json b/.kilo/kilo.json index 45c6f5a..04b2a5b 100644 --- a/.kilo/kilo.json +++ b/.kilo/kilo.json @@ -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 } } } \ No newline at end of file diff --git a/.kilo/plans/wfdash-driver-live-alerter.json b/.kilo/plans/wfdash-driver-live-alerter.json new file mode 100644 index 0000000..8b0bd1a --- /dev/null +++ b/.kilo/plans/wfdash-driver-live-alerter.json @@ -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 }]] } + } +} diff --git a/.kilo/plans/wfdash-driver-morning-summary.json b/.kilo/plans/wfdash-driver-morning-summary.json new file mode 100644 index 0000000..e7a38cb --- /dev/null +++ b/.kilo/plans/wfdash-driver-morning-summary.json @@ -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 }]] } + } +} diff --git a/src/app/api/invoicing/delivery-note-print/route.ts b/src/app/api/invoicing/delivery-note-print/route.ts index 522882c..9e0e2b1 100644 --- a/src/app/api/invoicing/delivery-note-print/route.ts +++ b/src/app/api/invoicing/delivery-note-print/route.ts @@ -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), diff --git a/src/app/api/invoicing/delivery-note/route.ts b/src/app/api/invoicing/delivery-note/route.ts index 8c33983..3ecaa9b 100644 --- a/src/app/api/invoicing/delivery-note/route.ts +++ b/src/app/api/invoicing/delivery-note/route.ts @@ -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({ diff --git a/src/lib/letterhead.test.ts b/src/lib/letterhead.test.ts new file mode 100644 index 0000000..f098203 --- /dev/null +++ b/src/lib/letterhead.test.ts @@ -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 ?? ""); + } + }); +}); diff --git a/src/lib/letterhead.ts b/src/lib/letterhead.ts index 58ab356..fcaece8 100644 --- a/src/lib/letterhead.ts +++ b/src/lib/letterhead.ts @@ -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 diff --git a/src/lib/server/delivery-notes-service.ts b/src/lib/server/delivery-notes-service.ts index 50d29f7..05ef8b5 100644 --- a/src/lib/server/delivery-notes-service.ts +++ b/src/lib/server/delivery-notes-service.ts @@ -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), diff --git a/src/lib/server/driver-stops.ts b/src/lib/server/driver-stops.ts index b2c08c3..6ca55e5 100644 --- a/src/lib/server/driver-stops.ts +++ b/src/lib/server/driver-stops.ts @@ -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,