erp-clinicas-backend-v2/src/modules/reports-clinical/services/report-schedule.service.ts
Adrian Flores Cortes 60917f75ff [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>
2026-01-30 20:05:13 -06:00

251 lines
7.5 KiB
TypeScript

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