docs(spec): purchase orders raised from a supplier quote

The ERP import is one direction only. This adds the other: read a supplier
quote with AI, review the lines, raise a PO in Mega's house layout, and email
it to the supplier.

- AI read proven against two real documents: a scanned invoice and a native
  ERP PO. Both extracted exactly, the scan to the cent.
- The PO's totals are derived from the reviewed lines, never copied from the
  quote; the quote's stated total is kept and compared.
- PO number MEGPPO-0001: a hyphen before the digits, so it cannot collide
  with the MEGPPO49676 series during the hybrid period.
- The AI key lives in app_settings and is edited in Settings, not in the
  environment.
This commit is contained in:
Dave 2026-11-05 22:12:25 +02:00
parent d055c344de
commit e0b9131f7e

View file

@ -0,0 +1,401 @@
# Purchase orders created from a supplier quote — design
**Date:** 2026-11-05
**Status:** approved in session (AI provider, email hand-off, PO number, API key in Settings)
**Supersedes:** nothing. Adds to the existing purchase-order import.
## Why
`/running/purchases` takes purchase orders as **PDFs from Mega's own ERP**
(`STD_po_DN.rpt`), read by a deterministic coordinate parser
(`lib/purchase-parser.ts`). That is one direction only: the ERP raises a PO, a person
downloads it, a person uploads it.
The missing direction is the one the office actually works in: a **supplier sends a
quote**, Mega decides to buy, and Mega needs to **raise its own purchase order** and
send it out. Today that order is typed by hand somewhere else, so it never reaches
the register — and a PO that never reaches the register is spend nobody sees on
`/running`, no machine carries the cost, and no invoice can be matched to it.
So:
1. **Raise a PO in this app**, from scratch, and email it to the supplier.
2. **Read the supplier quote with AI**, so the line items, quantities and prices are
typed for you — and are **reviewed by a person** before they become a PO.
3. **Book it to a supplier** (existing or new) and **to a machine**, so the spend
lands where the rest of the app expects it.
4. **Attach the invoice later**, against the PO — that half already works.
## What already exists (and must not change)
| Piece | Where | Note |
|---|---|---|
| `purchases` + `purchase_lines` collections | `scripts/setup-purchase-collections.mjs` | header + lines, cost code → machine |
| Cost-code → machine join | `lib/cost-code.ts` | `SS-02` finds `SS02`; never a guess |
| Find-or-create supplier | `lib/supplier-server.ts` `resolveSupplier` | code first, then folded name |
| Invoice against a PO | `purchase_invoices`, `components/PurchaseIntake.tsx` | amount, status, PDF |
| PO document generation | `lib/job-card-pdf.ts` | pdf-lib, in the browser, `/brand/logo.png` |
| Running-cost roll-up | `lib/purchase-booking.ts` | lines with an `asset` only |
The import path stays exactly as it is. A PO made here and a PO imported from the ERP
live in the same tables and roll up the same way — that is the point.
## The flow
1. **"Create purchase order"** on `/running/purchases` → opens the composer.
2. **Upload the quote** — PDF, JPG or PNG. A phone photo of a quote counts.
3. The server sends it to MiniMax and gets back a **draft**: supplier, quote number and
date, lines (description, qty, unit, rate, discount, VAT %), and the totals the
document states.
4. **Review** — the quote on the left, an **editable** line table on the right. Edit
anything; add or remove lines; pick the machine per line; pick or add the supplier.
5. **Save** → `purchases` + `purchase_lines` are written, the quote PDF is attached.
6. **PO document** — generated in Mega's house layout and saved on the record.
7. **Email** — opens a pre-filled draft in the reader's mail app and puts the PO PDF
where it can be attached.
## Reading the quote with AI
### Why AI and not the coordinate parser
The supplier documents are **scans**. The invoice on file (`INV_51013`, Specialized
Fuel Injection) is a CamScanner scan with **handwriting** on it. There is no text
layer to read, and the columns are not where the coordinate parser expects them.
### Why this was proved before it was designed
The reader was run against two real documents from the live database before this
design was written:
| Document | Result |
|---|---|
| `INV_51013` — scanned, handwritten | supplier "Specialized Fuel Injection Services (PTY) Ltd.", quote 4770110023, 3 lines with correct qty/rate/total, subtotal 6 122.08, VAT 918.31, **total 7 040.39 — matches the register to the cent** |
| `MEGPPO50532` — native PDF | line "repair Skid Steer Quick Coupler Frame", qty 1, rate 11 940.00, VAT 15.00, total 13 731.00 — all correct |
Both calls were made against the live MiniMax endpoint with the key from the
environment. The extraction is therefore **proven on Mega's real documents**, not
assumed.
### The two routes
| Input | Endpoint | Content block |
|---|---|---|
| PDF | `POST https://api.minimax.io/anthropic/v1/messages` | `{ type: "document", source: { type: "base64", media_type: "application/pdf", data } }` |
| JPG / PNG | `POST https://api.minimax.io/v1/chat/completions` | `{ type: "image_url", image_url: { url: "data:image/png;base64,…" } }` |
Model `MiniMax-M3.1-Flash`. The image route needs `reasoning_effort: "low"` —
without it the model spends the whole token budget thinking and returns nothing
(`reasoning_tokens: 4095`, empty content). This is a **budgeting fact, not a
preference**, and it is why the reader sets it explicitly.
The prompt asks for **strict JSON** and nothing else. The reader tolerates a fenced
block or surrounding prose by taking the outermost JSON object it can find, and
**rejects** a reply with no JSON rather than saving a half-read quote.
### What the AI is asked for
```json
{
"doc_type": "quote | invoice | unknown",
"supplier_name": "", "supplier_contact": "", "supplier_email": "", "supplier_address": "",
"quote_no": "", "quote_date": "", "currency": "ZAR",
"prices_include_vat": true,
"vat_pct": 15,
"lines": [{ "description": "", "qty": 0, "unit": "", "rate": 0, "discount_pct": 0, "vat_pct": 15, "line_total": 0 }],
"stated_subtotal": 0, "stated_vat": 0, "stated_total": 0,
"confidence": "high | medium | low",
"notes": []
}
```
**The AI never decides anything.** It proposes; a person confirms. The quote number,
the date and the totals it reports are shown beside the source and can be edited.
## The review step
### The PO's totals are DERIVED, never copied
This is the rule that keeps a PO made here as trustworthy as one imported from the ERP.
- `amount_excl` per line = qty × rate − discount
- `vat` per line = amount_excl × vat_pct
- `subtotal` = Σ amount_excl
- `vat` = Σ line vat
- `total` = subtotal + vat
The quote's own stated total is kept on the PO as `quote_total` and **compared** to
the computed total. If they differ, the difference is shown in amber beside the total
("the quote says R 48 074,66 — your lines come to R 48 087,16, a difference of
R 12,50"). A PO made here therefore **always balances by construction**, and the
comparison is what catches a misread line.
### VAT may be inside the unit price
A supplier quote may quote unit prices **VAT-inclusive**; Mega's ERP quotes them
exclusive and adds VAT. The AI reports which it read, the form shows both figures, and
the user can **flip the toggle**. This is self-correcting: if the AI reads it wrong,
the computed total will not match the quote's stated total, the amber difference
appears, and flipping the toggle makes it match. Nothing is silently wrong.
### The machine
A supplier quote carries no Mega cost codes, so the **person** picks the machine.
One picker for the whole PO (the common case: a quote for one machine), overridable
per line. The machine's code becomes the line's `cost_code`.
A line with **no machine** is saved with an **empty `asset`** and reported — exactly
the rule the import path follows. Money that exists but is not yet on a plant item is
a real state, not R0 and not a guess.
### The supplier
Match order is unchanged: the supplier **code** from the quote first, then the folded
**name** (`resolveSupplier`). A supplier that is not on the list is **created**, with
terms **unset** — not "cash", because a PO is not a till slip. An existing supplier's
terms are never overwritten.
The composer also lets the person **choose** a supplier from the list, so a misread
name never creates a duplicate.
## The PO document
Generated **on the server** by `/api/purchases/:id/document`, with pdf-lib, and laid
out to match the ERP document so a supplier sees what they always see.
The document is rendered **from the saved record**, not from a copy kept in the
browser, so there is one source of truth: what the supplier is sent is what the
register holds. (The blank job card is built in the browser because it is a blank
form — a PO is not.)
| Block | Content |
|---|---|
| Header | Company block (Mega Plant (Pty) Ltd, address, VAT reg, company reg) left; **`/brand/logo.png`** right |
| Title | `Purchase Order(s) #<po_no>` |
| Row 1 | **Subject** | **Description** |
| Row 2 | **To** — supplier name, code, address, contact, telephone, email | **PO details** — creation date, required by, req no, ordered by, telephone, email, currency, payment in |
| Row 3 | (blank, as in the ERP) | **Contact** — head office | **Delivery address** |
| Lines | No | Material Code | Cost Code | Description | Quantity | Unit | Rate | DISC% | VAT% | Amount, with the totals block bottom-right: Sub Total Amount / VAT Amount / Total Amount |
| Foot | Authorised Signatory line; "Subject to our terms and conditions of purchase ( Available on request )" |
| Footer | Printed date/time | Page 1 of 1 |
Company details live in **one file**, `lib/company.ts`, transcribed from the ERP
document so they can be corrected in one place. "Ordered By" is the **signed-in
user's** name and email (the `users` collection), not a hard-coded person.
A made PO's document is **generated on demand** from the record, so it is never stale
and never a second copy to keep in step. `purchases.source_pdf` therefore stays what
it is today: **the uploaded ERP document**, on imported POs only.
The quote is stored on `purchases.quote_pdf`. It is the evidence the PO was built
from — the document a reader checks the figures against — and it is the one file on
a made PO that is evidence rather than a rendering.
## The email hand-off
**A browser cannot attach a file to a `mailto:` link.** That is a platform limit,
not a choice, and the office mostly runs Windows. So the "Email" button does the two
things a browser can do, and the person does the one it cannot:
1. The PO PDF **downloads** to the device: `GET /api/purchases/:id/document` streams
the generated PDF with `Content-Disposition: attachment`. The download is started
first, and the draft opens a beat later (~400 ms), so the mail hand-off cannot
cancel the download.
2. A **pre-filled draft** opens in the default mail client:
`mailto:` with the supplier's address, a subject naming the PO number, and a body
carrying the PO number, date, required-by date, delivery address, the line summary
and the total.
The supplier's address comes from `suppliers.email` — a **new field** on the
suppliers collection, editable on `/expenses/suppliers`. If it is empty, the button
asks for the address instead of opening an empty draft.
The body ends with a line saying the order document is attached, because it is.
**Sending from the system is out of scope** — it is the next step, and the design
leaves room for it (the PDF is already a file on the record, and the supplier has an
address).
## The PO number
**`MEGPPO-0001`** — a hyphen before the digits, one global counter.
- ERP POs are `MEGPPO49676`, `MEGPPO50532`, `MEGPPO49421`, `MEGPPO50027`.
- A hyphen plus a different digit count means the two series **cannot collide**, which
is what the hybrid period needs.
- It reads as a normal Mega PO — no "APP" or "NEW" marker on a document a supplier
will see.
- When the office moves fully onto this system, **the hyphen is the only thing to
remove**: one pass over `po_no`. The counter lives in one constant.
The duplicate check stays strict: re-using a number already on file is a **409**, the
same as the import.
### The requisition number
A made PO carries **`MEGPREQ-0001`** — the same counter, the same shape, so the
document has the same field the ERP's PO has. `req_no` is generated the way
`po_no` is; the person never types it.
## The header, field by field
| Field on the PO | Where it comes from | Editable? |
|---|---|---|
| PO number | the counter | no |
| Requisition number | the counter | no |
| PO Creation Date | today | no |
| Required By | the person | **yes** — the delivery date |
| Subject / Description | the quote's subject, or what the person types | **yes** |
| To (supplier) | the quote's supplier, matched to the list | **yes** — pick or add |
| Code | the supplier's code | comes with the supplier |
| Currency | ZAR | **yes** |
| Payment In | the supplier's terms — `""` → "Current", `cash` → "Cash", `30` → "30 Days", `60` → "60 Days" | no — it is the supplier's terms |
| Ordered By | **the signed-in user** (name + email) | no |
| Telephone / Contact | `lib/company.ts` | no — one place to correct them |
| Delivery Address | `lib/company.ts` | **yes** — a PO may go elsewhere |
"Payment In" is read from the supplier's terms rather than typed, so the two screens
cannot say different things about the same supplier.
## The AI key lives in Settings
The key is **not** an environment variable. It is a row in the database, edited in the
app, so it can be swapped without a redeploy — the way Mega Dash and WFDASH already
do it.
**New collection `app_settings`** — key/value, PocketBase rules locked to
superusers, and the `value` field marked `hidden` so no non-superuser read can
return it. The browser never receives the key.
| Setting key | Meaning |
|---|---|
| `ai.provider` | `minimax` |
| `ai.api_key` | the key |
| `ai.model` | `MiniMax-M3.1-Flash` |
**Settings page** → a new **"AI & Automation"** section:
- Provider (MiniMax)
- API key — shown **masked** (`•••• a91f`); the field stays empty until a new key is
typed, so an empty submit never wipes a working key
- Model
- A **"Test connection"** button that makes one cheap call and reports
"connected" or the error
Writes are **Full-user only** (`refuseUnlessFull`), like every other write in the app.
If no key is saved, the composer says so plainly: **"No AI key is set. Add one in
Settings."** — and the rest of the app is unaffected.
## Data changes
### `purchases` — new fields
| Field | Type | Meaning |
|---|---|---|
| `origin` | text | `imported` (the ERP PDF) or `made` (a PO raised here) |
| `quote_no` | text | the quote this PO was built from |
| `quote_date` | text | as read from the quote |
| `quote_total` | number | **the total the quote stated**, kept for the record and compared to the computed total |
| `quote_pdf` | file | the quote document |
Existing fields are reused: `po_no`, `supplier`, `supplier_code`, `supplier_ref`,
`po_date`, `required_by`, `req_no`, `currency`, `subtotal`, `vat`, `total`,
`source_pdf`.
### `suppliers` — new field
| Field | Type | Meaning |
|---|---|---|
| `email` | text | where the PO is sent |
### `purchase_lines` — unchanged
Everything needed is already there: `material_code`, `cost_code`, `description`,
`qty`, `rate`, `disc_pct`, `vat_pct`, `amount_excl`, `vat`, `total_incl`,
`asset`.
## The files
**New**
| File | Purpose |
|---|---|
| `lib/company.ts` | the company block, delivery address, defaults — one place to correct them |
| `lib/quote-reader.ts` | server-only: the MiniMax call, JSON extraction, normalisation |
| `lib/quote-draft.ts` | pure: the draft type, totals, reconciliation, PO number, the mailto |
| `lib/purchase-order-pdf.ts` | pdf-lib: the PO document |
| `lib/app-settings.ts` | server-only: read/write the AI settings, mask the key |
| `components/PurchaseOrderComposer.tsx` | the composer (upload → review → save) |
| `components/AiSettings.tsx` | the Settings section |
| `app/api/purchases/quote/read/route.ts` | POST — read a quote with AI |
| `app/api/purchases/route.ts` | POST — create a PO from an approved draft |
| `app/api/purchases/[id]/document/route.ts` | GET — the PO PDF (download / open) |
| `app/api/settings/ai/route.ts` | GET / PUT — the AI settings |
| `scripts/setup-app-settings-collection.mjs` | creates `app_settings` |
| `lib/quote-draft.test.ts` | tests for the pure logic |
**Changed**
| File | Change |
|---|---|
| `components/PurchaseIntake.tsx` | the "Create purchase order" button; per-PO "PO document" and "Email"; an origin badge |
| `app/(dashboard)/running/purchases/page.tsx` | pass the machines and the supplier list into the composer |
| `app/(dashboard)/settings/page.tsx` | mount the AI settings section |
| `lib/data.ts` | the new `Purchase` fields |
| `scripts/setup-purchase-collections.mjs` | add the new `purchases` fields (idempotent) |
| `scripts/setup-suppliers-collection.mjs` | add `email` to `suppliers` (idempotent) |
| `AGENTS.md` | the new rules, in the house voice |
## Errors, refusals and honesty
| Situation | What happens |
|---|---|
| No AI key saved | "No AI key is set. Add one in Settings." Nothing else breaks. |
| The AI call fails or times out | The reason, plainly. No draft, nothing written. |
| The AI returns no JSON | "The document could not be read as a quote." Nothing written. |
| The AI reads the lines wrong | The amber difference beside the total, and the person edits. **Never saved silently.** |
| A line has no rate | Flagged "no rate" on the line, like the invoice amount rule. |
| The PO number is already on file | **409**, refused — the same spend twice is worse than an inconvenience. |
| A line has no machine | Saved with an empty `asset`, and named in the response and on the page. |
| The supplier is not on the list | Created with terms unset, and the person is told which supplier it became. |
| A PO is saved | `revalidatePath` on `/running/purchases`, `/running`, `/expenses/suppliers` |
## Mobile
The composer passes the standing checklist: no horizontal page scroll at 375px; no
text under 12px; no tap target under 44×44px; the line table scrolls inside its own
container with the **Description** column sticky and an **opaque** background (the
same trap the invoice register hit); below `lg` the quote preview and the line table
stack instead of sitting side by side.
## Tests
Pure logic, in `lib/quote-draft.test.ts`, on the project's own runner
(`npm run test`):
- totals from lines, exclusive and inclusive
- the difference against a quote's stated total, and when it is zero
- date normalisation (`dd/mm/yyyy`, `yyyy-mm-dd`, `d MMM yyyy`, unreadable)
- number normalisation (`1 234.50`, `1,234.50`, `R 1 234.50`)
- the PO number sequence, and that it cannot equal an ERP number
- the mailto: body, and that the subject carries the PO number
## Out of scope
- **Sending the PO from the system** — the next step; the PDF and the address are
already in place for it.
- Reading the supplier **invoice** with AI — invoices are still typed in against the
PO, as they are today.
- OCR of a photo taken in the app (the camera capture exists for slips) — the quote
is uploaded as a file.
- Bulk quote → many POs.
- Changing the ERP import path in any way.
## Risks
| Risk | Why it is acceptable |
|---|---|
| The AI misreads a line | It is a draft. The person reviews, the totals are compared against the quote, and the PO only exists after the person says so. |
| The API key sits in the database | It is behind PocketBase's superuser rules and a hidden field, and the app's own users never read it. It is the same trade the other apps already make. |
| A PO number clashes with an ERP number | The hyphen and a different digit count make it impossible; the 409 check is the second line of defence. |
| The AI costs money per quote | One call per quote, a few thousand tokens. Nothing runs on a schedule. |