diff --git a/src/app.ts b/src/app.ts index 43dec9e..3f4b684 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,7 @@ import morgan from 'morgan'; // Route imports import branchRoutes from './modules/branches/routes/branch.routes'; import posRoutes from './modules/pos/routes/pos.routes'; +import reportsRoutes from './modules/reports/routes/reports.routes'; // Error type interface AppError extends Error { @@ -68,6 +69,7 @@ app.get('/api', (_req: Request, res: Response) => { 'ecommerce', 'purchases', 'payment-terminals', + 'reports', 'mercadopago', 'clip', ], @@ -77,6 +79,7 @@ app.get('/api', (_req: Request, res: Response) => { // API Routes app.use('/api/branches', branchRoutes); app.use('/api/pos', posRoutes); +app.use('/api/reports', reportsRoutes); // TODO: Add remaining route modules as they are implemented // app.use('/api/cash', cashRouter); // app.use('/api/inventory', inventoryRouter); diff --git a/src/modules/reports/controllers/index.ts b/src/modules/reports/controllers/index.ts new file mode 100644 index 0000000..162f9a5 --- /dev/null +++ b/src/modules/reports/controllers/index.ts @@ -0,0 +1 @@ +export * from './reports.controller'; diff --git a/src/modules/reports/controllers/reports.controller.ts b/src/modules/reports/controllers/reports.controller.ts new file mode 100644 index 0000000..f4b6da4 --- /dev/null +++ b/src/modules/reports/controllers/reports.controller.ts @@ -0,0 +1,1030 @@ +import { Response, NextFunction } from 'express'; +import { BaseController } from '../../../shared/controllers/base.controller'; +import { AuthenticatedRequest } from '../../../shared/types'; +import { salesReportService } from '../services/sales-report.service'; +import { inventoryReportService } from '../services/inventory-report.service'; +import { customerReportService } from '../services/customer-report.service'; +import { financialReportService } from '../services/financial-report.service'; +import { dashboardService } from '../services/dashboard.service'; +import { reportSchedulerService } from '../services/report-scheduler.service'; +import { ReportFormat, ReportPeriod } from '../entities/report-config.entity'; + +class ReportsController extends BaseController { + // ==================== DASHBOARD ENDPOINTS ==================== + + /** + * GET /reports/dashboard/kpis - Get dashboard KPIs + */ + async getDashboardKPIs(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const branchId = this.getBranchId(req); + + const result = await dashboardService.getDashboardKPIs(tenantId, branchId); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/dashboard - Get complete dashboard + */ + async getCompleteDashboard(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + + const result = await dashboardService.getCompleteDashboard(tenantId, userId); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/dashboard/top-products - Get top selling products + */ + async getTopProducts(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const branchId = this.getBranchId(req); + const limit = parseInt(req.query.limit as string) || 10; + + const result = await dashboardService.getTopSellingProducts(tenantId, branchId, limit); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/dashboard/hourly-sales - Get hourly sales + */ + async getHourlySales(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const branchId = this.getBranchId(req); + + const result = await dashboardService.getHourlySales(tenantId, branchId); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/dashboard/branch-performance - Get branch performance + */ + async getBranchPerformance(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const period = req.query.period as 'today' | 'week' | 'month' || 'today'; + + const result = await dashboardService.getBranchPerformance(tenantId, period); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + // ==================== SALES REPORTS ==================== + + /** + * GET /reports/sales/summary - Get sales summary + */ + async getSalesSummary(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseReportFilters(req); + + const result = await salesReportService.getSalesSummary(tenantId, filters); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/sales/by-period - Get sales by period + */ + async getSalesByPeriod(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseReportFilters(req); + const groupBy = req.query.groupBy as 'day' | 'week' | 'month' || 'day'; + + const result = await salesReportService.getSalesByPeriod(tenantId, filters, groupBy); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/sales/by-product - Get sales by product + */ + async getSalesByProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseReportFilters(req); + const limit = parseInt(req.query.limit as string) || 50; + + const result = await salesReportService.getSalesByProduct(tenantId, filters, limit); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/sales/by-branch - Get sales by branch + */ + async getSalesByBranch(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseReportFilters(req); + + const result = await salesReportService.getSalesByBranch(tenantId, filters); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/sales/by-cashier - Get sales by cashier + */ + async getSalesByCashier(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseReportFilters(req); + + const result = await salesReportService.getSalesByCashier(tenantId, filters); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/sales/by-payment-method - Get sales by payment method + */ + async getSalesByPaymentMethod(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseReportFilters(req); + + const result = await salesReportService.getSalesByPaymentMethod(tenantId, filters); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/sales/by-hour - Get sales by hour + */ + async getSalesByHour(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseReportFilters(req); + + const result = await salesReportService.getSalesByHour(tenantId, filters); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/sales/comparison - Get sales comparison + */ + async getSalesComparison(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const currentFilters = this.parseReportFilters(req); + + // Calculate previous period + const daysDiff = Math.ceil((currentFilters.endDate.getTime() - currentFilters.startDate.getTime()) / (1000 * 60 * 60 * 24)); + const previousStartDate = new Date(currentFilters.startDate); + previousStartDate.setDate(previousStartDate.getDate() - daysDiff); + const previousEndDate = new Date(currentFilters.startDate); + previousEndDate.setDate(previousEndDate.getDate() - 1); + + const previousFilters = { + ...currentFilters, + startDate: previousStartDate, + endDate: previousEndDate, + }; + + const result = await salesReportService.getComparison(tenantId, currentFilters, previousFilters); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + // ==================== INVENTORY REPORTS ==================== + + /** + * GET /reports/inventory/summary - Get inventory summary + */ + async getInventorySummary(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseInventoryFilters(req); + + const result = await inventoryReportService.getStockLevelSummary(tenantId, filters); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/inventory/by-product - Get stock by product + */ + async getStockByProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseInventoryFilters(req); + const pagination = this.parsePagination(req.query); + + const result = await inventoryReportService.getStockByProduct(tenantId, filters, { + limit: pagination.limit, + offset: (pagination.page - 1) * pagination.limit, + sortBy: pagination.sortBy, + sortOrder: pagination.sortOrder, + }); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.paginated(res, { + data: result.data.data, + pagination: { + page: pagination.page, + limit: pagination.limit, + total: result.data.total, + totalPages: Math.ceil(result.data.total / pagination.limit), + hasNext: pagination.page * pagination.limit < result.data.total, + hasPrev: pagination.page > 1, + }, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/inventory/by-branch - Get stock by branch + */ + async getStockByBranch(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseInventoryFilters(req); + + const result = await inventoryReportService.getStockByBranch(tenantId, filters); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/inventory/by-category - Get stock by category + */ + async getStockByCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseInventoryFilters(req); + + const result = await inventoryReportService.getStockByCategory(tenantId, filters); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/inventory/movements - Get stock movements + */ + async getStockMovements(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseInventoryFilters(req); + const pagination = this.parsePagination(req.query); + + const result = await inventoryReportService.getStockMovements(tenantId, filters, { + limit: pagination.limit, + offset: (pagination.page - 1) * pagination.limit, + }); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.paginated(res, { + data: result.data.data, + pagination: { + page: pagination.page, + limit: pagination.limit, + total: result.data.total, + totalPages: Math.ceil(result.data.total / pagination.limit), + hasNext: pagination.page * pagination.limit < result.data.total, + hasPrev: pagination.page > 1, + }, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/inventory/low-stock - Get low stock alerts + */ + async getLowStockAlerts(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const branchId = this.getBranchId(req); + + const result = await inventoryReportService.getLowStockAlerts(tenantId, branchId); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/inventory/valuation - Get stock valuation + */ + async getStockValuation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseInventoryFilters(req); + + const result = await inventoryReportService.getStockValuation(tenantId, filters); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + // ==================== CUSTOMER REPORTS ==================== + + /** + * GET /reports/customers/summary - Get customer summary + */ + async getCustomerSummary(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseReportFilters(req); + + const result = await customerReportService.getCustomerSummary(tenantId, filters); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/customers/top - Get top customers + */ + async getTopCustomers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseReportFilters(req); + const limit = parseInt(req.query.limit as string) || 50; + + const result = await customerReportService.getTopCustomers(tenantId, filters, limit); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/customers/:customerId/history - Get customer purchase history + */ + async getCustomerHistory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { customerId } = req.params; + const filters = this.parseReportFilters(req); + + const result = await customerReportService.getCustomerPurchaseHistory( + tenantId, + customerId, + { startDate: filters.startDate, endDate: filters.endDate } + ); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/customers/segmentation - Get customer segmentation + */ + async getCustomerSegmentation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseReportFilters(req); + + const result = await customerReportService.getCustomerSegmentation(tenantId, filters); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/customers/loyalty - Get loyalty summary + */ + async getLoyaltySummary(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseReportFilters(req); + + const result = await customerReportService.getLoyaltySummary(tenantId, filters); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/customers/rfm - Get RFM analysis + */ + async getRFMAnalysis(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseReportFilters(req); + const limit = parseInt(req.query.limit as string) || 100; + + const result = await customerReportService.getRFMAnalysis(tenantId, filters, limit); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + // ==================== FINANCIAL REPORTS ==================== + + /** + * GET /reports/financial/revenue - Get revenue summary + */ + async getRevenueSummary(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseReportFilters(req); + + const result = await financialReportService.getRevenueSummary(tenantId, filters); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/financial/revenue-by-period - Get revenue by period + */ + async getRevenueByPeriod(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseReportFilters(req); + const groupBy = req.query.groupBy as 'day' | 'week' | 'month' || 'day'; + + const result = await financialReportService.getRevenueByPeriod(tenantId, filters, groupBy); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/financial/revenue-by-branch - Get revenue by branch + */ + async getRevenueByBranch(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseReportFilters(req); + + const result = await financialReportService.getRevenueByBranch(tenantId, filters); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/financial/margins - Get margin analysis + */ + async getMarginAnalysis(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseReportFilters(req); + const limit = parseInt(req.query.limit as string) || 50; + + const result = await financialReportService.getMarginAnalysis(tenantId, filters, limit); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/financial/taxes - Get tax summary + */ + async getTaxSummary(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const filters = this.parseReportFilters(req); + + const result = await financialReportService.getTaxSummary(tenantId, filters); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/financial/cash-flow - Get cash flow summary + */ + async getCashFlowSummary(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const branchId = this.getBranchId(req); + const filters = this.parseReportFilters(req); + + const result = await financialReportService.getCashFlowSummary(tenantId, filters, branchId); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/financial/reconciliation - Get daily reconciliation + */ + async getDailyReconciliation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const branchId = this.getBranchId(req); + const dateParam = req.query.date as string; + const date = dateParam ? new Date(dateParam) : new Date(); + + const result = await financialReportService.getDailySalesReconciliation(tenantId, date, branchId); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + // ==================== SCHEDULED REPORTS ==================== + + /** + * GET /reports/schedules - Get scheduled reports + */ + async getScheduledReports(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const status = req.query.status as string; + + const result = await reportSchedulerService.getScheduledReports(tenantId, { status: status as any }); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * POST /reports/schedules - Create scheduled report + */ + async createScheduledReport(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + + const result = await reportSchedulerService.createScheduledReport(tenantId, userId, req.body); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data, 201); + } catch (error) { + next(error); + } + } + + /** + * PUT /reports/schedules/:id - Update scheduled report + */ + async updateScheduledReport(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const { id } = req.params; + + const result = await reportSchedulerService.updateScheduledReport(tenantId, id, userId, req.body); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * DELETE /reports/schedules/:id - Delete scheduled report + */ + async deleteScheduledReport(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const result = await reportSchedulerService.deleteScheduledReport(tenantId, id); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, { message: 'Scheduled report deleted' }); + } catch (error) { + next(error); + } + } + + /** + * POST /reports/schedules/:id/pause - Pause scheduled report + */ + async pauseScheduledReport(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const { id } = req.params; + + const result = await reportSchedulerService.pauseScheduledReport(tenantId, id, userId); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * POST /reports/schedules/:id/resume - Resume scheduled report + */ + async resumeScheduledReport(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const { id } = req.params; + + const result = await reportSchedulerService.resumeScheduledReport(tenantId, id, userId); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * POST /reports/schedules/:id/run - Run scheduled report now + */ + async runScheduledReportNow(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const result = await reportSchedulerService.runReportNow(tenantId, id); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + // ==================== HELPER METHODS ==================== + + /** + * Parse report filters from query parameters + */ + private parseReportFilters(req: AuthenticatedRequest): { + startDate: Date; + endDate: Date; + branchIds?: string[]; + categoryIds?: string[]; + productIds?: string[]; + cashierIds?: string[]; + } { + const { startDate, endDate, period, branchIds, categoryIds, productIds, cashierIds } = req.query; + const branchId = this.getBranchId(req); + + let start: Date; + let end: Date; + + if (startDate && endDate) { + start = new Date(startDate as string); + end = new Date(endDate as string); + } else { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + switch (period as ReportPeriod) { + case ReportPeriod.TODAY: + start = today; + end = new Date(); + break; + case ReportPeriod.YESTERDAY: + start = new Date(today); + start.setDate(start.getDate() - 1); + end = today; + break; + case ReportPeriod.THIS_WEEK: + start = new Date(today); + start.setDate(start.getDate() - start.getDay()); + end = new Date(); + break; + case ReportPeriod.LAST_WEEK: + start = new Date(today); + start.setDate(start.getDate() - start.getDay() - 7); + end = new Date(start); + end.setDate(end.getDate() + 6); + break; + case ReportPeriod.THIS_MONTH: + start = new Date(today.getFullYear(), today.getMonth(), 1); + end = new Date(); + break; + case ReportPeriod.LAST_MONTH: + start = new Date(today.getFullYear(), today.getMonth() - 1, 1); + end = new Date(today.getFullYear(), today.getMonth(), 0); + break; + case ReportPeriod.THIS_QUARTER: + const quarterMonth = Math.floor(today.getMonth() / 3) * 3; + start = new Date(today.getFullYear(), quarterMonth, 1); + end = new Date(); + break; + case ReportPeriod.THIS_YEAR: + start = new Date(today.getFullYear(), 0, 1); + end = new Date(); + break; + default: + // Default to last 30 days + start = new Date(today); + start.setDate(start.getDate() - 30); + end = new Date(); + } + } + + const filters: any = { + startDate: start, + endDate: end, + }; + + if (branchId) { + filters.branchIds = [branchId]; + } else if (branchIds) { + filters.branchIds = (branchIds as string).split(','); + } + + if (categoryIds) { + filters.categoryIds = (categoryIds as string).split(','); + } + + if (productIds) { + filters.productIds = (productIds as string).split(','); + } + + if (cashierIds) { + filters.cashierIds = (cashierIds as string).split(','); + } + + return filters; + } + + /** + * Parse inventory filters + */ + private parseInventoryFilters(req: AuthenticatedRequest): { + branchIds?: string[]; + warehouseIds?: string[]; + categoryIds?: string[]; + productIds?: string[]; + startDate?: Date; + endDate?: Date; + } { + const { branchIds, warehouseIds, categoryIds, productIds, startDate, endDate } = req.query; + const branchId = this.getBranchId(req); + + const filters: any = {}; + + if (branchId) { + filters.branchIds = [branchId]; + } else if (branchIds) { + filters.branchIds = (branchIds as string).split(','); + } + + if (warehouseIds) { + filters.warehouseIds = (warehouseIds as string).split(','); + } + + if (categoryIds) { + filters.categoryIds = (categoryIds as string).split(','); + } + + if (productIds) { + filters.productIds = (productIds as string).split(','); + } + + if (startDate) { + filters.startDate = new Date(startDate as string); + } + + if (endDate) { + filters.endDate = new Date(endDate as string); + } + + return filters; + } +} + +export const reportsController = new ReportsController(); diff --git a/src/modules/reports/dto/index.ts b/src/modules/reports/dto/index.ts new file mode 100644 index 0000000..c31b645 --- /dev/null +++ b/src/modules/reports/dto/index.ts @@ -0,0 +1,50 @@ +// Export DTOs for reports module +// Report filters and request types are defined inline in services +// This file can be expanded with validation schemas if needed + +export interface ReportDateRangeDTO { + startDate: string; + endDate: string; +} + +export interface ReportFilterDTO { + startDate?: string; + endDate?: string; + period?: 'today' | 'yesterday' | 'this_week' | 'last_week' | 'this_month' | 'last_month' | 'this_quarter' | 'last_quarter' | 'this_year' | 'last_year' | 'custom'; + branchIds?: string[]; + categoryIds?: string[]; + productIds?: string[]; + cashierIds?: string[]; +} + +export interface CreateReportConfigDTO { + name: string; + description?: string; + type: 'sales' | 'inventory' | 'customer' | 'financial' | 'dashboard' | 'custom'; + defaultFormat?: 'pdf' | 'excel' | 'csv' | 'json'; + defaultPeriod?: string; + filters?: Record; + columns?: { field: string; label: string; width?: number; format?: string }[]; + groupBy?: string[]; + sortBy?: { field: string; direction: 'asc' | 'desc' }[]; + chartConfig?: Record; + isPublic?: boolean; +} + +// Note: CreateScheduledReportDTO is exported from services/report-scheduler.service.ts +// Use that one for creating scheduled reports via the API + +export interface CreateDashboardWidgetDTO { + title: string; + description?: string; + type: 'kpi' | 'chart_bar' | 'chart_line' | 'chart_pie' | 'chart_area' | 'table' | 'list' | 'map'; + size?: 'small' | 'medium' | 'large' | 'wide' | 'full'; + positionX?: number; + positionY?: number; + dataSource: string; + queryParams?: Record; + displayConfig?: Record; + chartConfig?: Record; + thresholds?: Record; + refreshInterval?: 'none' | '1m' | '5m' | '15m' | '30m' | '1h'; +} diff --git a/src/modules/reports/entities/dashboard-widget.entity.ts b/src/modules/reports/entities/dashboard-widget.entity.ts new file mode 100644 index 0000000..dc32744 --- /dev/null +++ b/src/modules/reports/entities/dashboard-widget.entity.ts @@ -0,0 +1,154 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum WidgetType { + KPI = 'kpi', + CHART_BAR = 'chart_bar', + CHART_LINE = 'chart_line', + CHART_PIE = 'chart_pie', + CHART_AREA = 'chart_area', + TABLE = 'table', + LIST = 'list', + MAP = 'map', +} + +export enum WidgetSize { + SMALL = 'small', // 1x1 + MEDIUM = 'medium', // 2x1 + LARGE = 'large', // 2x2 + WIDE = 'wide', // 4x1 + FULL = 'full', // 4x2 +} + +export enum RefreshInterval { + NONE = 'none', + ONE_MINUTE = '1m', + FIVE_MINUTES = '5m', + FIFTEEN_MINUTES = '15m', + THIRTY_MINUTES = '30m', + ONE_HOUR = '1h', +} + +@Entity('dashboard_widgets', { schema: 'retail' }) +@Index(['tenantId', 'userId']) +@Index(['tenantId', 'dashboardId']) +export class DashboardWidget { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Column({ name: 'dashboard_id', type: 'uuid', nullable: true }) + dashboardId: string; + + @Column({ length: 100 }) + title: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: WidgetType, + }) + type: WidgetType; + + @Column({ + type: 'enum', + enum: WidgetSize, + default: WidgetSize.MEDIUM, + }) + size: WidgetSize; + + // Position in dashboard grid + @Column({ name: 'position_x', type: 'int', default: 0 }) + positionX: number; + + @Column({ name: 'position_y', type: 'int', default: 0 }) + positionY: number; + + // Data source configuration + @Column({ name: 'data_source', length: 100 }) + dataSource: string; // e.g., 'sales.today', 'inventory.lowStock', 'customers.top' + + // Query parameters for the data source + @Column({ name: 'query_params', type: 'jsonb', nullable: true }) + queryParams: { + branchId?: string; + period?: string; + limit?: number; + groupBy?: string; + [key: string]: any; + }; + + // Display configuration + @Column({ name: 'display_config', type: 'jsonb', nullable: true }) + displayConfig: { + color?: string; + backgroundColor?: string; + icon?: string; + prefix?: string; + suffix?: string; + decimals?: number; + showTrend?: boolean; + showComparison?: boolean; + comparisonPeriod?: string; + }; + + // Chart specific configuration + @Column({ name: 'chart_config', type: 'jsonb', nullable: true }) + chartConfig: { + xAxis?: { field: string; label?: string }; + yAxis?: { field: string; label?: string }; + series?: { field: string; label?: string; color?: string }[]; + showLegend?: boolean; + showGrid?: boolean; + stacked?: boolean; + }; + + // Threshold alerts + @Column({ type: 'jsonb', nullable: true }) + thresholds: { + warning?: number; + critical?: number; + warningColor?: string; + criticalColor?: string; + }; + + @Column({ + name: 'refresh_interval', + type: 'enum', + enum: RefreshInterval, + default: RefreshInterval.FIVE_MINUTES, + }) + refreshInterval: RefreshInterval; + + @Column({ name: 'is_visible', type: 'boolean', default: true }) + isVisible: boolean; + + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder: number; + + @Column({ name: 'created_by', type: 'uuid' }) + createdBy: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; +} diff --git a/src/modules/reports/entities/index.ts b/src/modules/reports/entities/index.ts new file mode 100644 index 0000000..c3fdae7 --- /dev/null +++ b/src/modules/reports/entities/index.ts @@ -0,0 +1,3 @@ +export * from './report-config.entity'; +export * from './dashboard-widget.entity'; +export * from './scheduled-report.entity'; diff --git a/src/modules/reports/entities/report-config.entity.ts b/src/modules/reports/entities/report-config.entity.ts new file mode 100644 index 0000000..f47d822 --- /dev/null +++ b/src/modules/reports/entities/report-config.entity.ts @@ -0,0 +1,138 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum ReportType { + SALES = 'sales', + INVENTORY = 'inventory', + CUSTOMER = 'customer', + FINANCIAL = 'financial', + DASHBOARD = 'dashboard', + CUSTOM = 'custom', +} + +export enum ReportFormat { + PDF = 'pdf', + EXCEL = 'excel', + CSV = 'csv', + JSON = 'json', +} + +export enum ReportPeriod { + TODAY = 'today', + YESTERDAY = 'yesterday', + THIS_WEEK = 'this_week', + LAST_WEEK = 'last_week', + THIS_MONTH = 'this_month', + LAST_MONTH = 'last_month', + THIS_QUARTER = 'this_quarter', + LAST_QUARTER = 'last_quarter', + THIS_YEAR = 'this_year', + LAST_YEAR = 'last_year', + CUSTOM = 'custom', +} + +@Entity('report_configs', { schema: 'retail' }) +@Index(['tenantId', 'type']) +@Index(['tenantId', 'createdBy']) +export class ReportConfig { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: ReportType, + }) + type: ReportType; + + @Column({ name: 'default_format', type: 'enum', enum: ReportFormat, default: ReportFormat.PDF }) + defaultFormat: ReportFormat; + + @Column({ name: 'default_period', type: 'enum', enum: ReportPeriod, default: ReportPeriod.THIS_MONTH }) + defaultPeriod: ReportPeriod; + + // Filter configuration stored as JSON + @Column({ type: 'jsonb', nullable: true }) + filters: { + branchIds?: string[]; + categoryIds?: string[]; + productIds?: string[]; + customerIds?: string[]; + cashierIds?: string[]; + paymentMethods?: string[]; + [key: string]: any; + }; + + // Columns/fields to include in report + @Column({ type: 'jsonb', nullable: true }) + columns: { + field: string; + label: string; + width?: number; + format?: string; + sortOrder?: number; + }[]; + + // Grouping configuration + @Column({ name: 'group_by', type: 'jsonb', nullable: true }) + groupBy: string[]; + + // Sorting configuration + @Column({ name: 'sort_by', type: 'jsonb', nullable: true }) + sortBy: { + field: string; + direction: 'asc' | 'desc'; + }[]; + + // Chart configuration (for dashboard reports) + @Column({ name: 'chart_config', type: 'jsonb', nullable: true }) + chartConfig: { + type: 'bar' | 'line' | 'pie' | 'area' | 'table'; + title?: string; + xAxis?: string; + yAxis?: string; + series?: string[]; + }; + + @Column({ name: 'is_public', type: 'boolean', default: false }) + isPublic: boolean; + + @Column({ name: 'is_favorite', type: 'boolean', default: false }) + isFavorite: boolean; + + @Column({ name: 'created_by', type: 'uuid' }) + createdBy: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column({ name: 'last_run_at', type: 'timestamp with time zone', nullable: true }) + lastRunAt: Date; + + @Column({ name: 'run_count', type: 'int', default: 0 }) + runCount: number; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; +} diff --git a/src/modules/reports/entities/scheduled-report.entity.ts b/src/modules/reports/entities/scheduled-report.entity.ts new file mode 100644 index 0000000..30d99d0 --- /dev/null +++ b/src/modules/reports/entities/scheduled-report.entity.ts @@ -0,0 +1,185 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { ReportConfig, ReportFormat } from './report-config.entity'; + +export enum ScheduleFrequency { + DAILY = 'daily', + WEEKLY = 'weekly', + MONTHLY = 'monthly', + QUARTERLY = 'quarterly', +} + +export enum ScheduleStatus { + ACTIVE = 'active', + PAUSED = 'paused', + DISABLED = 'disabled', +} + +export enum DeliveryMethod { + EMAIL = 'email', + SFTP = 'sftp', + WEBHOOK = 'webhook', + STORAGE = 'storage', +} + +@Entity('scheduled_reports', { schema: 'retail' }) +@Index(['tenantId', 'status']) +@Index(['tenantId', 'nextRunAt']) +@Index(['tenantId', 'reportConfigId']) +export class ScheduledReport { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'report_config_id', type: 'uuid' }) + reportConfigId: string; + + @Column({ length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: ScheduleFrequency, + }) + frequency: ScheduleFrequency; + + // Cron expression for complex schedules + @Column({ name: 'cron_expression', length: 100, nullable: true }) + cronExpression: string; + + // Schedule timing details + @Column({ name: 'run_hour', type: 'int', default: 8 }) + runHour: number; // 0-23 + + @Column({ name: 'run_minute', type: 'int', default: 0 }) + runMinute: number; // 0-59 + + @Column({ name: 'run_day_of_week', type: 'int', nullable: true }) + runDayOfWeek: number; // 0-6 (Sunday-Saturday) for weekly + + @Column({ name: 'run_day_of_month', type: 'int', nullable: true }) + runDayOfMonth: number; // 1-31 for monthly + + @Column({ length: 50, default: 'America/Mexico_City' }) + timezone: string; + + @Column({ + type: 'enum', + enum: ScheduleStatus, + default: ScheduleStatus.ACTIVE, + }) + status: ScheduleStatus; + + @Column({ name: 'output_format', type: 'enum', enum: ReportFormat, default: ReportFormat.PDF }) + outputFormat: ReportFormat; + + // Delivery configuration + @Column({ name: 'delivery_method', type: 'enum', enum: DeliveryMethod, default: DeliveryMethod.EMAIL }) + deliveryMethod: DeliveryMethod; + + @Column({ name: 'delivery_config', type: 'jsonb' }) + deliveryConfig: { + // For email + recipients?: string[]; + ccRecipients?: string[]; + subject?: string; + bodyTemplate?: string; + // For SFTP + sftpHost?: string; + sftpPort?: number; + sftpPath?: string; + sftpUsername?: string; + sftpKeyId?: string; // Reference to secure key storage + // For webhook + webhookUrl?: string; + webhookHeaders?: Record; + // For storage + storagePath?: string; + }; + + // Report parameters to override defaults + @Column({ name: 'report_params', type: 'jsonb', nullable: true }) + reportParams: { + branchIds?: string[]; + period?: string; + customStartDate?: string; + customEndDate?: string; + [key: string]: any; + }; + + // Execution tracking + @Column({ name: 'last_run_at', type: 'timestamp with time zone', nullable: true }) + lastRunAt: Date; + + @Column({ name: 'last_run_status', length: 20, nullable: true }) + lastRunStatus: 'success' | 'failed' | 'skipped'; + + @Column({ name: 'last_run_error', type: 'text', nullable: true }) + lastRunError: string; + + @Column({ name: 'last_run_duration_ms', type: 'int', nullable: true }) + lastRunDurationMs: number; + + @Column({ name: 'next_run_at', type: 'timestamp with time zone', nullable: true }) + nextRunAt: Date; + + @Column({ name: 'run_count', type: 'int', default: 0 }) + runCount: number; + + @Column({ name: 'success_count', type: 'int', default: 0 }) + successCount: number; + + @Column({ name: 'failure_count', type: 'int', default: 0 }) + failureCount: number; + + // Retry configuration + @Column({ name: 'max_retries', type: 'int', default: 3 }) + maxRetries: number; + + @Column({ name: 'retry_delay_minutes', type: 'int', default: 15 }) + retryDelayMinutes: number; + + @Column({ name: 'current_retry_count', type: 'int', default: 0 }) + currentRetryCount: number; + + // Validity period + @Column({ name: 'start_date', type: 'date', nullable: true }) + startDate: Date; + + @Column({ name: 'end_date', type: 'date', nullable: true }) + endDate: Date; + + @Column({ name: 'created_by', type: 'uuid' }) + createdBy: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + // Relations + @ManyToOne(() => ReportConfig) + @JoinColumn({ name: 'report_config_id' }) + reportConfig: ReportConfig; +} diff --git a/src/modules/reports/index.ts b/src/modules/reports/index.ts new file mode 100644 index 0000000..6fdac9b --- /dev/null +++ b/src/modules/reports/index.ts @@ -0,0 +1,5 @@ +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; +export { default as reportsRoutes } from './routes/reports.routes'; diff --git a/src/modules/reports/reports.module.ts b/src/modules/reports/reports.module.ts new file mode 100644 index 0000000..cdd626d --- /dev/null +++ b/src/modules/reports/reports.module.ts @@ -0,0 +1,88 @@ +/** + * Reports Module (RT-008) + * + * Provides comprehensive reporting and analytics for ERP Retail: + * + * Features: + * - Multi-tenant and multi-branch support + * - Sales reports: by period, product, category, branch, cashier + * - Inventory reports: stock levels, movements, valuation + * - Customer reports: top customers, loyalty, purchase history, RFM analysis + * - Financial reports: revenue, margins, taxes, cash flow + * - Dashboard KPIs: today's sales, tickets, avg ticket, top products + * - Report scheduling with email delivery + * - Export formats: PDF, Excel, CSV, JSON + * + * Entities: + * - ReportConfig: Saved report configurations + * - DashboardWidget: Dashboard widget configurations + * - ScheduledReport: Scheduled report configurations and execution tracking + * + * Services: + * - SalesReportService: Sales analytics and reporting + * - InventoryReportService: Inventory analytics + * - CustomerReportService: Customer analytics and RFM + * - FinancialReportService: Revenue, margins, taxes + * - DashboardService: KPI aggregation and widgets + * - ReportSchedulerService: Scheduled report execution + * + * API Endpoints: + * + * Dashboard: + * - GET /api/reports/dashboard/kpis + * - GET /api/reports/dashboard + * - GET /api/reports/dashboard/top-products + * - GET /api/reports/dashboard/hourly-sales + * - GET /api/reports/dashboard/branch-performance + * + * Sales: + * - GET /api/reports/sales/summary + * - GET /api/reports/sales/by-period + * - GET /api/reports/sales/by-product + * - GET /api/reports/sales/by-branch + * - GET /api/reports/sales/by-cashier + * - GET /api/reports/sales/by-payment-method + * - GET /api/reports/sales/by-hour + * - GET /api/reports/sales/comparison + * + * Inventory: + * - GET /api/reports/inventory/summary + * - GET /api/reports/inventory/by-product + * - GET /api/reports/inventory/by-branch + * - GET /api/reports/inventory/by-category + * - GET /api/reports/inventory/movements + * - GET /api/reports/inventory/low-stock + * - GET /api/reports/inventory/valuation + * + * Customers: + * - GET /api/reports/customers/summary + * - GET /api/reports/customers/top + * - GET /api/reports/customers/segmentation + * - GET /api/reports/customers/loyalty + * - GET /api/reports/customers/rfm + * - GET /api/reports/customers/:customerId/history + * + * Financial: + * - GET /api/reports/financial/revenue + * - GET /api/reports/financial/revenue-by-period + * - GET /api/reports/financial/revenue-by-branch + * - GET /api/reports/financial/margins + * - GET /api/reports/financial/taxes + * - GET /api/reports/financial/cash-flow + * - GET /api/reports/financial/reconciliation + * + * Scheduled Reports: + * - GET /api/reports/schedules + * - POST /api/reports/schedules + * - PUT /api/reports/schedules/:id + * - DELETE /api/reports/schedules/:id + * - POST /api/reports/schedules/:id/pause + * - POST /api/reports/schedules/:id/resume + * - POST /api/reports/schedules/:id/run + */ + +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; +export { default as reportsRoutes } from './routes/reports.routes'; diff --git a/src/modules/reports/routes/reports.routes.ts b/src/modules/reports/routes/reports.routes.ts new file mode 100644 index 0000000..44e7134 --- /dev/null +++ b/src/modules/reports/routes/reports.routes.ts @@ -0,0 +1,241 @@ +import { Router } from 'express'; +import { reportsController } from '../controllers/reports.controller'; +import { authMiddleware, requireRoles } from '../../../shared/middleware/auth.middleware'; +import { tenantMiddleware } from '../../../shared/middleware/tenant.middleware'; +import { branchMiddleware } from '../../../shared/middleware/branch.middleware'; +import { AuthenticatedRequest } from '../../../shared/types'; + +const router = Router(); + +// All routes require tenant and authentication +router.use(tenantMiddleware); +router.use(authMiddleware); + +// Optional branch context for filtering +router.use(branchMiddleware); + +// ==================== DASHBOARD ROUTES ==================== + +// Get dashboard KPIs +router.get('/dashboard/kpis', (req, res, next) => + reportsController.getDashboardKPIs(req as AuthenticatedRequest, res, next) +); + +// Get complete dashboard with widgets +router.get('/dashboard', (req, res, next) => + reportsController.getCompleteDashboard(req as AuthenticatedRequest, res, next) +); + +// Get top selling products +router.get('/dashboard/top-products', (req, res, next) => + reportsController.getTopProducts(req as AuthenticatedRequest, res, next) +); + +// Get hourly sales +router.get('/dashboard/hourly-sales', (req, res, next) => + reportsController.getHourlySales(req as AuthenticatedRequest, res, next) +); + +// Get branch performance +router.get('/dashboard/branch-performance', (req, res, next) => + reportsController.getBranchPerformance(req as AuthenticatedRequest, res, next) +); + +// ==================== SALES REPORTS ==================== + +// Get sales summary +router.get('/sales/summary', (req, res, next) => + reportsController.getSalesSummary(req as AuthenticatedRequest, res, next) +); + +// Get sales by period +router.get('/sales/by-period', (req, res, next) => + reportsController.getSalesByPeriod(req as AuthenticatedRequest, res, next) +); + +// Get sales by product +router.get('/sales/by-product', (req, res, next) => + reportsController.getSalesByProduct(req as AuthenticatedRequest, res, next) +); + +// Get sales by branch +router.get('/sales/by-branch', (req, res, next) => + reportsController.getSalesByBranch(req as AuthenticatedRequest, res, next) +); + +// Get sales by cashier +router.get('/sales/by-cashier', (req, res, next) => + reportsController.getSalesByCashier(req as AuthenticatedRequest, res, next) +); + +// Get sales by payment method +router.get('/sales/by-payment-method', (req, res, next) => + reportsController.getSalesByPaymentMethod(req as AuthenticatedRequest, res, next) +); + +// Get sales by hour +router.get('/sales/by-hour', (req, res, next) => + reportsController.getSalesByHour(req as AuthenticatedRequest, res, next) +); + +// Get sales comparison +router.get('/sales/comparison', (req, res, next) => + reportsController.getSalesComparison(req as AuthenticatedRequest, res, next) +); + +// ==================== INVENTORY REPORTS ==================== + +// Get inventory summary +router.get('/inventory/summary', (req, res, next) => + reportsController.getInventorySummary(req as AuthenticatedRequest, res, next) +); + +// Get stock by product +router.get('/inventory/by-product', (req, res, next) => + reportsController.getStockByProduct(req as AuthenticatedRequest, res, next) +); + +// Get stock by branch +router.get('/inventory/by-branch', (req, res, next) => + reportsController.getStockByBranch(req as AuthenticatedRequest, res, next) +); + +// Get stock by category +router.get('/inventory/by-category', (req, res, next) => + reportsController.getStockByCategory(req as AuthenticatedRequest, res, next) +); + +// Get stock movements +router.get('/inventory/movements', (req, res, next) => + reportsController.getStockMovements(req as AuthenticatedRequest, res, next) +); + +// Get low stock alerts +router.get('/inventory/low-stock', (req, res, next) => + reportsController.getLowStockAlerts(req as AuthenticatedRequest, res, next) +); + +// Get stock valuation +router.get('/inventory/valuation', (req, res, next) => + reportsController.getStockValuation(req as AuthenticatedRequest, res, next) +); + +// ==================== CUSTOMER REPORTS ==================== + +// Get customer summary +router.get('/customers/summary', (req, res, next) => + reportsController.getCustomerSummary(req as AuthenticatedRequest, res, next) +); + +// Get top customers +router.get('/customers/top', (req, res, next) => + reportsController.getTopCustomers(req as AuthenticatedRequest, res, next) +); + +// Get customer segmentation +router.get('/customers/segmentation', (req, res, next) => + reportsController.getCustomerSegmentation(req as AuthenticatedRequest, res, next) +); + +// Get loyalty summary +router.get('/customers/loyalty', (req, res, next) => + reportsController.getLoyaltySummary(req as AuthenticatedRequest, res, next) +); + +// Get RFM analysis +router.get('/customers/rfm', (req, res, next) => + reportsController.getRFMAnalysis(req as AuthenticatedRequest, res, next) +); + +// Get customer purchase history +router.get('/customers/:customerId/history', (req, res, next) => + reportsController.getCustomerHistory(req as unknown as AuthenticatedRequest, res, next) +); + +// ==================== FINANCIAL REPORTS ==================== + +// Get revenue summary +router.get('/financial/revenue', (req, res, next) => + reportsController.getRevenueSummary(req as AuthenticatedRequest, res, next) +); + +// Get revenue by period +router.get('/financial/revenue-by-period', (req, res, next) => + reportsController.getRevenueByPeriod(req as AuthenticatedRequest, res, next) +); + +// Get revenue by branch +router.get('/financial/revenue-by-branch', (req, res, next) => + reportsController.getRevenueByBranch(req as AuthenticatedRequest, res, next) +); + +// Get margin analysis +router.get('/financial/margins', (req, res, next) => + reportsController.getMarginAnalysis(req as AuthenticatedRequest, res, next) +); + +// Get tax summary +router.get('/financial/taxes', (req, res, next) => + reportsController.getTaxSummary(req as AuthenticatedRequest, res, next) +); + +// Get cash flow summary +router.get('/financial/cash-flow', (req, res, next) => + reportsController.getCashFlowSummary(req as AuthenticatedRequest, res, next) +); + +// Get daily reconciliation +router.get('/financial/reconciliation', (req, res, next) => + reportsController.getDailyReconciliation(req as AuthenticatedRequest, res, next) +); + +// ==================== SCHEDULED REPORTS ==================== + +// Get scheduled reports +router.get('/schedules', (req, res, next) => + reportsController.getScheduledReports(req as AuthenticatedRequest, res, next) +); + +// Create scheduled report (requires manager role) +router.post( + '/schedules', + requireRoles('admin', 'manager'), + (req, res, next) => reportsController.createScheduledReport(req as AuthenticatedRequest, res, next) +); + +// Update scheduled report +router.put( + '/schedules/:id', + requireRoles('admin', 'manager'), + (req, res, next) => reportsController.updateScheduledReport(req as AuthenticatedRequest, res, next) +); + +// Delete scheduled report +router.delete( + '/schedules/:id', + requireRoles('admin', 'manager'), + (req, res, next) => reportsController.deleteScheduledReport(req as AuthenticatedRequest, res, next) +); + +// Pause scheduled report +router.post( + '/schedules/:id/pause', + requireRoles('admin', 'manager'), + (req, res, next) => reportsController.pauseScheduledReport(req as AuthenticatedRequest, res, next) +); + +// Resume scheduled report +router.post( + '/schedules/:id/resume', + requireRoles('admin', 'manager'), + (req, res, next) => reportsController.resumeScheduledReport(req as AuthenticatedRequest, res, next) +); + +// Run scheduled report now +router.post( + '/schedules/:id/run', + requireRoles('admin', 'manager'), + (req, res, next) => reportsController.runScheduledReportNow(req as AuthenticatedRequest, res, next) +); + +export default router; diff --git a/src/modules/reports/services/customer-report.service.ts b/src/modules/reports/services/customer-report.service.ts new file mode 100644 index 0000000..4e67916 --- /dev/null +++ b/src/modules/reports/services/customer-report.service.ts @@ -0,0 +1,736 @@ +import { Repository, Between } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm'; +import { ServiceResult } from '../../../shared/types'; +import { POSOrder, OrderStatus, OrderType } from '../../pos/entities/pos-order.entity'; + +export interface CustomerReportFilters { + startDate: Date; + endDate: Date; + branchIds?: string[]; + customerIds?: string[]; + loyaltyTier?: string; +} + +export interface CustomerSummary { + totalCustomers: number; + newCustomers: number; + returningCustomers: number; + activeCustomers: number; + averagePurchaseFrequency: number; + customerRetentionRate: number; +} + +export interface TopCustomer { + customerId: string; + customerName: string; + customerEmail: string; + customerPhone: string; + totalPurchases: number; + totalAmount: number; + averageTicket: number; + lastPurchaseDate: Date; + loyaltyTier: string; + loyaltyPoints: number; +} + +export interface CustomerPurchaseHistory { + customerId: string; + customerName: string; + purchases: { + orderId: string; + orderNumber: string; + orderDate: Date; + branchName: string; + total: number; + items: number; + paymentMethod: string; + }[]; + totalPurchases: number; + totalAmount: number; + averageTicket: number; + firstPurchase: Date; + lastPurchase: Date; +} + +export interface CustomerSegment { + segment: string; + segmentDescription: string; + customerCount: number; + percentOfCustomers: number; + totalRevenue: number; + percentOfRevenue: number; + avgPurchaseFrequency: number; + avgTicket: number; +} + +export interface CustomerLoyaltySummary { + totalMembers: number; + activeMembers: number; + pointsIssued: number; + pointsRedeemed: number; + pointsBalance: number; + redemptionRate: number; + byTier: { + tier: string; + tierName: string; + memberCount: number; + percentOfMembers: number; + totalSpent: number; + }[]; +} + +export interface CustomerRetention { + period: string; + newCustomers: number; + returningCustomers: number; + churnedCustomers: number; + retentionRate: number; + churnRate: number; +} + +export interface CustomerLifetimeValue { + customerId: string; + customerName: string; + customerSince: Date; + monthsAsCustomer: number; + totalPurchases: number; + totalRevenue: number; + averageMonthlySpend: number; + predictedLTV: number; +} + +export interface RFMAnalysis { + customerId: string; + customerName: string; + recencyScore: number; + frequencyScore: number; + monetaryScore: number; + rfmScore: number; + segment: string; + daysSinceLastPurchase: number; + purchaseCount: number; + totalSpent: number; +} + +export class CustomerReportService { + private orderRepository: Repository; + + constructor() { + this.orderRepository = AppDataSource.getRepository(POSOrder); + } + + /** + * Get customer summary + */ + async getCustomerSummary( + tenantId: string, + filters: CustomerReportFilters + ): Promise> { + try { + // Get total unique customers who made purchases + const customersQuery = ` + SELECT + COUNT(DISTINCT customer_id) as total_customers, + COUNT(DISTINCT CASE + WHEN created_at >= $2 THEN customer_id + END) as active_customers + FROM retail.pos_orders + WHERE tenant_id = $1 + AND customer_id IS NOT NULL + AND status = 'paid' + AND type = 'sale' + `; + + // New customers (first purchase in period) + const newCustomersQuery = ` + SELECT COUNT(DISTINCT customer_id) as new_customers + FROM retail.pos_orders o + WHERE tenant_id = $1 + AND customer_id IS NOT NULL + AND status = 'paid' + AND type = 'sale' + AND created_at BETWEEN $2 AND $3 + AND NOT EXISTS ( + SELECT 1 FROM retail.pos_orders o2 + WHERE o2.customer_id = o.customer_id + AND o2.tenant_id = o.tenant_id + AND o2.status = 'paid' + AND o2.type = 'sale' + AND o2.created_at < $2 + ) + `; + + // Returning customers + const returningQuery = ` + SELECT COUNT(DISTINCT customer_id) as returning_customers + FROM retail.pos_orders o + WHERE tenant_id = $1 + AND customer_id IS NOT NULL + AND status = 'paid' + AND type = 'sale' + AND created_at BETWEEN $2 AND $3 + AND EXISTS ( + SELECT 1 FROM retail.pos_orders o2 + WHERE o2.customer_id = o.customer_id + AND o2.tenant_id = o.tenant_id + AND o2.status = 'paid' + AND o2.type = 'sale' + AND o2.created_at < $2 + ) + `; + + // Average purchase frequency + const frequencyQuery = ` + SELECT AVG(purchase_count) as avg_frequency + FROM ( + SELECT customer_id, COUNT(*) as purchase_count + FROM retail.pos_orders + WHERE tenant_id = $1 + AND customer_id IS NOT NULL + AND status = 'paid' + AND type = 'sale' + AND created_at BETWEEN $2 AND $3 + GROUP BY customer_id + ) subq + `; + + const [customersResult, newResult, returningResult, frequencyResult] = await Promise.all([ + AppDataSource.query(customersQuery, [tenantId, filters.startDate]), + AppDataSource.query(newCustomersQuery, [tenantId, filters.startDate, filters.endDate]), + AppDataSource.query(returningQuery, [tenantId, filters.startDate, filters.endDate]), + AppDataSource.query(frequencyQuery, [tenantId, filters.startDate, filters.endDate]), + ]); + + const totalCustomers = Number(customersResult[0]?.total_customers || 0); + const activeCustomers = Number(customersResult[0]?.active_customers || 0); + const newCustomers = Number(newResult[0]?.new_customers || 0); + const returningCustomers = Number(returningResult[0]?.returning_customers || 0); + const avgFrequency = Number(frequencyResult[0]?.avg_frequency || 0); + + const retentionRate = totalCustomers > 0 + ? (returningCustomers / (totalCustomers - newCustomers)) * 100 + : 0; + + return { + success: true, + data: { + totalCustomers, + newCustomers, + returningCustomers, + activeCustomers, + averagePurchaseFrequency: avgFrequency, + customerRetentionRate: Math.min(100, Math.max(0, retentionRate)), + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'CUSTOMER_SUMMARY_ERROR', + message: error.message || 'Failed to generate customer summary', + }, + }; + } + } + + /** + * Get top customers by revenue + */ + async getTopCustomers( + tenantId: string, + filters: CustomerReportFilters, + limit: number = 50 + ): Promise> { + try { + const query = ` + SELECT + o.customer_id, + o.customer_name, + c.email as customer_email, + c.phone as customer_phone, + COUNT(o.id) as total_purchases, + SUM(o.total) as total_amount, + AVG(o.total) as average_ticket, + MAX(o.created_at) as last_purchase_date, + COALESCE(cm.tier, 'none') as loyalty_tier, + COALESCE(cm.points_balance, 0) as loyalty_points + FROM retail.pos_orders o + LEFT JOIN core.partners c ON o.customer_id = c.id + LEFT JOIN retail.customer_memberships cm ON o.customer_id = cm.customer_id AND cm.status = 'active' + WHERE o.tenant_id = $1 + AND o.customer_id IS NOT NULL + AND o.status = 'paid' + AND o.type = 'sale' + AND o.created_at BETWEEN $2 AND $3 + GROUP BY o.customer_id, o.customer_name, c.email, c.phone, cm.tier, cm.points_balance + ORDER BY total_amount DESC + LIMIT $4 + `; + + const result = await AppDataSource.query(query, [ + tenantId, + filters.startDate, + filters.endDate, + limit, + ]); + + const data: TopCustomer[] = result.map((row: any) => ({ + customerId: row.customer_id, + customerName: row.customer_name || 'Unknown', + customerEmail: row.customer_email || '', + customerPhone: row.customer_phone || '', + totalPurchases: Number(row.total_purchases), + totalAmount: Number(row.total_amount), + averageTicket: Number(row.average_ticket), + lastPurchaseDate: row.last_purchase_date, + loyaltyTier: row.loyalty_tier, + loyaltyPoints: Number(row.loyalty_points), + })); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'TOP_CUSTOMERS_ERROR', + message: error.message || 'Failed to get top customers', + }, + }; + } + } + + /** + * Get customer purchase history + */ + async getCustomerPurchaseHistory( + tenantId: string, + customerId: string, + filters?: { startDate?: Date; endDate?: Date } + ): Promise> { + try { + let query = ` + SELECT + o.id as order_id, + o.number as order_number, + o.created_at as order_date, + b.name as branch_name, + o.total, + (SELECT COUNT(*) FROM retail.pos_order_lines WHERE order_id = o.id) as items, + (SELECT method FROM retail.pos_payments WHERE order_id = o.id LIMIT 1) as payment_method + FROM retail.pos_orders o + JOIN retail.branches b ON o.branch_id = b.id + WHERE o.tenant_id = $1 + AND o.customer_id = $2 + AND o.status = 'paid' + AND o.type = 'sale' + `; + + const params: any[] = [tenantId, customerId]; + + if (filters?.startDate && filters?.endDate) { + query += ` AND o.created_at BETWEEN $3 AND $4`; + params.push(filters.startDate, filters.endDate); + } + + query += ` ORDER BY o.created_at DESC`; + + // Get customer info + const customerQuery = ` + SELECT customer_name + FROM retail.pos_orders + WHERE tenant_id = $1 AND customer_id = $2 + LIMIT 1 + `; + + const [ordersResult, customerResult] = await Promise.all([ + AppDataSource.query(query, params), + AppDataSource.query(customerQuery, [tenantId, customerId]), + ]); + + const purchases = ordersResult.map((row: any) => ({ + orderId: row.order_id, + orderNumber: row.order_number, + orderDate: row.order_date, + branchName: row.branch_name, + total: Number(row.total), + items: Number(row.items), + paymentMethod: row.payment_method || 'unknown', + })); + + const totalAmount = purchases.reduce((sum: number, p: { total: number }) => sum + p.total, 0); + const totalPurchases = purchases.length; + + return { + success: true, + data: { + customerId, + customerName: customerResult[0]?.customer_name || 'Unknown', + purchases, + totalPurchases, + totalAmount, + averageTicket: totalPurchases > 0 ? totalAmount / totalPurchases : 0, + firstPurchase: purchases.length > 0 ? purchases[purchases.length - 1].orderDate : new Date(), + lastPurchase: purchases.length > 0 ? purchases[0].orderDate : new Date(), + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'PURCHASE_HISTORY_ERROR', + message: error.message || 'Failed to get purchase history', + }, + }; + } + } + + /** + * Get customer segmentation + */ + async getCustomerSegmentation( + tenantId: string, + filters: CustomerReportFilters + ): Promise> { + try { + const query = ` + WITH customer_stats AS ( + SELECT + customer_id, + COUNT(*) as purchase_count, + SUM(total) as total_spent, + AVG(total) as avg_ticket, + MAX(created_at) as last_purchase + FROM retail.pos_orders + WHERE tenant_id = $1 + AND customer_id IS NOT NULL + AND status = 'paid' + AND type = 'sale' + AND created_at BETWEEN $2 AND $3 + GROUP BY customer_id + ), + segmented AS ( + SELECT + customer_id, + purchase_count, + total_spent, + avg_ticket, + CASE + WHEN total_spent >= 50000 AND purchase_count >= 10 THEN 'vip' + WHEN total_spent >= 20000 AND purchase_count >= 5 THEN 'loyal' + WHEN total_spent >= 5000 AND purchase_count >= 2 THEN 'regular' + WHEN purchase_count = 1 THEN 'new' + ELSE 'occasional' + END as segment + FROM customer_stats + ) + SELECT + segment, + COUNT(*) as customer_count, + SUM(total_spent) as total_revenue, + AVG(purchase_count) as avg_purchase_frequency, + AVG(avg_ticket) as avg_ticket + FROM segmented + GROUP BY segment + ORDER BY total_revenue DESC + `; + + const result = await AppDataSource.query(query, [ + tenantId, + filters.startDate, + filters.endDate, + ]); + + const totalCustomers = result.reduce((sum: number, row: any) => sum + Number(row.customer_count), 0); + const totalRevenue = result.reduce((sum: number, row: any) => sum + Number(row.total_revenue), 0); + + const segmentDescriptions: Record = { + vip: 'VIP - Alto valor y alta frecuencia', + loyal: 'Leal - Clientes recurrentes', + regular: 'Regular - Compras moderadas', + new: 'Nuevo - Primera compra', + occasional: 'Ocasional - Compras esporadicas', + }; + + const data: CustomerSegment[] = result.map((row: any) => ({ + segment: row.segment, + segmentDescription: segmentDescriptions[row.segment] || row.segment, + customerCount: Number(row.customer_count), + percentOfCustomers: totalCustomers > 0 ? (Number(row.customer_count) / totalCustomers) * 100 : 0, + totalRevenue: Number(row.total_revenue), + percentOfRevenue: totalRevenue > 0 ? (Number(row.total_revenue) / totalRevenue) * 100 : 0, + avgPurchaseFrequency: Number(row.avg_purchase_frequency), + avgTicket: Number(row.avg_ticket), + })); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'SEGMENTATION_ERROR', + message: error.message || 'Failed to get customer segmentation', + }, + }; + } + } + + /** + * Get loyalty program summary + */ + async getLoyaltySummary( + tenantId: string, + filters: CustomerReportFilters + ): Promise> { + try { + const summaryQuery = ` + SELECT + COUNT(*) as total_members, + COUNT(CASE WHEN status = 'active' THEN 1 END) as active_members, + COALESCE(SUM(points_earned), 0) as points_issued, + COALESCE(SUM(points_redeemed), 0) as points_redeemed, + COALESCE(SUM(points_balance), 0) as points_balance + FROM retail.customer_memberships + WHERE tenant_id = $1 + `; + + const tierQuery = ` + SELECT + tier, + ml.name as tier_name, + COUNT(*) as member_count, + COALESCE(SUM(cm.total_spent), 0) as total_spent + FROM retail.customer_memberships cm + LEFT JOIN retail.membership_levels ml ON cm.tier = ml.code AND cm.tenant_id = ml.tenant_id + WHERE cm.tenant_id = $1 + GROUP BY tier, ml.name + ORDER BY total_spent DESC + `; + + const [summaryResult, tierResult] = await Promise.all([ + AppDataSource.query(summaryQuery, [tenantId]), + AppDataSource.query(tierQuery, [tenantId]), + ]); + + const summary = summaryResult[0] || {}; + const pointsIssued = Number(summary.points_issued || 0); + const pointsRedeemed = Number(summary.points_redeemed || 0); + const totalMembers = Number(summary.total_members || 0); + + const byTier = tierResult.map((row: any) => ({ + tier: row.tier, + tierName: row.tier_name || row.tier, + memberCount: Number(row.member_count), + percentOfMembers: totalMembers > 0 ? (Number(row.member_count) / totalMembers) * 100 : 0, + totalSpent: Number(row.total_spent), + })); + + return { + success: true, + data: { + totalMembers, + activeMembers: Number(summary.active_members || 0), + pointsIssued, + pointsRedeemed, + pointsBalance: Number(summary.points_balance || 0), + redemptionRate: pointsIssued > 0 ? (pointsRedeemed / pointsIssued) * 100 : 0, + byTier, + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'LOYALTY_SUMMARY_ERROR', + message: error.message || 'Failed to get loyalty summary', + }, + }; + } + } + + /** + * Get RFM analysis + */ + async getRFMAnalysis( + tenantId: string, + filters: CustomerReportFilters, + limit: number = 100 + ): Promise> { + try { + const query = ` + WITH customer_rfm AS ( + SELECT + customer_id, + customer_name, + EXTRACT(DAY FROM NOW() - MAX(created_at)) as days_since_last, + COUNT(*) as purchase_count, + SUM(total) as total_spent + FROM retail.pos_orders + WHERE tenant_id = $1 + AND customer_id IS NOT NULL + AND status = 'paid' + AND type = 'sale' + AND created_at BETWEEN $2 AND $3 + GROUP BY customer_id, customer_name + ), + scored AS ( + SELECT + customer_id, + customer_name, + days_since_last, + purchase_count, + total_spent, + NTILE(5) OVER (ORDER BY days_since_last DESC) as recency_score, + NTILE(5) OVER (ORDER BY purchase_count ASC) as frequency_score, + NTILE(5) OVER (ORDER BY total_spent ASC) as monetary_score + FROM customer_rfm + ) + SELECT + customer_id, + customer_name, + recency_score, + frequency_score, + monetary_score, + (recency_score + frequency_score + monetary_score) as rfm_score, + CASE + WHEN recency_score >= 4 AND frequency_score >= 4 AND monetary_score >= 4 THEN 'Champions' + WHEN recency_score >= 3 AND frequency_score >= 3 AND monetary_score >= 3 THEN 'Loyal' + WHEN recency_score >= 4 AND frequency_score <= 2 THEN 'New Customers' + WHEN recency_score <= 2 AND frequency_score >= 4 THEN 'At Risk' + WHEN recency_score <= 2 AND frequency_score <= 2 AND monetary_score <= 2 THEN 'Lost' + ELSE 'Regular' + END as segment, + days_since_last as days_since_last_purchase, + purchase_count, + total_spent + FROM scored + ORDER BY rfm_score DESC + LIMIT $4 + `; + + const result = await AppDataSource.query(query, [ + tenantId, + filters.startDate, + filters.endDate, + limit, + ]); + + const data: RFMAnalysis[] = result.map((row: any) => ({ + customerId: row.customer_id, + customerName: row.customer_name || 'Unknown', + recencyScore: Number(row.recency_score), + frequencyScore: Number(row.frequency_score), + monetaryScore: Number(row.monetary_score), + rfmScore: Number(row.rfm_score), + segment: row.segment, + daysSinceLastPurchase: Number(row.days_since_last_purchase), + purchaseCount: Number(row.purchase_count), + totalSpent: Number(row.total_spent), + })); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'RFM_ANALYSIS_ERROR', + message: error.message || 'Failed to generate RFM analysis', + }, + }; + } + } + + /** + * Get customer retention by period + */ + async getCustomerRetention( + tenantId: string, + filters: CustomerReportFilters, + groupBy: 'week' | 'month' = 'month' + ): Promise> { + try { + const dateFormat = groupBy === 'week' ? 'IYYY-IW' : 'YYYY-MM'; + + const query = ` + WITH periods AS ( + SELECT DISTINCT TO_CHAR(created_at, '${dateFormat}') as period + FROM retail.pos_orders + WHERE tenant_id = $1 + AND created_at BETWEEN $2 AND $3 + AND status = 'paid' + AND type = 'sale' + ), + customer_periods AS ( + SELECT + customer_id, + TO_CHAR(created_at, '${dateFormat}') as period, + MIN(created_at) as first_purchase + FROM retail.pos_orders + WHERE tenant_id = $1 + AND customer_id IS NOT NULL + AND status = 'paid' + AND type = 'sale' + GROUP BY customer_id, TO_CHAR(created_at, '${dateFormat}') + ), + period_stats AS ( + SELECT + p.period, + COUNT(DISTINCT cp.customer_id) as total_customers, + COUNT(DISTINCT CASE + WHEN NOT EXISTS ( + SELECT 1 FROM customer_periods cp2 + WHERE cp2.customer_id = cp.customer_id + AND cp2.period < p.period + ) THEN cp.customer_id + END) as new_customers, + COUNT(DISTINCT CASE + WHEN EXISTS ( + SELECT 1 FROM customer_periods cp2 + WHERE cp2.customer_id = cp.customer_id + AND cp2.period < p.period + ) THEN cp.customer_id + END) as returning_customers + FROM periods p + LEFT JOIN customer_periods cp ON cp.period = p.period + GROUP BY p.period + ) + SELECT + period, + new_customers, + returning_customers, + COALESCE(LAG(new_customers + returning_customers) OVER (ORDER BY period) - returning_customers, 0) as churned_customers + FROM period_stats + ORDER BY period + `; + + const result = await AppDataSource.query(query, [tenantId, filters.startDate, filters.endDate]); + + const data: CustomerRetention[] = result.map((row: any) => { + const newCustomers = Number(row.new_customers || 0); + const returningCustomers = Number(row.returning_customers || 0); + const churnedCustomers = Number(row.churned_customers || 0); + const previousTotal = returningCustomers + churnedCustomers; + + return { + period: row.period, + newCustomers, + returningCustomers, + churnedCustomers, + retentionRate: previousTotal > 0 ? (returningCustomers / previousTotal) * 100 : 0, + churnRate: previousTotal > 0 ? (churnedCustomers / previousTotal) * 100 : 0, + }; + }); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'RETENTION_ERROR', + message: error.message || 'Failed to get customer retention', + }, + }; + } + } +} + +export const customerReportService = new CustomerReportService(); diff --git a/src/modules/reports/services/dashboard.service.ts b/src/modules/reports/services/dashboard.service.ts new file mode 100644 index 0000000..7d97eb0 --- /dev/null +++ b/src/modules/reports/services/dashboard.service.ts @@ -0,0 +1,694 @@ +import { Repository } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm'; +import { ServiceResult } from '../../../shared/types'; +import { DashboardWidget, WidgetType, RefreshInterval } from '../entities/dashboard-widget.entity'; +import { salesReportService } from './sales-report.service'; +import { inventoryReportService } from './inventory-report.service'; +import { customerReportService } from './customer-report.service'; +import { financialReportService } from './financial-report.service'; + +export interface DashboardKPIs { + todaySales: { + value: number; + change: number; + changePercent: number; + trend: 'up' | 'down' | 'stable'; + }; + todayOrders: { + value: number; + change: number; + changePercent: number; + trend: 'up' | 'down' | 'stable'; + }; + averageTicket: { + value: number; + change: number; + changePercent: number; + trend: 'up' | 'down' | 'stable'; + }; + activeCustomers: { + value: number; + change: number; + changePercent: number; + trend: 'up' | 'down' | 'stable'; + }; + lowStockItems: { + value: number; + critical: number; + }; + grossMargin: { + value: number; + change: number; + trend: 'up' | 'down' | 'stable'; + }; +} + +export interface TopSellingProduct { + productId: string; + productCode: string; + productName: string; + quantity: number; + revenue: number; + rank: number; +} + +export interface HourlySales { + hour: number; + hourLabel: string; + sales: number; + orders: number; +} + +export interface BranchPerformance { + branchId: string; + branchName: string; + sales: number; + orders: number; + averageTicket: number; + percentOfTotal: number; +} + +export interface PaymentMethodBreakdown { + method: string; + methodName: string; + amount: number; + count: number; + percentOfTotal: number; +} + +export interface WidgetData { + widgetId: string; + title: string; + type: WidgetType; + data: any; + lastUpdated: Date; +} + +export class DashboardService { + private widgetRepository: Repository; + + constructor() { + this.widgetRepository = AppDataSource.getRepository(DashboardWidget); + } + + /** + * Get dashboard KPIs for today + */ + async getDashboardKPIs( + tenantId: string, + branchId?: string + ): Promise> { + try { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const filters = { + startDate: today, + endDate: tomorrow, + branchIds: branchId ? [branchId] : undefined, + }; + + const yesterdayFilters = { + startDate: yesterday, + endDate: today, + branchIds: branchId ? [branchId] : undefined, + }; + + // Get today's and yesterday's sales + const [todayResult, yesterdayResult, lowStockResult] = await Promise.all([ + salesReportService.getSalesSummary(tenantId, filters), + salesReportService.getSalesSummary(tenantId, yesterdayFilters), + inventoryReportService.getLowStockAlerts(tenantId, branchId), + ]); + + if (!todayResult.success || !yesterdayResult.success) { + return { + success: false, + error: { + code: 'KPI_ERROR', + message: 'Failed to get KPIs', + }, + }; + } + + const today_ = todayResult.data; + const yesterday_ = yesterdayResult.data; + + const getTrend = (change: number): 'up' | 'down' | 'stable' => { + if (change > 0) return 'up'; + if (change < 0) return 'down'; + return 'stable'; + }; + + const salesChange = today_.totalSales - yesterday_.totalSales; + const salesChangePercent = yesterday_.totalSales > 0 + ? (salesChange / yesterday_.totalSales) * 100 + : 0; + + const ordersChange = today_.totalOrders - yesterday_.totalOrders; + const ordersChangePercent = yesterday_.totalOrders > 0 + ? (ordersChange / yesterday_.totalOrders) * 100 + : 0; + + const ticketChange = today_.averageTicket - yesterday_.averageTicket; + const ticketChangePercent = yesterday_.averageTicket > 0 + ? (ticketChange / yesterday_.averageTicket) * 100 + : 0; + + // Get margin from financial service + const revenueResult = await financialReportService.getRevenueSummary(tenantId, filters); + const grossMargin = revenueResult.success ? revenueResult.data.grossMargin : 0; + + const lowStockData = lowStockResult.success ? lowStockResult.data : []; + const criticalCount = lowStockData.filter(p => p.status === 'out_of_stock').length; + + return { + success: true, + data: { + todaySales: { + value: today_.totalSales, + change: salesChange, + changePercent: salesChangePercent, + trend: getTrend(salesChange), + }, + todayOrders: { + value: today_.totalOrders, + change: ordersChange, + changePercent: ordersChangePercent, + trend: getTrend(ordersChange), + }, + averageTicket: { + value: today_.averageTicket, + change: ticketChange, + changePercent: ticketChangePercent, + trend: getTrend(ticketChange), + }, + activeCustomers: { + value: 0, // Would come from customer service + change: 0, + changePercent: 0, + trend: 'stable', + }, + lowStockItems: { + value: lowStockData.length, + critical: criticalCount, + }, + grossMargin: { + value: grossMargin, + change: 0, + trend: 'stable', + }, + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'KPI_ERROR', + message: error.message || 'Failed to get dashboard KPIs', + }, + }; + } + } + + /** + * Get top selling products for today + */ + async getTopSellingProducts( + tenantId: string, + branchId?: string, + limit: number = 10 + ): Promise> { + try { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const result = await salesReportService.getSalesByProduct( + tenantId, + { + startDate: today, + endDate: tomorrow, + branchIds: branchId ? [branchId] : undefined, + }, + limit + ); + + if (!result.success) { + return { + success: false, + error: result.error, + }; + } + + const data: TopSellingProduct[] = result.data.map((p, index) => ({ + productId: p.productId, + productCode: p.productCode, + productName: p.productName, + quantity: p.quantity, + revenue: p.totalSales, + rank: index + 1, + })); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'TOP_PRODUCTS_ERROR', + message: error.message || 'Failed to get top products', + }, + }; + } + } + + /** + * Get hourly sales breakdown for today + */ + async getHourlySales( + tenantId: string, + branchId?: string + ): Promise> { + try { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const result = await salesReportService.getSalesByHour( + tenantId, + { + startDate: today, + endDate: tomorrow, + branchIds: branchId ? [branchId] : undefined, + } + ); + + if (!result.success) { + return { + success: false, + error: result.error, + }; + } + + const data: HourlySales[] = result.data.map(h => ({ + hour: h.hour, + hourLabel: h.hourLabel, + sales: h.sales, + orders: h.orders, + })); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'HOURLY_SALES_ERROR', + message: error.message || 'Failed to get hourly sales', + }, + }; + } + } + + /** + * Get branch performance comparison + */ + async getBranchPerformance( + tenantId: string, + period: 'today' | 'week' | 'month' = 'today' + ): Promise> { + try { + const today = new Date(); + today.setHours(0, 0, 0, 0); + let startDate: Date; + + switch (period) { + case 'week': + startDate = new Date(today); + startDate.setDate(startDate.getDate() - 7); + break; + case 'month': + startDate = new Date(today); + startDate.setMonth(startDate.getMonth() - 1); + break; + default: + startDate = today; + } + + const endDate = new Date(); + + const result = await salesReportService.getSalesByBranch( + tenantId, + { startDate, endDate } + ); + + if (!result.success) { + return { + success: false, + error: result.error, + }; + } + + const data: BranchPerformance[] = result.data.map(b => ({ + branchId: b.branchId, + branchName: b.branchName, + sales: b.totalSales, + orders: b.orders, + averageTicket: b.averageTicket, + percentOfTotal: b.percentOfTotal, + })); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'BRANCH_PERFORMANCE_ERROR', + message: error.message || 'Failed to get branch performance', + }, + }; + } + } + + /** + * Get payment method breakdown for today + */ + async getPaymentMethodBreakdown( + tenantId: string, + branchId?: string + ): Promise> { + try { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const result = await salesReportService.getSalesByPaymentMethod( + tenantId, + { + startDate: today, + endDate: tomorrow, + branchIds: branchId ? [branchId] : undefined, + } + ); + + if (!result.success) { + return { + success: false, + error: result.error, + }; + } + + const data: PaymentMethodBreakdown[] = result.data.map(p => ({ + method: p.method, + methodName: p.methodName, + amount: p.totalAmount, + count: p.transactionCount, + percentOfTotal: p.percentOfTotal, + })); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'PAYMENT_BREAKDOWN_ERROR', + message: error.message || 'Failed to get payment breakdown', + }, + }; + } + } + + // ==================== WIDGET MANAGEMENT ==================== + + /** + * Get user's dashboard widgets + */ + async getUserWidgets( + tenantId: string, + userId: string + ): Promise> { + try { + const widgets = await this.widgetRepository.find({ + where: { tenantId, userId, isVisible: true }, + order: { sortOrder: 'ASC', positionY: 'ASC', positionX: 'ASC' }, + }); + + return { success: true, data: widgets }; + } catch (error: any) { + return { + success: false, + error: { + code: 'GET_WIDGETS_ERROR', + message: error.message || 'Failed to get widgets', + }, + }; + } + } + + /** + * Create a new widget + */ + async createWidget( + tenantId: string, + userId: string, + data: Partial + ): Promise> { + try { + const widget = this.widgetRepository.create({ + ...data, + tenantId, + userId, + createdBy: userId, + }); + + const saved = await this.widgetRepository.save(widget); + return { success: true, data: saved }; + } catch (error: any) { + return { + success: false, + error: { + code: 'CREATE_WIDGET_ERROR', + message: error.message || 'Failed to create widget', + }, + }; + } + } + + /** + * Update widget + */ + async updateWidget( + tenantId: string, + widgetId: string, + userId: string, + data: Partial + ): Promise> { + try { + const widget = await this.widgetRepository.findOne({ + where: { id: widgetId, tenantId, userId }, + }); + + if (!widget) { + return { + success: false, + error: { + code: 'WIDGET_NOT_FOUND', + message: 'Widget not found', + }, + }; + } + + Object.assign(widget, data); + const saved = await this.widgetRepository.save(widget); + return { success: true, data: saved }; + } catch (error: any) { + return { + success: false, + error: { + code: 'UPDATE_WIDGET_ERROR', + message: error.message || 'Failed to update widget', + }, + }; + } + } + + /** + * Delete widget + */ + async deleteWidget( + tenantId: string, + widgetId: string, + userId: string + ): Promise> { + try { + const result = await this.widgetRepository.delete({ + id: widgetId, + tenantId, + userId, + }); + + if (result.affected === 0) { + return { + success: false, + error: { + code: 'WIDGET_NOT_FOUND', + message: 'Widget not found', + }, + }; + } + + return { success: true, data: undefined }; + } catch (error: any) { + return { + success: false, + error: { + code: 'DELETE_WIDGET_ERROR', + message: error.message || 'Failed to delete widget', + }, + }; + } + } + + /** + * Get widget data based on its configuration + */ + async getWidgetData( + tenantId: string, + widget: DashboardWidget + ): Promise> { + try { + let data: any; + const branchId = widget.queryParams?.branchId; + const limit = widget.queryParams?.limit || 10; + + // Fetch data based on dataSource + switch (widget.dataSource) { + case 'sales.today': + const kpis = await this.getDashboardKPIs(tenantId, branchId); + data = kpis.success ? kpis.data.todaySales : null; + break; + + case 'sales.orders': + const ordersKpis = await this.getDashboardKPIs(tenantId, branchId); + data = ordersKpis.success ? ordersKpis.data.todayOrders : null; + break; + + case 'sales.averageTicket': + const ticketKpis = await this.getDashboardKPIs(tenantId, branchId); + data = ticketKpis.success ? ticketKpis.data.averageTicket : null; + break; + + case 'sales.topProducts': + const topProducts = await this.getTopSellingProducts(tenantId, branchId, limit); + data = topProducts.success ? topProducts.data : null; + break; + + case 'sales.hourly': + const hourly = await this.getHourlySales(tenantId, branchId); + data = hourly.success ? hourly.data : null; + break; + + case 'sales.byBranch': + const periodParam = widget.queryParams?.period as 'today' | 'week' | 'month' | undefined; + const branches = await this.getBranchPerformance(tenantId, periodParam || 'today'); + data = branches.success ? branches.data : null; + break; + + case 'sales.paymentMethods': + const payments = await this.getPaymentMethodBreakdown(tenantId, branchId); + data = payments.success ? payments.data : null; + break; + + case 'inventory.lowStock': + const lowStock = await inventoryReportService.getLowStockAlerts(tenantId, branchId); + data = lowStock.success ? lowStock.data : null; + break; + + case 'inventory.summary': + const stockSummary = await inventoryReportService.getStockLevelSummary(tenantId, {}); + data = stockSummary.success ? stockSummary.data : null; + break; + + default: + data = null; + } + + return { + success: true, + data: { + widgetId: widget.id, + title: widget.title, + type: widget.type, + data, + lastUpdated: new Date(), + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'WIDGET_DATA_ERROR', + message: error.message || 'Failed to get widget data', + }, + }; + } + } + + /** + * Get complete dashboard with all widget data + */ + async getCompleteDashboard( + tenantId: string, + userId: string + ): Promise> { + try { + const [kpisResult, widgetsResult] = await Promise.all([ + this.getDashboardKPIs(tenantId), + this.getUserWidgets(tenantId, userId), + ]); + + if (!kpisResult.success) { + return { success: false, error: kpisResult.error }; + } + + const widgets: WidgetData[] = []; + + if (widgetsResult.success) { + for (const widget of widgetsResult.data) { + const dataResult = await this.getWidgetData(tenantId, widget); + if (dataResult.success) { + widgets.push(dataResult.data); + } + } + } + + return { + success: true, + data: { + kpis: kpisResult.data, + widgets, + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'DASHBOARD_ERROR', + message: error.message || 'Failed to get dashboard', + }, + }; + } + } +} + +export const dashboardService = new DashboardService(); diff --git a/src/modules/reports/services/financial-report.service.ts b/src/modules/reports/services/financial-report.service.ts new file mode 100644 index 0000000..1869c51 --- /dev/null +++ b/src/modules/reports/services/financial-report.service.ts @@ -0,0 +1,813 @@ +import { Repository, Between } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm'; +import { ServiceResult } from '../../../shared/types'; +import { POSOrder, OrderStatus, OrderType } from '../../pos/entities/pos-order.entity'; +import { POSPayment, PaymentMethod } from '../../pos/entities/pos-payment.entity'; + +export interface FinancialReportFilters { + startDate: Date; + endDate: Date; + branchIds?: string[]; +} + +export interface RevenueSummary { + grossRevenue: number; + netRevenue: number; + totalDiscounts: number; + totalRefunds: number; + totalTax: number; + costOfGoodsSold: number; + grossProfit: number; + grossMargin: number; +} + +export interface RevenueByPeriod { + period: string; + grossRevenue: number; + netRevenue: number; + discounts: number; + refunds: number; + tax: number; + grossProfit: number; +} + +export interface RevenueByBranch { + branchId: string; + branchCode: string; + branchName: string; + grossRevenue: number; + netRevenue: number; + discounts: number; + refunds: number; + tax: number; + grossProfit: number; + percentOfTotal: number; +} + +export interface MarginAnalysis { + productId: string; + productCode: string; + productName: string; + quantitySold: number; + revenue: number; + cost: number; + grossProfit: number; + marginPercent: number; +} + +export interface TaxSummary { + totalTaxCollected: number; + byTaxType: { + taxType: string; + taxName: string; + rate: number; + taxableBase: number; + taxAmount: number; + }[]; + byBranch: { + branchId: string; + branchName: string; + taxCollected: number; + }[]; +} + +export interface CashFlowSummary { + openingBalance: number; + cashIn: number; + cashOut: number; + closingBalance: number; + netCashFlow: number; + byPaymentMethod: { + method: string; + methodName: string; + amount: number; + percentOfTotal: number; + }[]; +} + +export interface ProfitLossStatement { + period: string; + revenue: { + grossSales: number; + discounts: number; + returns: number; + netSales: number; + }; + costOfSales: { + opening: number; + purchases: number; + closing: number; + cogs: number; + }; + grossProfit: number; + expenses: { + category: string; + amount: number; + }[]; + totalExpenses: number; + operatingProfit: number; + taxes: number; + netProfit: number; +} + +export interface AccountsReceivableSummary { + totalReceivables: number; + currentReceivables: number; + overdueReceivables: number; + aging: { + range: string; + minDays: number; + maxDays: number; + amount: number; + count: number; + }[]; + topDebtors: { + customerId: string; + customerName: string; + totalOwed: number; + oldestInvoiceDays: number; + }[]; +} + +export interface DailySalesReconciliation { + date: string; + branchId: string; + branchName: string; + sessions: { + sessionId: string; + cashier: string; + openingTime: string; + closingTime: string; + openingCash: number; + closingCash: number; + expectedCash: number; + cashDifference: number; + totalSales: number; + totalRefunds: number; + paymentBreakdown: { + method: string; + amount: number; + }[]; + }[]; + totalSales: number; + totalCash: number; + totalCards: number; + totalOther: number; + discrepancies: number; +} + +export class FinancialReportService { + private orderRepository: Repository; + private paymentRepository: Repository; + + constructor() { + this.orderRepository = AppDataSource.getRepository(POSOrder); + this.paymentRepository = AppDataSource.getRepository(POSPayment); + } + + /** + * Get revenue summary + */ + async getRevenueSummary( + tenantId: string, + filters: FinancialReportFilters + ): Promise> { + try { + const query = ` + SELECT + COALESCE(SUM(CASE WHEN type = 'sale' THEN total ELSE 0 END), 0) as gross_revenue, + COALESCE(SUM(CASE WHEN type = 'sale' THEN discount_amount ELSE 0 END), 0) as total_discounts, + COALESCE(SUM(CASE WHEN type = 'refund' THEN ABS(total) ELSE 0 END), 0) as total_refunds, + COALESCE(SUM(CASE WHEN type = 'sale' THEN tax_amount ELSE 0 END), 0) as total_tax + FROM retail.pos_orders + WHERE tenant_id = $1 + AND status = 'paid' + AND created_at BETWEEN $2 AND $3 + `; + + // COGS query (simplified - would need actual cost tracking) + const cogsQuery = ` + SELECT COALESCE(SUM(ol.quantity * ol.unit_cost), 0) as cogs + FROM retail.pos_order_lines ol + JOIN retail.pos_orders o ON ol.order_id = o.id + WHERE o.tenant_id = $1 + AND o.status = 'paid' + AND o.type = 'sale' + AND o.created_at BETWEEN $2 AND $3 + `; + + const params = [tenantId, filters.startDate, filters.endDate]; + + if (filters.branchIds?.length) { + // Would add branch filter + } + + const [revenueResult, cogsResult] = await Promise.all([ + AppDataSource.query(query, params), + AppDataSource.query(cogsQuery, params), + ]); + + const revenue = revenueResult[0] || {}; + const grossRevenue = Number(revenue.gross_revenue || 0); + const totalDiscounts = Number(revenue.total_discounts || 0); + const totalRefunds = Number(revenue.total_refunds || 0); + const totalTax = Number(revenue.total_tax || 0); + const cogs = Number(cogsResult[0]?.cogs || 0); + + const netRevenue = grossRevenue - totalRefunds; + const grossProfit = netRevenue - cogs; + const grossMargin = netRevenue > 0 ? (grossProfit / netRevenue) * 100 : 0; + + return { + success: true, + data: { + grossRevenue, + netRevenue, + totalDiscounts, + totalRefunds, + totalTax, + costOfGoodsSold: cogs, + grossProfit, + grossMargin, + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'REVENUE_SUMMARY_ERROR', + message: error.message || 'Failed to generate revenue summary', + }, + }; + } + } + + /** + * Get revenue by period + */ + async getRevenueByPeriod( + tenantId: string, + filters: FinancialReportFilters, + groupBy: 'day' | 'week' | 'month' = 'day' + ): Promise> { + try { + let dateFormat: string; + switch (groupBy) { + case 'week': + dateFormat = 'IYYY-IW'; + break; + case 'month': + dateFormat = 'YYYY-MM'; + break; + default: + dateFormat = 'YYYY-MM-DD'; + } + + const query = ` + SELECT + TO_CHAR(created_at, '${dateFormat}') as period, + COALESCE(SUM(CASE WHEN type = 'sale' THEN total ELSE 0 END), 0) as gross_revenue, + COALESCE(SUM(CASE WHEN type = 'sale' THEN discount_amount ELSE 0 END), 0) as discounts, + COALESCE(SUM(CASE WHEN type = 'refund' THEN ABS(total) ELSE 0 END), 0) as refunds, + COALESCE(SUM(CASE WHEN type = 'sale' THEN tax_amount ELSE 0 END), 0) as tax + FROM retail.pos_orders + WHERE tenant_id = $1 + AND status = 'paid' + AND created_at BETWEEN $2 AND $3 + GROUP BY TO_CHAR(created_at, '${dateFormat}') + ORDER BY period + `; + + // Simplified COGS by period + const cogsQuery = ` + SELECT + TO_CHAR(o.created_at, '${dateFormat}') as period, + COALESCE(SUM(ol.quantity * ol.unit_cost), 0) as cogs + FROM retail.pos_order_lines ol + JOIN retail.pos_orders o ON ol.order_id = o.id + WHERE o.tenant_id = $1 + AND o.status = 'paid' + AND o.type = 'sale' + AND o.created_at BETWEEN $2 AND $3 + GROUP BY TO_CHAR(o.created_at, '${dateFormat}') + `; + + const [revenueResult, cogsResult] = await Promise.all([ + AppDataSource.query(query, [tenantId, filters.startDate, filters.endDate]), + AppDataSource.query(cogsQuery, [tenantId, filters.startDate, filters.endDate]), + ]); + + const cogsMap = new Map(cogsResult.map((r: any) => [r.period, Number(r.cogs)])); + + const data: RevenueByPeriod[] = revenueResult.map((row: any) => { + const grossRevenue = Number(row.gross_revenue); + const refunds = Number(row.refunds); + const cogs = Number(cogsMap.get(row.period) || 0); + const netRevenue = grossRevenue - refunds; + const grossProfit = netRevenue - cogs; + + return { + period: row.period, + grossRevenue, + netRevenue, + discounts: Number(row.discounts), + refunds, + tax: Number(row.tax), + grossProfit, + }; + }); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'REVENUE_BY_PERIOD_ERROR', + message: error.message || 'Failed to get revenue by period', + }, + }; + } + } + + /** + * Get revenue by branch + */ + async getRevenueByBranch( + tenantId: string, + filters: FinancialReportFilters + ): Promise> { + try { + const query = ` + SELECT + o.branch_id, + b.code as branch_code, + b.name as branch_name, + COALESCE(SUM(CASE WHEN o.type = 'sale' THEN o.total ELSE 0 END), 0) as gross_revenue, + COALESCE(SUM(CASE WHEN o.type = 'sale' THEN o.discount_amount ELSE 0 END), 0) as discounts, + COALESCE(SUM(CASE WHEN o.type = 'refund' THEN ABS(o.total) ELSE 0 END), 0) as refunds, + COALESCE(SUM(CASE WHEN o.type = 'sale' THEN o.tax_amount ELSE 0 END), 0) as tax + FROM retail.pos_orders o + JOIN retail.branches b ON o.branch_id = b.id + WHERE o.tenant_id = $1 + AND o.status = 'paid' + AND o.created_at BETWEEN $2 AND $3 + GROUP BY o.branch_id, b.code, b.name + ORDER BY gross_revenue DESC + `; + + const result = await AppDataSource.query(query, [tenantId, filters.startDate, filters.endDate]); + const totalRevenue = result.reduce((sum: number, row: any) => sum + Number(row.gross_revenue), 0); + + const data: RevenueByBranch[] = result.map((row: any) => { + const grossRevenue = Number(row.gross_revenue); + const refunds = Number(row.refunds); + const netRevenue = grossRevenue - refunds; + + return { + branchId: row.branch_id, + branchCode: row.branch_code, + branchName: row.branch_name, + grossRevenue, + netRevenue, + discounts: Number(row.discounts), + refunds, + tax: Number(row.tax), + grossProfit: netRevenue * 0.3, // Simplified - would need actual COGS + percentOfTotal: totalRevenue > 0 ? (grossRevenue / totalRevenue) * 100 : 0, + }; + }); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'REVENUE_BY_BRANCH_ERROR', + message: error.message || 'Failed to get revenue by branch', + }, + }; + } + } + + /** + * Get margin analysis by product + */ + async getMarginAnalysis( + tenantId: string, + filters: FinancialReportFilters, + limit: number = 50 + ): Promise> { + try { + const query = ` + SELECT + ol.product_id, + ol.product_code, + ol.product_name, + SUM(ol.quantity) as quantity_sold, + SUM(ol.total) as revenue, + SUM(ol.quantity * COALESCE(ol.unit_cost, 0)) as cost, + SUM(ol.total) - SUM(ol.quantity * COALESCE(ol.unit_cost, 0)) as gross_profit + FROM retail.pos_order_lines ol + JOIN retail.pos_orders o ON ol.order_id = o.id + WHERE o.tenant_id = $1 + AND o.status = 'paid' + AND o.type = 'sale' + AND o.created_at BETWEEN $2 AND $3 + GROUP BY ol.product_id, ol.product_code, ol.product_name + ORDER BY gross_profit DESC + LIMIT $4 + `; + + const result = await AppDataSource.query(query, [ + tenantId, + filters.startDate, + filters.endDate, + limit, + ]); + + const data: MarginAnalysis[] = result.map((row: any) => { + const revenue = Number(row.revenue); + const cost = Number(row.cost); + const grossProfit = Number(row.gross_profit); + + return { + productId: row.product_id, + productCode: row.product_code, + productName: row.product_name, + quantitySold: Number(row.quantity_sold), + revenue, + cost, + grossProfit, + marginPercent: revenue > 0 ? (grossProfit / revenue) * 100 : 0, + }; + }); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'MARGIN_ANALYSIS_ERROR', + message: error.message || 'Failed to get margin analysis', + }, + }; + } + } + + /** + * Get tax summary + */ + async getTaxSummary( + tenantId: string, + filters: FinancialReportFilters + ): Promise> { + try { + // Total tax collected + const totalQuery = ` + SELECT COALESCE(SUM(tax_amount), 0) as total_tax + FROM retail.pos_orders + WHERE tenant_id = $1 + AND status = 'paid' + AND type = 'sale' + AND created_at BETWEEN $2 AND $3 + `; + + // Tax by branch + const branchQuery = ` + SELECT + o.branch_id, + b.name as branch_name, + COALESCE(SUM(o.tax_amount), 0) as tax_collected + FROM retail.pos_orders o + JOIN retail.branches b ON o.branch_id = b.id + WHERE o.tenant_id = $1 + AND o.status = 'paid' + AND o.type = 'sale' + AND o.created_at BETWEEN $2 AND $3 + GROUP BY o.branch_id, b.name + ORDER BY tax_collected DESC + `; + + const [totalResult, branchResult] = await Promise.all([ + AppDataSource.query(totalQuery, [tenantId, filters.startDate, filters.endDate]), + AppDataSource.query(branchQuery, [tenantId, filters.startDate, filters.endDate]), + ]); + + const totalTax = Number(totalResult[0]?.total_tax || 0); + + // Default IVA breakdown (simplified) + const byTaxType = [ + { + taxType: 'IVA', + taxName: 'Impuesto al Valor Agregado', + rate: 16, + taxableBase: totalTax / 0.16, + taxAmount: totalTax, + }, + ]; + + const byBranch = branchResult.map((row: any) => ({ + branchId: row.branch_id, + branchName: row.branch_name, + taxCollected: Number(row.tax_collected), + })); + + return { + success: true, + data: { + totalTaxCollected: totalTax, + byTaxType, + byBranch, + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'TAX_SUMMARY_ERROR', + message: error.message || 'Failed to get tax summary', + }, + }; + } + } + + /** + * Get cash flow summary + */ + async getCashFlowSummary( + tenantId: string, + filters: FinancialReportFilters, + branchId?: string + ): Promise> { + try { + // Get opening and closing from cash sessions + const sessionQuery = ` + SELECT + COALESCE(SUM(opening_cash), 0) as opening_balance, + COALESCE(SUM(closing_cash_counted), 0) as closing_balance + FROM retail.pos_sessions + WHERE tenant_id = $1 + AND opened_at::date >= $2::date + AND opened_at::date <= $3::date + ${branchId ? 'AND branch_id = $4' : ''} + `; + + // Get payments by method + const paymentQuery = ` + SELECT + p.method, + COALESCE(SUM(CASE WHEN p.amount > 0 THEN p.amount ELSE 0 END), 0) as cash_in, + COALESCE(SUM(CASE WHEN p.amount < 0 THEN ABS(p.amount) ELSE 0 END), 0) as cash_out + FROM retail.pos_payments p + JOIN retail.pos_orders o ON p.order_id = o.id + WHERE o.tenant_id = $1 + AND o.created_at BETWEEN $2 AND $3 + ${branchId ? 'AND o.branch_id = $4' : ''} + GROUP BY p.method + `; + + const params = branchId + ? [tenantId, filters.startDate, filters.endDate, branchId] + : [tenantId, filters.startDate, filters.endDate]; + + const [sessionResult, paymentResult] = await Promise.all([ + AppDataSource.query(sessionQuery, params), + AppDataSource.query(paymentQuery, params), + ]); + + const session = sessionResult[0] || {}; + const openingBalance = Number(session.opening_balance || 0); + const closingBalance = Number(session.closing_balance || 0); + + let totalCashIn = 0; + let totalCashOut = 0; + + const methodNames: Record = { + cash: 'Efectivo', + credit_card: 'Tarjeta de Credito', + debit_card: 'Tarjeta de Debito', + transfer: 'Transferencia', + wallet: 'Monedero', + check: 'Cheque', + credit: 'Credito', + gift_card: 'Tarjeta de Regalo', + loyalty_points: 'Puntos de Lealtad', + other: 'Otro', + }; + + const byPaymentMethod = paymentResult.map((row: any) => { + const cashIn = Number(row.cash_in); + const cashOut = Number(row.cash_out); + totalCashIn += cashIn; + totalCashOut += cashOut; + + return { + method: row.method, + methodName: methodNames[row.method] || row.method, + amount: cashIn - cashOut, + percentOfTotal: 0, // Will calculate after + }; + }); + + const totalAmount = byPaymentMethod.reduce((sum: number, m: { amount: number }) => sum + Math.abs(m.amount), 0); + byPaymentMethod.forEach((m: { amount: number; percentOfTotal: number }) => { + m.percentOfTotal = totalAmount > 0 ? (Math.abs(m.amount) / totalAmount) * 100 : 0; + }); + + return { + success: true, + data: { + openingBalance, + cashIn: totalCashIn, + cashOut: totalCashOut, + closingBalance, + netCashFlow: totalCashIn - totalCashOut, + byPaymentMethod, + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'CASH_FLOW_ERROR', + message: error.message || 'Failed to get cash flow summary', + }, + }; + } + } + + /** + * Get daily sales reconciliation + */ + async getDailySalesReconciliation( + tenantId: string, + date: Date, + branchId?: string + ): Promise> { + try { + const dateStr = date.toISOString().slice(0, 10); + + let branchFilter = ''; + const params: any[] = [tenantId, dateStr]; + + if (branchId) { + branchFilter = 'AND s.branch_id = $3'; + params.push(branchId); + } + + const query = ` + SELECT + s.branch_id, + b.name as branch_name, + s.id as session_id, + u.name as cashier, + s.opened_at, + s.closed_at, + s.opening_cash, + s.closing_cash_counted, + s.total_sales, + s.total_cash, + s.total_card, + s.total_refunds + FROM retail.pos_sessions s + JOIN retail.branches b ON s.branch_id = b.id + LEFT JOIN core.users u ON s.user_id = u.id + WHERE s.tenant_id = $1 + AND s.opened_at::date = $2::date + ${branchFilter} + ORDER BY b.name, s.opened_at + `; + + const result = await AppDataSource.query(query, params); + + // Group by branch + const branchMap = new Map(); + + for (const row of result) { + if (!branchMap.has(row.branch_id)) { + branchMap.set(row.branch_id, { + date: dateStr, + branchId: row.branch_id, + branchName: row.branch_name, + sessions: [], + totalSales: 0, + totalCash: 0, + totalCards: 0, + totalOther: 0, + discrepancies: 0, + }); + } + + const branch = branchMap.get(row.branch_id)!; + const expectedCash = Number(row.opening_cash) + Number(row.total_cash) - Number(row.total_refunds); + const actualCash = Number(row.closing_cash_counted); + const difference = actualCash - expectedCash; + + branch.sessions.push({ + sessionId: row.session_id, + cashier: row.cashier || 'Unknown', + openingTime: row.opened_at?.toISOString() || '', + closingTime: row.closed_at?.toISOString() || '', + openingCash: Number(row.opening_cash), + closingCash: actualCash, + expectedCash, + cashDifference: difference, + totalSales: Number(row.total_sales), + totalRefunds: Number(row.total_refunds), + paymentBreakdown: [ + { method: 'cash', amount: Number(row.total_cash) }, + { method: 'card', amount: Number(row.total_card) }, + ], + }); + + branch.totalSales += Number(row.total_sales); + branch.totalCash += Number(row.total_cash); + branch.totalCards += Number(row.total_card); + branch.discrepancies += Math.abs(difference); + } + + const data = Array.from(branchMap.values()); + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'RECONCILIATION_ERROR', + message: error.message || 'Failed to get reconciliation', + }, + }; + } + } + + /** + * Compare periods (current vs previous) + */ + async comparePeriods( + tenantId: string, + currentPeriod: FinancialReportFilters, + previousPeriod: FinancialReportFilters + ): Promise> { + try { + const [currentResult, previousResult] = await Promise.all([ + this.getRevenueSummary(tenantId, currentPeriod), + this.getRevenueSummary(tenantId, previousPeriod), + ]); + + if (!currentResult.success || !previousResult.success) { + return { + success: false, + error: { + code: 'COMPARISON_ERROR', + message: 'Failed to get comparison data', + }, + }; + } + + const current = currentResult.data; + const previous = previousResult.data; + + const revenueChange = current.netRevenue - previous.netRevenue; + const profitChange = current.grossProfit - previous.grossProfit; + const marginChange = current.grossMargin - previous.grossMargin; + + return { + success: true, + data: { + current, + previous, + changes: { + revenueChange, + revenueChangePercent: previous.netRevenue > 0 + ? (revenueChange / previous.netRevenue) * 100 + : 0, + profitChange, + profitChangePercent: previous.grossProfit > 0 + ? (profitChange / previous.grossProfit) * 100 + : 0, + marginChange, + }, + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'COMPARISON_ERROR', + message: error.message || 'Failed to compare periods', + }, + }; + } + } +} + +export const financialReportService = new FinancialReportService(); diff --git a/src/modules/reports/services/index.ts b/src/modules/reports/services/index.ts new file mode 100644 index 0000000..eda140e --- /dev/null +++ b/src/modules/reports/services/index.ts @@ -0,0 +1,6 @@ +export * from './sales-report.service'; +export * from './inventory-report.service'; +export * from './customer-report.service'; +export * from './financial-report.service'; +export * from './dashboard.service'; +export * from './report-scheduler.service'; diff --git a/src/modules/reports/services/inventory-report.service.ts b/src/modules/reports/services/inventory-report.service.ts new file mode 100644 index 0000000..4b957ee --- /dev/null +++ b/src/modules/reports/services/inventory-report.service.ts @@ -0,0 +1,720 @@ +import { Repository, LessThan, MoreThan, Between } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm'; +import { ServiceResult } from '../../../shared/types'; + +export interface InventoryReportFilters { + branchIds?: string[]; + warehouseIds?: string[]; + categoryIds?: string[]; + productIds?: string[]; + startDate?: Date; + endDate?: Date; +} + +export interface StockLevelSummary { + totalProducts: number; + totalSKUs: number; + totalStock: number; + totalValue: number; + lowStockItems: number; + outOfStockItems: number; + overstockItems: number; +} + +export interface StockByProduct { + productId: string; + productCode: string; + productName: string; + barcode: string; + categoryName: string; + currentStock: number; + minStock: number; + maxStock: number; + reorderPoint: number; + unitCost: number; + totalValue: number; + status: 'normal' | 'low' | 'out_of_stock' | 'overstock'; + lastMovementDate: Date; +} + +export interface StockByBranch { + branchId: string; + branchCode: string; + branchName: string; + totalProducts: number; + totalStock: number; + totalValue: number; + lowStockItems: number; + outOfStockItems: number; +} + +export interface StockByCategory { + categoryId: string; + categoryName: string; + totalProducts: number; + totalStock: number; + totalValue: number; + percentOfValue: number; +} + +export interface StockMovement { + movementId: string; + movementDate: Date; + movementType: string; + productId: string; + productCode: string; + productName: string; + quantity: number; + direction: 'in' | 'out'; + referenceType: string; + referenceNumber: string; + previousStock: number; + newStock: number; + unitCost: number; + totalValue: number; +} + +export interface StockMovementSummary { + totalIn: number; + totalOut: number; + netChange: number; + valueIn: number; + valueOut: number; + netValueChange: number; + movementsByType: { + type: string; + count: number; + quantity: number; + value: number; + }[]; +} + +export interface StockValuation { + method: 'FIFO' | 'LIFO' | 'AVERAGE' | 'SPECIFIC'; + totalValue: number; + byCategory: { + categoryId: string; + categoryName: string; + value: number; + percentOfTotal: number; + }[]; + byBranch: { + branchId: string; + branchName: string; + value: number; + percentOfTotal: number; + }[]; +} + +export interface StockAging { + ageRange: string; + minDays: number; + maxDays: number; + itemCount: number; + totalQuantity: number; + totalValue: number; + percentOfValue: number; +} + +export interface StockTurnover { + productId: string; + productCode: string; + productName: string; + avgStock: number; + totalSold: number; + turnoverRate: number; + daysOfStock: number; +} + +export class InventoryReportService { + /** + * Get stock level summary + */ + async getStockLevelSummary( + tenantId: string, + filters: InventoryReportFilters + ): Promise> { + try { + // This would query the actual stock tables + // For now, returning a structure that shows the expected data shape + const query = ` + SELECT + COUNT(DISTINCT product_id) as total_products, + COUNT(*) as total_skus, + COALESCE(SUM(current_stock), 0) as total_stock, + COALESCE(SUM(current_stock * unit_cost), 0) as total_value, + COUNT(CASE WHEN current_stock > 0 AND current_stock <= min_stock THEN 1 END) as low_stock_items, + COUNT(CASE WHEN current_stock <= 0 THEN 1 END) as out_of_stock_items, + COUNT(CASE WHEN max_stock > 0 AND current_stock > max_stock THEN 1 END) as overstock_items + FROM retail.stock_levels + WHERE tenant_id = $1 + `; + + // Execute query with AppDataSource + const result = await AppDataSource.query(query, [tenantId]); + const row = result[0] || {}; + + return { + success: true, + data: { + totalProducts: Number(row.total_products || 0), + totalSKUs: Number(row.total_skus || 0), + totalStock: Number(row.total_stock || 0), + totalValue: Number(row.total_value || 0), + lowStockItems: Number(row.low_stock_items || 0), + outOfStockItems: Number(row.out_of_stock_items || 0), + overstockItems: Number(row.overstock_items || 0), + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'STOCK_SUMMARY_ERROR', + message: error.message || 'Failed to generate stock summary', + }, + }; + } + } + + /** + * Get stock levels by product + */ + async getStockByProduct( + tenantId: string, + filters: InventoryReportFilters, + options: { limit?: number; offset?: number; sortBy?: string; sortOrder?: 'ASC' | 'DESC' } = {} + ): Promise> { + try { + const { limit = 50, offset = 0, sortBy = 'productName', sortOrder = 'ASC' } = options; + + const query = ` + SELECT + sl.product_id, + p.code as product_code, + p.name as product_name, + p.barcode, + c.name as category_name, + sl.current_stock, + sl.min_stock, + sl.max_stock, + sl.reorder_point, + sl.unit_cost, + sl.current_stock * sl.unit_cost as total_value, + CASE + WHEN sl.current_stock <= 0 THEN 'out_of_stock' + WHEN sl.current_stock <= sl.min_stock THEN 'low' + WHEN sl.max_stock > 0 AND sl.current_stock > sl.max_stock THEN 'overstock' + ELSE 'normal' + END as status, + sl.last_movement_date + FROM retail.stock_levels sl + JOIN core.products p ON sl.product_id = p.id + LEFT JOIN core.categories c ON p.category_id = c.id + WHERE sl.tenant_id = $1 + ORDER BY ${sortBy === 'productName' ? 'p.name' : sortBy} ${sortOrder} + LIMIT $2 OFFSET $3 + `; + + const countQuery = ` + SELECT COUNT(*) as total + FROM retail.stock_levels sl + WHERE sl.tenant_id = $1 + `; + + const [dataResult, countResult] = await Promise.all([ + AppDataSource.query(query, [tenantId, limit, offset]), + AppDataSource.query(countQuery, [tenantId]), + ]); + + const data: StockByProduct[] = dataResult.map((row: any) => ({ + productId: row.product_id, + productCode: row.product_code, + productName: row.product_name, + barcode: row.barcode, + categoryName: row.category_name, + currentStock: Number(row.current_stock), + minStock: Number(row.min_stock), + maxStock: Number(row.max_stock), + reorderPoint: Number(row.reorder_point), + unitCost: Number(row.unit_cost), + totalValue: Number(row.total_value), + status: row.status, + lastMovementDate: row.last_movement_date, + })); + + return { + success: true, + data: { + data, + total: Number(countResult[0]?.total || 0), + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'STOCK_BY_PRODUCT_ERROR', + message: error.message || 'Failed to get stock by product', + }, + }; + } + } + + /** + * Get stock levels by branch/warehouse + */ + async getStockByBranch( + tenantId: string, + filters: InventoryReportFilters + ): Promise> { + try { + const query = ` + SELECT + sl.branch_id, + b.code as branch_code, + b.name as branch_name, + COUNT(DISTINCT sl.product_id) as total_products, + COALESCE(SUM(sl.current_stock), 0) as total_stock, + COALESCE(SUM(sl.current_stock * sl.unit_cost), 0) as total_value, + COUNT(CASE WHEN sl.current_stock > 0 AND sl.current_stock <= sl.min_stock THEN 1 END) as low_stock_items, + COUNT(CASE WHEN sl.current_stock <= 0 THEN 1 END) as out_of_stock_items + FROM retail.stock_levels sl + JOIN retail.branches b ON sl.branch_id = b.id + WHERE sl.tenant_id = $1 + GROUP BY sl.branch_id, b.code, b.name + ORDER BY total_value DESC + `; + + const result = await AppDataSource.query(query, [tenantId]); + + const data: StockByBranch[] = result.map((row: any) => ({ + branchId: row.branch_id, + branchCode: row.branch_code, + branchName: row.branch_name, + totalProducts: Number(row.total_products), + totalStock: Number(row.total_stock), + totalValue: Number(row.total_value), + lowStockItems: Number(row.low_stock_items), + outOfStockItems: Number(row.out_of_stock_items), + })); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'STOCK_BY_BRANCH_ERROR', + message: error.message || 'Failed to get stock by branch', + }, + }; + } + } + + /** + * Get stock by category + */ + async getStockByCategory( + tenantId: string, + filters: InventoryReportFilters + ): Promise> { + try { + const query = ` + SELECT + c.id as category_id, + c.name as category_name, + COUNT(DISTINCT sl.product_id) as total_products, + COALESCE(SUM(sl.current_stock), 0) as total_stock, + COALESCE(SUM(sl.current_stock * sl.unit_cost), 0) as total_value + FROM retail.stock_levels sl + JOIN core.products p ON sl.product_id = p.id + JOIN core.categories c ON p.category_id = c.id + WHERE sl.tenant_id = $1 + GROUP BY c.id, c.name + ORDER BY total_value DESC + `; + + const result = await AppDataSource.query(query, [tenantId]); + const totalValue = result.reduce((sum: number, row: any) => sum + Number(row.total_value), 0); + + const data: StockByCategory[] = result.map((row: any) => ({ + categoryId: row.category_id, + categoryName: row.category_name, + totalProducts: Number(row.total_products), + totalStock: Number(row.total_stock), + totalValue: Number(row.total_value), + percentOfValue: totalValue > 0 ? (Number(row.total_value) / totalValue) * 100 : 0, + })); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'STOCK_BY_CATEGORY_ERROR', + message: error.message || 'Failed to get stock by category', + }, + }; + } + } + + /** + * Get stock movements for a period + */ + async getStockMovements( + tenantId: string, + filters: InventoryReportFilters, + options: { limit?: number; offset?: number } = {} + ): Promise> { + try { + const { limit = 100, offset = 0 } = options; + const { startDate, endDate } = filters; + + const query = ` + SELECT + sm.id as movement_id, + sm.movement_date, + sm.movement_type, + sm.product_id, + p.code as product_code, + p.name as product_name, + sm.quantity, + sm.direction, + sm.reference_type, + sm.reference_number, + sm.previous_stock, + sm.new_stock, + sm.unit_cost, + sm.quantity * sm.unit_cost as total_value + FROM retail.stock_movements sm + JOIN core.products p ON sm.product_id = p.id + WHERE sm.tenant_id = $1 + AND sm.movement_date BETWEEN $2 AND $3 + ORDER BY sm.movement_date DESC + LIMIT $4 OFFSET $5 + `; + + const countQuery = ` + SELECT COUNT(*) as total + FROM retail.stock_movements sm + WHERE sm.tenant_id = $1 + AND sm.movement_date BETWEEN $2 AND $3 + `; + + const [dataResult, countResult] = await Promise.all([ + AppDataSource.query(query, [tenantId, startDate, endDate, limit, offset]), + AppDataSource.query(countQuery, [tenantId, startDate, endDate]), + ]); + + const data: StockMovement[] = dataResult.map((row: any) => ({ + movementId: row.movement_id, + movementDate: row.movement_date, + movementType: row.movement_type, + productId: row.product_id, + productCode: row.product_code, + productName: row.product_name, + quantity: Number(row.quantity), + direction: row.direction, + referenceType: row.reference_type, + referenceNumber: row.reference_number, + previousStock: Number(row.previous_stock), + newStock: Number(row.new_stock), + unitCost: Number(row.unit_cost), + totalValue: Number(row.total_value), + })); + + return { + success: true, + data: { + data, + total: Number(countResult[0]?.total || 0), + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'STOCK_MOVEMENTS_ERROR', + message: error.message || 'Failed to get stock movements', + }, + }; + } + } + + /** + * Get stock movement summary + */ + async getStockMovementSummary( + tenantId: string, + filters: InventoryReportFilters + ): Promise> { + try { + const { startDate, endDate } = filters; + + const query = ` + SELECT + COALESCE(SUM(CASE WHEN direction = 'in' THEN quantity ELSE 0 END), 0) as total_in, + COALESCE(SUM(CASE WHEN direction = 'out' THEN quantity ELSE 0 END), 0) as total_out, + COALESCE(SUM(CASE WHEN direction = 'in' THEN quantity * unit_cost ELSE 0 END), 0) as value_in, + COALESCE(SUM(CASE WHEN direction = 'out' THEN quantity * unit_cost ELSE 0 END), 0) as value_out + FROM retail.stock_movements + WHERE tenant_id = $1 + AND movement_date BETWEEN $2 AND $3 + `; + + const typeQuery = ` + SELECT + movement_type as type, + COUNT(*) as count, + COALESCE(SUM(quantity), 0) as quantity, + COALESCE(SUM(quantity * unit_cost), 0) as value + FROM retail.stock_movements + WHERE tenant_id = $1 + AND movement_date BETWEEN $2 AND $3 + GROUP BY movement_type + ORDER BY value DESC + `; + + const [summaryResult, typeResult] = await Promise.all([ + AppDataSource.query(query, [tenantId, startDate, endDate]), + AppDataSource.query(typeQuery, [tenantId, startDate, endDate]), + ]); + + const summary = summaryResult[0] || {}; + + return { + success: true, + data: { + totalIn: Number(summary.total_in || 0), + totalOut: Number(summary.total_out || 0), + netChange: Number(summary.total_in || 0) - Number(summary.total_out || 0), + valueIn: Number(summary.value_in || 0), + valueOut: Number(summary.value_out || 0), + netValueChange: Number(summary.value_in || 0) - Number(summary.value_out || 0), + movementsByType: typeResult.map((row: any) => ({ + type: row.type, + count: Number(row.count), + quantity: Number(row.quantity), + value: Number(row.value), + })), + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'MOVEMENT_SUMMARY_ERROR', + message: error.message || 'Failed to get movement summary', + }, + }; + } + } + + /** + * Get stock valuation + */ + async getStockValuation( + tenantId: string, + filters: InventoryReportFilters + ): Promise> { + try { + const categoryResult = await this.getStockByCategory(tenantId, filters); + const branchResult = await this.getStockByBranch(tenantId, filters); + + if (!categoryResult.success || !branchResult.success) { + return { + success: false, + error: { + code: 'VALUATION_ERROR', + message: 'Failed to get valuation data', + }, + }; + } + + const totalValue = categoryResult.data.reduce((sum, c) => sum + c.totalValue, 0); + + return { + success: true, + data: { + method: 'AVERAGE', + totalValue, + byCategory: categoryResult.data.map(c => ({ + categoryId: c.categoryId, + categoryName: c.categoryName, + value: c.totalValue, + percentOfTotal: c.percentOfValue, + })), + byBranch: branchResult.data.map(b => ({ + branchId: b.branchId, + branchName: b.branchName, + value: b.totalValue, + percentOfTotal: totalValue > 0 ? (b.totalValue / totalValue) * 100 : 0, + })), + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'VALUATION_ERROR', + message: error.message || 'Failed to get stock valuation', + }, + }; + } + } + + /** + * Get low stock alerts + */ + async getLowStockAlerts( + tenantId: string, + branchId?: string + ): Promise> { + try { + let query = ` + SELECT + sl.product_id, + p.code as product_code, + p.name as product_name, + p.barcode, + c.name as category_name, + sl.current_stock, + sl.min_stock, + sl.max_stock, + sl.reorder_point, + sl.unit_cost, + sl.current_stock * sl.unit_cost as total_value, + CASE + WHEN sl.current_stock <= 0 THEN 'out_of_stock' + ELSE 'low' + END as status, + sl.last_movement_date + FROM retail.stock_levels sl + JOIN core.products p ON sl.product_id = p.id + LEFT JOIN core.categories c ON p.category_id = c.id + WHERE sl.tenant_id = $1 + AND sl.current_stock <= sl.min_stock + `; + + const params: any[] = [tenantId]; + + if (branchId) { + query += ' AND sl.branch_id = $2'; + params.push(branchId); + } + + query += ' ORDER BY sl.current_stock ASC'; + + const result = await AppDataSource.query(query, params); + + const data: StockByProduct[] = result.map((row: any) => ({ + productId: row.product_id, + productCode: row.product_code, + productName: row.product_name, + barcode: row.barcode, + categoryName: row.category_name, + currentStock: Number(row.current_stock), + minStock: Number(row.min_stock), + maxStock: Number(row.max_stock), + reorderPoint: Number(row.reorder_point), + unitCost: Number(row.unit_cost), + totalValue: Number(row.total_value), + status: row.status, + lastMovementDate: row.last_movement_date, + })); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'LOW_STOCK_ALERTS_ERROR', + message: error.message || 'Failed to get low stock alerts', + }, + }; + } + } + + /** + * Get stock turnover analysis + */ + async getStockTurnover( + tenantId: string, + filters: InventoryReportFilters, + limit: number = 50 + ): Promise> { + try { + const { startDate, endDate } = filters; + + const query = ` + WITH avg_stock AS ( + SELECT + product_id, + AVG(current_stock) as avg_stock + FROM retail.stock_level_history + WHERE tenant_id = $1 + AND recorded_at BETWEEN $2 AND $3 + GROUP BY product_id + ), + sales AS ( + SELECT + ol.product_id, + SUM(ol.quantity) as total_sold + FROM retail.pos_order_lines ol + JOIN retail.pos_orders o ON ol.order_id = o.id + WHERE o.tenant_id = $1 + AND o.created_at BETWEEN $2 AND $3 + AND o.status = 'paid' + AND o.type = 'sale' + GROUP BY ol.product_id + ) + SELECT + p.id as product_id, + p.code as product_code, + p.name as product_name, + COALESCE(a.avg_stock, 0) as avg_stock, + COALESCE(s.total_sold, 0) as total_sold, + CASE + WHEN COALESCE(a.avg_stock, 0) > 0 + THEN COALESCE(s.total_sold, 0) / a.avg_stock + ELSE 0 + END as turnover_rate, + CASE + WHEN COALESCE(s.total_sold, 0) > 0 + THEN (COALESCE(a.avg_stock, 0) * EXTRACT(DAY FROM ($3::timestamp - $2::timestamp))) / s.total_sold + ELSE 999 + END as days_of_stock + FROM core.products p + LEFT JOIN avg_stock a ON p.id = a.product_id + LEFT JOIN sales s ON p.id = s.product_id + WHERE p.tenant_id = $1 + ORDER BY turnover_rate DESC + LIMIT $4 + `; + + const result = await AppDataSource.query(query, [tenantId, startDate, endDate, limit]); + + const data: StockTurnover[] = result.map((row: any) => ({ + productId: row.product_id, + productCode: row.product_code, + productName: row.product_name, + avgStock: Number(row.avg_stock), + totalSold: Number(row.total_sold), + turnoverRate: Number(row.turnover_rate), + daysOfStock: Number(row.days_of_stock), + })); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'TURNOVER_ERROR', + message: error.message || 'Failed to get stock turnover', + }, + }; + } + } +} + +export const inventoryReportService = new InventoryReportService(); diff --git a/src/modules/reports/services/report-scheduler.service.ts b/src/modules/reports/services/report-scheduler.service.ts new file mode 100644 index 0000000..00a85b1 --- /dev/null +++ b/src/modules/reports/services/report-scheduler.service.ts @@ -0,0 +1,771 @@ +import { Repository, LessThanOrEqual, In } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm'; +import { ServiceResult } from '../../../shared/types'; +import { ScheduledReport, ScheduleStatus, ScheduleFrequency, DeliveryMethod } from '../entities/scheduled-report.entity'; +import { ReportConfig, ReportFormat } from '../entities/report-config.entity'; +import { salesReportService } from './sales-report.service'; +import { inventoryReportService } from './inventory-report.service'; +import { customerReportService } from './customer-report.service'; +import { financialReportService } from './financial-report.service'; + +export interface CreateScheduledReportDTO { + reportConfigId: string; + name: string; + description?: string; + frequency: ScheduleFrequency; + cronExpression?: string; + runHour?: number; + runMinute?: number; + runDayOfWeek?: number; + runDayOfMonth?: number; + timezone?: string; + outputFormat?: ReportFormat; + deliveryMethod: DeliveryMethod; + deliveryConfig: ScheduledReport['deliveryConfig']; + reportParams?: ScheduledReport['reportParams']; + startDate?: Date; + endDate?: Date; +} + +export interface ScheduledReportExecution { + scheduleId: string; + scheduleName: string; + reportConfigId: string; + reportName: string; + executedAt: Date; + status: 'success' | 'failed' | 'skipped'; + durationMs: number; + error?: string; + deliveryStatus?: string; + outputPath?: string; +} + +export class ReportSchedulerService { + private scheduleRepository: Repository; + private configRepository: Repository; + + constructor() { + this.scheduleRepository = AppDataSource.getRepository(ScheduledReport); + this.configRepository = AppDataSource.getRepository(ReportConfig); + } + + /** + * Create a scheduled report + */ + async createScheduledReport( + tenantId: string, + userId: string, + data: CreateScheduledReportDTO + ): Promise> { + try { + // Verify report config exists + const reportConfig = await this.configRepository.findOne({ + where: { id: data.reportConfigId, tenantId }, + }); + + if (!reportConfig) { + return { + success: false, + error: { + code: 'REPORT_CONFIG_NOT_FOUND', + message: 'Report configuration not found', + }, + }; + } + + const schedule = this.scheduleRepository.create({ + tenantId, + reportConfigId: data.reportConfigId, + name: data.name, + description: data.description, + frequency: data.frequency, + cronExpression: data.cronExpression, + runHour: data.runHour ?? 8, + runMinute: data.runMinute ?? 0, + runDayOfWeek: data.runDayOfWeek, + runDayOfMonth: data.runDayOfMonth, + timezone: data.timezone ?? 'America/Mexico_City', + status: ScheduleStatus.ACTIVE, + outputFormat: data.outputFormat ?? ReportFormat.PDF, + deliveryMethod: data.deliveryMethod, + deliveryConfig: data.deliveryConfig, + reportParams: data.reportParams, + startDate: data.startDate, + endDate: data.endDate, + createdBy: userId, + }); + + // Calculate next run time + schedule.nextRunAt = this.calculateNextRunTime(schedule); + + const saved = await this.scheduleRepository.save(schedule); + return { success: true, data: saved }; + } catch (error: any) { + return { + success: false, + error: { + code: 'CREATE_SCHEDULE_ERROR', + message: error.message || 'Failed to create scheduled report', + }, + }; + } + } + + /** + * Update a scheduled report + */ + async updateScheduledReport( + tenantId: string, + scheduleId: string, + userId: string, + data: Partial + ): Promise> { + try { + const schedule = await this.scheduleRepository.findOne({ + where: { id: scheduleId, tenantId }, + }); + + if (!schedule) { + return { + success: false, + error: { + code: 'SCHEDULE_NOT_FOUND', + message: 'Scheduled report not found', + }, + }; + } + + Object.assign(schedule, data, { updatedBy: userId }); + + // Recalculate next run time if schedule changed + if (data.frequency || data.runHour !== undefined || data.runMinute !== undefined || + data.runDayOfWeek !== undefined || data.runDayOfMonth !== undefined) { + schedule.nextRunAt = this.calculateNextRunTime(schedule); + } + + const saved = await this.scheduleRepository.save(schedule); + return { success: true, data: saved }; + } catch (error: any) { + return { + success: false, + error: { + code: 'UPDATE_SCHEDULE_ERROR', + message: error.message || 'Failed to update scheduled report', + }, + }; + } + } + + /** + * Delete a scheduled report + */ + async deleteScheduledReport( + tenantId: string, + scheduleId: string + ): Promise> { + try { + const result = await this.scheduleRepository.delete({ + id: scheduleId, + tenantId, + }); + + if (result.affected === 0) { + return { + success: false, + error: { + code: 'SCHEDULE_NOT_FOUND', + message: 'Scheduled report not found', + }, + }; + } + + return { success: true, data: undefined }; + } catch (error: any) { + return { + success: false, + error: { + code: 'DELETE_SCHEDULE_ERROR', + message: error.message || 'Failed to delete scheduled report', + }, + }; + } + } + + /** + * Pause a scheduled report + */ + async pauseScheduledReport( + tenantId: string, + scheduleId: string, + userId: string + ): Promise> { + try { + const schedule = await this.scheduleRepository.findOne({ + where: { id: scheduleId, tenantId }, + }); + + if (!schedule) { + return { + success: false, + error: { + code: 'SCHEDULE_NOT_FOUND', + message: 'Scheduled report not found', + }, + }; + } + + schedule.status = ScheduleStatus.PAUSED; + schedule.updatedBy = userId; + + const saved = await this.scheduleRepository.save(schedule); + return { success: true, data: saved }; + } catch (error: any) { + return { + success: false, + error: { + code: 'PAUSE_SCHEDULE_ERROR', + message: error.message || 'Failed to pause scheduled report', + }, + }; + } + } + + /** + * Resume a scheduled report + */ + async resumeScheduledReport( + tenantId: string, + scheduleId: string, + userId: string + ): Promise> { + try { + const schedule = await this.scheduleRepository.findOne({ + where: { id: scheduleId, tenantId }, + }); + + if (!schedule) { + return { + success: false, + error: { + code: 'SCHEDULE_NOT_FOUND', + message: 'Scheduled report not found', + }, + }; + } + + schedule.status = ScheduleStatus.ACTIVE; + schedule.nextRunAt = this.calculateNextRunTime(schedule); + schedule.currentRetryCount = 0; + schedule.updatedBy = userId; + + const saved = await this.scheduleRepository.save(schedule); + return { success: true, data: saved }; + } catch (error: any) { + return { + success: false, + error: { + code: 'RESUME_SCHEDULE_ERROR', + message: error.message || 'Failed to resume scheduled report', + }, + }; + } + } + + /** + * Get scheduled reports for a tenant + */ + async getScheduledReports( + tenantId: string, + options?: { status?: ScheduleStatus; reportConfigId?: string } + ): Promise> { + try { + const where: any = { tenantId }; + if (options?.status) where.status = options.status; + if (options?.reportConfigId) where.reportConfigId = options.reportConfigId; + + const schedules = await this.scheduleRepository.find({ + where, + relations: ['reportConfig'], + order: { nextRunAt: 'ASC' }, + }); + + return { success: true, data: schedules }; + } catch (error: any) { + return { + success: false, + error: { + code: 'GET_SCHEDULES_ERROR', + message: error.message || 'Failed to get scheduled reports', + }, + }; + } + } + + /** + * Get due scheduled reports (for the scheduler worker) + */ + async getDueScheduledReports(): Promise> { + try { + const now = new Date(); + + const schedules = await this.scheduleRepository.find({ + where: { + status: ScheduleStatus.ACTIVE, + nextRunAt: LessThanOrEqual(now), + }, + relations: ['reportConfig'], + }); + + return { success: true, data: schedules }; + } catch (error: any) { + return { + success: false, + error: { + code: 'GET_DUE_SCHEDULES_ERROR', + message: error.message || 'Failed to get due scheduled reports', + }, + }; + } + } + + /** + * Execute a scheduled report + */ + async executeScheduledReport( + schedule: ScheduledReport + ): Promise> { + const startTime = Date.now(); + + try { + // Get report configuration + const reportConfig = await this.configRepository.findOne({ + where: { id: schedule.reportConfigId }, + }); + + if (!reportConfig) { + return { + success: false, + error: { + code: 'REPORT_CONFIG_NOT_FOUND', + message: 'Report configuration not found', + }, + }; + } + + // Calculate date range based on report parameters + const { startDate, endDate } = this.calculateDateRange(schedule); + + // Generate report data based on type + let reportData: any; + const filters = { + startDate, + endDate, + branchIds: schedule.reportParams?.branchIds, + }; + + switch (reportConfig.type) { + case 'sales': + reportData = await salesReportService.getSalesSummary(schedule.tenantId, filters); + break; + case 'inventory': + reportData = await inventoryReportService.getStockLevelSummary(schedule.tenantId, filters); + break; + case 'customer': + reportData = await customerReportService.getCustomerSummary(schedule.tenantId, filters); + break; + case 'financial': + reportData = await financialReportService.getRevenueSummary(schedule.tenantId, filters); + break; + default: + reportData = { message: 'Custom report type - not implemented' }; + } + + // Format report based on output format + // This is where you would generate PDF, Excel, etc. + const formattedReport = await this.formatReport(reportData, schedule.outputFormat, reportConfig); + + // Deliver report + const deliveryResult = await this.deliverReport(schedule, formattedReport); + + const duration = Date.now() - startTime; + + // Update schedule + schedule.lastRunAt = new Date(); + schedule.lastRunStatus = 'success'; + schedule.lastRunDurationMs = duration; + schedule.nextRunAt = this.calculateNextRunTime(schedule); + schedule.runCount++; + schedule.successCount++; + schedule.currentRetryCount = 0; + + await this.scheduleRepository.save(schedule); + + // Update report config run stats + reportConfig.lastRunAt = new Date(); + reportConfig.runCount++; + await this.configRepository.save(reportConfig); + + return { + success: true, + data: { + scheduleId: schedule.id, + scheduleName: schedule.name, + reportConfigId: reportConfig.id, + reportName: reportConfig.name, + executedAt: new Date(), + status: 'success', + durationMs: duration, + deliveryStatus: deliveryResult.success ? 'delivered' : 'failed', + }, + }; + } catch (error: any) { + const duration = Date.now() - startTime; + + // Update schedule with failure + schedule.lastRunAt = new Date(); + schedule.lastRunStatus = 'failed'; + schedule.lastRunError = error.message; + schedule.lastRunDurationMs = duration; + schedule.failureCount++; + schedule.currentRetryCount++; + + // Check if should retry + if (schedule.currentRetryCount >= schedule.maxRetries) { + schedule.nextRunAt = this.calculateNextRunTime(schedule); + schedule.currentRetryCount = 0; + } else { + // Schedule retry + schedule.nextRunAt = new Date(Date.now() + schedule.retryDelayMinutes * 60 * 1000); + } + + await this.scheduleRepository.save(schedule); + + return { + success: false, + error: { + code: 'EXECUTE_SCHEDULE_ERROR', + message: error.message || 'Failed to execute scheduled report', + }, + }; + } + } + + /** + * Run report immediately (ad-hoc) + */ + async runReportNow( + tenantId: string, + scheduleId: string + ): Promise> { + try { + const schedule = await this.scheduleRepository.findOne({ + where: { id: scheduleId, tenantId }, + relations: ['reportConfig'], + }); + + if (!schedule) { + return { + success: false, + error: { + code: 'SCHEDULE_NOT_FOUND', + message: 'Scheduled report not found', + }, + }; + } + + return this.executeScheduledReport(schedule); + } catch (error: any) { + return { + success: false, + error: { + code: 'RUN_NOW_ERROR', + message: error.message || 'Failed to run report', + }, + }; + } + } + + /** + * Calculate next run time based on schedule + */ + private calculateNextRunTime(schedule: ScheduledReport): Date { + const now = new Date(); + const next = new Date(); + + // Set time + next.setHours(schedule.runHour, schedule.runMinute, 0, 0); + + // If time has passed today, start from tomorrow + if (next <= now) { + next.setDate(next.getDate() + 1); + } + + switch (schedule.frequency) { + case ScheduleFrequency.DAILY: + // Already set correctly above + break; + + case ScheduleFrequency.WEEKLY: + const targetDay = schedule.runDayOfWeek ?? 1; // Default Monday + const currentDay = next.getDay(); + const daysUntilTarget = (targetDay - currentDay + 7) % 7; + if (daysUntilTarget === 0 && next <= now) { + next.setDate(next.getDate() + 7); + } else { + next.setDate(next.getDate() + daysUntilTarget); + } + break; + + case ScheduleFrequency.MONTHLY: + const targetDate = schedule.runDayOfMonth ?? 1; + next.setDate(targetDate); + if (next <= now) { + next.setMonth(next.getMonth() + 1); + } + break; + + case ScheduleFrequency.QUARTERLY: + const currentMonth = next.getMonth(); + const quarterMonths = [0, 3, 6, 9]; // Jan, Apr, Jul, Oct + let nextQuarterMonth = quarterMonths.find(m => m > currentMonth); + if (!nextQuarterMonth) { + nextQuarterMonth = quarterMonths[0]; + next.setFullYear(next.getFullYear() + 1); + } + next.setMonth(nextQuarterMonth); + next.setDate(schedule.runDayOfMonth ?? 1); + break; + } + + // Check validity period + if (schedule.endDate && next > schedule.endDate) { + return null as unknown as Date; // Schedule expired + } + + return next; + } + + /** + * Calculate date range for report + */ + private calculateDateRange(schedule: ScheduledReport): { startDate: Date; endDate: Date } { + const endDate = new Date(); + endDate.setHours(23, 59, 59, 999); + + let startDate = new Date(); + startDate.setHours(0, 0, 0, 0); + + // If custom dates provided, use them + if (schedule.reportParams?.customStartDate && schedule.reportParams?.customEndDate) { + return { + startDate: new Date(schedule.reportParams.customStartDate), + endDate: new Date(schedule.reportParams.customEndDate), + }; + } + + // Calculate based on period or frequency + const period = schedule.reportParams?.period || schedule.frequency; + + switch (period) { + case 'daily': + startDate.setDate(startDate.getDate() - 1); + break; + case 'weekly': + startDate.setDate(startDate.getDate() - 7); + break; + case 'monthly': + startDate.setMonth(startDate.getMonth() - 1); + break; + case 'quarterly': + startDate.setMonth(startDate.getMonth() - 3); + break; + } + + return { startDate, endDate }; + } + + /** + * Format report based on output format + */ + private async formatReport( + data: any, + format: ReportFormat, + config: ReportConfig + ): Promise { + switch (format) { + case ReportFormat.JSON: + return JSON.stringify(data, null, 2); + + case ReportFormat.CSV: + return this.convertToCSV(data); + + case ReportFormat.EXCEL: + // Would use a library like exceljs + return this.convertToExcel(data, config); + + case ReportFormat.PDF: + // Would use a library like pdfkit or puppeteer + return this.convertToPDF(data, config); + + default: + return JSON.stringify(data); + } + } + + private convertToCSV(data: any): string { + if (!data || !data.data) return ''; + + if (Array.isArray(data.data)) { + if (data.data.length === 0) return ''; + + const headers = Object.keys(data.data[0]); + const rows = data.data.map((row: any) => + headers.map(h => JSON.stringify(row[h] ?? '')).join(',') + ); + + return [headers.join(','), ...rows].join('\n'); + } + + // For single object, convert key-value pairs + const entries = Object.entries(data.data || data); + const headers = ['Field', 'Value']; + const rows = entries.map(([key, value]) => + `${JSON.stringify(key)},${JSON.stringify(value)}` + ); + + return [headers.join(','), ...rows].join('\n'); + } + + private async convertToExcel(data: any, config: ReportConfig): Promise { + // Placeholder - would use exceljs library + const csv = this.convertToCSV(data); + return Buffer.from(csv); + } + + private async convertToPDF(data: any, config: ReportConfig): Promise { + // Placeholder - would use pdfkit or puppeteer + const json = JSON.stringify(data, null, 2); + return Buffer.from(json); + } + + /** + * Deliver report via configured method + */ + private async deliverReport( + schedule: ScheduledReport, + report: Buffer | string + ): Promise> { + try { + switch (schedule.deliveryMethod) { + case DeliveryMethod.EMAIL: + return this.deliverViaEmail(schedule, report); + + case DeliveryMethod.SFTP: + return this.deliverViaSFTP(schedule, report); + + case DeliveryMethod.WEBHOOK: + return this.deliverViaWebhook(schedule, report); + + case DeliveryMethod.STORAGE: + return this.deliverToStorage(schedule, report); + + default: + return { success: true, data: undefined }; + } + } catch (error: any) { + return { + success: false, + error: { + code: 'DELIVERY_ERROR', + message: error.message || 'Failed to deliver report', + }, + }; + } + } + + private async deliverViaEmail( + schedule: ScheduledReport, + report: Buffer | string + ): Promise> { + const { recipients, ccRecipients, subject, bodyTemplate } = schedule.deliveryConfig; + + if (!recipients?.length) { + return { + success: false, + error: { + code: 'NO_RECIPIENTS', + message: 'No email recipients configured', + }, + }; + } + + // TODO: Implement email sending with attachment + // Would use nodemailer or similar + console.log(`Would send report to: ${recipients.join(', ')}`); + + return { success: true, data: undefined }; + } + + private async deliverViaSFTP( + schedule: ScheduledReport, + report: Buffer | string + ): Promise> { + const { sftpHost, sftpPort, sftpPath, sftpUsername } = schedule.deliveryConfig; + + if (!sftpHost) { + return { + success: false, + error: { + code: 'NO_SFTP_CONFIG', + message: 'SFTP configuration incomplete', + }, + }; + } + + // TODO: Implement SFTP upload + // Would use ssh2-sftp-client or similar + console.log(`Would upload report to: ${sftpHost}:${sftpPort}${sftpPath}`); + + return { success: true, data: undefined }; + } + + private async deliverViaWebhook( + schedule: ScheduledReport, + report: Buffer | string + ): Promise> { + const { webhookUrl, webhookHeaders } = schedule.deliveryConfig; + + if (!webhookUrl) { + return { + success: false, + error: { + code: 'NO_WEBHOOK_URL', + message: 'Webhook URL not configured', + }, + }; + } + + // TODO: Implement webhook POST + // Would use fetch or axios + console.log(`Would POST report to: ${webhookUrl}`); + + return { success: true, data: undefined }; + } + + private async deliverToStorage( + schedule: ScheduledReport, + report: Buffer | string + ): Promise> { + const { storagePath } = schedule.deliveryConfig; + + // TODO: Implement storage (S3, local filesystem, etc.) + const filename = `report_${schedule.id}_${Date.now()}.${schedule.outputFormat}`; + const fullPath = `${storagePath || '/reports'}/${filename}`; + + console.log(`Would save report to: ${fullPath}`); + + return { success: true, data: undefined }; + } +} + +export const reportSchedulerService = new ReportSchedulerService(); diff --git a/src/modules/reports/services/sales-report.service.ts b/src/modules/reports/services/sales-report.service.ts new file mode 100644 index 0000000..aa2a46e --- /dev/null +++ b/src/modules/reports/services/sales-report.service.ts @@ -0,0 +1,645 @@ +import { Repository, Between, In } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm'; +import { ServiceResult } from '../../../shared/types'; +import { POSOrder, OrderStatus, OrderType } from '../../pos/entities/pos-order.entity'; +import { POSOrderLine } from '../../pos/entities/pos-order-line.entity'; +import { POSPayment, PaymentMethod } from '../../pos/entities/pos-payment.entity'; + +export interface SalesReportFilters { + startDate: Date; + endDate: Date; + branchIds?: string[]; + categoryIds?: string[]; + productIds?: string[]; + cashierIds?: string[]; + paymentMethods?: PaymentMethod[]; +} + +export interface SalesSummary { + totalSales: number; + totalOrders: number; + totalItems: number; + averageTicket: number; + totalDiscounts: number; + totalTax: number; + netSales: number; + totalRefunds: number; + netRevenue: number; +} + +export interface SalesByPeriod { + period: string; + date?: string; + sales: number; + orders: number; + items: number; + averageTicket: number; +} + +export interface SalesByProduct { + productId: string; + productCode: string; + productName: string; + quantity: number; + totalSales: number; + avgPrice: number; + percentOfTotal: number; +} + +export interface SalesByCategory { + categoryId: string; + categoryName: string; + quantity: number; + totalSales: number; + percentOfTotal: number; +} + +export interface SalesByBranch { + branchId: string; + branchName: string; + branchCode: string; + totalSales: number; + orders: number; + items: number; + averageTicket: number; + percentOfTotal: number; +} + +export interface SalesByCashier { + cashierId: string; + cashierName: string; + totalSales: number; + orders: number; + items: number; + averageTicket: number; + refunds: number; + voids: number; +} + +export interface SalesByPaymentMethod { + method: PaymentMethod; + methodName: string; + totalAmount: number; + transactionCount: number; + percentOfTotal: number; +} + +export interface SalesByHour { + hour: number; + hourLabel: string; + sales: number; + orders: number; + averageTicket: number; +} + +export class SalesReportService { + private orderRepository: Repository; + private orderLineRepository: Repository; + private paymentRepository: Repository; + + constructor() { + this.orderRepository = AppDataSource.getRepository(POSOrder); + this.orderLineRepository = AppDataSource.getRepository(POSOrderLine); + this.paymentRepository = AppDataSource.getRepository(POSPayment); + } + + /** + * Get sales summary for a period + */ + async getSalesSummary( + tenantId: string, + filters: SalesReportFilters + ): Promise> { + try { + const qb = this.orderRepository.createQueryBuilder('order') + .where('order.tenantId = :tenantId', { tenantId }) + .andWhere('order.createdAt BETWEEN :startDate AND :endDate', { + startDate: filters.startDate, + endDate: filters.endDate, + }) + .andWhere('order.status = :status', { status: OrderStatus.PAID }); + + if (filters.branchIds?.length) { + qb.andWhere('order.branchId IN (:...branchIds)', { branchIds: filters.branchIds }); + } + + if (filters.cashierIds?.length) { + qb.andWhere('order.userId IN (:...cashierIds)', { cashierIds: filters.cashierIds }); + } + + const orders = await qb.getMany(); + + const salesOrders = orders.filter(o => o.type === OrderType.SALE); + const refundOrders = orders.filter(o => o.type === OrderType.REFUND); + + const totalSales = salesOrders.reduce((sum, o) => sum + Number(o.total), 0); + const totalDiscounts = salesOrders.reduce((sum, o) => sum + Number(o.discountAmount), 0); + const totalTax = salesOrders.reduce((sum, o) => sum + Number(o.taxAmount), 0); + const totalRefunds = Math.abs(refundOrders.reduce((sum, o) => sum + Number(o.total), 0)); + + // Get total items + const itemsResult = await this.orderLineRepository + .createQueryBuilder('line') + .select('SUM(line.quantity)', 'totalItems') + .innerJoin('line.order', 'order') + .where('order.tenantId = :tenantId', { tenantId }) + .andWhere('order.createdAt BETWEEN :startDate AND :endDate', { + startDate: filters.startDate, + endDate: filters.endDate, + }) + .andWhere('order.status = :status', { status: OrderStatus.PAID }) + .andWhere('order.type = :type', { type: OrderType.SALE }) + .getRawOne(); + + const totalItems = Number(itemsResult?.totalItems || 0); + const totalOrders = salesOrders.length; + const averageTicket = totalOrders > 0 ? totalSales / totalOrders : 0; + const netSales = totalSales - totalDiscounts; + const netRevenue = totalSales - totalRefunds; + + return { + success: true, + data: { + totalSales, + totalOrders, + totalItems, + averageTicket, + totalDiscounts, + totalTax, + netSales, + totalRefunds, + netRevenue, + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'SALES_SUMMARY_ERROR', + message: error.message || 'Failed to generate sales summary', + }, + }; + } + } + + /** + * Get sales by period (day, week, month) + */ + async getSalesByPeriod( + tenantId: string, + filters: SalesReportFilters, + groupBy: 'day' | 'week' | 'month' = 'day' + ): Promise> { + try { + let dateFormat: string; + switch (groupBy) { + case 'week': + dateFormat = 'IYYY-IW'; + break; + case 'month': + dateFormat = 'YYYY-MM'; + break; + default: + dateFormat = 'YYYY-MM-DD'; + } + + const result = await this.orderRepository + .createQueryBuilder('order') + .select(`TO_CHAR(order.createdAt, '${dateFormat}')`, 'period') + .addSelect('SUM(order.total)', 'sales') + .addSelect('COUNT(order.id)', 'orders') + .addSelect('AVG(order.total)', 'averageTicket') + .where('order.tenantId = :tenantId', { tenantId }) + .andWhere('order.createdAt BETWEEN :startDate AND :endDate', { + startDate: filters.startDate, + endDate: filters.endDate, + }) + .andWhere('order.status = :status', { status: OrderStatus.PAID }) + .andWhere('order.type = :type', { type: OrderType.SALE }) + .groupBy('period') + .orderBy('period', 'ASC') + .getRawMany(); + + // Get items per period + const itemsResult = await this.orderLineRepository + .createQueryBuilder('line') + .select(`TO_CHAR(order.createdAt, '${dateFormat}')`, 'period') + .addSelect('SUM(line.quantity)', 'items') + .innerJoin('line.order', 'order') + .where('order.tenantId = :tenantId', { tenantId }) + .andWhere('order.createdAt BETWEEN :startDate AND :endDate', { + startDate: filters.startDate, + endDate: filters.endDate, + }) + .andWhere('order.status = :status', { status: OrderStatus.PAID }) + .andWhere('order.type = :type', { type: OrderType.SALE }) + .groupBy('period') + .getRawMany(); + + const itemsMap = new Map(itemsResult.map(i => [i.period, Number(i.items)])); + + const data: SalesByPeriod[] = result.map(r => ({ + period: r.period, + sales: Number(r.sales), + orders: Number(r.orders), + items: itemsMap.get(r.period) || 0, + averageTicket: Number(r.averageTicket), + })); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'SALES_BY_PERIOD_ERROR', + message: error.message || 'Failed to generate sales by period', + }, + }; + } + } + + /** + * Get sales by product + */ + async getSalesByProduct( + tenantId: string, + filters: SalesReportFilters, + limit: number = 50 + ): Promise> { + try { + const result = await this.orderLineRepository + .createQueryBuilder('line') + .select('line.productId', 'productId') + .addSelect('line.productCode', 'productCode') + .addSelect('line.productName', 'productName') + .addSelect('SUM(line.quantity)', 'quantity') + .addSelect('SUM(line.total)', 'totalSales') + .addSelect('AVG(line.unitPrice)', 'avgPrice') + .innerJoin('line.order', 'order') + .where('order.tenantId = :tenantId', { tenantId }) + .andWhere('order.createdAt BETWEEN :startDate AND :endDate', { + startDate: filters.startDate, + endDate: filters.endDate, + }) + .andWhere('order.status = :status', { status: OrderStatus.PAID }) + .andWhere('order.type = :type', { type: OrderType.SALE }) + .groupBy('line.productId') + .addGroupBy('line.productCode') + .addGroupBy('line.productName') + .orderBy('SUM(line.total)', 'DESC') + .limit(limit) + .getRawMany(); + + const totalSales = result.reduce((sum, r) => sum + Number(r.totalSales), 0); + + const data: SalesByProduct[] = result.map(r => ({ + productId: r.productId, + productCode: r.productCode, + productName: r.productName, + quantity: Number(r.quantity), + totalSales: Number(r.totalSales), + avgPrice: Number(r.avgPrice), + percentOfTotal: totalSales > 0 ? (Number(r.totalSales) / totalSales) * 100 : 0, + })); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'SALES_BY_PRODUCT_ERROR', + message: error.message || 'Failed to generate sales by product', + }, + }; + } + } + + /** + * Get sales by branch + */ + async getSalesByBranch( + tenantId: string, + filters: SalesReportFilters + ): Promise> { + try { + const result = await this.orderRepository + .createQueryBuilder('order') + .select('order.branchId', 'branchId') + .addSelect('SUM(order.total)', 'totalSales') + .addSelect('COUNT(order.id)', 'orders') + .addSelect('AVG(order.total)', 'averageTicket') + .where('order.tenantId = :tenantId', { tenantId }) + .andWhere('order.createdAt BETWEEN :startDate AND :endDate', { + startDate: filters.startDate, + endDate: filters.endDate, + }) + .andWhere('order.status = :status', { status: OrderStatus.PAID }) + .andWhere('order.type = :type', { type: OrderType.SALE }) + .groupBy('order.branchId') + .orderBy('SUM(order.total)', 'DESC') + .getRawMany(); + + // Get items per branch + const itemsResult = await this.orderLineRepository + .createQueryBuilder('line') + .select('order.branchId', 'branchId') + .addSelect('SUM(line.quantity)', 'items') + .innerJoin('line.order', 'order') + .where('order.tenantId = :tenantId', { tenantId }) + .andWhere('order.createdAt BETWEEN :startDate AND :endDate', { + startDate: filters.startDate, + endDate: filters.endDate, + }) + .andWhere('order.status = :status', { status: OrderStatus.PAID }) + .andWhere('order.type = :type', { type: OrderType.SALE }) + .groupBy('order.branchId') + .getRawMany(); + + const itemsMap = new Map(itemsResult.map(i => [i.branchId, Number(i.items)])); + const totalSales = result.reduce((sum, r) => sum + Number(r.totalSales), 0); + + // TODO: Join with branches table to get branch names + const data: SalesByBranch[] = result.map(r => ({ + branchId: r.branchId, + branchName: 'Branch', // Would come from join + branchCode: 'BR', // Would come from join + totalSales: Number(r.totalSales), + orders: Number(r.orders), + items: itemsMap.get(r.branchId) || 0, + averageTicket: Number(r.averageTicket), + percentOfTotal: totalSales > 0 ? (Number(r.totalSales) / totalSales) * 100 : 0, + })); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'SALES_BY_BRANCH_ERROR', + message: error.message || 'Failed to generate sales by branch', + }, + }; + } + } + + /** + * Get sales by cashier + */ + async getSalesByCashier( + tenantId: string, + filters: SalesReportFilters + ): Promise> { + try { + const qb = this.orderRepository.createQueryBuilder('order') + .select('order.userId', 'cashierId') + .addSelect('SUM(CASE WHEN order.type = :saleType THEN order.total ELSE 0 END)', 'totalSales') + .addSelect('COUNT(CASE WHEN order.type = :saleType THEN 1 END)', 'orders') + .addSelect('AVG(CASE WHEN order.type = :saleType THEN order.total END)', 'averageTicket') + .addSelect('SUM(CASE WHEN order.type = :refundType THEN ABS(order.total) ELSE 0 END)', 'refunds') + .addSelect('COUNT(CASE WHEN order.status = :voidedStatus THEN 1 END)', 'voids') + .where('order.tenantId = :tenantId', { tenantId }) + .andWhere('order.createdAt BETWEEN :startDate AND :endDate', { + startDate: filters.startDate, + endDate: filters.endDate, + }) + .setParameters({ + saleType: OrderType.SALE, + refundType: OrderType.REFUND, + voidedStatus: OrderStatus.VOIDED, + }) + .groupBy('order.userId') + .orderBy('SUM(CASE WHEN order.type = :saleType THEN order.total ELSE 0 END)', 'DESC'); + + if (filters.branchIds?.length) { + qb.andWhere('order.branchId IN (:...branchIds)', { branchIds: filters.branchIds }); + } + + const result = await qb.getRawMany(); + + // Get items per cashier + const itemsResult = await this.orderLineRepository + .createQueryBuilder('line') + .select('order.userId', 'cashierId') + .addSelect('SUM(line.quantity)', 'items') + .innerJoin('line.order', 'order') + .where('order.tenantId = :tenantId', { tenantId }) + .andWhere('order.createdAt BETWEEN :startDate AND :endDate', { + startDate: filters.startDate, + endDate: filters.endDate, + }) + .andWhere('order.status = :status', { status: OrderStatus.PAID }) + .andWhere('order.type = :type', { type: OrderType.SALE }) + .groupBy('order.userId') + .getRawMany(); + + const itemsMap = new Map(itemsResult.map(i => [i.cashierId, Number(i.items)])); + + // TODO: Join with users table to get cashier names + const data: SalesByCashier[] = result.map(r => ({ + cashierId: r.cashierId, + cashierName: 'Cashier', // Would come from join + totalSales: Number(r.totalSales), + orders: Number(r.orders), + items: itemsMap.get(r.cashierId) || 0, + averageTicket: Number(r.averageTicket) || 0, + refunds: Number(r.refunds), + voids: Number(r.voids), + })); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'SALES_BY_CASHIER_ERROR', + message: error.message || 'Failed to generate sales by cashier', + }, + }; + } + } + + /** + * Get sales by payment method + */ + async getSalesByPaymentMethod( + tenantId: string, + filters: SalesReportFilters + ): Promise> { + try { + const result = await this.paymentRepository + .createQueryBuilder('payment') + .select('payment.method', 'method') + .addSelect('SUM(payment.amount)', 'totalAmount') + .addSelect('COUNT(payment.id)', 'transactionCount') + .innerJoin('payment.order', 'order') + .where('order.tenantId = :tenantId', { tenantId }) + .andWhere('order.createdAt BETWEEN :startDate AND :endDate', { + startDate: filters.startDate, + endDate: filters.endDate, + }) + .andWhere('order.status = :status', { status: OrderStatus.PAID }) + .andWhere('payment.amount > 0') + .groupBy('payment.method') + .orderBy('SUM(payment.amount)', 'DESC') + .getRawMany(); + + const totalAmount = result.reduce((sum, r) => sum + Number(r.totalAmount), 0); + + const methodNames: Record = { + [PaymentMethod.CASH]: 'Efectivo', + [PaymentMethod.CREDIT_CARD]: 'Tarjeta de Credito', + [PaymentMethod.DEBIT_CARD]: 'Tarjeta de Debito', + [PaymentMethod.TRANSFER]: 'Transferencia', + [PaymentMethod.CHECK]: 'Cheque', + [PaymentMethod.LOYALTY_POINTS]: 'Puntos de Lealtad', + [PaymentMethod.VOUCHER]: 'Vale/Voucher', + [PaymentMethod.OTHER]: 'Otro', + }; + + const data: SalesByPaymentMethod[] = result.map(r => ({ + method: r.method as PaymentMethod, + methodName: methodNames[r.method as PaymentMethod] || r.method, + totalAmount: Number(r.totalAmount), + transactionCount: Number(r.transactionCount), + percentOfTotal: totalAmount > 0 ? (Number(r.totalAmount) / totalAmount) * 100 : 0, + })); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'SALES_BY_PAYMENT_ERROR', + message: error.message || 'Failed to generate sales by payment method', + }, + }; + } + } + + /** + * Get sales by hour of day + */ + async getSalesByHour( + tenantId: string, + filters: SalesReportFilters + ): Promise> { + try { + const result = await this.orderRepository + .createQueryBuilder('order') + .select('EXTRACT(HOUR FROM order.createdAt)', 'hour') + .addSelect('SUM(order.total)', 'sales') + .addSelect('COUNT(order.id)', 'orders') + .addSelect('AVG(order.total)', 'averageTicket') + .where('order.tenantId = :tenantId', { tenantId }) + .andWhere('order.createdAt BETWEEN :startDate AND :endDate', { + startDate: filters.startDate, + endDate: filters.endDate, + }) + .andWhere('order.status = :status', { status: OrderStatus.PAID }) + .andWhere('order.type = :type', { type: OrderType.SALE }) + .groupBy('EXTRACT(HOUR FROM order.createdAt)') + .orderBy('hour', 'ASC') + .getRawMany(); + + const formatHour = (hour: number): string => { + const h = hour % 12 || 12; + const ampm = hour < 12 ? 'AM' : 'PM'; + return `${h}:00 ${ampm}`; + }; + + const data: SalesByHour[] = result.map(r => ({ + hour: Number(r.hour), + hourLabel: formatHour(Number(r.hour)), + sales: Number(r.sales), + orders: Number(r.orders), + averageTicket: Number(r.averageTicket), + })); + + return { success: true, data }; + } catch (error: any) { + return { + success: false, + error: { + code: 'SALES_BY_HOUR_ERROR', + message: error.message || 'Failed to generate sales by hour', + }, + }; + } + } + + /** + * Get comparison between two periods + */ + async getComparison( + tenantId: string, + currentPeriod: SalesReportFilters, + previousPeriod: SalesReportFilters + ): Promise> { + try { + const [currentResult, previousResult] = await Promise.all([ + this.getSalesSummary(tenantId, currentPeriod), + this.getSalesSummary(tenantId, previousPeriod), + ]); + + if (!currentResult.success || !previousResult.success) { + return { + success: false, + error: { + code: 'COMPARISON_ERROR', + message: 'Failed to get comparison data', + }, + }; + } + + const current = currentResult.data; + const previous = previousResult.data; + + const salesChange = current.totalSales - previous.totalSales; + const ordersChange = current.totalOrders - previous.totalOrders; + const avgTicketChange = current.averageTicket - previous.averageTicket; + + return { + success: true, + data: { + current, + previous, + changes: { + salesChange, + salesChangePercent: previous.totalSales > 0 + ? (salesChange / previous.totalSales) * 100 + : 0, + ordersChange, + ordersChangePercent: previous.totalOrders > 0 + ? (ordersChange / previous.totalOrders) * 100 + : 0, + avgTicketChange, + avgTicketChangePercent: previous.averageTicket > 0 + ? (avgTicketChange / previous.averageTicket) * 100 + : 0, + }, + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'COMPARISON_ERROR', + message: error.message || 'Failed to generate comparison', + }, + }; + } + } +} + +export const salesReportService = new SalesReportService();