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

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': 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.tsfindPurchase(), invoice-no normalization, outbox row, printed-total columns
  • apps/store-server/src/repos.ts — generalize createCustomercreateParty(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:createCustomercreateParty and extend POST /parties in api.ts:112 to accept kind|gstin|stateCodesmall 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.

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:

-- 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.