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>
293 lines
9.7 KiB
TypeScript
293 lines
9.7 KiB
TypeScript
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,
|
|
}
|
|
},
|
|
}
|