MEGAINFRA-WEBAPP/docs/n8n/delivery-note-ocr.md
DEls 8dbf393446 feat(delivery-notes): enhance delivery note upload and processing
- 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.
2026-08-26 15:33:30 +02:00

14 KiB
Raw Blame History

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

  1. Watches the delivery_notes PocketBase collection for new rows where processing_status = "pending". Either poll every 30–60 s or wire a PocketBase webhook to trigger on row insert.
  2. For each pending DN, downloads slip_image from PocketBase.
  3. Runs OCR on the slip (image → vision model, PDF → text extract first then vision on rendered pages).
  4. 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
  5. 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_status based on confidence
  • Creating received_items rows
  • Marking is_verified = true for 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)

  1. Schedule Trigger every 30 s.
  2. PocketBase HTTP Request: list delivery_notes where processing_status = "pending" (filter via PB REST API).
  3. For each row:
    1. Download slip_image
    2. OCR (vision model)
    3. LLM structured extraction (with the prompt below)
    4. Compute confidence
    5. HTTP Request → POST /api/delivery-notes/{id}/ocr-result with the body and x-ingest-secret
    6. On 200: log receivedItemsCreated, move on
    7. 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
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 fires
  • delivery_notes.ocr_confidence — 0..100 integer
  • delivery_notes.ocr_completed_at — ISO timestamp of when OCR finished
  • delivery_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

  1. Open your n8n instance (https://wfdash.n8n.apphub.co.za)
  2. Workflows → ⋯ menu → Import from File (or paste JSON via Import from Clipboard)
  3. Select delivery-note-ocr-workflow.json
  4. n8n will create the workflow with placeholder credential IDs (REPLACE_PB_TOKEN_ID, REPLACE_OPENAI_KEY_ID)
  5. 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 / openaiKey and update the value in each node's expression {{ $credentials.pbToken }} / {{ $credentials.openaiKey }}
  6. Set workflow-level env vars:
    • MEGA_PB_URL=https://pb.mega.infra.apphub.co.za
    • MEGA_NEXT_URL=https://your-nextjs-app.example.com
    • N8N_INGEST_SECRET=<same value as in your .env.local>
  7. Toggle Active to ON

Notes on the JSON shape

  • I deliberately avoided the @n8n/n8n-nodes-langchain.openAi node 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 httpRequest to OpenAI's /v1/chat/completions endpoint works with a simpler httpHeaderAuth credential 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 JSON Code node uses JS — n8n's standard Code node. It pulls choices[0].message.content out of OpenAI's response and parses the embedded JSON.
  • The POST result to app node uses {{ $('List pending DNs').item.json.id }} to reference the upstream record id from outside the splitInBatches loop body.
  • The Mark ocr-complete in PB PATCH 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.