diff --git a/docs/superpowers/specs/2026-11-05-purchase-order-from-quote-design.md b/docs/superpowers/specs/2026-11-05-purchase-order-from-quote-design.md new file mode 100644 index 0000000..2a58409 --- /dev/null +++ b/docs/superpowers/specs/2026-11-05-purchase-order-from-quote-design.md @@ -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) #` | +| 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. |