|
|
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')
|
|
|
})
|
|
|
})
|