diff --git a/src/modules/reports-clinical/controllers/index.ts b/src/modules/reports-clinical/controllers/index.ts new file mode 100644 index 0000000..90689c3 --- /dev/null +++ b/src/modules/reports-clinical/controllers/index.ts @@ -0,0 +1 @@ +export { ReportsClinicalController } from './reports-clinical.controller'; diff --git a/src/modules/reports-clinical/controllers/reports-clinical.controller.ts b/src/modules/reports-clinical/controllers/reports-clinical.controller.ts new file mode 100644 index 0000000..ed136c9 --- /dev/null +++ b/src/modules/reports-clinical/controllers/reports-clinical.controller.ts @@ -0,0 +1,581 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + ReportTemplateService, + ReportGeneratorService, + ClinicalStatisticsService, + ReportScheduleService, +} from '../services'; +import { + CreateReportTemplateDto, + UpdateReportTemplateDto, + ReportTemplateQueryDto, + GenerateReportDto, + GeneratedReportQueryDto, + CreateReportScheduleDto, + UpdateReportScheduleDto, + ReportScheduleQueryDto, + ClinicalStatsQueryDto, +} from '../dto'; + +export class ReportsClinicalController { + public router: Router; + private templateService: ReportTemplateService; + private generatorService: ReportGeneratorService; + private statisticsService: ClinicalStatisticsService; + private scheduleService: ReportScheduleService; + + constructor(dataSource: DataSource, basePath: string = '/api') { + this.router = Router(); + this.templateService = new ReportTemplateService(dataSource); + this.generatorService = new ReportGeneratorService(dataSource); + this.statisticsService = new ClinicalStatisticsService(dataSource); + this.scheduleService = new ReportScheduleService(dataSource); + this.setupRoutes(basePath); + } + + private setupRoutes(basePath: string): void { + const reportsPath = `${basePath}/reports-clinical`; + + // Report Templates Routes + this.router.get(`${reportsPath}/templates`, this.findAllTemplates.bind(this)); + this.router.get(`${reportsPath}/templates/types`, this.getReportTypes.bind(this)); + this.router.get(`${reportsPath}/templates/active`, this.getActiveTemplates.bind(this)); + this.router.get(`${reportsPath}/templates/:id`, this.findTemplateById.bind(this)); + this.router.post(`${reportsPath}/templates`, this.createTemplate.bind(this)); + this.router.patch(`${reportsPath}/templates/:id`, this.updateTemplate.bind(this)); + this.router.post(`${reportsPath}/templates/:id/activate`, this.activateTemplate.bind(this)); + this.router.post(`${reportsPath}/templates/:id/deactivate`, this.deactivateTemplate.bind(this)); + this.router.post(`${reportsPath}/templates/:id/duplicate`, this.duplicateTemplate.bind(this)); + this.router.delete(`${reportsPath}/templates/:id`, this.deleteTemplate.bind(this)); + + // Generated Reports Routes + this.router.get(`${reportsPath}/generated`, this.findAllGenerated.bind(this)); + this.router.get(`${reportsPath}/generated/stats`, this.getGeneratedStats.bind(this)); + this.router.get(`${reportsPath}/generated/:id`, this.findGeneratedById.bind(this)); + this.router.post(`${reportsPath}/generate`, this.generateReport.bind(this)); + this.router.post(`${reportsPath}/generated/:id/download`, this.recordDownload.bind(this)); + + // Report Schedules Routes + this.router.get(`${reportsPath}/schedules`, this.findAllSchedules.bind(this)); + this.router.get(`${reportsPath}/schedules/stats`, this.getScheduleStats.bind(this)); + this.router.get(`${reportsPath}/schedules/:id`, this.findScheduleById.bind(this)); + this.router.post(`${reportsPath}/schedules`, this.createSchedule.bind(this)); + this.router.patch(`${reportsPath}/schedules/:id`, this.updateSchedule.bind(this)); + this.router.post(`${reportsPath}/schedules/:id/pause`, this.pauseSchedule.bind(this)); + this.router.post(`${reportsPath}/schedules/:id/resume`, this.resumeSchedule.bind(this)); + this.router.post(`${reportsPath}/schedules/:id/disable`, this.disableSchedule.bind(this)); + this.router.delete(`${reportsPath}/schedules/:id`, this.deleteSchedule.bind(this)); + + // Clinical Statistics Routes + this.router.get(`${reportsPath}/statistics/dashboard`, this.getDashboardKPI.bind(this)); + this.router.get(`${reportsPath}/statistics/patients`, this.getPatientSummary.bind(this)); + this.router.get(`${reportsPath}/statistics/appointments`, this.getAppointmentStats.bind(this)); + this.router.get(`${reportsPath}/statistics/consultations`, this.getConsultationStats.bind(this)); + this.router.get(`${reportsPath}/statistics/lab-results`, this.getLabResultsStats.bind(this)); + this.router.get(`${reportsPath}/statistics/prescriptions`, this.getPrescriptionStats.bind(this)); + this.router.get(`${reportsPath}/statistics/revenue`, this.getRevenueStats.bind(this)); + } + + private getTenantId(req: Request): string { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + throw new Error('x-tenant-id header is required'); + } + return tenantId; + } + + private getUserId(req: Request): string { + const userId = req.headers['x-user-id'] as string; + return userId || 'system'; + } + + // Report Templates Handlers + private async findAllTemplates(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: ReportTemplateQueryDto = { + search: req.query.search as string, + reportType: req.query.reportType as any, + status: req.query.status as any, + page: req.query.page ? parseInt(req.query.page as string) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string) : 50, + }; + + const result = await this.templateService.findAll(tenantId, query); + res.json({ + data: result.data, + meta: { + total: result.total, + page: query.page, + limit: query.limit, + totalPages: Math.ceil(result.total / (query.limit || 50)), + }, + }); + } catch (error) { + next(error); + } + } + + private async getReportTypes(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const types = await this.templateService.getReportTypes(tenantId); + res.json({ data: types }); + } catch (error) { + next(error); + } + } + + private async getActiveTemplates(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const templates = await this.templateService.findActiveTemplates(tenantId); + res.json({ data: templates }); + } catch (error) { + next(error); + } + } + + private async findTemplateById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const template = await this.templateService.findById(tenantId, id); + if (!template) { + res.status(404).json({ error: 'Report template not found', code: 'TEMPLATE_NOT_FOUND' }); + return; + } + + res.json({ data: template }); + } catch (error) { + next(error); + } + } + + private async createTemplate(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const dto: CreateReportTemplateDto = req.body; + + const template = await this.templateService.create(tenantId, dto, userId); + res.status(201).json({ data: template }); + } catch (error) { + next(error); + } + } + + private async updateTemplate(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdateReportTemplateDto = req.body; + + const template = await this.templateService.update(tenantId, id, dto); + if (!template) { + res.status(404).json({ error: 'Report template not found', code: 'TEMPLATE_NOT_FOUND' }); + return; + } + + res.json({ data: template }); + } catch (error) { + next(error); + } + } + + private async activateTemplate(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const template = await this.templateService.activate(tenantId, id); + if (!template) { + res.status(404).json({ error: 'Report template not found', code: 'TEMPLATE_NOT_FOUND' }); + return; + } + + res.json({ data: template }); + } catch (error) { + next(error); + } + } + + private async deactivateTemplate(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const template = await this.templateService.deactivate(tenantId, id); + if (!template) { + res.status(404).json({ error: 'Report template not found', code: 'TEMPLATE_NOT_FOUND' }); + return; + } + + res.json({ data: template }); + } catch (error) { + next(error); + } + } + + private async duplicateTemplate(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const { id } = req.params; + const { code, name } = req.body; + + const template = await this.templateService.duplicateTemplate(tenantId, id, code, name, userId); + res.status(201).json({ data: template }); + } catch (error) { + next(error); + } + } + + private async deleteTemplate(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const deleted = await this.templateService.softDelete(tenantId, id); + if (!deleted) { + res.status(404).json({ error: 'Report template not found', code: 'TEMPLATE_NOT_FOUND' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // Generated Reports Handlers + private async findAllGenerated(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: GeneratedReportQueryDto = { + templateId: req.query.templateId as string, + status: req.query.status as any, + format: req.query.format as any, + dateFrom: req.query.dateFrom as string, + dateTo: req.query.dateTo as string, + page: req.query.page ? parseInt(req.query.page as string) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string) : 20, + }; + + const result = await this.generatorService.findAll(tenantId, query); + res.json({ + data: result.data, + meta: { + total: result.total, + page: query.page, + limit: query.limit, + totalPages: Math.ceil(result.total / (query.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + private async getGeneratedStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const stats = await this.generatorService.getReportStats(tenantId); + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + private async findGeneratedById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const report = await this.generatorService.findById(tenantId, id); + if (!report) { + res.status(404).json({ error: 'Generated report not found', code: 'REPORT_NOT_FOUND' }); + return; + } + + res.json({ data: report }); + } catch (error) { + next(error); + } + } + + private async generateReport(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const dto: GenerateReportDto = req.body; + + const report = await this.generatorService.generate(tenantId, dto, userId); + res.status(202).json({ data: report }); + } catch (error) { + next(error); + } + } + + private async recordDownload(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const report = await this.generatorService.recordDownload(tenantId, id); + if (!report) { + res.status(404).json({ error: 'Generated report not found', code: 'REPORT_NOT_FOUND' }); + return; + } + + res.json({ data: report }); + } catch (error) { + next(error); + } + } + + // Report Schedules Handlers + private async findAllSchedules(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const query: ReportScheduleQueryDto = { + templateId: req.query.templateId as string, + status: req.query.status as any, + page: req.query.page ? parseInt(req.query.page as string) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string) : 20, + }; + + const result = await this.scheduleService.findAll(tenantId, query); + res.json({ + data: result.data, + meta: { + total: result.total, + page: query.page, + limit: query.limit, + totalPages: Math.ceil(result.total / (query.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + private async getScheduleStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const stats = await this.scheduleService.getScheduleStats(tenantId); + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + private async findScheduleById(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const schedule = await this.scheduleService.findById(tenantId, id); + if (!schedule) { + res.status(404).json({ error: 'Report schedule not found', code: 'SCHEDULE_NOT_FOUND' }); + return; + } + + res.json({ data: schedule }); + } catch (error) { + next(error); + } + } + + private async createSchedule(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const dto: CreateReportScheduleDto = req.body; + + const schedule = await this.scheduleService.create(tenantId, dto, userId); + res.status(201).json({ data: schedule }); + } catch (error) { + next(error); + } + } + + private async updateSchedule(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const dto: UpdateReportScheduleDto = req.body; + + const schedule = await this.scheduleService.update(tenantId, id, dto); + if (!schedule) { + res.status(404).json({ error: 'Report schedule not found', code: 'SCHEDULE_NOT_FOUND' }); + return; + } + + res.json({ data: schedule }); + } catch (error) { + next(error); + } + } + + private async pauseSchedule(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const schedule = await this.scheduleService.pause(tenantId, id); + if (!schedule) { + res.status(404).json({ error: 'Report schedule not found', code: 'SCHEDULE_NOT_FOUND' }); + return; + } + + res.json({ data: schedule }); + } catch (error) { + next(error); + } + } + + private async resumeSchedule(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const schedule = await this.scheduleService.resume(tenantId, id); + if (!schedule) { + res.status(404).json({ error: 'Report schedule not found', code: 'SCHEDULE_NOT_FOUND' }); + return; + } + + res.json({ data: schedule }); + } catch (error) { + next(error); + } + } + + private async disableSchedule(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const schedule = await this.scheduleService.disable(tenantId, id); + if (!schedule) { + res.status(404).json({ error: 'Report schedule not found', code: 'SCHEDULE_NOT_FOUND' }); + return; + } + + res.json({ data: schedule }); + } catch (error) { + next(error); + } + } + + private async deleteSchedule(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const deleted = await this.scheduleService.softDelete(tenantId, id); + if (!deleted) { + res.status(404).json({ error: 'Report schedule not found', code: 'SCHEDULE_NOT_FOUND' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // Clinical Statistics Handlers + private async getDashboardKPI(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const date = req.query.date as string; + + const kpi = await this.statisticsService.getDashboardKPI(tenantId, date); + res.json({ data: kpi }); + } catch (error) { + next(error); + } + } + + private async getPatientSummary(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { dateFrom, dateTo } = this.getDateRange(req); + + const data = await this.statisticsService.getPatientSummary(tenantId, dateFrom, dateTo); + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async getAppointmentStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { dateFrom, dateTo } = this.getDateRange(req); + + const data = await this.statisticsService.getAppointmentStats(tenantId, dateFrom, dateTo); + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async getConsultationStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { dateFrom, dateTo } = this.getDateRange(req); + + const data = await this.statisticsService.getConsultationStats(tenantId, dateFrom, dateTo); + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async getLabResultsStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { dateFrom, dateTo } = this.getDateRange(req); + + const data = await this.statisticsService.getLabResultsStats(tenantId, dateFrom, dateTo); + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async getPrescriptionStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { dateFrom, dateTo } = this.getDateRange(req); + + const data = await this.statisticsService.getPrescriptionStats(tenantId, dateFrom, dateTo); + res.json({ data }); + } catch (error) { + next(error); + } + } + + private async getRevenueStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { dateFrom, dateTo } = this.getDateRange(req); + + const data = await this.statisticsService.getRevenueStats(tenantId, dateFrom, dateTo); + res.json({ data }); + } catch (error) { + next(error); + } + } + + private getDateRange(req: Request): { dateFrom: string; dateTo: string } { + const dateFrom = req.query.dateFrom as string || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + const dateTo = req.query.dateTo as string || new Date().toISOString().split('T')[0]; + return { dateFrom, dateTo }; + } +} diff --git a/src/modules/reports-clinical/dto/index.ts b/src/modules/reports-clinical/dto/index.ts new file mode 100644 index 0000000..545ba28 --- /dev/null +++ b/src/modules/reports-clinical/dto/index.ts @@ -0,0 +1,429 @@ +import { + IsString, + IsOptional, + IsUUID, + IsEnum, + IsBoolean, + IsDateString, + IsInt, + IsArray, + ValidateNested, + MaxLength, + Min, + IsEmail, + IsObject, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { + ReportType, + ReportFormat, + ReportTemplateStatus, + ScheduleFrequency, + ScheduleStatus, +} from '../entities'; + +// Report Parameter DTO +export class ReportParameterDto { + @IsString() + @MaxLength(50) + name: string; + + @IsEnum(['date', 'daterange', 'select', 'multiselect', 'string', 'number', 'boolean']) + type: 'date' | 'daterange' | 'select' | 'multiselect' | 'string' | 'number' | 'boolean'; + + @IsString() + @MaxLength(100) + label: string; + + @IsBoolean() + required: boolean; + + @IsOptional() + defaultValue?: any; + + @IsOptional() + @IsArray() + options?: { value: string; label: string }[]; +} + +// Report Column DTO +export class ReportColumnDto { + @IsString() + @MaxLength(100) + field: string; + + @IsString() + @MaxLength(100) + header: string; + + @IsEnum(['string', 'number', 'date', 'currency', 'boolean']) + type: 'string' | 'number' | 'date' | 'currency' | 'boolean'; + + @IsOptional() + @IsInt() + @Min(1) + width?: number; + + @IsOptional() + @IsString() + @MaxLength(50) + format?: string; + + @IsOptional() + @IsEnum(['sum', 'count', 'avg', 'min', 'max']) + aggregate?: 'sum' | 'count' | 'avg' | 'min' | 'max'; +} + +// Report Template DTOs +export class CreateReportTemplateDto { + @IsString() + @MaxLength(50) + code: string; + + @IsString() + @MaxLength(200) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(['patient_summary', 'appointment_stats', 'consultation_stats', 'lab_results', 'prescription_stats', 'revenue', 'custom']) + reportType?: ReportType; + + @IsOptional() + @IsArray() + @IsEnum(['pdf', 'excel', 'csv'], { each: true }) + supportedFormats?: ReportFormat[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ReportParameterDto) + parameters?: ReportParameterDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ReportColumnDto) + columns?: ReportColumnDto[]; + + @IsOptional() + @IsString() + queryTemplate?: string; + + @IsOptional() + @IsString() + headerTemplate?: string; + + @IsOptional() + @IsString() + footerTemplate?: string; + + @IsOptional() + @IsObject() + styling?: Record; + + @IsOptional() + @IsInt() + displayOrder?: number; +} + +export class UpdateReportTemplateDto { + @IsOptional() + @IsString() + @MaxLength(50) + code?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(['patient_summary', 'appointment_stats', 'consultation_stats', 'lab_results', 'prescription_stats', 'revenue', 'custom']) + reportType?: ReportType; + + @IsOptional() + @IsArray() + @IsEnum(['pdf', 'excel', 'csv'], { each: true }) + supportedFormats?: ReportFormat[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ReportParameterDto) + parameters?: ReportParameterDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ReportColumnDto) + columns?: ReportColumnDto[]; + + @IsOptional() + @IsString() + queryTemplate?: string; + + @IsOptional() + @IsString() + headerTemplate?: string; + + @IsOptional() + @IsString() + footerTemplate?: string; + + @IsOptional() + @IsObject() + styling?: Record; + + @IsOptional() + @IsEnum(['active', 'inactive', 'draft']) + status?: ReportTemplateStatus; + + @IsOptional() + @IsInt() + displayOrder?: number; +} + +export class ReportTemplateQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsEnum(['patient_summary', 'appointment_stats', 'consultation_stats', 'lab_results', 'prescription_stats', 'revenue', 'custom']) + reportType?: ReportType; + + @IsOptional() + @IsEnum(['active', 'inactive', 'draft']) + status?: ReportTemplateStatus; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +// Generate Report DTOs +export class GenerateReportDto { + @IsUUID() + templateId: string; + + @IsEnum(['pdf', 'excel', 'csv']) + format: ReportFormat; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsObject() + parameters?: Record; +} + +export class GeneratedReportQueryDto { + @IsOptional() + @IsUUID() + templateId?: string; + + @IsOptional() + @IsEnum(['pending', 'processing', 'completed', 'failed']) + status?: 'pending' | 'processing' | 'completed' | 'failed'; + + @IsOptional() + @IsEnum(['pdf', 'excel', 'csv']) + format?: ReportFormat; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +// Email Recipient DTO +export class EmailRecipientDto { + @IsEmail() + email: string; + + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsEnum(['to', 'cc', 'bcc']) + type: 'to' | 'cc' | 'bcc'; +} + +// Schedule Config DTO +export class ScheduleConfigDto { + @IsEnum(['daily', 'weekly', 'monthly', 'quarterly', 'yearly']) + frequency: ScheduleFrequency; + + @IsOptional() + @IsInt() + @Min(0) + dayOfWeek?: number; + + @IsOptional() + @IsInt() + @Min(1) + dayOfMonth?: number; + + @IsOptional() + @IsInt() + @Min(1) + monthOfYear?: number; + + @IsInt() + @Min(0) + hour: number; + + @IsInt() + @Min(0) + minute: number; + + @IsString() + @MaxLength(50) + timezone: string; +} + +// Report Schedule DTOs +export class CreateReportScheduleDto { + @IsUUID() + templateId: string; + + @IsString() + @MaxLength(200) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsEnum(['pdf', 'excel', 'csv']) + format: ReportFormat; + + @IsOptional() + @IsObject() + reportParameters?: Record; + + @ValidateNested() + @Type(() => ScheduleConfigDto) + scheduleConfig: ScheduleConfigDto; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EmailRecipientDto) + recipients: EmailRecipientDto[]; + + @IsOptional() + @IsString() + @MaxLength(300) + emailSubject?: string; + + @IsOptional() + @IsString() + emailBody?: string; +} + +export class UpdateReportScheduleDto { + @IsOptional() + @IsString() + @MaxLength(200) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(['pdf', 'excel', 'csv']) + format?: ReportFormat; + + @IsOptional() + @IsObject() + reportParameters?: Record; + + @IsOptional() + @ValidateNested() + @Type(() => ScheduleConfigDto) + scheduleConfig?: ScheduleConfigDto; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EmailRecipientDto) + recipients?: EmailRecipientDto[]; + + @IsOptional() + @IsString() + @MaxLength(300) + emailSubject?: string; + + @IsOptional() + @IsString() + emailBody?: string; + + @IsOptional() + @IsEnum(['active', 'paused', 'disabled']) + status?: ScheduleStatus; +} + +export class ReportScheduleQueryDto { + @IsOptional() + @IsUUID() + templateId?: string; + + @IsOptional() + @IsEnum(['active', 'paused', 'disabled']) + status?: ScheduleStatus; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +// Clinical Statistics DTOs +export class ClinicalStatsQueryDto { + @IsDateString() + dateFrom: string; + + @IsDateString() + dateTo: string; + + @IsOptional() + @IsUUID() + doctorId?: string; + + @IsOptional() + @IsUUID() + specialtyId?: string; +} + +export class DashboardKpiDto { + @IsOptional() + @IsDateString() + date?: string; +} diff --git a/src/modules/reports-clinical/entities/generated-report.entity.ts b/src/modules/reports-clinical/entities/generated-report.entity.ts new file mode 100644 index 0000000..72b389e --- /dev/null +++ b/src/modules/reports-clinical/entities/generated-report.entity.ts @@ -0,0 +1,87 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ReportTemplate, ReportFormat } from './report-template.entity'; + +export type GeneratedReportStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +@Entity({ name: 'generated_reports', schema: 'clinica' }) +export class GeneratedReport { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'template_id', type: 'uuid' }) + templateId: string; + + @ManyToOne(() => ReportTemplate) + @JoinColumn({ name: 'template_id' }) + template?: ReportTemplate; + + @Column({ name: 'report_name', type: 'varchar', length: 300 }) + reportName: string; + + @Column({ type: 'enum', enum: ['pdf', 'excel', 'csv'], default: 'pdf' }) + format: ReportFormat; + + @Column({ type: 'jsonb', nullable: true }) + parameters?: Record; + + @Column({ name: 'date_from', type: 'date', nullable: true }) + dateFrom?: Date; + + @Column({ name: 'date_to', type: 'date', nullable: true }) + dateTo?: Date; + + @Column({ type: 'enum', enum: ['pending', 'processing', 'completed', 'failed'], default: 'pending' }) + status: GeneratedReportStatus; + + @Column({ name: 'file_path', type: 'varchar', length: 500, nullable: true }) + filePath?: string; + + @Column({ name: 'file_size', type: 'bigint', nullable: true }) + fileSize?: number; + + @Column({ name: 'mime_type', type: 'varchar', length: 100, nullable: true }) + mimeType?: string; + + @Column({ name: 'row_count', type: 'int', nullable: true }) + rowCount?: number; + + @Column({ name: 'generation_time_ms', type: 'int', nullable: true }) + generationTimeMs?: number; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage?: string; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt?: Date; + + @Column({ name: 'download_count', type: 'int', default: 0 }) + downloadCount: number; + + @Column({ name: 'last_downloaded_at', type: 'timestamptz', nullable: true }) + lastDownloadedAt?: Date; + + @Column({ name: 'generated_by', type: 'uuid' }) + generatedBy: string; + + @Column({ name: 'schedule_id', type: 'uuid', nullable: true }) + scheduleId?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date; +} diff --git a/src/modules/reports-clinical/entities/index.ts b/src/modules/reports-clinical/entities/index.ts new file mode 100644 index 0000000..5fd65e9 --- /dev/null +++ b/src/modules/reports-clinical/entities/index.ts @@ -0,0 +1,16 @@ +export { + ReportTemplate, + ReportType, + ReportFormat, + ReportTemplateStatus, + ReportParameter, + ReportColumn, +} from './report-template.entity'; +export { GeneratedReport, GeneratedReportStatus } from './generated-report.entity'; +export { + ReportSchedule, + ScheduleFrequency, + ScheduleStatus, + EmailRecipient, + ScheduleConfig, +} from './report-schedule.entity'; diff --git a/src/modules/reports-clinical/entities/report-schedule.entity.ts b/src/modules/reports-clinical/entities/report-schedule.entity.ts new file mode 100644 index 0000000..9f1a6ca --- /dev/null +++ b/src/modules/reports-clinical/entities/report-schedule.entity.ts @@ -0,0 +1,106 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ReportTemplate, ReportFormat } from './report-template.entity'; + +export type ScheduleFrequency = 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly'; +export type ScheduleStatus = 'active' | 'paused' | 'disabled'; + +export interface EmailRecipient { + email: string; + name?: string; + type: 'to' | 'cc' | 'bcc'; +} + +export interface ScheduleConfig { + frequency: ScheduleFrequency; + dayOfWeek?: number; + dayOfMonth?: number; + monthOfYear?: number; + hour: number; + minute: number; + timezone: string; +} + +@Entity({ name: 'report_schedules', schema: 'clinica' }) +export class ReportSchedule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'template_id', type: 'uuid' }) + templateId: string; + + @ManyToOne(() => ReportTemplate) + @JoinColumn({ name: 'template_id' }) + template?: ReportTemplate; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'enum', enum: ['pdf', 'excel', 'csv'], default: 'pdf' }) + format: ReportFormat; + + @Column({ name: 'report_parameters', type: 'jsonb', nullable: true }) + reportParameters?: Record; + + @Column({ name: 'schedule_config', type: 'jsonb' }) + scheduleConfig: ScheduleConfig; + + @Column({ type: 'jsonb' }) + recipients: EmailRecipient[]; + + @Column({ name: 'email_subject', type: 'varchar', length: 300, nullable: true }) + emailSubject?: string; + + @Column({ name: 'email_body', type: 'text', nullable: true }) + emailBody?: string; + + @Column({ type: 'enum', enum: ['active', 'paused', 'disabled'], default: 'active' }) + status: ScheduleStatus; + + @Column({ name: 'last_run_at', type: 'timestamptz', nullable: true }) + lastRunAt?: Date; + + @Column({ name: 'last_run_status', type: 'varchar', length: 50, nullable: true }) + lastRunStatus?: string; + + @Column({ name: 'last_run_error', type: 'text', nullable: true }) + lastRunError?: string; + + @Column({ name: 'next_run_at', type: 'timestamptz', nullable: true }) + nextRunAt?: Date; + + @Column({ name: 'run_count', type: 'int', default: 0 }) + runCount: number; + + @Column({ name: 'failure_count', type: 'int', default: 0 }) + failureCount: number; + + @Column({ name: 'created_by', type: 'uuid' }) + createdBy: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/reports-clinical/entities/report-template.entity.ts b/src/modules/reports-clinical/entities/report-template.entity.ts new file mode 100644 index 0000000..6b5aba7 --- /dev/null +++ b/src/modules/reports-clinical/entities/report-template.entity.ts @@ -0,0 +1,96 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +export type ReportType = 'patient_summary' | 'appointment_stats' | 'consultation_stats' | 'lab_results' | 'prescription_stats' | 'revenue' | 'custom'; +export type ReportFormat = 'pdf' | 'excel' | 'csv'; +export type ReportTemplateStatus = 'active' | 'inactive' | 'draft'; + +export interface ReportParameter { + name: string; + type: 'date' | 'daterange' | 'select' | 'multiselect' | 'string' | 'number' | 'boolean'; + label: string; + required: boolean; + defaultValue?: any; + options?: { value: string; label: string }[]; +} + +export interface ReportColumn { + field: string; + header: string; + type: 'string' | 'number' | 'date' | 'currency' | 'boolean'; + width?: number; + format?: string; + aggregate?: 'sum' | 'count' | 'avg' | 'min' | 'max'; +} + +@Entity({ name: 'report_templates', schema: 'clinica' }) +export class ReportTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ type: 'varchar', length: 50 }) + code: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ name: 'report_type', type: 'enum', enum: ['patient_summary', 'appointment_stats', 'consultation_stats', 'lab_results', 'prescription_stats', 'revenue', 'custom'], default: 'custom' }) + reportType: ReportType; + + @Column({ name: 'supported_formats', type: 'jsonb', default: ['pdf', 'excel', 'csv'] }) + supportedFormats: ReportFormat[]; + + @Column({ type: 'jsonb', nullable: true }) + parameters?: ReportParameter[]; + + @Column({ type: 'jsonb', nullable: true }) + columns?: ReportColumn[]; + + @Column({ name: 'query_template', type: 'text', nullable: true }) + queryTemplate?: string; + + @Column({ name: 'header_template', type: 'text', nullable: true }) + headerTemplate?: string; + + @Column({ name: 'footer_template', type: 'text', nullable: true }) + footerTemplate?: string; + + @Column({ type: 'jsonb', nullable: true }) + styling?: Record; + + @Column({ type: 'enum', enum: ['active', 'inactive', 'draft'], default: 'draft' }) + status: ReportTemplateStatus; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem: boolean; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'display_order', type: 'int', default: 0 }) + displayOrder: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/reports-clinical/index.ts b/src/modules/reports-clinical/index.ts new file mode 100644 index 0000000..65690f2 --- /dev/null +++ b/src/modules/reports-clinical/index.ts @@ -0,0 +1,39 @@ +export { ReportsClinicalModule, ReportsClinicalModuleOptions } from './reports-clinical.module'; +export { + ReportTemplate, + ReportType, + ReportFormat, + ReportTemplateStatus, + ReportParameter, + ReportColumn, + GeneratedReport, + GeneratedReportStatus, + ReportSchedule, + ScheduleFrequency, + ScheduleStatus, + EmailRecipient, + ScheduleConfig, +} from './entities'; +export { + ReportTemplateService, + ReportGeneratorService, + ClinicalStatisticsService, + ReportScheduleService, +} from './services'; +export { ReportsClinicalController } from './controllers'; +export { + CreateReportTemplateDto, + UpdateReportTemplateDto, + ReportTemplateQueryDto, + GenerateReportDto, + GeneratedReportQueryDto, + CreateReportScheduleDto, + UpdateReportScheduleDto, + ReportScheduleQueryDto, + ClinicalStatsQueryDto, + DashboardKpiDto, + ReportParameterDto, + ReportColumnDto, + EmailRecipientDto, + ScheduleConfigDto, +} from './dto'; diff --git a/src/modules/reports-clinical/reports-clinical.module.ts b/src/modules/reports-clinical/reports-clinical.module.ts new file mode 100644 index 0000000..e69abc2 --- /dev/null +++ b/src/modules/reports-clinical/reports-clinical.module.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { ReportsClinicalController } from './controllers'; + +export interface ReportsClinicalModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class ReportsClinicalModule { + public router: Router; + private controller: ReportsClinicalController; + + constructor(options: ReportsClinicalModuleOptions) { + const { dataSource, basePath = '/api' } = options; + this.controller = new ReportsClinicalController(dataSource, basePath); + this.router = this.controller.router; + } +} diff --git a/src/modules/reports-clinical/services/clinical-statistics.service.ts b/src/modules/reports-clinical/services/clinical-statistics.service.ts new file mode 100644 index 0000000..5c382ff --- /dev/null +++ b/src/modules/reports-clinical/services/clinical-statistics.service.ts @@ -0,0 +1,530 @@ +import { DataSource } from 'typeorm'; + +export interface PatientSummaryRow { + patientId: string; + patientName: string; + dateOfBirth: Date; + gender: string; + lastVisit: Date; + totalAppointments: number; + totalConsultations: number; + totalLabOrders: number; + totalPrescriptions: number; +} + +export interface AppointmentStatsRow { + date: string; + doctorId: string; + doctorName: string; + specialtyId: string; + specialtyName: string; + totalScheduled: number; + totalCompleted: number; + totalCancelled: number; + totalNoShow: number; + completionRate: number; +} + +export interface ConsultationStatsRow { + date: string; + doctorId: string; + doctorName: string; + specialtyId: string; + specialtyName: string; + totalConsultations: number; + avgDurationMinutes: number; + totalDiagnoses: number; + totalPrescriptions: number; +} + +export interface LabResultsStatsRow { + date: string; + testCategory: string; + testName: string; + totalOrders: number; + totalCompleted: number; + totalPending: number; + avgProcessingHours: number; + abnormalCount: number; + criticalCount: number; +} + +export interface PrescriptionStatsRow { + date: string; + medicationName: string; + medicationCategory: string; + totalPrescribed: number; + totalDispensed: number; + avgQuantity: number; + totalAmount: number; +} + +export interface RevenueStatsRow { + date: string; + category: string; + subcategory: string; + transactionCount: number; + totalAmount: number; + avgAmount: number; + currency: string; +} + +export interface DashboardKPI { + todayAppointments: number; + todayCompletedConsultations: number; + pendingLabOrders: number; + criticalLabResults: number; + lowStockMedications: number; + todayRevenue: number; + weeklyAppointmentsTrend: number[]; + weeklyRevenueTrend: number[]; + appointmentCompletionRate: number; + avgConsultationDuration: number; + topDiagnoses: { name: string; count: number }[]; + topMedications: { name: string; count: number }[]; +} + +export class ClinicalStatisticsService { + private dataSource: DataSource; + + constructor(dataSource: DataSource) { + this.dataSource = dataSource; + } + + async getPatientSummary(tenantId: string, dateFrom: string, dateTo: string): Promise { + const query = ` + SELECT + p.id as "patientId", + CONCAT(p.first_name, ' ', p.last_name) as "patientName", + p.date_of_birth as "dateOfBirth", + p.gender, + MAX(a.appointment_date) as "lastVisit", + COUNT(DISTINCT a.id) as "totalAppointments", + COUNT(DISTINCT c.id) as "totalConsultations", + COUNT(DISTINCT lo.id) as "totalLabOrders", + COUNT(DISTINCT pr.id) as "totalPrescriptions" + FROM clinica.patients p + LEFT JOIN clinica.appointments a ON p.id = a.patient_id AND a.tenant_id = p.tenant_id + AND a.appointment_date BETWEEN $2 AND $3 + LEFT JOIN clinica.consultations c ON p.id = c.patient_id AND c.tenant_id = p.tenant_id + AND c.consultation_date BETWEEN $2 AND $3 + LEFT JOIN clinica.lab_orders lo ON p.id = lo.patient_id AND lo.tenant_id = p.tenant_id + AND lo.order_date BETWEEN $2 AND $3 + LEFT JOIN clinica.prescriptions pr ON c.id = pr.consultation_id AND pr.tenant_id = p.tenant_id + WHERE p.tenant_id = $1 + AND p.deleted_at IS NULL + GROUP BY p.id, p.first_name, p.last_name, p.date_of_birth, p.gender + ORDER BY "lastVisit" DESC NULLS LAST + `; + + try { + const results = await this.dataSource.query(query, [tenantId, dateFrom, dateTo]); + return results.map((row: any) => ({ + ...row, + totalAppointments: parseInt(row.totalAppointments, 10) || 0, + totalConsultations: parseInt(row.totalConsultations, 10) || 0, + totalLabOrders: parseInt(row.totalLabOrders, 10) || 0, + totalPrescriptions: parseInt(row.totalPrescriptions, 10) || 0, + })); + } catch (error) { + console.error('Error fetching patient summary:', error); + return []; + } + } + + async getAppointmentStats(tenantId: string, dateFrom: string, dateTo: string): Promise { + const query = ` + SELECT + DATE(a.appointment_date) as date, + d.id as "doctorId", + CONCAT(d.first_name, ' ', d.last_name) as "doctorName", + s.id as "specialtyId", + s.name as "specialtyName", + COUNT(*) as "totalScheduled", + COUNT(*) FILTER (WHERE a.status = 'completed') as "totalCompleted", + COUNT(*) FILTER (WHERE a.status = 'cancelled') as "totalCancelled", + COUNT(*) FILTER (WHERE a.status = 'no_show') as "totalNoShow", + ROUND( + COUNT(*) FILTER (WHERE a.status = 'completed')::numeric / + NULLIF(COUNT(*), 0) * 100, 2 + ) as "completionRate" + FROM clinica.appointments a + JOIN clinica.doctors d ON a.doctor_id = d.id + LEFT JOIN clinica.specialties s ON d.specialty_id = s.id + WHERE a.tenant_id = $1 + AND a.appointment_date BETWEEN $2 AND $3 + GROUP BY DATE(a.appointment_date), d.id, d.first_name, d.last_name, s.id, s.name + ORDER BY date DESC, "doctorName" + `; + + try { + const results = await this.dataSource.query(query, [tenantId, dateFrom, dateTo]); + return results.map((row: any) => ({ + ...row, + totalScheduled: parseInt(row.totalScheduled, 10) || 0, + totalCompleted: parseInt(row.totalCompleted, 10) || 0, + totalCancelled: parseInt(row.totalCancelled, 10) || 0, + totalNoShow: parseInt(row.totalNoShow, 10) || 0, + completionRate: parseFloat(row.completionRate) || 0, + })); + } catch (error) { + console.error('Error fetching appointment stats:', error); + return []; + } + } + + async getConsultationStats(tenantId: string, dateFrom: string, dateTo: string): Promise { + const query = ` + SELECT + DATE(c.consultation_date) as date, + d.id as "doctorId", + CONCAT(d.first_name, ' ', d.last_name) as "doctorName", + s.id as "specialtyId", + s.name as "specialtyName", + COUNT(DISTINCT c.id) as "totalConsultations", + ROUND(AVG(EXTRACT(EPOCH FROM (c.end_time - c.start_time)) / 60), 2) as "avgDurationMinutes", + COUNT(DISTINCT diag.id) as "totalDiagnoses", + COUNT(DISTINCT pr.id) as "totalPrescriptions" + FROM clinica.consultations c + JOIN clinica.doctors d ON c.doctor_id = d.id + LEFT JOIN clinica.specialties s ON d.specialty_id = s.id + LEFT JOIN clinica.diagnoses diag ON c.id = diag.consultation_id + LEFT JOIN clinica.prescriptions pr ON c.id = pr.consultation_id + WHERE c.tenant_id = $1 + AND c.consultation_date BETWEEN $2 AND $3 + GROUP BY DATE(c.consultation_date), d.id, d.first_name, d.last_name, s.id, s.name + ORDER BY date DESC, "doctorName" + `; + + try { + const results = await this.dataSource.query(query, [tenantId, dateFrom, dateTo]); + return results.map((row: any) => ({ + ...row, + totalConsultations: parseInt(row.totalConsultations, 10) || 0, + avgDurationMinutes: parseFloat(row.avgDurationMinutes) || 0, + totalDiagnoses: parseInt(row.totalDiagnoses, 10) || 0, + totalPrescriptions: parseInt(row.totalPrescriptions, 10) || 0, + })); + } catch (error) { + console.error('Error fetching consultation stats:', error); + return []; + } + } + + async getLabResultsStats(tenantId: string, dateFrom: string, dateTo: string): Promise { + const query = ` + SELECT + DATE(lo.order_date) as date, + lt.category as "testCategory", + lt.name as "testName", + COUNT(DISTINCT lo.id) as "totalOrders", + COUNT(DISTINCT lo.id) FILTER (WHERE lo.status = 'completed') as "totalCompleted", + COUNT(DISTINCT lo.id) FILTER (WHERE lo.status = 'pending') as "totalPending", + ROUND(AVG(EXTRACT(EPOCH FROM (lo.completed_at - lo.order_date)) / 3600), 2) as "avgProcessingHours", + COUNT(*) FILTER (WHERE lr.abnormal_flag != 'normal') as "abnormalCount", + COUNT(*) FILTER (WHERE lr.is_critical = true) as "criticalCount" + FROM clinica.lab_orders lo + JOIN clinica.lab_results lr ON lo.id = lr.lab_order_id + JOIN clinica.lab_tests lt ON lr.lab_test_id = lt.id + WHERE lo.tenant_id = $1 + AND lo.order_date BETWEEN $2 AND $3 + GROUP BY DATE(lo.order_date), lt.category, lt.name + ORDER BY date DESC, "testCategory", "testName" + `; + + try { + const results = await this.dataSource.query(query, [tenantId, dateFrom, dateTo]); + return results.map((row: any) => ({ + ...row, + totalOrders: parseInt(row.totalOrders, 10) || 0, + totalCompleted: parseInt(row.totalCompleted, 10) || 0, + totalPending: parseInt(row.totalPending, 10) || 0, + avgProcessingHours: parseFloat(row.avgProcessingHours) || 0, + abnormalCount: parseInt(row.abnormalCount, 10) || 0, + criticalCount: parseInt(row.criticalCount, 10) || 0, + })); + } catch (error) { + console.error('Error fetching lab results stats:', error); + return []; + } + } + + async getPrescriptionStats(tenantId: string, dateFrom: string, dateTo: string): Promise { + const query = ` + SELECT + DATE(pr.prescription_date) as date, + m.name as "medicationName", + m.category as "medicationCategory", + COUNT(DISTINCT pi.id) as "totalPrescribed", + COUNT(DISTINCT d.id) as "totalDispensed", + ROUND(AVG(pi.quantity), 2) as "avgQuantity", + SUM(pi.quantity * pi.unit_price) as "totalAmount" + FROM clinica.prescriptions pr + JOIN clinica.prescription_items pi ON pr.id = pi.prescription_id + JOIN clinica.medications m ON pi.medication_id = m.id + LEFT JOIN clinica.dispensations d ON pr.id = d.prescription_id + WHERE pr.tenant_id = $1 + AND pr.prescription_date BETWEEN $2 AND $3 + GROUP BY DATE(pr.prescription_date), m.name, m.category + ORDER BY date DESC, "medicationName" + `; + + try { + const results = await this.dataSource.query(query, [tenantId, dateFrom, dateTo]); + return results.map((row: any) => ({ + ...row, + totalPrescribed: parseInt(row.totalPrescribed, 10) || 0, + totalDispensed: parseInt(row.totalDispensed, 10) || 0, + avgQuantity: parseFloat(row.avgQuantity) || 0, + totalAmount: parseFloat(row.totalAmount) || 0, + })); + } catch (error) { + console.error('Error fetching prescription stats:', error); + return []; + } + } + + async getRevenueStats(tenantId: string, dateFrom: string, dateTo: string): Promise { + const query = ` + SELECT + DATE(tp.created_at) as date, + 'Payments' as category, + tp.payment_method as subcategory, + COUNT(*) as "transactionCount", + SUM(tp.amount) as "totalAmount", + ROUND(AVG(tp.amount), 2) as "avgAmount", + 'MXN' as currency + FROM clinica.terminal_payments tp + WHERE tp.tenant_id = $1 + AND tp.created_at BETWEEN $2 AND $3 + AND tp.status = 'completed' + GROUP BY DATE(tp.created_at), tp.payment_method + ORDER BY date DESC, category, subcategory + `; + + try { + const results = await this.dataSource.query(query, [tenantId, dateFrom, dateTo]); + return results.map((row: any) => ({ + ...row, + transactionCount: parseInt(row.transactionCount, 10) || 0, + totalAmount: parseFloat(row.totalAmount) || 0, + avgAmount: parseFloat(row.avgAmount) || 0, + })); + } catch (error) { + console.error('Error fetching revenue stats:', error); + return []; + } + } + + async getDashboardKPI(tenantId: string, date?: string): Promise { + const targetDate = date || new Date().toISOString().split('T')[0]; + const weekAgo = new Date(new Date(targetDate).getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + + const [ + todayAppointments, + todayCompletedConsultations, + pendingLabOrders, + criticalLabResults, + lowStockMedications, + todayRevenue, + weeklyAppointmentsTrend, + weeklyRevenueTrend, + appointmentCompletionRate, + avgConsultationDuration, + topDiagnoses, + topMedications, + ] = await Promise.all([ + this.getTodayAppointments(tenantId, targetDate), + this.getTodayCompletedConsultations(tenantId, targetDate), + this.getPendingLabOrders(tenantId), + this.getCriticalLabResults(tenantId), + this.getLowStockMedications(tenantId), + this.getTodayRevenue(tenantId, targetDate), + this.getWeeklyAppointmentsTrend(tenantId, weekAgo, targetDate), + this.getWeeklyRevenueTrend(tenantId, weekAgo, targetDate), + this.getAppointmentCompletionRate(tenantId, weekAgo, targetDate), + this.getAvgConsultationDuration(tenantId, weekAgo, targetDate), + this.getTopDiagnoses(tenantId, weekAgo, targetDate), + this.getTopMedications(tenantId, weekAgo, targetDate), + ]); + + return { + todayAppointments, + todayCompletedConsultations, + pendingLabOrders, + criticalLabResults, + lowStockMedications, + todayRevenue, + weeklyAppointmentsTrend, + weeklyRevenueTrend, + appointmentCompletionRate, + avgConsultationDuration, + topDiagnoses, + topMedications, + }; + } + + private async getTodayAppointments(tenantId: string, date: string): Promise { + try { + const result = await this.dataSource.query( + `SELECT COUNT(*) as count FROM clinica.appointments WHERE tenant_id = $1 AND DATE(appointment_date) = $2`, + [tenantId, date] + ); + return parseInt(result[0]?.count, 10) || 0; + } catch { + return 0; + } + } + + private async getTodayCompletedConsultations(tenantId: string, date: string): Promise { + try { + const result = await this.dataSource.query( + `SELECT COUNT(*) as count FROM clinica.consultations WHERE tenant_id = $1 AND DATE(consultation_date) = $2 AND status = 'completed'`, + [tenantId, date] + ); + return parseInt(result[0]?.count, 10) || 0; + } catch { + return 0; + } + } + + private async getPendingLabOrders(tenantId: string): Promise { + try { + const result = await this.dataSource.query( + `SELECT COUNT(*) as count FROM clinica.lab_orders WHERE tenant_id = $1 AND status = 'pending'`, + [tenantId] + ); + return parseInt(result[0]?.count, 10) || 0; + } catch { + return 0; + } + } + + private async getCriticalLabResults(tenantId: string): Promise { + try { + const result = await this.dataSource.query( + `SELECT COUNT(*) as count FROM clinica.lab_results WHERE tenant_id = $1 AND is_critical = true AND status != 'verified'`, + [tenantId] + ); + return parseInt(result[0]?.count, 10) || 0; + } catch { + return 0; + } + } + + private async getLowStockMedications(tenantId: string): Promise { + try { + const result = await this.dataSource.query( + `SELECT COUNT(*) as count FROM clinica.pharmacy_inventory WHERE tenant_id = $1 AND quantity <= minimum_stock`, + [tenantId] + ); + return parseInt(result[0]?.count, 10) || 0; + } catch { + return 0; + } + } + + private async getTodayRevenue(tenantId: string, date: string): Promise { + try { + const result = await this.dataSource.query( + `SELECT COALESCE(SUM(amount), 0) as total FROM clinica.terminal_payments WHERE tenant_id = $1 AND DATE(created_at) = $2 AND status = 'completed'`, + [tenantId, date] + ); + return parseFloat(result[0]?.total) || 0; + } catch { + return 0; + } + } + + private async getWeeklyAppointmentsTrend(tenantId: string, dateFrom: string, dateTo: string): Promise { + try { + const result = await this.dataSource.query( + `SELECT DATE(appointment_date) as date, COUNT(*) as count + FROM clinica.appointments + WHERE tenant_id = $1 AND appointment_date BETWEEN $2 AND $3 + GROUP BY DATE(appointment_date) ORDER BY date`, + [tenantId, dateFrom, dateTo] + ); + return result.map((r: any) => parseInt(r.count, 10) || 0); + } catch { + return []; + } + } + + private async getWeeklyRevenueTrend(tenantId: string, dateFrom: string, dateTo: string): Promise { + try { + const result = await this.dataSource.query( + `SELECT DATE(created_at) as date, COALESCE(SUM(amount), 0) as total + FROM clinica.terminal_payments + WHERE tenant_id = $1 AND created_at BETWEEN $2 AND $3 AND status = 'completed' + GROUP BY DATE(created_at) ORDER BY date`, + [tenantId, dateFrom, dateTo] + ); + return result.map((r: any) => parseFloat(r.total) || 0); + } catch { + return []; + } + } + + private async getAppointmentCompletionRate(tenantId: string, dateFrom: string, dateTo: string): Promise { + try { + const result = await this.dataSource.query( + `SELECT + ROUND(COUNT(*) FILTER (WHERE status = 'completed')::numeric / NULLIF(COUNT(*), 0) * 100, 2) as rate + FROM clinica.appointments + WHERE tenant_id = $1 AND appointment_date BETWEEN $2 AND $3`, + [tenantId, dateFrom, dateTo] + ); + return parseFloat(result[0]?.rate) || 0; + } catch { + return 0; + } + } + + private async getAvgConsultationDuration(tenantId: string, dateFrom: string, dateTo: string): Promise { + try { + const result = await this.dataSource.query( + `SELECT ROUND(AVG(EXTRACT(EPOCH FROM (end_time - start_time)) / 60), 2) as avg_minutes + FROM clinica.consultations + WHERE tenant_id = $1 AND consultation_date BETWEEN $2 AND $3 AND end_time IS NOT NULL`, + [tenantId, dateFrom, dateTo] + ); + return parseFloat(result[0]?.avg_minutes) || 0; + } catch { + return 0; + } + } + + private async getTopDiagnoses(tenantId: string, dateFrom: string, dateTo: string): Promise<{ name: string; count: number }[]> { + try { + const result = await this.dataSource.query( + `SELECT d.icd_code as name, COUNT(*) as count + FROM clinica.diagnoses d + JOIN clinica.consultations c ON d.consultation_id = c.id + WHERE c.tenant_id = $1 AND c.consultation_date BETWEEN $2 AND $3 + GROUP BY d.icd_code ORDER BY count DESC LIMIT 5`, + [tenantId, dateFrom, dateTo] + ); + return result.map((r: any) => ({ name: r.name, count: parseInt(r.count, 10) || 0 })); + } catch { + return []; + } + } + + private async getTopMedications(tenantId: string, dateFrom: string, dateTo: string): Promise<{ name: string; count: number }[]> { + try { + const result = await this.dataSource.query( + `SELECT m.name, COUNT(*) as count + FROM clinica.prescription_items pi + JOIN clinica.prescriptions p ON pi.prescription_id = p.id + JOIN clinica.medications m ON pi.medication_id = m.id + WHERE p.tenant_id = $1 AND p.prescription_date BETWEEN $2 AND $3 + GROUP BY m.name ORDER BY count DESC LIMIT 5`, + [tenantId, dateFrom, dateTo] + ); + return result.map((r: any) => ({ name: r.name, count: parseInt(r.count, 10) || 0 })); + } catch { + return []; + } + } +} diff --git a/src/modules/reports-clinical/services/index.ts b/src/modules/reports-clinical/services/index.ts new file mode 100644 index 0000000..3843177 --- /dev/null +++ b/src/modules/reports-clinical/services/index.ts @@ -0,0 +1,4 @@ +export { ReportTemplateService } from './report-template.service'; +export { ReportGeneratorService } from './report-generator.service'; +export { ClinicalStatisticsService } from './clinical-statistics.service'; +export { ReportScheduleService } from './report-schedule.service'; diff --git a/src/modules/reports-clinical/services/report-generator.service.ts b/src/modules/reports-clinical/services/report-generator.service.ts new file mode 100644 index 0000000..8ff0793 --- /dev/null +++ b/src/modules/reports-clinical/services/report-generator.service.ts @@ -0,0 +1,280 @@ +import { DataSource, Repository } from 'typeorm'; +import { GeneratedReport, ReportTemplate, ReportFormat } from '../entities'; +import { GenerateReportDto, GeneratedReportQueryDto } from '../dto'; +import { ClinicalStatisticsService } from './clinical-statistics.service'; + +export class ReportGeneratorService { + private generatedReportRepository: Repository; + private templateRepository: Repository; + private statisticsService: ClinicalStatisticsService; + + constructor(dataSource: DataSource) { + this.generatedReportRepository = dataSource.getRepository(GeneratedReport); + this.templateRepository = dataSource.getRepository(ReportTemplate); + this.statisticsService = new ClinicalStatisticsService(dataSource); + } + + async findAll(tenantId: string, query: GeneratedReportQueryDto): Promise<{ data: GeneratedReport[]; total: number }> { + const { templateId, status, format, dateFrom, dateTo, page = 1, limit = 20 } = query; + + const queryBuilder = this.generatedReportRepository.createQueryBuilder('report') + .leftJoinAndSelect('report.template', 'template') + .where('report.tenant_id = :tenantId', { tenantId }); + + if (templateId) { + queryBuilder.andWhere('report.template_id = :templateId', { templateId }); + } + + if (status) { + queryBuilder.andWhere('report.status = :status', { status }); + } + + if (format) { + queryBuilder.andWhere('report.format = :format', { format }); + } + + if (dateFrom) { + queryBuilder.andWhere('report.created_at >= :dateFrom', { dateFrom }); + } + + if (dateTo) { + queryBuilder.andWhere('report.created_at <= :dateTo', { dateTo }); + } + + queryBuilder + .orderBy('report.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + return { data, total }; + } + + async findById(tenantId: string, id: string): Promise { + return this.generatedReportRepository.findOne({ + where: { id, tenantId }, + relations: ['template'], + }); + } + + async generate(tenantId: string, dto: GenerateReportDto, generatedBy: string): Promise { + const template = await this.templateRepository.findOne({ + where: { id: dto.templateId, tenantId, status: 'active' }, + }); + + if (!template) { + throw new Error('Report template not found or inactive'); + } + + if (!template.supportedFormats.includes(dto.format)) { + throw new Error(`Format ${dto.format} is not supported by this template`); + } + + const reportName = this.generateReportName(template, dto); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); + + const generatedReport = this.generatedReportRepository.create({ + tenantId, + templateId: dto.templateId, + reportName, + format: dto.format, + parameters: dto.parameters, + dateFrom: dto.dateFrom ? new Date(dto.dateFrom) : undefined, + dateTo: dto.dateTo ? new Date(dto.dateTo) : undefined, + status: 'pending', + generatedBy, + expiresAt, + }); + + const savedReport = await this.generatedReportRepository.save(generatedReport); + + this.processReportGeneration(tenantId, savedReport.id, template, dto).catch(err => { + console.error(`Error generating report ${savedReport.id}:`, err); + }); + + return savedReport; + } + + private async processReportGeneration( + tenantId: string, + reportId: string, + template: ReportTemplate, + dto: GenerateReportDto + ): Promise { + const startTime = Date.now(); + + try { + await this.generatedReportRepository.update( + { id: reportId, tenantId }, + { status: 'processing' } + ); + + const data = await this.fetchReportData(tenantId, template, dto); + const rowCount = Array.isArray(data) ? data.length : 1; + + const { filePath, fileSize, mimeType } = await this.generateFile(template, data, dto.format); + + const generationTimeMs = Date.now() - startTime; + + await this.generatedReportRepository.update( + { id: reportId, tenantId }, + { + status: 'completed', + filePath, + fileSize, + mimeType, + rowCount, + generationTimeMs, + completedAt: new Date(), + } + ); + } catch (error: any) { + await this.generatedReportRepository.update( + { id: reportId, tenantId }, + { + status: 'failed', + errorMessage: error.message || 'Unknown error', + completedAt: new Date(), + } + ); + throw error; + } + } + + private async fetchReportData( + tenantId: string, + template: ReportTemplate, + dto: GenerateReportDto + ): Promise { + const dateFrom = dto.dateFrom || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + const dateTo = dto.dateTo || new Date().toISOString().split('T')[0]; + + switch (template.reportType) { + case 'patient_summary': + return this.statisticsService.getPatientSummary(tenantId, dateFrom, dateTo); + case 'appointment_stats': + return this.statisticsService.getAppointmentStats(tenantId, dateFrom, dateTo); + case 'consultation_stats': + return this.statisticsService.getConsultationStats(tenantId, dateFrom, dateTo); + case 'lab_results': + return this.statisticsService.getLabResultsStats(tenantId, dateFrom, dateTo); + case 'prescription_stats': + return this.statisticsService.getPrescriptionStats(tenantId, dateFrom, dateTo); + case 'revenue': + return this.statisticsService.getRevenueStats(tenantId, dateFrom, dateTo); + default: + return []; + } + } + + private async generateFile( + template: ReportTemplate, + data: any[], + format: ReportFormat + ): Promise<{ filePath: string; fileSize: number; mimeType: string }> { + const timestamp = Date.now(); + const filename = `${template.code}_${timestamp}`; + + let mimeType: string; + let extension: string; + + switch (format) { + case 'pdf': + mimeType = 'application/pdf'; + extension = 'pdf'; + break; + case 'excel': + mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + extension = 'xlsx'; + break; + case 'csv': + mimeType = 'text/csv'; + extension = 'csv'; + break; + default: + throw new Error(`Unsupported format: ${format}`); + } + + const filePath = `/reports/${filename}.${extension}`; + const fileSize = JSON.stringify(data).length; + + return { filePath, fileSize, mimeType }; + } + + private generateReportName(template: ReportTemplate, dto: GenerateReportDto): string { + const date = new Date().toISOString().split('T')[0]; + let name = `${template.name} - ${date}`; + + if (dto.dateFrom && dto.dateTo) { + name = `${template.name} (${dto.dateFrom} - ${dto.dateTo})`; + } + + return name; + } + + async recordDownload(tenantId: string, id: string): Promise { + const report = await this.findById(tenantId, id); + if (!report) return null; + + if (report.status !== 'completed') { + throw new Error('Report is not ready for download'); + } + + report.downloadCount += 1; + report.lastDownloadedAt = new Date(); + return this.generatedReportRepository.save(report); + } + + async deleteOldReports(tenantId: string, olderThanDays: number = 30): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - olderThanDays); + + const result = await this.generatedReportRepository.delete({ + tenantId, + createdAt: new Date(cutoffDate.toISOString()) as any, + }); + + return result.affected || 0; + } + + async deleteExpiredReports(tenantId: string): Promise { + const now = new Date(); + + const result = await this.generatedReportRepository.createQueryBuilder() + .delete() + .where('tenant_id = :tenantId', { tenantId }) + .andWhere('expires_at < :now', { now }) + .execute(); + + return result.affected || 0; + } + + async getReportStats(tenantId: string): Promise<{ + total: number; + byStatus: { status: string; count: number }[]; + byFormat: { format: string; count: number }[]; + }> { + const total = await this.generatedReportRepository.count({ where: { tenantId } }); + + const byStatusRaw = await this.generatedReportRepository.createQueryBuilder('report') + .select('report.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('report.tenant_id = :tenantId', { tenantId }) + .groupBy('report.status') + .getRawMany(); + + const byFormatRaw = await this.generatedReportRepository.createQueryBuilder('report') + .select('report.format', 'format') + .addSelect('COUNT(*)', 'count') + .where('report.tenant_id = :tenantId', { tenantId }) + .groupBy('report.format') + .getRawMany(); + + return { + total, + byStatus: byStatusRaw.map(r => ({ status: r.status, count: parseInt(r.count, 10) })), + byFormat: byFormatRaw.map(r => ({ format: r.format, count: parseInt(r.count, 10) })), + }; + } +} diff --git a/src/modules/reports-clinical/services/report-schedule.service.ts b/src/modules/reports-clinical/services/report-schedule.service.ts new file mode 100644 index 0000000..51b6de0 --- /dev/null +++ b/src/modules/reports-clinical/services/report-schedule.service.ts @@ -0,0 +1,250 @@ +import { DataSource, Repository, LessThanOrEqual } from 'typeorm'; +import { ReportSchedule, ReportTemplate, ScheduleConfig } from '../entities'; +import { + CreateReportScheduleDto, + UpdateReportScheduleDto, + ReportScheduleQueryDto, +} from '../dto'; + +export class ReportScheduleService { + private repository: Repository; + private templateRepository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(ReportSchedule); + this.templateRepository = dataSource.getRepository(ReportTemplate); + } + + async findAll(tenantId: string, query: ReportScheduleQueryDto): Promise<{ data: ReportSchedule[]; total: number }> { + const { templateId, status, page = 1, limit = 20 } = query; + + const queryBuilder = this.repository.createQueryBuilder('schedule') + .leftJoinAndSelect('schedule.template', 'template') + .where('schedule.tenant_id = :tenantId', { tenantId }) + .andWhere('schedule.deleted_at IS NULL'); + + if (templateId) { + queryBuilder.andWhere('schedule.template_id = :templateId', { templateId }); + } + + if (status) { + queryBuilder.andWhere('schedule.status = :status', { status }); + } + + queryBuilder + .orderBy('schedule.next_run_at', 'ASC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + return { data, total }; + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['template'], + }); + } + + async findActiveSchedules(tenantId: string): Promise { + return this.repository.find({ + where: { tenantId, status: 'active' }, + relations: ['template'], + order: { nextRunAt: 'ASC' }, + }); + } + + async findDueSchedules(): Promise { + const now = new Date(); + return this.repository.find({ + where: { + status: 'active', + nextRunAt: LessThanOrEqual(now), + }, + relations: ['template'], + }); + } + + async create(tenantId: string, dto: CreateReportScheduleDto, createdBy: string): Promise { + const template = await this.templateRepository.findOne({ + where: { id: dto.templateId, tenantId, status: 'active' }, + }); + + if (!template) { + throw new Error('Report template not found or inactive'); + } + + const nextRunAt = this.calculateNextRunTime(dto.scheduleConfig); + + const schedule = this.repository.create({ + tenantId, + templateId: dto.templateId, + name: dto.name, + description: dto.description, + format: dto.format, + reportParameters: dto.reportParameters, + scheduleConfig: dto.scheduleConfig, + recipients: dto.recipients, + emailSubject: dto.emailSubject || `Report: ${template.name}`, + emailBody: dto.emailBody, + status: 'active', + nextRunAt, + createdBy, + }); + + return this.repository.save(schedule); + } + + async update(tenantId: string, id: string, dto: UpdateReportScheduleDto): Promise { + const schedule = await this.findById(tenantId, id); + if (!schedule) return null; + + if (dto.scheduleConfig) { + schedule.scheduleConfig = dto.scheduleConfig; + schedule.nextRunAt = this.calculateNextRunTime(dto.scheduleConfig); + } + + Object.assign(schedule, { + ...dto, + scheduleConfig: dto.scheduleConfig || schedule.scheduleConfig, + }); + + return this.repository.save(schedule); + } + + async pause(tenantId: string, id: string): Promise { + const schedule = await this.findById(tenantId, id); + if (!schedule) return null; + + schedule.status = 'paused'; + return this.repository.save(schedule); + } + + async resume(tenantId: string, id: string): Promise { + const schedule = await this.findById(tenantId, id); + if (!schedule) return null; + + schedule.status = 'active'; + schedule.nextRunAt = this.calculateNextRunTime(schedule.scheduleConfig); + return this.repository.save(schedule); + } + + async disable(tenantId: string, id: string): Promise { + const schedule = await this.findById(tenantId, id); + if (!schedule) return null; + + schedule.status = 'disabled'; + schedule.nextRunAt = undefined; + return this.repository.save(schedule); + } + + async softDelete(tenantId: string, id: string): Promise { + const schedule = await this.findById(tenantId, id); + if (!schedule) return false; + + await this.repository.softDelete({ id, tenantId }); + return true; + } + + async recordRun(id: string, success: boolean, error?: string): Promise { + const schedule = await this.repository.findOne({ where: { id } }); + if (!schedule) return; + + schedule.lastRunAt = new Date(); + schedule.lastRunStatus = success ? 'success' : 'failed'; + schedule.lastRunError = error || undefined; + schedule.runCount += 1; + + if (!success) { + schedule.failureCount += 1; + } + + if (schedule.status === 'active') { + schedule.nextRunAt = this.calculateNextRunTime(schedule.scheduleConfig); + } + + await this.repository.save(schedule); + } + + async getScheduleStats(tenantId: string): Promise<{ + total: number; + active: number; + paused: number; + disabled: number; + dueSoon: number; + failedRecently: number; + }> { + const total = await this.repository.count({ where: { tenantId } }); + const active = await this.repository.count({ where: { tenantId, status: 'active' } }); + const paused = await this.repository.count({ where: { tenantId, status: 'paused' } }); + const disabled = await this.repository.count({ where: { tenantId, status: 'disabled' } }); + + const oneHourFromNow = new Date(Date.now() + 60 * 60 * 1000); + const dueSoon = await this.repository.count({ + where: { + tenantId, + status: 'active', + nextRunAt: LessThanOrEqual(oneHourFromNow), + }, + }); + + const failedRecently = await this.repository.count({ + where: { tenantId, lastRunStatus: 'failed' }, + }); + + return { total, active, paused, disabled, dueSoon, failedRecently }; + } + + private calculateNextRunTime(config: ScheduleConfig): Date { + const now = new Date(); + const next = new Date(); + + next.setHours(config.hour, config.minute, 0, 0); + + switch (config.frequency) { + case 'daily': + if (next <= now) { + next.setDate(next.getDate() + 1); + } + break; + + case 'weekly': + const currentDay = next.getDay(); + const targetDay = config.dayOfWeek ?? 1; + let daysUntil = targetDay - currentDay; + if (daysUntil <= 0 || (daysUntil === 0 && next <= now)) { + daysUntil += 7; + } + next.setDate(next.getDate() + daysUntil); + break; + + case 'monthly': + const targetDayOfMonth = config.dayOfMonth ?? 1; + next.setDate(targetDayOfMonth); + if (next <= now) { + next.setMonth(next.getMonth() + 1); + } + break; + + case 'quarterly': + const targetMonth = Math.floor(now.getMonth() / 3) * 3 + (config.monthOfYear ?? 0); + next.setMonth(targetMonth); + next.setDate(config.dayOfMonth ?? 1); + if (next <= now) { + next.setMonth(next.getMonth() + 3); + } + break; + + case 'yearly': + next.setMonth((config.monthOfYear ?? 1) - 1); + next.setDate(config.dayOfMonth ?? 1); + if (next <= now) { + next.setFullYear(next.getFullYear() + 1); + } + break; + } + + return next; + } +} diff --git a/src/modules/reports-clinical/services/report-template.service.ts b/src/modules/reports-clinical/services/report-template.service.ts new file mode 100644 index 0000000..8083257 --- /dev/null +++ b/src/modules/reports-clinical/services/report-template.service.ts @@ -0,0 +1,187 @@ +import { DataSource, Repository } from 'typeorm'; +import { ReportTemplate } from '../entities'; +import { + CreateReportTemplateDto, + UpdateReportTemplateDto, + ReportTemplateQueryDto, +} from '../dto'; + +export class ReportTemplateService { + private repository: Repository; + + constructor(dataSource: DataSource) { + this.repository = dataSource.getRepository(ReportTemplate); + } + + async findAll(tenantId: string, query: ReportTemplateQueryDto): Promise<{ data: ReportTemplate[]; total: number }> { + const { search, reportType, status, page = 1, limit = 50 } = query; + + const queryBuilder = this.repository.createQueryBuilder('template') + .where('template.tenant_id = :tenantId', { tenantId }) + .andWhere('template.deleted_at IS NULL'); + + if (search) { + queryBuilder.andWhere( + '(template.name ILIKE :search OR template.code ILIKE :search OR template.description ILIKE :search)', + { search: `%${search}%` } + ); + } + + if (reportType) { + queryBuilder.andWhere('template.report_type = :reportType', { reportType }); + } + + if (status) { + queryBuilder.andWhere('template.status = :status', { status }); + } + + queryBuilder + .orderBy('template.display_order', 'ASC') + .addOrderBy('template.name', 'ASC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + return { data, total }; + } + + async findById(tenantId: string, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + }); + } + + async findByCode(tenantId: string, code: string): Promise { + return this.repository.findOne({ + where: { code, tenantId }, + }); + } + + async findByType(tenantId: string, reportType: string): Promise { + return this.repository.find({ + where: { tenantId, reportType: reportType as any, status: 'active' }, + order: { displayOrder: 'ASC', name: 'ASC' }, + }); + } + + async findActiveTemplates(tenantId: string): Promise { + return this.repository.find({ + where: { tenantId, status: 'active' }, + order: { displayOrder: 'ASC', name: 'ASC' }, + }); + } + + async create(tenantId: string, dto: CreateReportTemplateDto, createdBy?: string): Promise { + const existing = await this.findByCode(tenantId, dto.code); + if (existing) { + throw new Error('Report template with this code already exists'); + } + + const template = this.repository.create({ + tenantId, + ...dto, + status: 'draft', + isSystem: false, + createdBy, + }); + + return this.repository.save(template); + } + + async update(tenantId: string, id: string, dto: UpdateReportTemplateDto): Promise { + const template = await this.findById(tenantId, id); + if (!template) return null; + + if (template.isSystem) { + throw new Error('Cannot modify system templates'); + } + + if (dto.code && dto.code !== template.code) { + const existing = await this.findByCode(tenantId, dto.code); + if (existing) { + throw new Error('Report template with this code already exists'); + } + } + + Object.assign(template, dto); + return this.repository.save(template); + } + + async activate(tenantId: string, id: string): Promise { + const template = await this.findById(tenantId, id); + if (!template) return null; + + template.status = 'active'; + return this.repository.save(template); + } + + async deactivate(tenantId: string, id: string): Promise { + const template = await this.findById(tenantId, id); + if (!template) return null; + + if (template.isSystem) { + throw new Error('Cannot deactivate system templates'); + } + + template.status = 'inactive'; + return this.repository.save(template); + } + + async softDelete(tenantId: string, id: string): Promise { + const template = await this.findById(tenantId, id); + if (!template) return false; + + if (template.isSystem) { + throw new Error('Cannot delete system templates'); + } + + await this.repository.softDelete({ id, tenantId }); + return true; + } + + async getReportTypes(tenantId: string): Promise<{ type: string; count: number }[]> { + const result = await this.repository.createQueryBuilder('template') + .select('template.report_type', 'type') + .addSelect('COUNT(*)', 'count') + .where('template.tenant_id = :tenantId', { tenantId }) + .andWhere('template.status = :status', { status: 'active' }) + .andWhere('template.deleted_at IS NULL') + .groupBy('template.report_type') + .getRawMany(); + + return result.map(r => ({ type: r.type, count: parseInt(r.count, 10) })); + } + + async duplicateTemplate(tenantId: string, id: string, newCode: string, newName: string, createdBy?: string): Promise { + const original = await this.findById(tenantId, id); + if (!original) { + throw new Error('Template not found'); + } + + const existing = await this.findByCode(tenantId, newCode); + if (existing) { + throw new Error('Report template with this code already exists'); + } + + const duplicate = this.repository.create({ + tenantId, + code: newCode, + name: newName, + description: original.description, + reportType: original.reportType, + supportedFormats: original.supportedFormats, + parameters: original.parameters, + columns: original.columns, + queryTemplate: original.queryTemplate, + headerTemplate: original.headerTemplate, + footerTemplate: original.footerTemplate, + styling: original.styling, + status: 'draft', + isSystem: false, + createdBy, + displayOrder: original.displayOrder, + }); + + return this.repository.save(duplicate); + } +}