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/apps/store-server/test/batch.test.ts

143 lines
7.2 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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