feat: named registers, X/Z reports, daily rollup, fix drawerSessionId
Registers: - New register table with location association - CRUD service + API routes (POST/GET/PATCH/DELETE /registers) - Drawer sessions now link to a register via registerId - Register ID persisted in localStorage per device X/Z Reports: - ReportService with getDrawerReport() (X or Z depending on session state) - Z report auto-displayed on drawer close in the drawer dialog - X report (Current Shift Report) button on open drawer view - Report shows: sales summary, payment breakdown, discounts, cash accountability, adjustments Daily Rollup: - ReportService.getDailyReport() aggregates all sessions at a location for a date - New /reports/daily endpoint with locationId + date params - Frontend daily report page with date picker, location selector, session breakdown Critical Fix: - drawerSessionId is now populated on transactions when completing (was never set before) - This enables accurate per-drawer reporting and cash accountability Migration 0044: register table, drawer_session.register_id column Tests: 14 new (register CRUD, drawer report X/Z, drawerSessionId population, daily rollup, register-drawer link) Full suite: 367 passed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -902,4 +902,153 @@ suite('POS', { tags: ['pos'] }, (t) => {
|
||||
t.assert.status(res2, 200)
|
||||
t.assert.ok(res2.data.data.some((p: any) => p.id === consumable.data.id))
|
||||
})
|
||||
|
||||
// ─── Registers ────────────────────────────────────────────────────────────
|
||||
|
||||
t.test('creates a register', { tags: ['registers', 'create'] }, async () => {
|
||||
const res = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Register 1' })
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.name, 'Register 1')
|
||||
t.assert.equal(res.data.locationId, LOCATION_ID)
|
||||
t.assert.equal(res.data.isActive, true)
|
||||
})
|
||||
|
||||
t.test('lists registers for a location', { tags: ['registers', 'list'] }, async () => {
|
||||
await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Register 2' })
|
||||
const res = await t.api.get('/v1/registers', { locationId: LOCATION_ID })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.length >= 2)
|
||||
t.assert.ok(res.data.pagination)
|
||||
})
|
||||
|
||||
t.test('lists all registers (lookup)', { tags: ['registers', 'list'] }, async () => {
|
||||
const res = await t.api.get('/v1/registers/all', { locationId: LOCATION_ID })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.length >= 2)
|
||||
})
|
||||
|
||||
t.test('updates a register name', { tags: ['registers', 'update'] }, async () => {
|
||||
const created = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Old Name' })
|
||||
const res = await t.api.patch(`/v1/registers/${created.data.id}`, { name: 'New Name' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.name, 'New Name')
|
||||
})
|
||||
|
||||
t.test('deactivates a register', { tags: ['registers', 'delete'] }, async () => {
|
||||
const created = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Delete Me' })
|
||||
const res = await t.api.del(`/v1/registers/${created.data.id}`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.isActive, false)
|
||||
})
|
||||
|
||||
// ─── Drawer Reports (X/Z) ────────────────────────────────────────────────
|
||||
|
||||
t.test('cleanup: close any open drawers for report tests', { tags: ['reports', 'setup'] }, async () => {
|
||||
const current = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
|
||||
if (current.status === 200 && current.data?.id) {
|
||||
await t.api.post(`/v1/drawer/${current.data.id}/close`, { closingBalance: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
t.test('drawer report returns correct data for a session with transactions', { tags: ['reports', 'drawer'] }, async () => {
|
||||
// Open drawer
|
||||
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
||||
t.assert.status(drawer, 201)
|
||||
|
||||
// Make a cash sale
|
||||
const txn1 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
await t.api.post(`/v1/transactions/${txn1.data.id}/line-items`, { description: 'Report Item 1', qty: 1, unitPrice: 50 })
|
||||
await t.api.post(`/v1/transactions/${txn1.data.id}/complete`, { paymentMethod: 'cash', amountTendered: 60 })
|
||||
|
||||
// Make a card sale
|
||||
const txn2 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
await t.api.post(`/v1/transactions/${txn2.data.id}/line-items`, { description: 'Report Item 2', qty: 1, unitPrice: 30 })
|
||||
await t.api.post(`/v1/transactions/${txn2.data.id}/complete`, { paymentMethod: 'card_present' })
|
||||
|
||||
// Void a transaction
|
||||
const txn3 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
await t.api.post(`/v1/transactions/${txn3.data.id}/line-items`, { description: 'Voided Item', qty: 1, unitPrice: 10 })
|
||||
await t.api.post(`/v1/transactions/${txn3.data.id}/void`)
|
||||
|
||||
// Get X report (drawer still open)
|
||||
const xReport = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`)
|
||||
t.assert.status(xReport, 200)
|
||||
t.assert.equal(xReport.data.sales.transactionCount, 2)
|
||||
t.assert.greaterThan(xReport.data.sales.grossSales, 0)
|
||||
// Voided transactions don't go through complete() so drawerSessionId isn't set
|
||||
// They won't appear in the drawer report — this is correct behavior
|
||||
t.assert.ok(xReport.data.payments.cash)
|
||||
t.assert.ok(xReport.data.payments.card_present)
|
||||
t.assert.equal(xReport.data.cash.actualBalance, null) // not closed yet
|
||||
|
||||
// Close drawer
|
||||
const closingAmount = 100 + xReport.data.cash.cashSales
|
||||
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: closingAmount })
|
||||
|
||||
// Get Z report (drawer closed)
|
||||
const zReport = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`)
|
||||
t.assert.status(zReport, 200)
|
||||
t.assert.ok(zReport.data.session.closedAt)
|
||||
t.assert.ok(zReport.data.cash.actualBalance !== null)
|
||||
t.assert.ok(typeof zReport.data.cash.overShort === 'number')
|
||||
})
|
||||
|
||||
t.test('drawerSessionId is populated on completed transactions', { tags: ['reports', 'drawer-session-id'] }, async () => {
|
||||
// Cleanup any open drawer
|
||||
const cur = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
|
||||
if (cur.status === 200 && cur.data?.id) await t.api.post(`/v1/drawer/${cur.data.id}/close`, { closingBalance: 0 })
|
||||
|
||||
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
||||
t.assert.status(drawer, 201)
|
||||
|
||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, { description: 'Session ID Test', qty: 1, unitPrice: 20 })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { paymentMethod: 'card_present' })
|
||||
|
||||
const completed = await t.api.get(`/v1/transactions/${txn.data.id}`)
|
||||
t.assert.status(completed, 200)
|
||||
t.assert.equal(completed.data.drawerSessionId, drawer.data.id)
|
||||
|
||||
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 })
|
||||
})
|
||||
|
||||
// ─── Daily Report ─────────────────────────────────────────────────────────
|
||||
|
||||
t.test('daily report aggregates across sessions', { tags: ['reports', 'daily'] }, async () => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const res = await t.api.get('/v1/reports/daily', { locationId: LOCATION_ID, date: today })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.date, today)
|
||||
t.assert.ok(res.data.location)
|
||||
t.assert.ok(Array.isArray(res.data.sessions))
|
||||
t.assert.ok(typeof res.data.sales.grossSales === 'number')
|
||||
t.assert.ok(typeof res.data.payments === 'object')
|
||||
t.assert.ok(typeof res.data.cash.totalExpected === 'number')
|
||||
})
|
||||
|
||||
t.test('daily report rejects missing params', { tags: ['reports', 'daily', 'validation'] }, async () => {
|
||||
const res = await t.api.get('/v1/reports/daily', {})
|
||||
t.assert.status(res, 400)
|
||||
})
|
||||
|
||||
t.test('opens drawer with register', { tags: ['registers', 'drawer'] }, async () => {
|
||||
// Cleanup any open drawer
|
||||
const cur = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
|
||||
if (cur.status === 200 && cur.data?.id) await t.api.post(`/v1/drawer/${cur.data.id}/close`, { closingBalance: 0 })
|
||||
|
||||
const reg = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Report Register' })
|
||||
t.assert.status(reg, 201)
|
||||
|
||||
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, registerId: reg.data.id, openingBalance: 100 })
|
||||
t.assert.status(drawer, 201)
|
||||
|
||||
// Get report to check register info
|
||||
const report = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`)
|
||||
t.assert.status(report, 200)
|
||||
t.assert.ok(report.data.session.register)
|
||||
t.assert.equal(report.data.session.register.name, 'Report Register')
|
||||
|
||||
// Cleanup
|
||||
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user