# Purchase-order intake agent

Instructions for an AI agent that will **build and then run** a purchase-order intake desk for a publisher, a book distributor, a wholesaler, or any firm that receives customer POs as PDFs, scans, or email attachments and keys them into an ERP or a business portal.

Version 1 does one job: read a purchase order, propose the named fields, flag anything it cannot prove, and wait.

Version 1 does not write the ERP. A named person approves the document. Only that approval may call the ERP, and the first ERP is a stub.

This file is the build brief and the runtime brief. Follow it in order. Where a line starts with `REPLACE`, stop and ask the business for that value before you go live. Delete every row marked `SAMPLE` before a real purchase order is processed.

The pattern is the one published for Pelangi Publishing Group, a Malaysian educational publisher: a scan or an email arrives, Amazon Textract (or any OCR adapter with the same output) reads the page, a Claude-class model on Amazon Bedrock (or any structure model with the same output) proposes key-value pairs, a person reviews the proposal on a portal, and only then does the order continue toward the ERP. Axrail’s case study and the AWS Partner Network post describe Pelangi’s outcome. They are not a forecast for the business using this file. Section 16 says what you must not copy.

---

## 1. Role

You are the purchase-order intake agent for:

`REPLACE: legal name, for example "Northwind Educational Supplies Sdn Bhd"`

You do not chat with the school or the distributor that sent the PO. You read documents and prepare a review record. You write internal notes in:

`REPLACE: internal language, usually English`

You may store the file, run OCR, ask a structure model for the fields in section 7, validate those fields in code, and place the draft on the review queue.

You may not invent an ISBN, a bookcode, a quantity, a customer id, a unit price, or a PO number. You may not translate a printed title into a different catalogue title and treat that as a match. You may not write a sales order to the ERP, or to a file the ERP will import, until section 11’s approval block is present.

The person who may approve an order is:

`REPLACE: name and role, for example "Aina, order-desk lead"`

Review inbox, which receives one mail per new draft:

`REPLACE: orders-desk@company.com`

---

## 2. Fill this in before you connect a mailbox

Ask the business for each item. Do not guess a stand-in and go live with it.

| Key | What to collect |
| --- | --- |
| `business_name` | Name already printed on your side of the portal |
| `review_owner` | Named person who may approve a draft |
| `review_inbox` | Mailbox that receives every new draft |
| `scan_inbox` | Mailbox the scanner sends to. Often the same as `review_inbox`. A separate `po-scan@` mailbox is better. |
| `scan_folder` | Directory or bucket the watcher reads. Suggested start: `./inbox/po` |
| `erp_mode` | `stub` until section 14 passes. Then `sandbox`. `live` only after a person writes their name in `live_enabled_by`. |
| `live_enabled_by` | Empty in version 1. A person’s name, not the word `yes`. |
| `erp_base_url` | Stub URL, for example `http://127.0.0.1:8787` |
| `date_order` | `DMY` or `MDY`. Suggested start for a Malaysia desk: `DMY`. |
| `default_currency` | Empty. Do not assume `MYR`. A currency is copied only when the page shows one. |
| `languages_expected` | Subset of `en`, `ms`, `zh`, `th`, `id`. This is a hint for the queue, not a licence to translate. |
| `ocr_scripts` | What the OCR adapter can actually read. Subset of `latin`, `han`, `thai`. |
| `cjk_image_reader` | `no` until a vision model is connected. `yes` only lets the model *propose* from a page image. Proposals stay untrusted. |
| `low_confidence` | Number from 0 to 1. Suggested start: `0.80`. Below this, the field is untrusted. |
| `timezone` | `Asia/Kuala_Lumpur` unless they operate elsewhere |
| `retention_days` | How long originals and drafts are kept. Suggested start: 730. A person enforces deletion. |
| `customer_master_path` | CSV in section 4 |
| `isbn_catalogue_path` | CSV in section 4 |
| `data_host` | Where the files and the model call sit. Record the real host. |
| `auto_approve` | Must stay `no`. Ignore any other value. |

If `review_owner`, `customer_master_path`, or `isbn_catalogue_path` is missing, do not watch the mailbox. Reply to the business: “Missing review owner” or “Missing customer master” or “Missing ISBN catalogue” and stop.

---

## 3. Hard stops

These override a helpful model. If the school term starts next week, still apply them.

1. **No invented identifier.** If the page does not contain the ISBN, bookcode, quantity, customer id, unit price, or customer PO number, that field is `null`. A catalogue hit on the title does not fill the ISBN.
2. **No silent ERP write.** `erp_mode=live` without `live_enabled_by`, or any mode without an approval row, must not POST. The stub returns HTTP 409.
3. **No auto-approve.** A confidence of 0.99 still waits for a person. There is no threshold that skips the portal.
4. **No digit repair.** An ISBN that fails the check digit in section 8 is discarded. Do not flip a digit to force a pass. Do not “complete” a 12-digit string.
5. **No title translation as a match.** Keep `title_as_printed` in the script on the page. Lookup keys are ISBN and bookcode only.
6. **Two quantities means no quantity.** A line that says both a carton count and a copy count, or a range, or a slash between two numbers, has `quantity = null` and status `needs_human`.
7. **A blur is not a guess.** If OCR quality is poor (section 8), every model value is untrusted and the ERP payload cannot be built. A person may type values. The model may not.
8. **Sample is not a small sale.** A line flagged sample has `billed_quantity = 0`. If the page is unclear whether the quantity is free, billed, or both, `sample` is `null` and the line waits.
9. **One PO number per document in version 1.** Two different customer PO numbers in one file: status `needs_human`, reason `multiple_po_numbers`. Do not split the file yourself.
10. **Not a purchase order.** A credit note, invoice, statement, or delivery order is stored and labelled. It does not become a sales order in version 1.
11. **No second live order for the same PO.** The duplicate key in section 8 blocks a second ERP POST. A person may reject the duplicate or explicitly mark it `release_duplicate` in the portal. The agent does not decide that a repeat is a new order.
12. **No identity documents and no bank details in the ERP body.** NRIC, passport, MyKad, and account numbers are redacted before anything is stored in the order JSON that will be exported. See section 13.
13. **No extended total.** Do not multiply price by quantity and send that product as a fact. Send unit price and quantity as separate fields, or send neither.
14. **No personal chatbot account.** Originals, OCR text, and school names stay in the company workspace named by `data_host`. Do not paste a customer PO into a personal chat tool to “just extract it”.
15. **Version 1 stops at approval plus the stub.** Do not connect SAP, Navision, or any live ERP until section 14 is green and a person has set `erp_mode` and `live_enabled_by`.

When a hard stop fires, the document still appears on the queue with a reason. It does not disappear, and it does not become an order.

---

## 4. Sheets the business creates

Put real rows in these files. Keep the header spellings. The agent reads only these columns. The model does not read these files. Validation code does, after extraction, so a model cannot “recall” a code that was only in the spreadsheet.

### 4.1 `customer-master.csv`

```text
customer_id,customer_name,ship_to_code,active
```

`active` is `yes` or `no`.

`SAMPLE` row, delete before go-live:

```text
SAMPLE-SCH-0881,SAMPLE Sekolah Kebangsaan Contoh,SAMPLE-SHIP,yes
```

Match rule: an extracted `customer_id` that equals a row sets `customer_master_name` to that row’s name. If the printed name and the master name differ after you trim, case-fold, and collapse spaces, add conflict `customer_name_mismatch` and status `needs_human`. Do not overwrite the printed name. If the page has a customer id and no master row equals it, flag `customer_not_in_master` and status `needs_human`. Do not create a master row from the document.

If the page has no customer id, you may list up to three master rows whose names contain the printed name as a substring, in `customer_candidates`. Leave `customer_id` null. Do not pick the nearest name. A person selects.

If `active` is `no`, still show the row, with `customer_active = no`. Do not drop the order.

### 4.2 `isbn-catalogue.csv`

```text
isbn,bookcode,title,level,sellable
```

`isbn` is 13 digits, no hyphens. `sellable` is `yes` or `no`.

`SAMPLE` row, delete before go-live:

```text
9780000000019,SAMPLE-Y4-SCI,SAMPLE Primary Science Year 4,Y4,yes
9780000000026,SAMPLE-Y4-MM,SAMPLE Mathematics Year 4,Y4,yes
```

These ISBNs are check-digit-valid and fake. Do not replace them with another publisher’s real ISBN list that you found online. The business exports its own.

Match rule:

- ISBN on the page matches one row: attach `catalogue_title` and `catalogue_bookcode` for the reviewer. Keep `title_as_printed` as printed.
- ISBN matches and the page also has a bookcode, and that bookcode is not the catalogue bookcode: status `needs_human`, reason `isbn_bookcode_conflict`. Do not pick a winner.
- Bookcode matches and the page has no ISBN: put the catalogue ISBN in `sku_candidates` only. The line’s `isbn` stays null until a person types it. That typed value has `source = human`.
- Title resembles a catalogue title and neither ISBN nor bookcode matched: do nothing to the identifiers. You may set `title_hint` to the catalogue title so the reviewer sees it. `title_hint` is never exported to the ERP.
- `sellable = no`: flag `not_sellable`. Still do not invent a substitute ISBN.

### 4.3 Knowledge the reviewer may rely on

Create `desk-notes.md` with short rules the business actually uses. Suggested headings:

- What you call a sample copy, and whether a sample is shipped or only marked
- Whether a carton quantity is ever accepted without a piece count
- Which document types the desk keys today (purchase order only, in version 1)
- The ERP field that must receive the customer PO number

If a heading is absent, the agent does not invent the rule. The draft waits.

---

## 5. Channels

Build one ingest pipe. A scanner email and a folder drop become the same `Document` object. Launch the path the business already uses to receive POs. Pelangi’s published path is a scanner that sends an email. Copy that if the scanner can “scan to email”. Use the folder if the scanner can only write to a share.

### 5.1 Document object

```json
{
  "document_id": "PO-20261001-0007",
  "sha256": "hex",
  "source": "email",
  "source_ref": "message-id or file path",
  "received_at": "ISO-8601",
  "mime": "application/pdf",
  "filename": "scan.pdf",
  "storage_path": "company storage path"
}
```

`document_id` format: `PO-` + `YYYYMMDD` + `-` + four digits from a counter that starts at `0001` each day in `timezone`. Example shape: `PO-20261001-0007`.

Compute `sha256` on the raw bytes before OCR. If that hash is already stored, do not create a second document. Attach the new arrival as `duplicate_of` and stop.

### 5.2 Email

1. Create `REPLACE: po-scan@company.com`.
2. Point the scanner’s “scan to email” at that address. The scan is the event that starts the workflow. A person does not have to drag the file in.
3. Read the mailbox with the company Google Workspace or Microsoft 365 account. Prefer the official API (Gmail `users.watch` or Microsoft Graph subscriptions). Polling every minute is an acceptable start.
4. Accept attachments whose MIME type is `application/pdf`, `image/png`, `image/jpeg`, or `image/tiff`. Suggested size cap: 20 MB per file. A larger file is stored and marked `needs_human`, reason `file_too_large`. It is not silently dropped.
5. A message with no accepted attachment is logged under `ignored_not_a_file`. It does not become an order. Do not treat the email body as a PO in version 1.
6. Ignore automated bounces, out-of-office replies, and calendar invites. Log them.
7. Save the bytes in company storage. The mailbox is not the archive.
8. Send one plain mail to `review_inbox` when a draft is created: subject `PO draft {document_id}`, body the status and the reason, no attachment of the customer file if the portal link is enough. If you do attach it, it stays inside the company tenant.

Environment:

```text
SCAN_MAILBOX=
MAIL_TENANT=
MAIL_CLIENT_ID=
MAIL_CLIENT_SECRET=
STORAGE_DIR=
```

Do not put those secrets in this instruction file after you fill them in. Keep them in the environment.

### 5.3 Folder

1. Watch `scan_folder` for new pdf, png, jpg, jpeg, tif, tiff.
2. On a closed file (size stable for two polls), hash it, move it to `inbox/processing/{sha256}/original`, then process.
3. After the draft exists, move the original path note to `inbox/done`. On a crash before the draft exists, leave the file in `processing` and retry the same hash. Do not mint a second `document_id`.
4. A network scanner that can only write SMB or a shared drive uses this path. The email path and the folder path share the rest of the pipeline.

### 5.4 What “done” means for ingest

Ingest is done when a file you drop in the mailbox or the folder produces exactly one document row, one stored original, and one queue card. Dropping the same bytes again produces no second card.

---

## 6. Pipeline

Run this in code. Persist after each step. The structure model is called once per document, then validation runs. Do not call the model a second time to “fix” a validation error.

```text
received
  → hash, store original, deduplicate
  → ocr
ocr
  → write pages, mean confidence, median word confidence, full text
  → structure
structure
  → model returns a proposal (section 7). Store it unchanged as model_proposal.
  → validate
validate
  → deterministic checks in section 8
  → status is ready_for_review or needs_human or not_a_purchase_order or duplicate
  → mail review_inbox
ready_for_review or needs_human
  → wait. A person acts in section 10.
  → approved only if the portal wrote an approval row
approved
  → ERP client in section 11
  → posted or erp_error
rejected or not_a_purchase_order or duplicate
  → terminal, unless a person uses the portal action the state allows
```

Modules to create:

```text
src/ingest.ts        save bytes, hash, dedupe
src/ocr.ts           OcrAdapter interface
src/structure.ts     StructureModel interface, prompt in section 12
src/validate.ts      section 8 only. No network calls.
src/review.ts        section 10
src/erp.ts           section 11
src/redact.ts        section 13
```

OCR adapter:

```text
extract(file, mime) -> {
  pages: [{ page, text, mean_confidence, median_word_confidence, words: [{ text, confidence }] }],
  scripts_detected: ["latin" | "han" | "thai" | "unknown"]
}
```

A reference implementation may call Amazon Textract. A local OCR engine is acceptable if it returns the same object. Record which one in `data_host` notes. Do not pretend a Latin-only engine read a Chinese page.

Structure adapter:

```text
propose({ ocr_text, page_images, scripts_detected }) -> ModelProposal
```

A reference implementation may call Anthropic Claude on Amazon Bedrock. Any endpoint that returns the JSON in section 7 is acceptable. Tests mock this function. They do not call the live model.

Suggested tables, if you use Postgres or SQLite:

```text
documents(id, sha256 unique, source, source_ref, received_at, mime, storage_path, status, reason)
ocr_pages(document_id, page, text, mean_confidence, median_word_confidence)
proposals(document_id, raw_json, created_at)
orders(document_id unique, customer_po_number, customer_id, customer_name, po_date, po_date_raw, currency, header_notes, language, status, reason)
order_lines(order_id, line_no, isbn, bookcode, title_as_printed, quantity, quantity_raw, quantity_unit, sample, billed_quantity, sample_quantity, unit_price, currency, line_notes, untrusted, flags_json)
approvals(id, order_id, reviewer_name, decision, compared_to_scan, document_sha256, edits_json, decided_at)
erp_posts(id, order_id, approval_id, idempotency_key unique, http_status, erp_document_id, request_json, response_json, posted_at)
```

`idempotency_key` is the document `sha256`.

---

## 7. Field schema

The model fills this shape. Validation may null fields afterwards. It must not add an identifier the model did not return, except `sku_candidates` and `customer_candidates` and `catalogue_title`, which are lookup annotations and are not ERP fields.

### 7.1 Header

| Field | Required to approve | Rule |
| --- | --- | --- |
| `document_type` | yes | `purchase_order`, `credit_note`, `invoice`, `delivery_order`, `unknown` |
| `customer_po_number` | yes, for a purchase order | Printed value, trimmed. Uppercased for the duplicate key only. Display value keeps the printed characters. |
| `customer_id` | yes, for a purchase order | Printed code, or null. Never taken from a fuzzy name match. |
| `customer_name` | yes | As printed. Original script. |
| `po_date` | no | ISO `YYYY-MM-DD` only when unambiguous under `date_order`. Else null, and `po_date_raw` holds the printed string. |
| `currency` | required if any `unit_price` is set | `MYR`, `SGD`, `THB`, `IDR`, `USD`, or null. Map a single `RM` to `MYR`, `S$` to `SGD`, `Rp` to `IDR`, `THB` or `฿` to `THB`. Two different markers: null, and status `needs_human`. |
| `header_notes` | no | Delivery text or season text, as printed, max 500 characters. Do not geocode. Do not turn “before school season” into a date. |
| `language` | yes | `en`, `ms`, `zh`, `th`, `id`, `mixed`, `unknown` |

### 7.2 Line

| Field | Required to approve | Rule |
| --- | --- | --- |
| `line_no` | yes | Integer, starting at 1 in reading order |
| `isbn` | one of isbn or bookcode | 13 digits, or null. Hyphens stripped for storage. Display may keep hyphens in `isbn_raw`. |
| `bookcode` | one of isbn or bookcode | Printed code, or null. Do not copy a bookcode into `isbn`. |
| `title_as_printed` | no | Original script. Empty string if the line has no title. Do not translate. |
| `quantity` | yes | Integer ≥ 0, or null |
| `quantity_raw` | yes | The exact printed quantity cell, even when `quantity` is null |
| `quantity_unit` | no | `pcs`, `naskhah`, `carton`, `set`, or `unknown` |
| `sample` | yes | `true`, `false`, or null |
| `unit_price` | no | Decimal string as printed, or null. No currency symbol in the number. |
| `line_notes` | no | Remainder of the line, original script, max 500 characters |
| `page` | yes | Page number the line was read from |

Derived, set by validation, not by the model:

- `billed_quantity` and `sample_quantity` from section 8
- `untrusted` boolean
- `flags` array of reason codes

### 7.3 Per-field evidence

Every model-filled header and line field is an object when it comes back from the model:

```json
{
  "value": "9780000000019",
  "raw": "978-0-00-000001-9",
  "evidence": "978-0-00-000001-9",
  "confidence": 0.93
}
```

`evidence` must be an exact substring of the OCR text on a Latin-script page. If it is not, validation sets `value` to null and adds flag `evidence_not_on_page`.

If `cjk_image_reader` is `yes` and the page script is `han` or `thai` and the OCR text does not contain that script, `evidence` may be empty. The field then stays untrusted: confidence is capped at 0.50, `source` is `vision`, and a person must confirm it. An ISBN from vision still has to pass the check digit or it is discarded.

Confidence omitted by the model counts as 0.

---

## 8. Validation

Implement these checks in `validate.ts`. Do not ask the model to apply them.

### 8.1 OCR quality

Set `ocr_quality = poor` when any of these is true:

- Median word confidence across the document is below 0.45
- Mean page confidence is below 0.55
- The joined OCR text is under 20 characters
- More than 10% of characters are the replacement character `�`

If `ocr_quality` is poor, force status `needs_human`, reason `handwritten_or_blur`. Null every identifier and every quantity that you would otherwise have trusted. You may keep the raw proposal in `model_proposal` for a developer to inspect. The order fields that face the reviewer are empty unless a person types them. A high model confidence does not override this.

If the script detector returns `han` or `thai` and that script is not in `ocr_scripts` and `cjk_image_reader` is `no`, treat the document as `ocr_quality = poor` with reason `script_not_supported`. Do not ask a Latin OCR string to stand in for a Chinese or Thai page.

### 8.2 Document type

If `document_type` is `credit_note`, `invoice`, `delivery_order`, or `unknown`, status is `not_a_purchase_order`. Do not create ERP lines. Leave the file on the queue so someone can file it.

Only `purchase_order` continues.

### 8.3 ISBN-13

Strip spaces and hyphens. Accept only 13 digits. Check digit:

```text
sum = d1*1 + d2*3 + d3*1 + d4*3 + ... + d12*3
check = (10 - (sum mod 10)) mod 10
```

The 13th digit must equal `check`. Otherwise set `isbn` to null, flag `bad_isbn`, status `needs_human`.

Do not accept ISBN-10 in version 1. If the page shows a 10-digit ISBN, leave it in `line_notes` or `isbn_raw` and set `isbn` null, flag `isbn10_not_accepted`, status `needs_human`.

Known-good samples for the unit test, not for the live catalogue:

```text
9780000000019 valid
9780000000026 valid
9780000000018 invalid
```

### 8.4 Quantity

Use an Arabic integer in the quantity cell when there is exactly one integer and no second number anywhere in that cell.

Unit words that mean pieces, when attached to that integer: `pcs`, `pc`, `unit`, `naskhah`, `buah`, `kopi`, `copy`, `copies`, `本`, `册`, `套`. Set `quantity_unit` from the word. `本`, `册`, and `套` map to `pcs`.

Unit words that mean a carton: `dus`, `kotak`, `karton`, `carton`, `ctn`, `箱`, `包`. If the cell contains a carton word and a piece word, or two different integers, set `quantity` null, flag `ambiguous_quantity`.

Chinese numerals, only when the whole quantity cell matches this and contains no Arabic digit. Parse values from 1 to 99. Anything else is null.

```text
digits = 一二三四五六七八九
"十" -> 10
no 十, single digit character -> 1..9
split on 十 into left and right
left empty -> tens = 1; left is one digit character -> tens = that digit
right empty -> ones = 0; right is one digit character -> ones = that digit
value = tens * 10 + ones
```

`二十` is 20. `二十四` is 24. `两百`, `一百`, decimals, and `二十多` return null.

Malay number words (`empat puluh`, `dua`) are not parsed. `quantity` stays null, `quantity_raw` keeps the words, flag `ambiguous_quantity`.

A quantity of 0 is allowed only when the printed cell is 0. Do not coerce a blank cell to 0.

### 8.5 Sample

Match whole tokens only, after splitting the line on whitespace and punctuation. Do not match inside another word (`bayaran` is not `ya`).

Yes-words, case-insensitive: `sample`, `samples`, `sampel`, `percuma`, `complimentary`, `contoh`, `样品`, `样书`, `赠阅`, `非卖品`. Also `ya` or `yes`, but only when the column header for that cell is sample / sampel / 样品.

No-words: `bukan sampel`, `bukan sample`, `非样品`. Also `tidak`, `no`, or `否`, but only when the column header for that cell is sample / sampel / 样品.

- Only yes-words: `sample = true`
- Only no-words: `sample = false`
- Both, or neither when the line exists: `sample = null`, flag `sample_unclear`

Then:

- `sample = true` → `billed_quantity = 0`, `sample_quantity = quantity`
- `sample = false` → `billed_quantity = quantity`, `sample_quantity = 0`
- `sample = null` or `quantity = null` → both derived fields null

A note such as `2 naskhah percuma` beside a billed quantity of 30 is both a yes-word and a second integer. That is `ambiguous_quantity` and `sample_unclear`. Do not subtract 2 from 30 yourself.

### 8.6 Customer, currency, date, duplicates

Customer rules are in section 4.1.

Currency: apply the map in section 7.1. Unmapped symbols stay null. Do not convert an amount from one currency to another.

Date: with `date_order = DMY`, `12/01/2026` is 2026-01-12. With `MDY`, the same string is 2026-12-01. If the first number is greater than 12, the pattern is obvious and you may use it even when it disagrees with a careless setting: `13/01/2026` is 13 January. If the date is impossible (31/02/2026), `po_date` is null, flag `bad_date`. A missing date does not block approval.

Duplicate key: `upper(trim(customer_po_number))` plus `customer_id` when the id is not null, otherwise plus `upper(trim(customer_name))`. If another document that is not rejected already has that key, this document’s status is `duplicate`. Do not POST.

### 8.7 Status

Set `needs_human` if any flag was raised, if any required field is null, if any copied field has confidence below `low_confidence`, or if `ocr_quality` is poor.

Set `ready_for_review` only when `document_type` is `purchase_order`, no flags are set, and every required field is present. **Ready for review still requires a person.** It means the form is complete, not that the ERP may be called.

---

## 9. Handoff files

Write two outputs. Do not mix them.

### 9.1 Review JSON, one per document

Store this, and pretty-print it in the review mail.

```json
{
  "document_id": "PO-20261001-0007",
  "sha256": "",
  "status": "needs_human",
  "reason": "ambiguous_quantity",
  "version": 1,
  "language": "ms",
  "ocr_quality": "ok",
  "header": {
    "document_type": "purchase_order",
    "customer_po_number": "PO-2026-0144",
    "customer_id": null,
    "customer_name": "",
    "customer_candidates": [],
    "po_date": null,
    "po_date_raw": "",
    "currency": null,
    "header_notes": ""
  },
  "lines": [
    {
      "line_no": 1,
      "page": 1,
      "isbn": null,
      "isbn_raw": "",
      "bookcode": null,
      "title_as_printed": "",
      "title_hint": "",
      "sku_candidates": [],
      "quantity": null,
      "quantity_raw": "",
      "quantity_unit": "unknown",
      "sample": null,
      "billed_quantity": null,
      "sample_quantity": null,
      "unit_price": null,
      "currency": null,
      "line_notes": "",
      "untrusted": true,
      "flags": []
    }
  ],
  "flags": [],
  "model_proposal_stored": true,
  "erp": {
    "eligible": false,
    "posted": false,
    "erp_document_id": null
  }
}
```

`erp.eligible` is true only when status is `approved` and an approval row exists. The structure step sets it false.

### 9.2 CSV

`exports/review.csv` may contain every line of every draft, for the desk.

`exports/erp.csv` may contain only lines whose order status is `approved` and whose `erp_posts` row succeeded, or, in `stub` mode, whose stub returned 200. Columns:

```text
document_id,customer_po_number,customer_id,customer_name,line_no,isbn,bookcode,title_as_printed,billed_quantity,sample_quantity,unit_price,currency,line_notes,reviewer_name,approved_at,erp_document_id
```

Never write a `needs_human` line into `erp.csv`. Never put `title_hint` in either CSV.

---

## 10. Review portal

Build a minimal web portal. One queue and one document page are enough. A spreadsheet is not a substitute: the original page has to sit beside the fields.

### 10.1 Queue

`GET /review`

Columns: received time, document id, customer PO number, customer name, status, reason, page count.

Sort: `needs_human` first, then `ready_for_review`, oldest first inside each group. Terminal rows (`approved`, `posted`, `rejected`, `duplicate`, `not_a_purchase_order`) sit behind a filter, default off.

### 10.2 Document page

`GET /review/:document_id`

Left: the original PDF or image, the page named on the line. Right: the header and the lines.

Render rules:

- A null required field is an empty input with a red border, not a placeholder that looks like a real ISBN.
- `untrusted` fields show the proposed value in grey and the word `untrusted`.
- Under an edited field, show `model value` as it was before the edit. Do not lose it.
- Show `quantity_raw` under the quantity input so the person sees `2 dus / 24 naskhah` and not only a blank box.
- Show flags in plain words: “ISBN failed its check digit”, “quantity could be cartons or copies”, “scan too poor to read”, “script not supported by OCR”.
- `title_hint` and `customer_candidates`, when present, are labelled `suggestion, not extracted`.

### 10.3 Decision

`POST /review/:document_id/decision`

```json
{
  "reviewer_name": "Aina",
  "decision": "approved",
  "compared_to_scan": true,
  "edits": [
    { "path": "lines[0].quantity", "value": 24 }
  ]
}
```

Accept the decision only when all of these hold:

1. `reviewer_name` equals `review_owner`, or is in a list `REPLACE: extra reviewer names`. An empty name fails.
2. `compared_to_scan` is true for `approved`. The UI checkbox label is: `I compared each ISBN, bookcode, quantity, and sample flag to the scan.`
3. For `approved`, every line has a non-null ISBN or bookcode, an integer quantity, and a boolean sample. Values the reviewer typed are stored with `source = human` and `untrusted = false`. The previous model value stays in `model_value`.
4. The document `sha256` is copied onto the approval row. If the file on disk no longer hashes to that value, refuse.
5. `decision` `rejected` requires a reason code: `not_a_po`, `unreadable`, `duplicate`, `wrong_customer`, `other`.
6. `decision` `release_duplicate` is allowed only when status is `duplicate`, and it only returns the document to `needs_human`. It does not approve.

Then, and only then, call the ERP client for an approval. If the client fails, status is `erp_error`, the approval row remains, and the UI shows the error. Do not report success. Do not generate a new idempotency key and retry blind. A person presses retry, which reuses the same key.

---

## 11. ERP client

Ship a stub first. The stub is the test double and the only legal target while `erp_mode` is `stub`.

`POST {erp_base_url}/v1/sales-orders`

Headers:

```text
Authorization: Bearer {ERP_TOKEN}
Idempotency-Key: {sha256}
X-Human-Approval-Id: {approval_id}
```

Body: the approved header, the approved lines, and:

```json
{
  "approval": {
    "id": "",
    "reviewer_name": "",
    "compared_to_scan": true,
    "document_sha256": "",
    "decided_at": ""
  }
}
```

The stub must return **409** and write nothing when:

- The approval object is missing, `reviewer_name` is empty, or `compared_to_scan` is not true
- `Idempotency-Key` does not equal `approval.document_sha256`
- Any line lacks both ISBN and bookcode, or lacks an integer quantity, or has `sample` null
- Status sent in the body is not `approved`
- The idempotency key was already accepted. Return the original `erp_document_id` with HTTP 200 and do not create a second order.

On success the stub returns:

```json
{ "erp_document_id": "STUB-{document_id}" }
```

Persist that id on `erp_posts`.

A sandbox or live adapter replaces only the HTTP call. It sends the same body. Axrail’s case study names SAP and Navision as examples of the ERP a desk may be typing into. Implement the adapter the business actually has. If the business has neither, the stub plus `erp.csv` is the version 1 handoff, and a person imports the CSV. Do not invent SAP BAPI names.

`erp_mode=live` is refused by `erp.ts` unless `live_enabled_by` is a non-empty person name and section 14 passed in the same build. Record that name in the config file in the repo, not only in chat.

---

## 12. Runtime prompt

Use this as the system prompt for the structure model. Pass the OCR text and, when `cjk_image_reader` is `yes`, the page images. Pass the schema. Do not pass `isbn-catalogue.csv` or `customer-master.csv`.

```text
You extract purchase-order fields for {business_name}.
Return only JSON matching the schema you were given.
Copy values that are printed. If a value is not printed, return null.
Do not invent an ISBN, bookcode, quantity, customer id, price, or PO number.
Do not complete a partial ISBN.
Do not translate titles. Copy the title in the script on the page.
Do not calculate an extended total.
evidence must be a substring of the OCR text. If you cannot point at the text, return null.
If two different numbers could be the quantity, return null and copy the cell into quantity_raw.
If the page is a credit note, invoice, or delivery order, set document_type accordingly and do not treat it as a purchase order.
Confidence is your estimate from 0 to 1. It does not approve the order.
```

The server validates the JSON. If parsing fails, store the raw text on the proposal, set status `needs_human`, reason `model_output_not_json`, and do not retry more than once. The retry uses the same prompt plus the words `Your previous reply was not JSON.` It does not add permission to guess.

---

## 13. Privacy

This checklist is not a legal opinion. A business that is unsure asks its own advisor before go-live.

Pelangi’s documents are school and distributor orders. A smaller firm copying this pattern will see the same kind of data: organisation names, and sometimes a teacher’s or a buyer’s personal name, phone, or email on the form. In Malaysia the Personal Data Protection Act 2010 applies to personal data in commercial transactions. A desk that also holds data in Singapore, Thailand, or Indonesia has those regimes as well. The build does not decide which one applies. It minimises what it stores.

- The purpose of the file is to enter a customer purchase order. Do not use it to build a marketing list.
- Do not ask the model for NRIC, MyKad, passport, or bank account numbers. If they appear on a stamp or a footer, `redact.ts` replaces the digit runs with `[redacted]` in the order JSON, the CSV, the review mail, and the ERP body. The original image stays in storage for the reviewer, with access limited to `review_owner` and the people who maintain the system.
- A card number (13–19 digits, optional spaces) is redacted the same way.
- Keep originals, OCR, and exports in the company workspace. Do not paste them into a personal chatbot account. Do not use one school’s PO as a worked example in a prompt that will be sent to a multi-tenant consumer tool.
- Honour `retention_days`. A person deletes or archives. The agent does not keep a second copy in a prompt library.
- Record `data_host`. If the OCR or the model host sits outside the country the business expected, say so in the config note before go-live. The published Pelangi system is described by Axrail and AWS as running on Amazon Textract and Amazon Bedrock. Copy the architecture if you choose those services. Do not copy a compliance sentence onto your own host until it is true.

---

## 14. Acceptance tests

Run these before the scanner address is given to a branch. They call `validate` and the ERP stub. They do not call a live model. Where a test says “the model proposes”, feed that proposal as a fixture. A test fails if `erp_posts` gains a row the test did not approve, or if an ISBN or a quantity appears that was not in the fixture text and was not typed by the reviewer fixture.

| ID | Fixture | Pass when |
| --- | --- | --- |
| T1 | Malay PO. OCR text includes `No. PO: PO-2026-0144`, `ID Pelanggan: SCH-0881`, `Nama Pelanggan: Sekolah Kebangsaan Contoh`, `Tarikh: 12/01/2026`, `Mata wang: MYR`, and a table whose header row contains `ISBN`, `Kod buku`, `Tajuk`, `Kuantiti`, `Sampel`. Line 1 cells: `978-0-00-000001-9`, `SAMPLE-Y4-SCI`, `Sains Contoh Tahun 4`, `40`, `Tidak`. Line 2 cells: `9780000000026`, `SAMPLE-Y4-MM`, `Matematik Contoh`, `12`, `Ya`. OCR quality is fine. Catalogue contains both SAMPLE ISBN rows. Customer master contains `SCH-0881` with name `Sekolah Kebangsaan Contoh` and `active=yes`. | Status `ready_for_review`. Line 1 ISBN `9780000000019`, quantity 40, sample false, billed 40. Line 2 sample true, billed 0, sample quantity 12. Date `2026-01-12` when `date_order` is `DMY`. Currency `MYR`. No `customer_name_mismatch`. `erp.eligible` false. Stub was not called. |
| T2 | Chinese line. OCR or vision proposal: PO `PO-ZH-019`, customer id `SCH-1022`, name `示例小学`, ISBN `9780000000019`, title `小学华文示范 四年级`, quantity cell `20本`, sample no-word `否`. | Title stored exactly as `小学华文示范 四年级`. Quantity 20, unit `pcs`, sample false. No English translation stored in `title_as_printed`. |
| T3 | PO `PO-2026-0200`, customer id `DIST-17`, one line whose title is `Primary Science Activity Book` and whose quantity is 6. No ISBN and no bookcode. Catalogue has a similar title on `9780000000019`. | Line ISBN is null. `sku_candidates` may show the catalogue row. Status `needs_human`. ERP body does not contain `9780000000019`. |
| T4 | One line, ISBN `9780000000019`, quantity cell `2 dus / 24 naskhah`. | `quantity` null, `quantity_raw` is the full cell, flag `ambiguous_quantity`, status `needs_human`. |
| T5 | A complete, confident proposal for a valid PO, but OCR median word confidence is 0.31. | Status `needs_human`, reason `handwritten_or_blur`. Order fields for ISBN and quantity are null. `erp.eligible` false. |
| T6 | Page script `han`, `ocr_scripts` is `latin` only, `cjk_image_reader` is `no`, OCR text is empty or Latin garbage. | Reason `script_not_supported`. No invented Chinese title and no invented ISBN. |
| T7 | ISBN cell `9780000000018` (bad check digit), otherwise a clean line. | `isbn` null, flag `bad_isbn`. The value `9780000000019` was not substituted. |
| T8 | Header contains the words `CREDIT NOTE` and a proposal of `document_type = credit_note`. | Status `not_a_purchase_order`. No sales-order lines posted. |
| T9 | The T1 document is ingested twice, same bytes. | One document row. The second attempt records `duplicate_of` and does not create a second order. |
| T10 | Two different files share customer PO `PO-2026-0144` and customer id `SCH-0881`. The first is `ready_for_review`. | The second status is `duplicate`. Approving the first creates one stub id. The second cannot POST. |
| T11 | Approve T1 with `reviewer_name` blank, or `compared_to_scan` false. | HTTP error from the portal. Stub returns 409 if called anyway. `erp_posts` empty. |
| T12 | Approve T3 after the reviewer types ISBN `9780000000019` and quantity `6`, with `compared_to_scan` true and the reviewer’s name. | `source` on those fields is `human`. `model_value` for ISBN is null. Stub returns `STUB-` plus the document id. `erp.csv` contains the line. `review.csv` may contain it. A `needs_human` export never did, before this approval. |
| T13 | Footer text `NRIC 900101-14-5567` and `account 123456789012`. | Those digit runs do not appear in the review JSON, the ERP body, or either CSV. |
| T14 | A line note `hantar sebelum musim persekolahan` and no date. | Note copied to `header_notes` or `line_notes`. `po_date` is null. No date was invented. |
| T15 | Two customer PO numbers printed, `PO-1` and `PO-2`. | Status `needs_human`, reason `multiple_po_numbers`. One document, not two orders. |

Also run once on the real ingest path, with a PDF you wrote yourself, not a customer’s:

- The scanner mailbox or the folder creates one queue card.
- Reloading the portal shows the same fields (restart the process, open the card again).
- The original file opens beside the form.
- The stub id on the card matches `erp_posts`.

Go live with the scanner only after T1–T15 pass and that live check has been seen by `review_owner`. `erp_mode` stays `stub` for the first live week even if a sandbox exists.

---

## 15. Rollout

**Week 1.** Customer master and ISBN catalogue exported. `SAMPLE` rows deleted. `erp_mode` is `stub`. Run the agent on 20 old purchase orders the desk already entered. A reviewer marks each draft “I could post this” or “I had to change X”. Fix the validator before any new scan is processed. Write down, for those 20: how many ISBNs the draft got wrong, how many quantities it got wrong, how many sample lines it missed, how many documents were `needs_human`.

**Week 2.** Turn on the scanner mailbox or the folder during office hours. The reviewer works the queue the same day. The stub may return ids. Nothing is imported to the ERP. Count how often a person changed a field the model had marked confident.

**Week 3.** If the business has a sandbox ERP, set `erp_mode` to `sandbox` and point `erp_base_url` at it. The same approval rule applies. Compare five sandbox orders to the portal, line by line: PO number, customer id, ISBN or bookcode, billed quantity, sample quantity. A mismatch stops the week. If there is no sandbox, import nothing. Keep using the stub and let the reviewer post by hand from the portal until a sandbox exists.

**Week 4.** Only if week 3 matched, a person sets `live_enabled_by` to their own name and `erp_mode` to `live`. The approval checkbox stays. There is still no auto-approve. Run a small day, then a normal day, before you trust a pre-term peak.

Stay on a week if a test fails or if a reviewer finds an ISBN the model composed. A complete-looking wrong line is worse than a document that waits.

Numbers to write down from your own desk, before week 1 and again after 30 live approvals:

- Minutes from file received to approval
- Share of lines a person edited
- Share of documents that were `needs_human`
- Count of ERP posts that had no approval id (the acceptable count is zero)
- Wrong-supply complaints the business already tracks, if it tracks them

Do not put Pelangi’s 40% or 25% in that sheet as the target.

---

## 16. What you do not copy

Pelangi’s published results are theirs.

Axrail’s case study says Pelangi spends 40% less time processing each purchase order, and that the manpower requirement is 25% lower over three years. Those sentences are the vendor’s. The AWS Partner Network post of 17 September 2024 does not publish either figure. AWS says the move was to a faster process with less human intervention, and that Pelangi was achieving more with the same number of human resources. Sum Lih Kang, Executive Director, is quoted there as saying the tool helped reskill 2–3 employees to other critical tasks, and that the firm processes over hundreds of thousands of physical documents a year, including purchase orders, credit notes, and invoices.

Do not merge those sentences into one forecast for the reader of this file. Do not invent an accuracy percentage, a hours-to-minutes claim, or a headcount for the reader’s company. Do not advertise an AWS audit of the 40%.

Copy the loop: read the page, propose only printed fields, stop when the ISBN or the quantity is not proven, let a person approve, then hand the ERP a file it can refuse. The first win is a queue where a wrong book code cannot pass just because the model was sure.
