import { describe, expect, it } from 'vitest' import { openDb, type DB } from '../src/db' import { ensureBatchTrackingSeed, seedIfEmpty } from '../src/seed' import { batchesForStore, commitBill, createOverrideApproval, expiryView, stockView } from '../src/repos' import { commitPurchase } from '../src/repos-purchase' /** * S5 batch/expiry — server layer against a hermetic in-memory DB. seedIfEmpty already * calls ensureBatchTrackingSeed, so the demo perishables (105 Maggi, 107 Amul Butter) * arrive batch-tracked with dated lots and an on-hand-preserving offset. */ function fresh(): DB { const db = openDb(':memory:') seedIfEmpty(db) return db } const itemId = (db: DB, code: string): string => (db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string }).id const onHand = (db: DB, code: string): number => Number((stockView(db, 't1', 's1').find((r) => r['code'] === code) as { on_hand: number }).on_hand) describe('S5 seed — batch tracking + preserved totals', () => { it('marks Amul Butter (107) and Maggi (105) batch-tracked', () => { const db = fresh() for (const code of ['105', '107']) { const it = db.prepare(`SELECT batch_tracked FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { batch_tracked: number } expect(it.batch_tracked).toBe(1) } }) it('splits opening stock into lots WITHOUT changing total on-hand', () => { const db = fresh() // seed opening was 120 each; the lot movements are offset by a negative un-batched OPENING. expect(onHand(db, '107')).toBe(120) expect(onHand(db, '105')).toBe(120) // the lots themselves carry the re-attributed quantities const batches = batchesForStore(db, 't1', 's1') const sum = batches.filter((b) => b['item_id'] === itemId(db, '107')).reduce((a, b) => a + Number(b['on_hand']), 0) expect(sum).toBe(8 + 12 + 30) }) it('is idempotent — a second call adds no batches', () => { const db = fresh() const before = batchesForStore(db, 't1', 's1').length ensureBatchTrackingSeed(db) expect(batchesForStore(db, 't1', 's1').length).toBe(before) }) it('exposes an expired-with-stock lot and near-expiry lots to the dashboard feed', () => { const db = fresh() const today = new Date().toISOString().slice(0, 10) const rows = expiryView(db, 't1', 's1') const expired = rows.filter((r) => r['expiry_date'] !== null && String(r['expiry_date']) < today) expect(expired.length).toBeGreaterThan(0) // value at sale price is derivable: Amul 107 expired lot = 8 × ₹275 const amulExpired = expired.find((r) => r['code'] === '107')! expect(Number(amulExpired['on_hand']) * Number(amulExpired['sale_price_paise'])).toBe(8 * 27_500) }) }) describe('S5 purchase capture — batch upsert + stock IN', () => { it('creates the batch, attributes the IN movement to it, and re-purchase reuses the same lot', () => { const db = fresh() const amul = itemId(db, '107') const supplier = (db.prepare(`SELECT id FROM party WHERE tenant_id='t1' AND code='SU002'`).get() as { id: string }).id const before = batchesForStore(db, 't1', 's1').filter((b) => b['item_id'] === amul).length commitPurchase(db, { tenantId: 't1', storeId: 's1', userId: 'u3', supplierId: supplier, invoiceNo: 'AA-900', invoiceDate: '2026-07-10', businessDate: '2026-07-10', lines: [{ itemId: amul, name: 'Amul Butter 500g', qty: 20, unitCostPaise: 24_000, taxRateBp: 1_200, batchNo: 'B-FRESH', expiryDate: '2026-12-01' }], clientTotalPaise: 20 * 24_000 + Math.round(20 * 24_000 * 1_200 / 10_000), }) const after = batchesForStore(db, 't1', 's1').filter((b) => b['item_id'] === amul) expect(after.length).toBe(before + 1) const fresh1 = after.find((b) => b['batch_no'] === 'B-FRESH')! expect(Number(fresh1['on_hand'])).toBe(20) // a second invoice for the same batch_no upserts (no duplicate lot), on-hand adds up commitPurchase(db, { tenantId: 't1', storeId: 's1', userId: 'u3', supplierId: supplier, invoiceNo: 'AA-901', invoiceDate: '2026-07-10', businessDate: '2026-07-10', lines: [{ itemId: amul, name: 'Amul Butter 500g', qty: 5, unitCostPaise: 24_000, taxRateBp: 1_200, batchNo: 'B-FRESH', expiryDate: '2026-12-01' }], clientTotalPaise: 5 * 24_000 + Math.round(5 * 24_000 * 1_200 / 10_000), }) const reused = batchesForStore(db, 't1', 's1').filter((b) => b['item_id'] === amul && b['batch_no'] === 'B-FRESH') expect(reused.length).toBe(1) expect(Number(reused[0]!['on_hand'])).toBe(25) }) }) describe('S5 sale — batch-attributed movement + belongs validation', () => { const line = (id: string, batchId?: string) => ({ itemId: id, name: 'Amul Butter 500g', hsn: '0405', qty: 2, unitCode: 'PCS', unitPricePaise: 27_500, priceIncludesTax: true, taxClassCode: 'GST12', ...(batchId !== undefined ? { batchId } : {}), }) it('decrements the picked batch, not the item at large', () => { const db = fresh() const amul = itemId(db, '107') const far = batchesForStore(db, 't1', 's1').find((b) => b['batch_no'] === 'B-107B')! const farId = String(far['id']) const before = Number(far['on_hand']) commitBill(db, { tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: '2026-07-10', lines: [line(amul, farId)], payments: [{ mode: 'CASH', amountPaise: 55_000 }], clientPayablePaise: 55_000, }) const after = batchesForStore(db, 't1', 's1').find((b) => b['id'] === farId)! expect(Number(after['on_hand'])).toBe(before - 2) }) it('rejects a sale whose batch belongs to a different item (E-1311) — nothing committed', () => { const db = fresh() const amul = itemId(db, '107') const maggiBatch = String(batchesForStore(db, 't1', 's1').find((b) => b['batch_no'] === 'B-105A')!['id']) const billCount = () => (db.prepare('SELECT COUNT(*) n FROM bill').get() as { n: number }).n expect(() => commitBill(db, { tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: '2026-07-10', lines: [line(amul, maggiBatch)], payments: [{ mode: 'CASH', amountPaise: 55_000 }], clientPayablePaise: 55_000, })).toThrow(/E-1311/) expect(billCount()).toBe(0) }) it('audits a BATCH_OVERRIDE when an expired-batch sale is approved', () => { const db = fresh() const amul = itemId(db, '107') const expired = String(batchesForStore(db, 't1', 's1').find((b) => b['batch_no'] === 'B-107X')!['id']) const approvalId = createOverrideApproval(db, 't1', 'u2', { itemId: amul, kind: 'batch', beforePaise: 0, afterPaise: 0 }) // Suresh commitBill(db, { tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: '2026-07-10', lines: [line(amul, expired)], payments: [{ mode: 'CASH', amountPaise: 55_000 }], clientPayablePaise: 55_000, overrides: [{ itemId: amul, kind: 'batch', before: 0, after: 0, approvalId }], }) const audit = db.prepare(`SELECT after_json FROM audit_log WHERE action='BATCH_OVERRIDE'`).get() as { after_json: string } | undefined expect(audit).toBeDefined() expect((JSON.parse(audit!.after_json) as { approvedBy: string }).approvedBy).toBe('Suresh') }) })