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:
parent
d055c344de
commit
e0b9131f7e
1 changed files with 401 additions and 0 deletions
|
|
@ -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. |
|
||||
Loading…
Reference in a new issue