feat(po-email): n8n workflow + runbook to auto-file POs from the MEGA mailbox

Polls ai@megainfra.co.za through Microsoft Graph, downloads the PO attachment and
writes it into the same purchase_orders pending queue the browser upload uses, so
the existing MEGA - Purchase Order Ingest workflow OCRs and finalises it. The
source email is archived once filed; POs that cannot be matched to a site are
tagged PO Needs Review instead of being filed against the wrong site.
This commit is contained in:
DEls 2026-10-28 16:07:29 +02:00
parent 8e1cbc8b75
commit e7c8c90e32
3 changed files with 631 additions and 5 deletions

161
docs/n8n/po-email-intake.md Normal file
View file

@ -0,0 +1,161 @@
# PO email intake — ai@megainfra.co.za → MEGA
**Status: built and tested. INACTIVE — waiting on two things from your IT admin (steps 1–2 below).**
Workflow: [MEGA - PO Email Intake (Microsoft Graph)](https://wfdash.n8n.apphub.co.za/workflow/bQ5PioGTSKnsWVh0)
Source: [`po-email-intake.workflow.js`](./po-email-intake.workflow.js) (rebuildable from this file)
---
## What it does
Every 5 minutes the workflow:
1. lists the inbox of `ai@megainfra.co.za` through Microsoft Graph,
2. finds emails carrying a purchase-order document (PDF / JPEG / PNG / WEBP),
3. works out which MEGA site the PO belongs to,
4. files the document into the **same PocketBase queue the browser upload button uses** — a
`purchase_orders` row with `processing_status: pending`, the file attached, `file_hash` set,
and a unique `PENDING-…` placeholder number,
5. archives the source email once the row is filed.
Step 4 is the important part: the existing **MEGA - Purchase Order Ingest** workflow
(`005iI4jX5mq9DsTR`) already polls that queue, so OCR, supplier matching, line-item extraction
and finalisation all happen in the pipeline that is already running and trusted. **No changes to
the dashboard app were needed** — the email path is just another way into the same door.
If a PO cannot be matched to a site, the email is tagged `PO Needs Review` in Outlook and left in
the inbox. Nothing is filed against the wrong site and nothing is silently dropped.
## Why this is not an IMAP workflow
`megainfra.co.za` is a **Microsoft 365 / Exchange Online** custom domain
(`autodiscover.megainfra.co.za → autodiscover.outlook.com`, SPF includes
`spf.protection.outlook.com`). The tenant has password-based mail protocols switched off:
| Endpoint | Result |
| --- | --- |
| `outlook.office365.com:993` (IMAP) | `Basic authentication is disabled.` |
| `smtp.office365.com:587` (SMTP) | `535 5.7.139 … SmtpClientAuthentication is disabled for the Tenant.` |
So no password-based mail client, n8n IMAP node or script can read this mailbox, whatever you
type into a sign-in form. Reading it needs OAuth2 against Microsoft Graph. There is also no
fallback IMAP route: `mail.megainfra.co.za` does not resolve, and HostH's
`securemail.synaq.com` mail ports are unreachable from the app servers.
---
## Step 1 — your IT admin creates the app registration (once)
In the Azure portal → **App registrations** → **New registration**:
- Name: `MEGA PO Email Intake`
- Supported account types: **Accounts in this organizational directory only**
- Redirect URI: leave blank (this is a client-credentials app, no sign-in redirect)
Then:
- **Certificates & secrets** → **New client secret** → copy the secret value (shown once).
- **API permissions** → **Add a permission** → **Microsoft Graph** → **Application permissions** →
add **Mail.ReadWrite** → **Grant admin consent**.
- `Mail.Read` would only allow reading. We also move and tag messages, so **`Mail.ReadWrite`
(application)** is the one to grant.
- Copy **Tenant ID**, **Application (client) ID** and the **client secret value**.
Send those three values to the person wiring up n8n. The account password is not needed and is
not stored anywhere.
## Step 2 — create the n8n credential (once)
In n8n → **Credentials** → **New** → **OAuth2 API**:
| Field | Value |
| --- | --- |
| Name | `Microsoft Graph (MEGA PO)` |
| Grant Type | **Client Credentials** |
| Token URL | `https://login.microsoftonline.com/<TENANT_ID>/oauth2/v2.0/token` |
| Client ID | Application (client) ID from the app registration |
| Client Secret | the client secret value |
| Scopes | `https://graph.microsoft.com/.default` |
| Auth Style | Header → `Bearer` |
## Step 3 — bind the credential and switch it on
The six Microsoft Graph nodes still need the credential selected:
`List Inbox POs` · `Download Attachment` · `Archive Message` ·
`Archive Message (already filed)` · `Flag Needs Review` ·
`Flag Needs Review (no site)`
The three PocketBase nodes (`Read Sites`, `Duplicate Check`, `Create PO Row`) are already bound
to the existing **`MEGAINFRASTRUCTURE`** credential — no secret sits inline in the workflow.
Once the credential is selected on those six nodes, activate the workflow (the schedule trigger
starts polling every 5 minutes).
---
## How a PO is matched to a site
This is the one design decision worth understanding.
MEGA's finalisation step deliberately **never re-maps the site after OCR** —
`src/app/api/purchase-orders/process-batch/route.ts` states it outright: *"`site` was set at
upload by the person who uploaded the … and is NEVER touched here"*, because OCR only matches a
real project code on roughly 8% of documents. The old
`src/app/api/purchase-orders/ingest/route.ts`, which used to match the site from
`project_code` server-side, is deprecated (see
`docs/superpowers/plans/2026-09-11-ingestion-restructure.md`).
So the site has to be known when the row is created, and the workflow reads it out of the email
itself, in this order:
1. exact project code — `C26/09`, `C26/13`, `C26/14`, `001`, `HEAD`
2. project code appearing anywhere in subject + body preview + attachment filename
3. site name — *The Hills*, *Cedar Gate*, *Munyaka 2*, *Nellmapius Ext 25*, *Mega Head Office*
If none of those match, the email is tagged `PO Needs Review` and left in the inbox for a human.
It is never filed against a guessed site.
> If you would rather have the OCR decide the site, that needs a change to the
> `process-batch` route (or to the queue) so the site can be corrected after extraction. That is
> a small change to the app, not to this workflow — say the word and it can be done.
## Duplicate protection
Before filing, the workflow looks for an existing `purchase_orders` row with the **same file hash
at the same site** (same rule the browser upload uses). A re-send of the same document is
archived rather than filed twice. The placeholder `purchase_order_number` is derived from the
file hash, so PocketBase's unique index is a second line of defence.
## What it deliberately does not do
- Only the inbox is read — not other folders. 25 most recent messages per run.
- Only PDF / JPEG / PNG / WEBP attachments are considered. Everything else is ignored.
- Messages already tagged `PO Needs Review` or `PO Ingested` are skipped on later runs, so a
flagged email is not re-processed every 5 minutes.
- Non-PO attachments that merely look PO-ish are flagged for review, not filed — MEGA's own
document-kind check then refuses anything that turns out to be an invoice.
## Timing
| Step | When |
| --- | --- |
| Email picked up | within 5 minutes of arriving |
| OCR + finalisation | the existing MEGA workflow wakes every 3 minutes and claims one pending row per run, so a PO reaches the dashboard roughly 3–8 minutes after the email lands |
| Archived email | as soon as the row is filed |
## Rollback
Deactivate the workflow (n8n → the workflow → **Deactivate**). Nothing else changes and the
dashboard keeps working. Rows already filed by email are ordinary `purchase_orders` rows and
behave exactly like browser uploads.
## Troubleshooting
| Symptom | Where to look |
| --- | --- |
| No emails picked up | n8n execution log for `List Inbox POs` — a 401/403 means the Graph credential is wrong or admin consent was not granted |
| POs tagged `PO Needs Review` | the email did not name a site; add the site/project code to the subject or check the site table in `Read Sites` |
| Row created but never finalised | the existing `MEGA - Purchase Order Ingest` workflow — check its `Fetch Pending Batches` / `Escalate Failure` nodes |
| Same PO filed twice | `Duplicate Check` compares `file_hash` + `site`; a different site for the same document is deliberately not treated as a duplicate |

View file

@ -0,0 +1,463 @@
/**
* MEGA - PO Email Intake (Microsoft Graph)
* -----------------------------------------
* n8n Workflow SDK source. Rebuild with the n8n MCP `create_workflow_from_code` tool
* (validate first), or rebuild by hand in the n8n UI using
* docs/n8n/po-email-intake.md as the wiring reference.
*
* Live workflow: https://wfdash.n8n.apphub.co.za/workflow/bQ5PioGTSKnsWVh0
*
* What it does: polls ai@megainfra.co.za for purchase-order emails, downloads the PO
* attachment, and files it into the same PocketBase pending queue the browser upload
* button uses, so the existing "MEGA - Purchase Order Ingest" workflow OCRs and
* finalises it. The source email is archived once the row is filed; anything that
* cannot be matched to a site is tagged "PO Needs Review" in Outlook instead.
*
* Credentials referenced (none of them contain an inline secret):
* - "Microsoft Graph (MEGA PO)" (oAuth2Api) -> created by the admin, see the runbook
* - "MEGAINFRASTRUCTURE" (pocketbaseHttpApi) -> already exists on this instance
*
* Set the Graph credential on these six nodes after creating it:
* List Inbox POs, Download Attachment, Archive Message,
* Archive Message (already filed), Flag Needs Review, Flag Needs Review (no site)
*/
import { workflow, node, trigger, ifElse, splitInBatches, nextBatch, newCredential, expr } from '@n8n/workflow-sdk';
const MAILBOX = 'ai@megainfra.co.za';
const GRAPH = 'https://graph.microsoft.com/v1.0';
const MSG = GRAPH + '/users/' + MAILBOX + '/messages/';
const GRAPH_CRED = { oAuth2Api: newCredential('Microsoft Graph (MEGA PO)') };
const PB_CRED = { pocketbaseHttpApi: newCredential('MEGAINFRASTRUCTURE') };
const schedule = trigger({
type: 'n8n-nodes-base.scheduleTrigger',
version: 1.3,
config: {
name: 'Every 5 minutes',
parameters: { rule: { interval: [{ field: 'minutes', minutesInterval: 5 }] } }
}
});
const readSites = node({
type: 'n8n-nodes-pocketbase.pocketbaseHttp',
version: 1,
config: {
name: 'Read Sites',
parameters: {
resource: 'sites',
operation: 'search',
parameters: { allElements: true, elementsPerPage: 50, fields: ['name', 'project_code'] }
},
credentials: PB_CRED
}
});
const collectSites = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Collect Sites',
parameters: {
mode: 'runOnceForAllItems',
jsCode: `const items = $input.all();
const sites = [];
for (const it of items) {
const j = it && it.json ? it.json : {};
if (j && j.id) sites.push({ id: j.id, name: j.name || '', project_code: j.project_code || '' });
}
return [{ json: { sites: sites } }];`
}
}
});
const listInbox = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.5,
config: {
name: 'List Inbox POs',
parameters: {
method: 'GET',
url: GRAPH + '/users/' + MAILBOX + '/mailFolders/inbox/messages',
sendQuery: true,
queryParameters: {
parameters: [
{ name: '$select', value: 'id,subject,from,bodyPreview,receivedDateTime,hasAttachments,categories' },
{ name: '$expand', value: 'attachments' },
{ name: '$orderby', value: 'receivedDateTime desc' },
{ name: '$top', value: '25' }
]
},
authentication: 'genericCredentialType',
genericAuthType: 'oAuth2Api',
options: { response: { response: { responseFormat: 'json', neverError: true } } }
},
credentials: GRAPH_CRED
}
});
const selectDocs = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Select PO Documents',
parameters: {
mode: 'runOnceForAllItems',
jsCode: `const body = $input.first() ? $input.first().json : {};
const messages = body && Array.isArray(body.value) ? body.value : [];
let sites = [];
try { sites = ($('Collect Sites').item.json.sites) || []; } catch (e) { sites = []; }
const DOC_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
const POISH = /(\\bp\\/?o\\b|\\bpo\\b|purchase\\s*order|\\border\\b)/i;
function norm(s) { return String(s || '').toUpperCase().replace(/\\s+/g, ' ').trim(); }
function matchSite(text) {
const t = norm(text);
if (!t) return null;
const flat = t.replace(/[^A-Z0-9]/g, '');
for (const s of sites) { const c = norm(s.project_code); if (c && t === c) return s; }
for (const s of sites) { const c = norm(s.project_code); if (c && c.length >= 3 && t.indexOf(c) >= 0) return s; }
for (const s of sites) { const n = norm(s.name); if (n && n.length >= 4 && t.indexOf(n) >= 0) return s; }
for (const s of sites) { const c = norm(s.project_code).replace(/[^A-Z0-9]/g, ''); if (c && c.length >= 3 && flat.indexOf(c) >= 0) return s; }
return null;
}
const out = [];
for (const m of messages) {
if (!m || !m.id || m.hasAttachments !== true) continue;
const cats = Array.isArray(m.categories) ? m.categories : [];
if (cats.indexOf('PO Needs Review') >= 0 || cats.indexOf('PO Ingested') >= 0) continue;
const atts = Array.isArray(m.attachments) ? m.attachments : [];
const subject = m.subject || '';
const preview = m.bodyPreview || '';
const from = (m.from && m.from.emailAddress && m.from.emailAddress.address) || '';
for (const a of atts) {
if (!a || a.isInline === true) continue;
const ct = String(a.contentType || '').toLowerCase();
const name = String(a.name || '');
if (DOC_TYPES.indexOf(ct) < 0) continue;
const hay = subject + ' ' + preview + ' ' + name;
const site = matchSite(hay);
const poish = POISH.test(hay);
if (!site && !poish) continue;
out.push({ json: {
messageId: m.id,
attachmentId: a.id,
filename: name,
contentType: ct,
size: a.size || 0,
subject: subject,
bodyPreview: preview,
from: from,
received: m.receivedDateTime || '',
ready: !!site,
siteId: site ? site.id : '',
siteCode: site ? site.project_code : '',
siteName: site ? site.name : ''
} });
}
}
return out;`
}
}
});
const loop = splitInBatches({
version: 3,
config: { name: 'Loop Documents', parameters: { batchSize: 1 } }
});
const siteResolved = ifElse({
version: 2.2,
config: {
name: 'Site Resolved?',
parameters: {
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
conditions: [{ leftValue: expr('{{ $json.ready }}'), operator: { type: 'boolean', operation: 'equals' }, rightValue: true }],
combinator: 'and'
}
}
}
});
const download = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.5,
config: {
name: 'Download Attachment',
parameters: {
method: 'GET',
url: expr('"' + MSG + '" + $json.messageId + "/attachments/" + $json.attachmentId + "/$value"'),
authentication: 'genericCredentialType',
genericAuthType: 'oAuth2Api',
options: { response: { response: { responseFormat: 'file', outputPropertyName: 'data' } } }
},
credentials: GRAPH_CRED
}
});
const capture = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Capture File',
parameters: {
mode: 'runOnceForAllItems',
jsCode: `const items = $input.all();
const out = [];
for (const item of items) {
let meta = {};
try { meta = $('Select PO Documents').item.json || {}; } catch (e) { meta = {}; }
if (!meta.messageId) { try { meta = $('Loop Documents').item.json || {}; } catch (e) { meta = {}; } }
const bin = (item.binary && item.binary.data) || {};
const b64 = typeof bin.data === 'string' ? bin.data : '';
let fileHash = '';
try {
const crypto = require('crypto');
if (b64) fileHash = crypto.createHash('sha256').update(Buffer.from(b64, 'base64')).digest('hex');
} catch (e) { fileHash = ''; }
const stamp = Date.now().toString(36).toUpperCase();
out.push({
json: {
messageId: meta.messageId || '',
attachmentId: meta.attachmentId || '',
filename: bin.fileName || meta.filename || 'purchase-order.pdf',
contentType: bin.mimeType || meta.contentType || 'application/pdf',
fileHash: fileHash,
siteId: meta.siteId || '',
siteCode: meta.siteCode || '',
purchaseOrderNumber: fileHash ? ('PENDING-' + fileHash.slice(0, 12).toUpperCase()) : ('PENDING-' + stamp),
emailMeta: {
source: 'email',
message_id: meta.messageId || '',
subject: meta.subject || '',
from: meta.from || '',
received: meta.received || '',
site_code: meta.siteCode || '',
file_name: bin.fileName || meta.filename || ''
}
},
binary: item.binary
});
}
return out;`
}
}
});
const dupCheck = node({
type: 'n8n-nodes-pocketbase.pocketbaseHttp',
version: 1,
config: {
name: 'Duplicate Check',
alwaysOutputData: true,
parameters: {
resource: 'purchase_orders',
operation: 'search',
parameters: {
elementsPerPage: 1,
filter: expr('"=file_hash=\'" + $("Capture File").item.json.fileHash + "\' && site=\'" + $("Capture File").item.json.siteId + "\' && processing_status!=\'failed\'"')
}
},
credentials: PB_CRED
}
});
const alreadyFiled = ifElse({
version: 2.2,
config: {
name: 'Already Filed?',
parameters: {
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'loose' },
conditions: [{ leftValue: expr('{{ $json.id }}'), operator: { type: 'string', operation: 'notEmpty' }, rightValue: '' }],
combinator: 'and'
}
}
}
});
const createPo = node({
type: 'n8n-nodes-pocketbase.pocketbaseHttp',
version: 1,
config: {
name: 'Create PO Row',
onError: 'continueRegularOutput',
parameters: {
resource: 'purchase_orders',
operation: 'create',
bodyType: ['fields', 'binaryData'],
binaryPropertyName: 'data',
binaryFieldName: 'purchase_order_file',
fields: {
assignments: [
{ id: 'f1', name: 'purchase_order_number', value: expr('{{ $("Capture File").item.json.purchaseOrderNumber }}'), type: 'string' },
{ id: 'f2', name: 'site', value: expr('{{ $("Capture File").item.json.siteId }}'), type: 'string' },
{ id: 'f3', name: 'processing_status', value: 'pending', type: 'string' },
{ id: 'f4', name: 'attempts', value: 0, type: 'number' },
{ id: 'f5', name: 'is_verified', value: false, type: 'boolean' },
{ id: 'f6', name: 'file_hash', value: expr('{{ $("Capture File").item.json.fileHash }}'), type: 'string' },
{ id: 'f7', name: 'date_uploaded', value: expr('{{ $now.toISO() }}'), type: 'string' },
{ id: 'f8', name: 'raw_ocr_data', value: expr('{{ $("Capture File").item.json.emailMeta }}'), type: 'object' }
]
}
},
credentials: PB_CRED
}
});
const checkResult = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Check Result',
parameters: {
mode: 'runOnceForAllItems',
jsCode: `const j = $input.first() ? $input.first().json : {};
let outcome = 'failed';
let detail = '';
let poId = '';
if (j && j.id) {
outcome = 'filed';
poId = j.id;
} else {
const err = j && (j.error || j.message) ? String(j.error || j.message) : JSON.stringify(j).slice(0, 300);
detail = err;
}
return [{ json: { outcome: outcome, detail: detail, poId: poId } }];`
}
}
});
const filedOk = ifElse({
version: 2.2,
config: {
name: 'Filed OK?',
parameters: {
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'loose' },
conditions: [{ leftValue: expr('{{ $json.outcome }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'filed' }],
combinator: 'and'
}
}
}
});
const archiveNew = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.5,
config: {
name: 'Archive Message',
parameters: {
method: 'POST',
url: expr('"' + MSG + '" + $("Capture File").item.json.messageId + "/move"'),
sendHeaders: true,
headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] },
sendBody: true,
specifyBody: 'json',
jsonBody: '={"destinationId":"archive"}',
authentication: 'genericCredentialType',
genericAuthType: 'oAuth2Api',
options: { response: { response: { responseFormat: 'json', neverError: true } } }
},
credentials: GRAPH_CRED
}
});
const archiveDup = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.5,
config: {
name: 'Archive Message (already filed)',
parameters: {
method: 'POST',
url: expr('"' + MSG + '" + $("Capture File").item.json.messageId + "/move"'),
sendHeaders: true,
headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] },
sendBody: true,
specifyBody: 'json',
jsonBody: '={"destinationId":"archive"}',
authentication: 'genericCredentialType',
genericAuthType: 'oAuth2Api',
options: { response: { response: { responseFormat: 'json', neverError: true } } }
},
credentials: GRAPH_CRED
}
});
const flagReview = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.5,
config: {
name: 'Flag Needs Review',
parameters: {
method: 'PATCH',
url: expr('"' + MSG + '" + $("Capture File").item.json.messageId'),
sendHeaders: true,
headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] },
sendBody: true,
specifyBody: 'json',
jsonBody: '={"categories":["PO Needs Review"]}',
authentication: 'genericCredentialType',
genericAuthType: 'oAuth2Api',
options: { response: { response: { responseFormat: 'json', neverError: true } } }
},
credentials: GRAPH_CRED
}
});
const flagNoSite = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.5,
config: {
name: 'Flag Needs Review (no site)',
parameters: {
method: 'PATCH',
url: expr('"' + MSG + '" + $json.messageId'),
sendHeaders: true,
headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] },
sendBody: true,
specifyBody: 'json',
jsonBody: '={"categories":["PO Needs Review"]}',
authentication: 'genericCredentialType',
genericAuthType: 'oAuth2Api',
options: { response: { response: { responseFormat: 'json', neverError: true } } }
},
credentials: GRAPH_CRED
}
});
const summary = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Run Summary',
parameters: {
mode: 'runOnceForAllItems',
jsCode: `const items = $input.all();
return [{ json: { finished: true, processed: items.length } }];`
}
}
});
export default workflow('mega-po-email-intake', 'MEGA - PO Email Intake (Microsoft Graph)')
.add(schedule)
.to(readSites)
.to(collectSites)
.to(listInbox)
.to(selectDocs)
.to(loop
.onDone(summary)
.onEachBatch(siteResolved
.onTrue(download
.to(capture)
.to(dupCheck)
.to(alreadyFiled
.onTrue(archiveDup.to(nextBatch(loop)))
.onFalse(createPo
.to(checkResult)
.to(filedOk
.onTrue(archiveNew.to(nextBatch(loop)))
.onFalse(flagReview.to(nextBatch(loop)))))))
.onFalse(flagNoSite.to(nextBatch(loop))))
);

View file

@ -186,10 +186,12 @@ Test cases:
4. **Duplicate PO** — upload the same PO twice. First call 200, second call 409. 4. **Duplicate PO** — upload the same PO twice. First call 200, second call 409.
5. **Unknown project_code** — expect 200 with `warnings: ["site_unmatched"]`. 5. **Unknown project_code** — expect 200 with `warnings: ["site_unmatched"]`.
## Future: email ingestion ## Email ingestion — built
The same `/api/purchase-orders/ingest` callback will be reused when email ingestion is built. Add a second n8n workflow with an **Email Trigger** (IMAP / Gmail / Outlook node) that: Email ingestion exists now: **MEGA - PO Email Intake (Microsoft Graph)**
1. Downloads the PO attachment from the email. (`bQ5PioGTSKnsWVh0`). See [po-email-intake.md](./po-email-intake.md).
2. Runs the same OCR + parser + callback flow.
No changes needed on the Next.js side. It does **not** use `/api/purchase-orders/ingest` — that route is deprecated (see
`docs/superpowers/plans/2026-09-11-ingestion-restructure.md`). Instead the email workflow writes
the PO document straight into the same `purchase_orders` pending queue this workflow polls, so
the OCR + parser + callback flow above is reused unchanged. No changes on the Next.js side.