[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:
parent
1b38818354
commit
60917f75ff
1
src/modules/reports-clinical/controllers/index.ts
Normal file
1
src/modules/reports-clinical/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { ReportsClinicalController } from './reports-clinical.controller';
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
429
src/modules/reports-clinical/dto/index.ts
Normal file
429
src/modules/reports-clinical/dto/index.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
16
src/modules/reports-clinical/entities/index.ts
Normal file
16
src/modules/reports-clinical/entities/index.ts
Normal 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';
|
||||
106
src/modules/reports-clinical/entities/report-schedule.entity.ts
Normal file
106
src/modules/reports-clinical/entities/report-schedule.entity.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
39
src/modules/reports-clinical/index.ts
Normal file
39
src/modules/reports-clinical/index.ts
Normal 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';
|
||||
19
src/modules/reports-clinical/reports-clinical.module.ts
Normal file
19
src/modules/reports-clinical/reports-clinical.module.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
4
src/modules/reports-clinical/services/index.ts
Normal file
4
src/modules/reports-clinical/services/index.ts
Normal 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';
|
||||
@ -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) })),
|
||||
};
|
||||
}
|
||||
}
|
||||
250
src/modules/reports-clinical/services/report-schedule.service.ts
Normal file
250
src/modules/reports-clinical/services/report-schedule.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
187
src/modules/reports-clinical/services/report-template.service.ts
Normal file
187
src/modules/reports-clinical/services/report-template.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user