[CL-011] feat: Implement reports-clinical module for clinical reporting

Features:
- Report templates with customizable layouts
- Report generator for patient summaries, stats, KPIs
- Clinical statistics service with dashboard data
- Scheduled reports with email delivery
- Export formats: PDF, Excel, CSV

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-30 20:05:13 -06:00
parent 1b38818354
commit 60917f75ff
14 changed files with 2625 additions and 0 deletions

View File

@ -0,0 +1 @@
export { ReportsClinicalController } from './reports-clinical.controller';

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 };
}
}

View File

@ -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<string, any>;
@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<string, any>;
@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<string, any>;
}
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<string, any>;
@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<string, any>;
@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;
}

View File

@ -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<string, any>;
@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;
}

View File

@ -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';

View File

@ -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<string, any>;
@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;
}

View File

@ -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<string, any>;
@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;
}

View File

@ -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';

View File

@ -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;
}
}

View File

@ -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<PatientSummaryRow[]> {
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<AppointmentStatsRow[]> {
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<ConsultationStatsRow[]> {
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<LabResultsStatsRow[]> {
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<PrescriptionStatsRow[]> {
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<RevenueStatsRow[]> {
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<DashboardKPI> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number[]> {
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<number[]> {
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<number> {
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<number> {
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 [];
}
}
}

View File

@ -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';

View File

@ -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<GeneratedReport>;
private templateRepository: Repository<ReportTemplate>;
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<GeneratedReport | null> {
return this.generatedReportRepository.findOne({
where: { id, tenantId },
relations: ['template'],
});
}
async generate(tenantId: string, dto: GenerateReportDto, generatedBy: string): Promise<GeneratedReport> {
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<void> {
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<any[]> {
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<GeneratedReport | null> {
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<number> {
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<number> {
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) })),
};
}
}

View File

@ -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<ReportSchedule>;
private templateRepository: Repository<ReportTemplate>;
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<ReportSchedule | null> {
return this.repository.findOne({
where: { id, tenantId },
relations: ['template'],
});
}
async findActiveSchedules(tenantId: string): Promise<ReportSchedule[]> {
return this.repository.find({
where: { tenantId, status: 'active' },
relations: ['template'],
order: { nextRunAt: 'ASC' },
});
}
async findDueSchedules(): Promise<ReportSchedule[]> {
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<ReportSchedule> {
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<ReportSchedule | null> {
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<ReportSchedule | null> {
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<ReportSchedule | null> {
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<ReportSchedule | null> {
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<boolean> {
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<void> {
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;
}
}

View File

@ -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<ReportTemplate>;
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<ReportTemplate | null> {
return this.repository.findOne({
where: { id, tenantId },
});
}
async findByCode(tenantId: string, code: string): Promise<ReportTemplate | null> {
return this.repository.findOne({
where: { code, tenantId },
});
}
async findByType(tenantId: string, reportType: string): Promise<ReportTemplate[]> {
return this.repository.find({
where: { tenantId, reportType: reportType as any, status: 'active' },
order: { displayOrder: 'ASC', name: 'ASC' },
});
}
async findActiveTemplates(tenantId: string): Promise<ReportTemplate[]> {
return this.repository.find({
where: { tenantId, status: 'active' },
order: { displayOrder: 'ASC', name: 'ASC' },
});
}
async create(tenantId: string, dto: CreateReportTemplateDto, createdBy?: string): Promise<ReportTemplate> {
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<ReportTemplate | null> {
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<ReportTemplate | null> {
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<ReportTemplate | null> {
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<boolean> {
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<ReportTemplate> {
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);
}
}