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:
ryan
2026-04-05 02:21:55 +00:00
parent a29e924544
commit 663f00b099
17 changed files with 1062 additions and 6 deletions

View File

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