MEGAINFRA-WEBAPP/docs/n8n/delivery-note-ocr.md

367 lines
14 KiB
Markdown
Raw Normal View 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)
```json
{
"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
## 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 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`](./delivery-note-ocr-workflow.ts). It's written against `@n8n/workflow-sdk`. To import into your n8n instance:
```bash
# 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`](./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
```bash
# 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.