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>
251 lines
7.5 KiB
TypeScript
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;
|
|
}
|
|
}
|