22 KiB
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': PurchaseEntrytoCUSTOM(auto-overrides the registry wireframe)apps/backoffice/src/api.ts— client fns:getSuppliers,createSupplier,checkInvoice,getLastCosts,getTaxClasses,postPurchase,getPurchasesapps/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 columnsapps/store-server/src/repos.ts— generalizecreateCustomer→createParty(db, tenantId, {name, kind, phone?, gstin?, stateCode?})apps/store-server/src/db.ts— 2 new columns onpurchase, laterlabel_job(see §6)packages/billing-engine— NEW exportedcomputePurchase(lines)so client and server share one math (same-engine idiom ascomputeBill;repos-purchase.ts:lineMathrefactors 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 /itemswithstatus:'draft'→ line added; lands on the Items "incomplete" worklist. - Loose/KG item (
unit.decimals=3): Qty accepts12.500. Scale label (2xEAN): 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 routesGET /purchases/last-costs,GET /tax-classesinapi.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):
- Insert
purchaseheader (+ NEW printed/other-charges columns) — exists - Insert
purchase_linerows — exists stock_movementrows,reason='PURCHASE',qty_delta=+qtyper line — exists (stock IN; derived stock, invariant I4)- Item
sale_price_paise/mrp_paiseupdates +ITEM_PRICE_UPDATEaudit per changed line — exists PURCHASE_COMMITaudit row — exists- NEW: outbox envelope row — parity with
commitBill(repos.ts:174-177); currently missing fromcommitPurchase, violating the D14 dormant-outbox discipline. Same shape,doc_type='PURCHASE'. - 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, mirroringr.post('/bills')atapi.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. NEWGET /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.tsxaside. 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 becomesqty + free_qty; landed-cost math divides by total units. Enter-stop set driven bypurchases.column_profilesetting 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 → NEWPOST /label-jobs(bulk)./catalog/labelspage 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;computePurchaseextends: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_typefrom supplierstate_codevs store; storecgst/sgst/igstsplit onpurchase(3 columns) — prerequisite for GSTR-2B recon and ITC reporting. Engine change incomputePurchasemirrorscomputeBill's split. - P1-7 Purchase List goes live. Wire existing
listPurchases→ NEWGET /purchases; replace the/purchases/listregistry entry with a live page (pattern-copyBillsPageinlive.tsx); row click → read-only detail drawer renderingpayloadlines + 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 showsPI-000212instead of supplier invoice no; reuse the series block incommitBill(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:
-- 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.