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:
292
packages/backend/src/services/report.service.ts
Normal file
292
packages/backend/src/services/report.service.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { eq, and, sql, gte, lt } from 'drizzle-orm'
|
||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||
import { transactions, drawerSessions, drawerAdjustments, registers } from '../db/schema/pos.js'
|
||||
import { locations } from '../db/schema/stores.js'
|
||||
import { users } from '../db/schema/users.js'
|
||||
import { NotFoundError } from '../lib/errors.js'
|
||||
|
||||
interface PaymentBreakdown {
|
||||
count: number
|
||||
total: number
|
||||
}
|
||||
|
||||
interface DrawerReport {
|
||||
session: {
|
||||
id: string
|
||||
openedAt: string
|
||||
closedAt: string | null
|
||||
openingBalance: string
|
||||
closingBalance: string | null
|
||||
expectedBalance: string | null
|
||||
overShort: string | null
|
||||
status: string
|
||||
notes: string | null
|
||||
denominations: Record<string, number> | null
|
||||
register: { id: string; name: string } | null
|
||||
openedBy: { id: string; firstName: string; lastName: string } | null
|
||||
closedBy: { id: string; firstName: string; lastName: string } | null
|
||||
}
|
||||
sales: {
|
||||
grossSales: number
|
||||
netSales: number
|
||||
transactionCount: number
|
||||
voidCount: number
|
||||
refundTotal: number
|
||||
}
|
||||
payments: Record<string, PaymentBreakdown>
|
||||
discounts: {
|
||||
total: number
|
||||
count: number
|
||||
}
|
||||
cash: {
|
||||
openingBalance: number
|
||||
cashSales: number
|
||||
cashIn: number
|
||||
cashOut: number
|
||||
expectedBalance: number
|
||||
actualBalance: number | null
|
||||
overShort: number | null
|
||||
}
|
||||
adjustments: { id: string; type: string; amount: string; reason: string; createdAt: string }[]
|
||||
}
|
||||
|
||||
export const ReportService = {
|
||||
async getDrawerReport(db: PostgresJsDatabase<any>, drawerSessionId: string): Promise<DrawerReport> {
|
||||
// Fetch session with register and user info
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(drawerSessions)
|
||||
.where(eq(drawerSessions.id, drawerSessionId))
|
||||
.limit(1)
|
||||
|
||||
if (!session) throw new NotFoundError('Drawer session')
|
||||
|
||||
// Fetch register info
|
||||
let register: { id: string; name: string } | null = null
|
||||
if (session.registerId) {
|
||||
const [reg] = await db.select({ id: registers.id, name: registers.name }).from(registers).where(eq(registers.id, session.registerId)).limit(1)
|
||||
register = reg ?? null
|
||||
}
|
||||
|
||||
// Fetch user info
|
||||
const [openedByUser] = await db.select({ id: users.id, firstName: users.firstName, lastName: users.lastName }).from(users).where(eq(users.id, session.openedBy)).limit(1)
|
||||
let closedByUser = null
|
||||
if (session.closedBy) {
|
||||
const [u] = await db.select({ id: users.id, firstName: users.firstName, lastName: users.lastName }).from(users).where(eq(users.id, session.closedBy)).limit(1)
|
||||
closedByUser = u ?? null
|
||||
}
|
||||
|
||||
// Aggregate transaction data for this drawer session
|
||||
const txns = await db
|
||||
.select({
|
||||
status: transactions.status,
|
||||
transactionType: transactions.transactionType,
|
||||
paymentMethod: transactions.paymentMethod,
|
||||
total: transactions.total,
|
||||
discountTotal: transactions.discountTotal,
|
||||
roundingAdjustment: transactions.roundingAdjustment,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(eq(transactions.drawerSessionId, drawerSessionId))
|
||||
|
||||
// Calculate sales
|
||||
let grossSales = 0
|
||||
let refundTotal = 0
|
||||
let transactionCount = 0
|
||||
let voidCount = 0
|
||||
let discountTotalSum = 0
|
||||
let discountCount = 0
|
||||
const payments: Record<string, PaymentBreakdown> = {}
|
||||
|
||||
for (const txn of txns) {
|
||||
if (txn.status === 'voided') {
|
||||
voidCount++
|
||||
continue
|
||||
}
|
||||
if (txn.status !== 'completed') continue
|
||||
|
||||
const total = parseFloat(txn.total ?? '0')
|
||||
const discAmt = parseFloat(txn.discountTotal ?? '0')
|
||||
|
||||
if (txn.transactionType === 'refund') {
|
||||
refundTotal += total
|
||||
} else {
|
||||
grossSales += total
|
||||
transactionCount++
|
||||
}
|
||||
|
||||
if (discAmt > 0) {
|
||||
discountTotalSum += discAmt
|
||||
discountCount++
|
||||
}
|
||||
|
||||
const method = txn.paymentMethod ?? 'unknown'
|
||||
if (!payments[method]) payments[method] = { count: 0, total: 0 }
|
||||
payments[method].count++
|
||||
payments[method].total += total
|
||||
}
|
||||
|
||||
// Cash accountability
|
||||
const cashPayment = payments['cash'] ?? { count: 0, total: 0 }
|
||||
const cashRounding = txns
|
||||
.filter((t) => t.status === 'completed' && t.paymentMethod === 'cash')
|
||||
.reduce((sum, t) => sum + parseFloat(t.roundingAdjustment ?? '0'), 0)
|
||||
const cashSales = cashPayment.total + cashRounding
|
||||
|
||||
// Adjustments
|
||||
const adjRows = await db
|
||||
.select()
|
||||
.from(drawerAdjustments)
|
||||
.where(eq(drawerAdjustments.drawerSessionId, drawerSessionId))
|
||||
|
||||
let cashIn = 0
|
||||
let cashOut = 0
|
||||
for (const adj of adjRows) {
|
||||
if (adj.type === 'cash_in') cashIn += parseFloat(adj.amount)
|
||||
else cashOut += parseFloat(adj.amount)
|
||||
}
|
||||
|
||||
const openingBalance = parseFloat(session.openingBalance)
|
||||
const expectedBalance = openingBalance + cashSales + cashIn - cashOut
|
||||
const actualBalance = session.closingBalance ? parseFloat(session.closingBalance) : null
|
||||
const overShort = actualBalance !== null ? Math.round((actualBalance - expectedBalance) * 100) / 100 : null
|
||||
|
||||
return {
|
||||
session: {
|
||||
id: session.id,
|
||||
openedAt: session.openedAt.toISOString(),
|
||||
closedAt: session.closedAt?.toISOString() ?? null,
|
||||
openingBalance: session.openingBalance,
|
||||
closingBalance: session.closingBalance,
|
||||
expectedBalance: session.expectedBalance,
|
||||
overShort: session.overShort,
|
||||
status: session.status,
|
||||
notes: session.notes,
|
||||
denominations: session.denominations,
|
||||
register,
|
||||
openedBy: openedByUser ?? null,
|
||||
closedBy: closedByUser,
|
||||
},
|
||||
sales: {
|
||||
grossSales: Math.round(grossSales * 100) / 100,
|
||||
netSales: Math.round((grossSales - refundTotal) * 100) / 100,
|
||||
transactionCount,
|
||||
voidCount,
|
||||
refundTotal: Math.round(refundTotal * 100) / 100,
|
||||
},
|
||||
payments,
|
||||
discounts: {
|
||||
total: Math.round(discountTotalSum * 100) / 100,
|
||||
count: discountCount,
|
||||
},
|
||||
cash: {
|
||||
openingBalance,
|
||||
cashSales: Math.round(cashSales * 100) / 100,
|
||||
cashIn: Math.round(cashIn * 100) / 100,
|
||||
cashOut: Math.round(cashOut * 100) / 100,
|
||||
expectedBalance: Math.round(expectedBalance * 100) / 100,
|
||||
actualBalance,
|
||||
overShort,
|
||||
},
|
||||
adjustments: adjRows.map((a) => ({
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
amount: a.amount,
|
||||
reason: a.reason,
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
})),
|
||||
}
|
||||
},
|
||||
|
||||
async getDailyReport(db: PostgresJsDatabase<any>, locationId: string, date: string) {
|
||||
// Get location info
|
||||
const [location] = await db
|
||||
.select({ id: locations.id, name: locations.name, timezone: locations.timezone })
|
||||
.from(locations)
|
||||
.where(eq(locations.id, locationId))
|
||||
.limit(1)
|
||||
|
||||
if (!location) throw new NotFoundError('Location')
|
||||
|
||||
// Find all drawer sessions opened at this location on the given date
|
||||
const dayStart = new Date(`${date}T00:00:00`)
|
||||
const dayEnd = new Date(`${date}T00:00:00`)
|
||||
dayEnd.setDate(dayEnd.getDate() + 1)
|
||||
|
||||
const sessions = await db
|
||||
.select()
|
||||
.from(drawerSessions)
|
||||
.where(and(
|
||||
eq(drawerSessions.locationId, locationId),
|
||||
gte(drawerSessions.openedAt, dayStart),
|
||||
lt(drawerSessions.openedAt, dayEnd),
|
||||
))
|
||||
|
||||
// Get individual reports for each session
|
||||
const sessionReports = await Promise.all(
|
||||
sessions.map((s) => this.getDrawerReport(db, s.id))
|
||||
)
|
||||
|
||||
// Aggregate
|
||||
const sales = { grossSales: 0, netSales: 0, transactionCount: 0, voidCount: 0, refundTotal: 0 }
|
||||
const payments: Record<string, PaymentBreakdown> = {}
|
||||
const discounts = { total: 0, count: 0 }
|
||||
const cash = { totalOpening: 0, totalCashSales: 0, totalCashIn: 0, totalCashOut: 0, totalExpected: 0, totalActual: 0, totalOverShort: 0 }
|
||||
|
||||
for (const report of sessionReports) {
|
||||
sales.grossSales += report.sales.grossSales
|
||||
sales.netSales += report.sales.netSales
|
||||
sales.transactionCount += report.sales.transactionCount
|
||||
sales.voidCount += report.sales.voidCount
|
||||
sales.refundTotal += report.sales.refundTotal
|
||||
|
||||
for (const [method, data] of Object.entries(report.payments)) {
|
||||
if (!payments[method]) payments[method] = { count: 0, total: 0 }
|
||||
payments[method].count += data.count
|
||||
payments[method].total += data.total
|
||||
}
|
||||
|
||||
discounts.total += report.discounts.total
|
||||
discounts.count += report.discounts.count
|
||||
|
||||
cash.totalOpening += report.cash.openingBalance
|
||||
cash.totalCashSales += report.cash.cashSales
|
||||
cash.totalCashIn += report.cash.cashIn
|
||||
cash.totalCashOut += report.cash.cashOut
|
||||
cash.totalExpected += report.cash.expectedBalance
|
||||
if (report.cash.actualBalance !== null) cash.totalActual += report.cash.actualBalance
|
||||
if (report.cash.overShort !== null) cash.totalOverShort += report.cash.overShort
|
||||
}
|
||||
|
||||
// Round all aggregated values
|
||||
for (const key of Object.keys(sales) as (keyof typeof sales)[]) {
|
||||
sales[key] = Math.round(sales[key] * 100) / 100
|
||||
}
|
||||
for (const data of Object.values(payments)) {
|
||||
data.total = Math.round(data.total * 100) / 100
|
||||
}
|
||||
discounts.total = Math.round(discounts.total * 100) / 100
|
||||
for (const key of Object.keys(cash) as (keyof typeof cash)[]) {
|
||||
cash[key] = Math.round(cash[key] * 100) / 100
|
||||
}
|
||||
|
||||
return {
|
||||
date,
|
||||
location: { id: location.id, name: location.name },
|
||||
sessions: sessionReports.map((r) => ({
|
||||
id: r.session.id,
|
||||
register: r.session.register,
|
||||
openedBy: r.session.openedBy,
|
||||
openedAt: r.session.openedAt,
|
||||
closedAt: r.session.closedAt,
|
||||
status: r.session.status,
|
||||
overShort: r.cash.overShort,
|
||||
grossSales: r.sales.grossSales,
|
||||
})),
|
||||
sales,
|
||||
payments,
|
||||
discounts,
|
||||
cash,
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user