refractor code,
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
import { parseQuery } from '../../http/validate'
|
||||
import { ok } from '../../http/respond'
|
||||
import * as service from './analytics.service'
|
||||
import { summaryQuerySchema, reportQuerySchema } from './analytics.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
router.get('/summary', async (req, res, next) => {
|
||||
try {
|
||||
const { period } = parseQuery(summaryQuerySchema, req)
|
||||
ok(res, { data: await service.getSummary(req.companyId, period) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/dashboard', async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.getDashboard(req.companyId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/sources', async (req, res, next) => {
|
||||
try {
|
||||
ok(res, { data: await service.getSources(req.companyId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/report', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const query = parseQuery(reportQuerySchema, req)
|
||||
const result = await service.getReport(req.companyId, query)
|
||||
if (result.format === 'CSV') {
|
||||
const start = result.startDate.toISOString().slice(0, 10)
|
||||
const end = result.endDate.toISOString().slice(0, 10)
|
||||
res.setHeader('Content-Type', 'text/csv')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="report-${start}-${end}.csv"`)
|
||||
return res.send(result.csv)
|
||||
}
|
||||
ok(res, { data: result.report })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const summaryQuerySchema = z.object({
|
||||
period: z.string().default('30d'),
|
||||
})
|
||||
|
||||
export const reportQuerySchema = z.object({
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
format: z.string().default('JSON'),
|
||||
period: z.string().optional(),
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { generateFinancialReport, toCsv } from '../../services/financialReportService'
|
||||
|
||||
function getRangeFromPeriod(period: string) {
|
||||
const now = new Date()
|
||||
switch (period) {
|
||||
case 'WEEKLY': return { from: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), to: now }
|
||||
case 'QUARTERLY': return { from: new Date(now.getFullYear(), now.getMonth() - 2, 1), to: now }
|
||||
case 'ANNUAL': return { from: new Date(now.getFullYear(), 0, 1), to: now }
|
||||
default: return { from: new Date(now.getFullYear(), now.getMonth(), 1), to: now }
|
||||
}
|
||||
}
|
||||
|
||||
function pct(current: number, previous: number) {
|
||||
if (previous === 0) return current > 0 ? 100 : 0
|
||||
return Math.round(((current - previous) / previous) * 100)
|
||||
}
|
||||
|
||||
export async function getSummary(companyId: string, period: string) {
|
||||
const days = parseInt(period.replace('d', '')) || 30
|
||||
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
||||
const [totalReservations, activeVehicles, totalRevenue, totalCustomers] = await Promise.all([
|
||||
prisma.reservation.count({ where: { companyId, createdAt: { gte: since } } }),
|
||||
prisma.vehicle.count({ where: { companyId, status: 'AVAILABLE' } }),
|
||||
prisma.reservation.aggregate({ where: { companyId, status: { in: ['COMPLETED'] }, createdAt: { gte: since } }, _sum: { totalAmount: true } }),
|
||||
prisma.customer.count({ where: { companyId } }),
|
||||
])
|
||||
return { totalReservations, activeVehicles, totalRevenue: totalRevenue._sum.totalAmount ?? 0, totalCustomers, period }
|
||||
}
|
||||
|
||||
export async function getDashboard(companyId: string) {
|
||||
const now = new Date()
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const prevMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||
const prevMonthEnd = new Date(monthStart.getTime() - 1)
|
||||
|
||||
const [
|
||||
totalBookings, previousBookings, activeVehicles, previousActiveVehicles,
|
||||
totalCustomers, previousCustomers, monthlyRevenueAgg, previousRevenueAgg,
|
||||
recentReservations, sourceGroups, subscription,
|
||||
] = await Promise.all([
|
||||
prisma.reservation.count({ where: { companyId, createdAt: { gte: monthStart } } }),
|
||||
prisma.reservation.count({ where: { companyId, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } } }),
|
||||
prisma.vehicle.count({ where: { companyId, status: { in: ['AVAILABLE', 'RENTED'] } } }),
|
||||
prisma.vehicle.count({ where: { companyId, createdAt: { lte: prevMonthEnd }, status: { in: ['AVAILABLE', 'RENTED'] } } }),
|
||||
prisma.customer.count({ where: { companyId } }),
|
||||
prisma.customer.count({ where: { companyId, createdAt: { lte: prevMonthEnd } } }),
|
||||
prisma.reservation.aggregate({ where: { companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: monthStart } }, _sum: { totalAmount: true } }),
|
||||
prisma.reservation.aggregate({ where: { companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } }, _sum: { totalAmount: true } }),
|
||||
prisma.reservation.findMany({ where: { companyId }, include: { customer: true, vehicle: true }, orderBy: { createdAt: 'desc' }, take: 8 }),
|
||||
prisma.reservation.groupBy({ by: ['source'], where: { companyId }, _count: { id: true }, _sum: { totalAmount: true } }),
|
||||
prisma.subscription.findUnique({ where: { companyId } }),
|
||||
])
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
totalBookings,
|
||||
activeVehicles,
|
||||
monthlyRevenue: monthlyRevenueAgg._sum.totalAmount ?? 0,
|
||||
totalCustomers,
|
||||
bookingsChange: pct(totalBookings, previousBookings),
|
||||
vehiclesChange: pct(activeVehicles, previousActiveVehicles),
|
||||
revenueChange: pct(monthlyRevenueAgg._sum.totalAmount ?? 0, previousRevenueAgg._sum.totalAmount ?? 0),
|
||||
customersChange: pct(totalCustomers, previousCustomers),
|
||||
},
|
||||
recentReservations: (recentReservations as any[]).map((r) => ({
|
||||
id: r.id,
|
||||
bookingRef: r.contractNumber ?? r.id.slice(-8).toUpperCase(),
|
||||
customerName: `${r.customer.firstName} ${r.customer.lastName}`,
|
||||
vehicleName: `${r.vehicle.make} ${r.vehicle.model}`,
|
||||
startDate: r.startDate,
|
||||
endDate: r.endDate,
|
||||
status: r.status,
|
||||
totalAmount: r.totalAmount,
|
||||
})),
|
||||
sourceBreakdown: (sourceGroups as any[]).map((g) => ({
|
||||
source: g.source,
|
||||
count: g._count.id,
|
||||
revenue: g._sum.totalAmount ?? 0,
|
||||
})),
|
||||
subscription: subscription ? {
|
||||
status: subscription.status,
|
||||
planName: subscription.plan,
|
||||
trialEndsAt: subscription.trialEndAt,
|
||||
} : null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSources(companyId: string) {
|
||||
return prisma.reservation.groupBy({ by: ['source'], where: { companyId }, _count: { id: true } })
|
||||
}
|
||||
|
||||
export async function getReport(companyId: string, query: { from?: string; to?: string; format: string; period?: string }) {
|
||||
const accountingSettings = await prisma.accountingSettings.findUnique({ where: { companyId } })
|
||||
const derivedRange = getRangeFromPeriod(query.period || accountingSettings?.reportingPeriod || 'MONTHLY')
|
||||
const startDate = query.from ? new Date(query.from) : derivedRange.from
|
||||
const endDate = query.to ? new Date(query.to) : derivedRange.to
|
||||
const report = await generateFinancialReport(companyId, startDate, endDate)
|
||||
return { report, startDate, endDate, format: query.format, csv: query.format === 'CSV' ? toCsv(report.rows) : null }
|
||||
}
|
||||
Reference in New Issue
Block a user