- Updated the delivery note upload API to include site ID for better project association. - Modified the delivery note schema to support additional fields such as processing status, OCR confidence, and received by name. - Enhanced the UI components for uploading delivery notes to allow file drop functionality and improved user feedback during file selection. - Refactored purchasing page to integrate delivery notes that require review, providing better visibility and management of pending items. - Added new KPIs to track active purchase orders and fulfillment percentages, improving overall dashboard insights.
14 KiB
n8n Workflow — Delivery Note OCR
This is the handoff doc for the n8n workflow that processes delivery note slips uploaded from the MEGA Infrastructure dashboard. The Next.js side is implemented by Claude; you build this workflow in n8n using your MCP access.
What this workflow does
- Watches the
delivery_notesPocketBase collection for new rows whereprocessing_status = "pending". Either poll every 30–60 s or wire a PocketBase webhook to trigger on row insert. - For each pending DN, downloads
slip_imagefrom PocketBase. - Runs OCR on the slip (image → vision model, PDF → text extract first then vision on rendered pages).
- Sends the OCR text through an LLM with a structured-output prompt to
extract:
- PO number
- Delivery note number
- Date received
- Received-by name (signature)
- Line items: description, qty, unit, unit_price
- Overall confidence (0..1) the AI has in the extraction
- POSTs the structured result back to Next.js at
POST /api/delivery-notes/[id]/ocr-result(where[id]is the PocketBase record id of the delivery note).
The Next.js route handles:
- Updating the DN's
processing_statusbased on confidence - Creating
received_itemsrows - Marking
is_verified = truefor high-confidence matches - Idempotency (safe to replay)
When this workflow runs
Poll PocketBase on a schedule (every 30–60 s) for rows where:
processing_status = "pending"
After processing, the route will set processing_status to either:
| Value | Meaning |
|---|---|
auto-verified |
Confidence ≥ 0.8 — fully verified, received_items created, is_verified = true |
needs-review |
Confidence 0.5–0.8 — received_items created as draft, is_verified = false, user reviews |
needs-review |
Confidence < 0.5 — no received_items created, user manually captures line items |
failed |
OCR pipeline errored; row stays pending until retried |
Use this status in your polling query to find work.
Downloading the slip
GET /api/files/{collectionId}/{recordId}/{fieldName}/{filename}
Or use the PocketBase SDK to fetch the file directly. The slip_image
field on delivery_notes records is the file.
What to extract (LLM structured output)
| Field | Type | Notes |
|---|---|---|
po_number |
string | null | Match against purchase_orders.purchase_order_number |
delivery_note_number |
string | null | Match against delivery_note_number on existing DNs |
date_received |
YYYY-MM-DD | null |
From the slip header. Overrides the upload-time date. |
received_by_name |
string | null | Signature/name on the slip |
confidence |
number 0..1 (or 0..100) | Overall confidence. The route auto-clamps. |
raw_ocr |
string | null | Full OCR text — saved on raw_ocr_data.raw_ocr for audit (truncated to 50 KB) |
items |
array (see below) | One entry per line item on the slip |
items[] shape
| Field | Type | Notes |
|---|---|---|
description |
string | Free text from the slip |
qty |
number | Quantity received |
unit |
string | null | bag, m², ea, etc. |
unit_price |
number | null | From the slip (optional, used in audit) |
matched_po_item_id |
PocketBase id | null | The id of the matching purchase_order_items row, if you can match it |
match_confidence |
number 0..1 | Per-line confidence. Saved in condition_notes. |
If you can't match a line to a PO item, leave matched_po_item_id = null.
The user will fix it manually in the exception inbox.
Confidence score (how to compute it)
The route uses three thresholds: 0.8 (auto), 0.5 (draft), below that (manual). Compute it however you want, but here's a reasonable starting formula:
score = 0
if po_number matches an existing PO: score += 0.30
if delivery_note_number parseable: score += 0.10
if supplier matches PO supplier: score += 0.10
if line items fuzzy-match PO items: score += 0.30 × (% matched)
if line totals add up to grand total: score += 0.20
Tune the weights after running for a week or two. The thresholds above
assume a default of gpt-4o-mini or similar mid-tier vision model.
Callback endpoint
POST /api/delivery-notes/{id}/ocr-result
Headers:
Content-Type: application/json
x-ingest-secret: ${N8N_INGEST_SECRET} ← same env var as the PO webhook
Body: (see shape above)
{id} is the PocketBase id of the delivery note (15-char alphanumeric).
Response codes
| Code | Meaning | What to do |
|---|---|---|
200 |
Accepted. received_items rows created (or skipped if tier=manual). |
Mark processing_status="ocr-complete" in your n8n node so you don't reprocess |
401 |
Bad / missing x-ingest-secret |
Log "secret mismatch — check N8N_INGEST_SECRET env". Do not retry. |
404 |
Delivery note id doesn't exist | Log "dn_not_found for id={id}" — the record may have been deleted. |
400 |
Body validation failed | Log the body; do not retry; user needs to investigate. |
500 |
PocketBase update failed | Retry once with backoff. After 2 failures, mark processing_status="failed" so the user sees it in the exception inbox. |
Response body (200)
{
"ok": true,
"tier": "auto" | "draft" | "manual",
"receivedItemsCreated": 3,
"receivedItems": [
{ "id": "...", "po_item_id": "...", "qty": 25 }
],
"durationMs": 1234
}
Suggested n8n flow (high-level)
- Schedule Trigger every 30 s.
- PocketBase HTTP Request: list delivery_notes where
processing_status = "pending"(filter via PB REST API). - For each row:
- Download
slip_image - OCR (vision model)
- LLM structured extraction (with the prompt below)
- Compute confidence
- HTTP Request →
POST /api/delivery-notes/{id}/ocr-resultwith the body andx-ingest-secret - On 200: log
receivedItemsCreated, move on - On 5xx: retry once after 5 s; on second failure, update the DN's
processing_status="failed"directly via PocketBase API so the exception inbox surfaces it
- Download
Recommended LLM prompt (skeleton)
You are extracting structured data from a delivery note slip image.
Return ONLY valid JSON matching this schema:
{
"po_number": string | null,
"delivery_note_number": string | null,
"date_received": "YYYY-MM-DD" | null,
"received_by_name": string | null,
"confidence": number, // 0..1 — your overall certainty
"items": [
{
"description": string,
"qty": number,
"unit": string | null,
"unit_price": number | null
}
]
}
Rules:
- confidence is your honest assessment: 0.9+ only if everything is
clearly legible, 0.5–0.8 if some fields are guesses, < 0.5 if
the slip is unreadable
- dates must be ISO format or null
- quantities must be numbers (not strings)
- if the slip has no readable items, return an empty items array
If you have a PO list to match against (recommended), inject it as context:
Reference purchase orders:
- PO-1234 | Cement 50kg bag × 50 @ R110 | Acme Supplies
- PO-1235 | ...
For each line item, return `matched_po_item_id` if you can identify
the matching PO line; otherwise null.
Where things live in PocketBase
delivery_notes.processing_status—"pending"(your queue), or"auto-verified"/"needs-review"/"failed"after the webhook firesdelivery_notes.ocr_confidence— 0..100 integerdelivery_notes.ocr_completed_at— ISO timestamp of when OCR finisheddelivery_notes.raw_ocr_data— JSON, includes the full payload you sent (so audits can replay)received_items— auto-created rows from successful (auto + draft) tiers
Environment
N8N_INGEST_SECRET— shared secret header (same value as.env.local)MEGA_PB_URL— PocketBase URL (already in.env.local)MEGA_PB_ADMIN_TOKEN— admin token for the workflow (PB service token, not user)
Workflow code (n8n Workflow SDK)
The reference workflow is in docs/n8n/delivery-note-ocr-workflow.ts. It's written against @n8n/workflow-sdk. To import into your n8n instance:
# In your n8n project (or a separate dir with the SDK installed)
npm install @n8n/workflow-sdk
npx n8n-workflow-sdk import ./delivery-note-ocr-workflow.ts \
--base-url "$MEGA_N8N_URL" \
--api-key "$MEGA_N8N_API_KEY"
(Or whatever import subcommand the SDK exposes — check npx @n8n/workflow-sdk --help for current usage.)
If the SDK import command isn't available in your environment, paste the workflow JSON into n8n manually via Workflows → Import from File. The TypeScript source documents every node + its config so the JSON shape is one-to-one.
Workflow shape (one node per line)
[Schedule Trigger — every 30 s]
│
▼
[List pending DNs (PB HTTP)] ── 0..N items
│
▼
[SplitInBatches — batchSize 1]
│
▼
[Download slip image] ← loop body
│
▼
[Vision LLM extract (GPT-4o-mini)]
│
▼
[Structured output parser]
│
▼
[POST /api/delivery-notes/{id}/ocr-result]
│
▼
[Mark as ocr-complete in PB]
│
▼
[nextBatch → loop back]
Credentials to configure after import
| Node | Credential name | What to put |
|---|---|---|
| List pending DNs / Download slip | PocketBase Admin Token | POCKETBASE_ADMIN_TOKEN from .env.local |
| OCR extract | OpenAI Vision API | your OpenAI key (or swap to Claude/Gemini — node config is open to whatever the LangChain node supports) |
| POST result to app | (none — uses x-ingest-secret header from N8N_INGEST_SECRET) |
— |
| Mark as ocr-complete | PocketBase Admin Token | same as above |
Workflow environment variables
| Variable | Value |
|---|---|
MEGA_PB_URL |
https://pb.mega.infra.apphub.co.za |
MEGA_NEXT_URL |
The public URL of your Next.js app (the one the app sees as request.url) |
N8N_INGEST_SECRET |
Same value as in .env.local |
Customising for Claude / Gemini / etc.
Swap the @n8n/n8n-nodes-langchain.openAi node in ocrExtract for the equivalent provider (e.g. @n8n/n8n-nodes-langchain.anthropic with model claude-3-5-sonnet-latest, or Google's Gemini vision model). Keep the structured-output prompt the same — vision models all accept JSON schema instructions.
Importable JSON workflow
The workflow is also available as a ready-to-import JSON file at
docs/n8n/delivery-note-ocr-workflow.json. It has been validated as parseable JSON.
How to import
- Open your n8n instance (https://wfdash.n8n.apphub.co.za)
- Workflows → ⋯ menu → Import from File (or paste JSON via Import from Clipboard)
- Select
delivery-note-ocr-workflow.json - n8n will create the workflow with placeholder credential IDs (
REPLACE_PB_TOKEN_ID,REPLACE_OPENAI_KEY_ID) - Replace those placeholders with real credentials:
- Create credential PocketBase Admin Token (header auth, value = your PB superuser token), update the 4 nodes that reference it
- Create credential OpenAI API Key (header auth, value =
Bearer <your-key>), update the OCR node - Or use the generic credential name
pbToken/openaiKeyand update the value in each node's expression{{ $credentials.pbToken }}/{{ $credentials.openaiKey }}
- Set workflow-level env vars:
MEGA_PB_URL=https://pb.mega.infra.apphub.co.zaMEGA_NEXT_URL=https://your-nextjs-app.example.comN8N_INGEST_SECRET=<same value as in your .env.local>
- Toggle Active to ON
Notes on the JSON shape
- I deliberately avoided the
@n8n/n8n-nodes-langchain.openAinode because:- The LangChain node requires a specific LLM credential type per provider, and the credential ID needs to match a real one in your instance
- The raw
httpRequestto OpenAI's/v1/chat/completionsendpoint works with a simplerhttpHeaderAuthcredential that's easier to set up - You can swap it for the LangChain node later if you want richer streaming / tool-calling
- The
Parse OCR JSONCode node uses JS — n8n's standard Code node. It pullschoices[0].message.contentout of OpenAI's response and parses the embedded JSON. - The
POST result to appnode uses{{ $('List pending DNs').item.json.id }}to reference the upstream record id from outside the splitInBatches loop body. - The
Mark ocr-complete in PBPATCH runs unconditionally (continueOnFail: true) — if PB is briefly down, we don't want to block the OCR result delivery; the next poll will pick up any row still in "pending".
Once imported, test with one row
# Trigger a test by uploading one delivery note via the existing UI:
# /dashboard/purchasing → click "Upload Delivery Note" → pick a PO → upload any image
#
# Or insert directly:
curl -X POST https://pb.mega.infra.apphub.co.za/api/collections/delivery_notes/records \
-H "Authorization: <your token>" \
-H "Content-Type: application/json" \
-d '{
"delivery_note_number": "TEST-DN-001",
"project_code": "TEST",
"purchase_order": "<some PO id>",
"slip_image": "<upload a test slip first>",
"processing_status": "pending",
"is_verified": false
}'
#
# Within 30 s the workflow should pick it up and POST back to the webhook.
Watch the workflow's execution log in n8n and the DN's processing_status in PocketBase admin to confirm the round-trip works.
Changelog
2026-08-26 — initial filter bug fix
The first version of the workflow filter was:
=processing_status='pending' && slip_image!=''
PocketBase rejects this with HTTP 400 "Something went wrong while
processing your request" because !='' doesn't work on file fields —
file fields compare against null, not empty string. The corrected
filter is:
=processing_status='pending'
The slip_image check is also redundant: the upload route only sets
processing_status='pending' after a successful file upload, so every
pending row already has a slip.