You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/docs/13-SPEC-PURCHASE-ENTRY.md

188 lines
22 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# Purchase Entry — `/purchases/entry` Implementation Spec
Scope: back-office web (React + `@sims/ui` + `@sims/scanning` + store-server API). Everything below is implementable today against the current tree. Key discovery: `apps/store-server/src/repos-purchase.ts` **already exists** (`commitPurchase`, `lastCosts`, `listPurchases`) with `purchase`/`purchase_line` tables in `db.ts`, but **nothing is wired into `api.ts`** and the page at `/purchases/entry` is a registry wireframe. This spec turns that into the real screen.
**Files touched (master list)**
- NEW `apps/backoffice/src/pages/PurchaseEntry.tsx` — the screen
- `apps/backoffice/src/main.tsx` — add `'/purchases/entry': PurchaseEntry` to `CUSTOM` (auto-overrides the registry wireframe)
- `apps/backoffice/src/api.ts` — client fns: `getSuppliers`, `createSupplier`, `checkInvoice`, `getLastCosts`, `getTaxClasses`, `postPurchase`, `getPurchases`
- `apps/store-server/src/api.ts` — NEW routes (listed in §6)
- `apps/store-server/src/repos-purchase.ts``findPurchase()`, invoice-no normalization, outbox row, printed-total columns
- `apps/store-server/src/repos.ts` — generalize `createCustomer``createParty(db, tenantId, {name, kind, phone?, gstin?, stateCode?})`
- `apps/store-server/src/db.ts` — 2 new columns on `purchase`, later `label_job` (see §6)
- `packages/billing-engine` — NEW exported `computePurchase(lines)` so client and server share one math (same-engine idiom as `computeBill`; `repos-purchase.ts:lineMath` refactors to call it)
---
## 1. Screen layout (zones)
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ HEADER STRIP (collapses to one line after date; Alt+E reopens) │
│ Supplier ▌amu… → [HUL Distributor ▾] Invoice no [HD/2651] Date [08-07] │
│ ⚠ dup: "HD/2651 already entered 04-Jul" chip: Interstate (IGST) │
├──────────────────────────────────────────────┬───────────────────────────────┤
│ ┌──────────────────────────────────────────┐ │ ITEM CARD (P1 right rail) │
│ │ SCAN / TYPE ▌ (the sacred line, again) │ │ Amul Butter 500g │
│ └──────────────────────────────────────────┘ │ Stock 9 · MRP 310 │
│ # Item Qty Cost MRP Mgn% Amt │ Last costs (this supplier) │
│ 1 Amul Butter 500g 24 261.90 310 11% 6.6k│ 261.90 04-Jul │
│ 2 Amul Cheese 200g 12 112.50↑ 135 9%⚠ 1.4k│ 259.80 12-Jun │
│ cursor stops: Qty → Cost (→MRP per profile) │ 258.00 28-May │
├──────────────────────────────────────────────┴───────────────────────────────┤
│ FOOTER Lines 2 · Qty 36 | Taxable 7,912.80 · GST 395.64 · TOTAL ₹8,308.44 │
│ Printed total [________] Δ — [Ctrl+Enter] Check & Post │
└──────────────────────────────────────────────────────────────────────────────┘
```
**Keymap (page-scoped, one `useEffect` keydown handler exactly like `BillingScreen.tsx:200`)**
| Key | Action |
|---|---|
| Enter | Confirm field / advance cell (item → qty → cost → [MRP] → scan box) |
| Esc | Back one level: cell → scan box → (empty) header summary; never deletes a line |
| Ctrl+Enter | Post flow: totals-check overlay → price-update overlay → commit |
| `n*` prefix + scan/code | Qty multiplier (`24*` + scan = qty 24), via existing `parseEntry` |
| ↑ / ↓ | Select grid line (when scan box empty) / navigate type-ahead lists |
| F2 | Edit Qty of selected line |
| Del, Del | Remove selected line (first Del arms inline "Del again to remove", 2 s timeout) |
| Tab / Shift+Tab | Visit every cell including profile-skipped ones (MRP, later Disc/Free) |
| Alt+E | Reopen header strip to edit supplier/invoice/date |
| F1 | Keymap cheat-sheet overlay |
**Focus doctrine:** identical sentinel to POS — the active input's `onBlur` refocuses itself via `setTimeout(0)` unless an overlay is open (copy the pattern at `BillingScreen.tsx:264`). No toast/dialog ever fires system-initiated; all errors render inline in the zone they belong to. Back office may normally toast, but this page adopts the counter creed per 09-UX §3.2.
---
## 2. P0 — the working fast path (ship first)
### P0-1 · Supplier type-ahead with create-new
**Behavior.** On mount, fetch suppliers once (`GET /parties?kind=supplier` — exists) and focus the supplier field. Each keystroke filters client-side with the same startsWith-first ranking as `BillingScreen.tsx:search()`; dropdown shows top 8 (name + GSTIN + last-purchase date from `GET /purchases` cache). ↑/↓ + Enter picks and advances focus to Invoice No. Last-used supplier id is remembered in `localStorage['pe.lastSupplier']` and pre-selected on mount — Enter alone accepts it (distributor stacks). No match on Enter → inline panel "Create supplier Amul Agy — [Enter] create · [Esc] retype"; second Enter opens a 2-field micro-form (Name prefilled from typed text, GSTIN optional with checksum validation via `@sims/domain/gstin`); Enter saves and advances.
**Keystrokes.** Existing supplier: 3 chars + Enter = 4. Repeat supplier: 1. Create: name + Enter×3 (+ GSTIN typing).
**Focus.** Mount → supplier. After first line exists the header is read-only (Alt+E to edit; editing supplier after lines exist re-runs the last-cost prefetch and re-flags cost chips).
**Edge cases.** GSTIN state (first 2 chars) ≠ `store.state_code` → render "Interstate (IGST)" chip (display-only in P0; split stored in P1-6). Duplicate supplier names allowed, disambiguated by GSTIN in the dropdown.
**Touches.** `PurchaseEntry.tsx`; server: generalize `repos.ts:createCustomer``createParty` and extend `POST /parties` in `api.ts:112` to accept `kind|gstin|stateCode`**small NEW server change** (route exists, currently hardcodes `'customer'`).
### P0-2 · Invoice no + date + duplicate-invoice warning
**Behavior.** Invoice no: free text, normalized `trim().toUpperCase()`. On Enter → fire NEW `GET /purchases/check?supplierId=&invoiceNo=`; if hit, red inline notice under the field: "Already entered 04-Jul · ₹8,150 · 12 lines — posting will be rejected (E-7301)". The warning never blocks typing lines (the server hard-rejects at post anyway via the existing `UNIQUE(tenant_id, supplier_id, invoice_no)` + 409 at `repos-purchase.ts:58`). Date defaults to today; Enter accepts; accepts `0807`, `08/07`, `08-07-2026`. After date-Enter the header collapses to a one-line summary and focus lands in the scan box.
**Keystrokes.** invoice chars + Enter + Enter = typing + 2.
**Edge cases.** Future invoice date → amber inline "dated tomorrow?" (accepted on second Enter). Same normalization applied inside `commitPurchase` so `hd/2651` and `HD/2651` cannot double-post — **add `invoiceNo = input.invoiceNo.trim().toUpperCase()` at `repos-purchase.ts:41`**.
**Touches.** `PurchaseEntry.tsx`; NEW repo fn `findPurchase(db, tenantId, supplierId, invoiceNo)` in `repos-purchase.ts` + NEW route in `api.ts`.
### P0-3 · Line grid — one scan/type box, POS grammar, Enter-advances-cell
**Behavior.** The entry box reuses `parseEntry` + `WedgeDetector` from `@sims/scanning` verbatim (same wiring as `BillingScreen.tsx:112,260`): digits ≥8 → barcode lookup, <8 item code, letters fuzzy search (top-8 dropdown, ↑/↓ + Enter; single hit adds immediately), `24*` prefix qty multiplier. Item cache = `GET /items` loaded at mount (2k rows today; move to server-side `q=` when catalogs grow).
On resolve, append line `{item, qty: pending ?? 1, costPaise: supplierLast ?? last ?? undefined, mrpPaise: item.mrpPaise}` from the last-cost dict (P0 prefetch: NEW `GET /purchases/last-costs?supplierId=` wiring the **existing** `lastCosts()` repo fn) and move focus into the line's **Qty** cell. Cell flow: type digits (typing replaces prefill; bare Enter keeps it) Enter **Cost** (₹ with paise, prefilled last cost; bare Enter accepts) Enter back to scan box. **MRP is Tab-reachable but not an Enter stop** in the default column profile (`purchases.column_profile` setting row; a pharmacy profile later adds Batch/Expiry stops P2).
Tax per line resolves client-side from `item.taxClassCode` via `resolveTaxClass(taxClasses, code, invoiceDate)` (NEW trivial `GET /tax-classes` route wiring existing `listTaxClasses`). Line amount/tax math calls the shared `computePurchase` 6) so the footer equals the server to the paisa.
**Cost-changed highlight.** If entered cost differs from `supplierLast` beyond `purchases.cost_warn_pct` (setting row; default 0 = any change), render an amber chip in the row: `↑ ₹2.10 vs 04-Jun`. Never a dialog the price decision batches to post time (P0-4). Margin column recomputes live: `landed = cost × (1 + rateBp/10000)`; `margin% = (salePrice landed) / salePrice`. Below `purchases.margin_floor_pct` amber cell, never blocks.
**Keystrokes/line.** Scan(0) + qty digits + Enter + Enter = qty + 2 when cost unchanged; +cost digits when changed. Meets the 5 keys/line budget.
**Edge cases.**
- *Re-scan of an item already on the grid*: merge (+addQty), flash row, focus its Qty cell.
- *Scan burst landing in a Qty/Cost cell* (purchaser scans without refocusing): cells also feed a page-level `WedgeDetector`; a burst that terminates inside a cell commits the cell with its prior value and resolves the code as a new line the wedge suffix Enter must never write barcode digits into Qty.
- *Unknown barcode*: inline panel under the scan box "Unknown code [Enter] Quick-create · [Esc] skip" 4-field micro-form (Name, Cost, MRP, GST class defaulting store slab) existing `POST /items` with `status:'draft'` line added; lands on the Items "incomplete" worklist.
- *Loose/KG item* (`unit.decimals=3`): Qty accepts `12.500`. *Scale label (`2x` EAN)*: not meaningful for purchases inline hint "scale label enter by item code".
- *Qty 0 / non-integer cost*: red cell, Enter refused; server re-validates (`repos-purchase.ts:42-47`). *Cost 0*: allowed (free goods) but amber.
- ↑/↓ from an empty scan box selects grid rows; F2/Del act on the selection.
**Touches.** `PurchaseEntry.tsx`; NEW routes `GET /purchases/last-costs`, `GET /tax-classes` in `api.ts`.
### P0-4 · Consolidated price/MRP update prompt (at post, never mid-entry)
**Behavior.** During the post flow, lines where (a) cost changed vs supplierLast, or (b) entered MRP `item.mrpPaise`, populate one overlay grid: Item · cost oldnew · current sale · **suggested sale** · checkbox (default ON). Suggested sale preserves the item's previous margin: `round₹(newLanded / (1 oldMargin%))`, capped at MRP when MRP exists (Legal Metrology, issue GST-15). Space toggles a row, ↑/↓ moves, typing overrides the suggestion, Enter confirms all checked rows set `newSalePricePaise`/`newMrpPaise` on the line inputs. The **server side already exists**: `commitPurchase` applies them with `ITEM_PRICE_UPDATE` audit rows (`repos-purchase.ts:89-97`).
**Keystrokes.** Usually 1 (Enter accepts all). Esc returns to the grid without posting.
**Edge cases.** Item whose sale price would drop below new landed cost row pre-checked with red margin; suggested never exceeds MRP.
**Touches.** `PurchaseEntry.tsx` only.
### P0-5 · Footer totals + "supplier's printed total" cross-check
**Behavior.** Footer renders live Taxable / GST / Total from `computePurchase`. `Ctrl+Enter` opens the **totals-check overlay**: one numeric field "Bill total as printed on the paper" (autofocused). Enter compares against computed total: within `purchases.total_tolerance` (setting; default 99 paise) proceed to P0-4 overlay commit. Mismatch red line `Printed 8,320.00 · Computed 8,308.44 · Δ +11.56` with ranked hints (largest line by value, line count) and two keys: `[F2] Add other charges ₹11.56` (fills `otherChargesPaise`, delta recheck passes) · `[Esc] back to lines`. Explicit `[F10] Post anyway` records the mismatch (`printed_total_paise` stored; `PURCHASE_TOTAL_MISMATCH` audit row). This is the manual sibling of the Purchase Inbox verdict math (09-UX §3.4).
**Keystrokes.** Ctrl+Enter + printed digits + Enter (+1 Enter in P0-4) the entire close ceremony is ~digits+3.
**Touches.** `PurchaseEntry.tsx`; schema: `ALTER TABLE purchase ADD COLUMN printed_total_paise INTEGER; ADD COLUMN other_charges_paise INTEGER NOT NULL DEFAULT 0` in `db.ts` (dev DB is disposable recreate or ship the ALTERs); `commitPurchase` total check becomes `taxable + tax + otherCharges === clientTotalPaise` and persists both columns **NEW server change**.
### P0-6 · Post — single action, one server transaction
**Behavior.** After the two overlays, client calls **NEW `POST /purchases`** with `{storeId, supplierId, invoiceNo, invoiceDate, businessDate: today, lines[], otherChargesPaise, printedTotalPaise?, clientTotalPaise}`. On 200: green inline notice "Posted · AA/912 · 8,308.44 · 42 units into stock", grid clears, supplier stays selected, focus Invoice No (next bill from the same distributor stack is 2 fields away). On 409 duplicate: red notice, nothing cleared. On engine-mismatch 409: red notice "totals disagree refresh tax data (E-7302)"; cart kept.
**Server transaction** (single `db.transaction`, `repos-purchase.ts:68` mostly exists):
1. Insert `purchase` header (+ NEW printed/other-charges columns) exists
2. Insert `purchase_line` rows exists
3. `stock_movement` rows, `reason='PURCHASE'`, `qty_delta=+qty` per line exists (stock IN; derived stock, invariant I4)
4. Item `sale_price_paise`/`mrp_paise` updates + `ITEM_PRICE_UPDATE` audit per changed line exists
5. `PURCHASE_COMMIT` audit row exists
6. **NEW: outbox envelope row** parity with `commitBill` (`repos.ts:174-177`); currently missing from `commitPurchase`, violating the D14 dormant-outbox discipline. Same shape, `doc_type='PURCHASE'`.
7. Label-queue note P1-3. Supplier ledger credit P2-2 (no ledger tables exist yet do not fake it).
Duplicate rejection: pre-check + `UNIQUE` exists. Validation (qty > 0, integer cost, non-empty invoice) — exists.
**Touches.** `api.ts` (route + `sess()` for tenant/user, mirroring `r.post('/bills')` at `api.ts:119`), `repos-purchase.ts` (items 6, printed-total), `db.ts`.
### P0-7 · Shared purchase math in the engine
**Behavior.** Export `computePurchase(lines: {qty, unitCostPaise, taxRateBp}[]): {perLine: {taxablePaise, taxPaise, totalPaise}[], totals}` from `@sims/billing-engine` with exactly the rounding at `repos-purchase.ts:33-37` (`Math.round(qty*cost)`, `Math.round(taxable*bp/10000)`). `lineMath` refactors to call it; `PurchaseEntry.tsx` imports it for the footer. This is what makes the 409 agreement check honest rather than a paisa-drift trap.
**Touches.** `packages/billing-engine/src/` (new file + index export, + golden test), `repos-purchase.ts`.
---
## 3. P1 — makes it pleasant at 200 lines/day
- **P1-1 Right-rail item card.** On cell focus, show item photo-less card: stock on hand (existing `stockView`), current sale/MRP, live margin, **last 3 costs from this supplier with dates**. NEW `GET /purchases/cost-history?itemId=&supplierId=` → NEW repo fn (`SELECT unit_cost_paise, p.invoice_date FROM purchase_line pl JOIN purchase p … ORDER BY p.created_at_wall DESC LIMIT 3`). Touches: `repos-purchase.ts`, `api.ts`, `PurchaseEntry.tsx` aside. Zero keystrokes — pure glanceability.
- **P1-2 Free-qty column + column profiles.** `ALTER TABLE purchase_line ADD COLUMN free_qty REAL NOT NULL DEFAULT 0`; stock movement becomes `qty + free_qty`; landed-cost math divides by total units. Enter-stop set driven by `purchases.column_profile` setting row (kirana: Qty+Cost; adds Free/Disc/MRP per profile).
- **P1-3 Label queue on post.** NEW table `label_job(id, tenant_id, store_id, item_id, qty, mrp_paise, source_doc_id, status, created_at_wall)`. After a successful post, if any line had new MRP or a draft item: inline prompt "Print labels for 5 new/changed items? [Enter] queue · [Esc] skip", quantities defaulting to received qty → NEW `POST /label-jobs` (bulk). `/catalog/labels` page reads them (replaces registry sample rows). Commit of jobs happens *outside* the purchase transaction (prompt is post-post); each job row is trivially idempotent to re-create.
- **P1-4 Disc% column.** `ALTER TABLE purchase_line ADD COLUMN disc_bp INTEGER NOT NULL DEFAULT 0`; `computePurchase` extends: `taxable = round(qty*cost*(1disc_bp/10000))`. Enter stop only in profiles that include it.
- **P1-5 Crash-proof draft.** Journal the in-progress entry to `localStorage['pe.draft']` on every line commit; on mount with a draft present, inline banner "Resume AA/912 (14 lines)? [Enter] resume · [Esc] discard". A 180-line invoice must survive a browser crash. No server.
- **P1-6 Local/Interstate tax split.** Derive `supply_type` from supplier `state_code` vs store; store `cgst/sgst/igst` split on `purchase` (3 columns) — prerequisite for GSTR-2B recon and ITC reporting. Engine change in `computePurchase` mirrors `computeBill`'s split.
- **P1-7 Purchase List goes live.** Wire existing `listPurchases` → NEW `GET /purchases`; replace the `/purchases/list` registry entry with a live page (pattern-copy `BillsPage` in `live.tsx`); row click → read-only detail drawer rendering `payload` lines + audit chip. "New purchase" button → navigate `/purchases/entry`.
## 4. P2 — deliberate deferrals
- **P2-1 Batch/expiry cells** (pharmacy profile) — needs batch tables; POS batch-picking doesn't exist yet either.
- **P2-2 Supplier ledger credit** in the post transaction + Suppliers ledger tab — blocked on accounting tables.
- **P2-3 Internal PI doc number** via `doc_series` (`doc_type='PURCHASE'`) so the list shows `PI-000212` instead of supplier invoice no; reuse the series block in `commitBill` (`repos.ts:139-151`).
- **P2-4 RCM flag per line** (freight defaults on) — GST-12 self-invoice machinery.
- **P2-5 Pack/unit conversion** (`CASE(24) → PCS`) from a pack master; until then purchasers enter stocking units.
- **P2-6 Tax-inclusive-cost toggle** per supplier (some local bills print inclusive costs).
- **P2-7 Telemetry**: keys/line and seconds/line emitted per posted purchase (09-UX §3.8 budget: ≤5 keys, ≤8 s) — land with the shared telemetry sink.
---
## 5. NEW server endpoints (all in `apps/store-server/src/api.ts`, behind `requireAuth`)
| Route | Backing fn | Status |
|---|---|---|
| `POST /purchases` | `commitPurchase` | repo EXISTS — wire route (P0) |
| `GET /purchases` | `listPurchases` | repo EXISTS — wire route (P0/P1-7) |
| `GET /purchases/last-costs?supplierId=` | `lastCosts` | repo EXISTS — wire route (P0) |
| `GET /purchases/check?supplierId=&invoiceNo=` | `findPurchase` | NEW repo fn (P0) |
| `GET /tax-classes` | `listTaxClasses` | repo EXISTS — wire route (P0) |
| `POST /parties` accepts `kind/gstin/stateCode` | `createParty` | generalize existing (P0) |
| `GET /purchases/cost-history?itemId=&supplierId=` | NEW repo fn | P1 |
| `POST /label-jobs` + `GET /label-jobs` | NEW repo fns | P1 |
**Table shapes.** `purchase` and `purchase_line` already exist in `db.ts:95-109`. Deltas:
```sql
-- P0
ALTER TABLE purchase ADD COLUMN printed_total_paise INTEGER; -- as typed from the paper, NULL if skipped
ALTER TABLE purchase ADD COLUMN other_charges_paise INTEGER NOT NULL DEFAULT 0;
-- P1
ALTER TABLE purchase_line ADD COLUMN free_qty REAL NOT NULL DEFAULT 0;
ALTER TABLE purchase_line ADD COLUMN disc_bp INTEGER NOT NULL DEFAULT 0;
CREATE TABLE label_job (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, store_id TEXT NOT NULL,
item_id TEXT NOT NULL, qty INTEGER NOT NULL, mrp_paise INTEGER,
source_doc_id TEXT, status TEXT NOT NULL DEFAULT 'queued', created_at_wall TEXT NOT NULL
);
```
Last-cost lookup needs no new table — it derives from `purchase_line` join `purchase` (idx `idx_pline_item` exists). Note for scale: `lastCosts` currently full-scans all purchase lines per call (`repos-purchase.ts:110-114`); acceptable now, add `ORDER BY … LIMIT` per item via a window query when purchases exceed ~50k lines.
---
## 6. Keystroke count — realistic 3-line invoice (Amul Agency, AA/912, dated today)
Line 1: 24 butter, cost unchanged. Line 2: 12 cheese, cost changed to 312.50. Line 3: 6 milk powder, cost unchanged, new MRP 310. Scans = 0 keys by convention.
| Step | Keys | Count |
|---|---|---|
| Supplier `amu` + Enter | `a m u ↵` | 4 |
| Invoice no `AA/912` + Enter | 6 + ↵ | 7 |
| Date (today) Enter | ↵ | 1 |
| L1: scan · `24` ↵ · ↵ (cost prefill ok) | | 4 |
| L2: scan · `12` ↵ · `312.5` ↵ | | 9 |
| L3: scan · `6` ↵ · ↵ · Tab `310` ↵ | | 8 |
| Post: Ctrl+Enter · printed `8964.5` ↵ | | 8 |
| Price-update overlay: Enter (accept both suggestions) | ↵ | 1 |
| **Total** | **3 scans** | **42 keys** |
~14 keys of once-per-invoice overhead; steady-state 49 keys/line — inside the published "~5 keys/line, 12-line bill ≈ 85 keystrokes" budget from 09-UX §3.2, and every one of those keys is Enter, digits, or Tab on the numpad side of the keyboard.