/** * IIT 批量操作 Controller * * 功能: * - 一键全量质控(事件级) * - 一键全量数据汇总 * * 用途: * - 运营管理端手动触发 * - 未来可作为 AI 工具暴露 * * 版本:v3.1 - 事件级质控 */ import { FastifyRequest, FastifyReply } from 'fastify'; import { PrismaClient } from '@prisma/client'; import { logger } from '../../../common/logging/index.js'; import { RedcapAdapter } from '../../iit-manager/adapters/RedcapAdapter.js'; import { createSkillRunner } from '../../iit-manager/engines/SkillRunner.js'; import { QcExecutor } from '../../iit-manager/engines/QcExecutor.js'; import { QcReportService } from '../../iit-manager/services/QcReportService.js'; const prisma = new PrismaClient(); interface BatchRequest { Params: { projectId: string }; } export class IitBatchController { /** * 一键全量质控(事件级) * * POST /api/v1/admin/iit-projects/:projectId/batch-qc * * 功能: * 1. 使用 SkillRunner 进行事件级质控 * 2. 每个 record+event 组合独立质控 * 3. 规则根据 applicableEvents/applicableForms 动态过滤 * 4. 质控日志自动保存到 iit_qc_logs(含 eventId) */ async batchQualityCheck( request: FastifyRequest, reply: FastifyReply ) { const { projectId } = request.params; const startTime = Date.now(); try { logger.info('[V3.1] Batch QC started', { projectId }); const project = await prisma.iitProject.findUnique({ where: { id: projectId }, select: { id: true }, }); if (!project) { return reply.status(404).send({ error: '项目不存在' }); } const executor = new QcExecutor(projectId); const batchResult = await executor.executeBatch({ triggeredBy: 'manual' }); const { totalRecords, totalEvents, passed, failed, warnings, fieldStatusWrites, executionTimeMs } = batchResult; const passRate = totalEvents > 0 ? `${((passed / totalEvents) * 100).toFixed(1)}%` : '0%'; // 自动刷新 QcReport 缓存,使业务端立即看到最新数据 try { await QcReportService.refreshReport(projectId); logger.info('[V3.1] QcReport cache refreshed after batch QC', { projectId }); } catch (reportErr: any) { logger.warn('[V3.1] QcReport refresh failed (non-blocking)', { projectId, error: reportErr.message }); } const durationMs = Date.now() - startTime; logger.info('[V3.1] Batch QC completed', { projectId, totalRecords, totalEvents, passed, failed, warnings, durationMs, }); return reply.send({ success: true, message: '事件级全量质控完成(V3.1 QcExecutor)', stats: { totalRecords, totalEventCombinations: totalEvents, passed, failed, warnings, fieldStatusWrites, passRate, }, durationMs, }); } catch (error: any) { logger.error('Batch QC failed', { projectId, error: error.message }); return reply.status(500).send({ error: `质控失败: ${error.message}` }); } } /** * 一键全量数据汇总 * * POST /api/v1/admin/iit-projects/:projectId/batch-summary * * 功能: * 1. 获取 REDCap 中所有记录 * 2. 获取项目的所有表单(instruments) * 3. 为每条记录生成/更新录入汇总 */ async batchSummary( request: FastifyRequest, reply: FastifyReply ) { const { projectId } = request.params; const startTime = Date.now(); try { logger.info('🔄 开始全量数据汇总', { projectId }); // 1. 获取项目配置 const project = await prisma.iitProject.findUnique({ where: { id: projectId } }); if (!project) { return reply.status(404).send({ error: '项目不存在' }); } // 2. 从 REDCap 获取所有记录和表单信息 const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken); const [allRecords, instruments] = await Promise.all([ adapter.exportRecords({}), adapter.exportInstruments() ]); if (!allRecords || allRecords.length === 0) { return reply.send({ success: true, message: '项目暂无记录', stats: { totalRecords: 0 } }); } const totalForms = instruments?.length || 10; // 3. 按 record_id 分组并计算表单完成状态 const recordMap = new Map; firstSeen: Date }>(); for (const record of allRecords) { const recordId = record.record_id || record.id; if (!recordId) continue; const existing = recordMap.get(recordId); if (existing) { // 合并数据 existing.data = { ...existing.data, ...record }; // 记录表单 if (record.redcap_repeat_instrument) { existing.forms.add(record.redcap_repeat_instrument); } } else { recordMap.set(recordId, { data: record, forms: new Set(record.redcap_repeat_instrument ? [record.redcap_repeat_instrument] : []), firstSeen: new Date() }); } } // 4. 为每条记录更新汇总 let summaryCount = 0; for (const [recordId, { data, forms, firstSeen }] of recordMap.entries()) { // 计算表单完成状态(简化:有数据即认为完成) const formStatus: Record = {}; const completedForms = forms.size || 1; // 至少有1个表单有数据 for (const form of forms) { formStatus[form] = 2; // 2 = 完成 } const completionRate = Math.min(100, Math.round((completedForms / totalForms) * 100)); await prisma.iitRecordSummary.upsert({ where: { projectId_recordId: { projectId, recordId } }, create: { projectId, recordId, enrolledAt: firstSeen, lastUpdatedAt: new Date(), formStatus, totalForms, completedForms, completionRate, updateCount: 1 }, update: { lastUpdatedAt: new Date(), formStatus, totalForms, completedForms, completionRate, updateCount: { increment: 1 } } }); summaryCount++; } // 5. 更新项目统计 const avgCompletionRate = await prisma.iitRecordSummary.aggregate({ where: { projectId }, _avg: { completionRate: true } }); await prisma.iitQcProjectStats.upsert({ where: { projectId }, create: { projectId, totalRecords: recordMap.size, avgCompletionRate: avgCompletionRate._avg.completionRate || 0 }, update: { totalRecords: recordMap.size, avgCompletionRate: avgCompletionRate._avg.completionRate || 0 } }); const durationMs = Date.now() - startTime; logger.info('✅ 全量数据汇总完成', { projectId, totalRecords: recordMap.size, summaryCount, durationMs }); return reply.send({ success: true, message: '全量数据汇总完成', stats: { totalRecords: recordMap.size, summariesUpdated: summaryCount, totalForms, avgCompletionRate: `${(avgCompletionRate._avg.completionRate || 0).toFixed(1)}%` }, durationMs }); } catch (error: any) { logger.error('❌ 全量数据汇总失败', { projectId, error: error.message }); return reply.status(500).send({ error: `汇总失败: ${error.message}` }); } } } export const iitBatchController = new IitBatchController();