|
|
# 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 old→new · 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*(1−disc_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 4–9 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. |