- 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.
366 lines
14 KiB
Markdown
366 lines
14 KiB
Markdown
# 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.
|