V3.1 QC Engine: - QcExecutor unified entry + D1-D7 dimension engines + three-level aggregation - HealthScoreEngine + CompletenessEngine + ProtocolDeviationEngine + QcAggregator - B4 flexible cron scheduling (project-level cronExpression + pg-boss dispatcher) - Prisma migrations for qc_field_status, event_status, project_stats GCP Business Reports (Phase A - 4 reports): - D1 Eligibility: record_summary full list + qc_field_status D1 overlay - D2 Completeness: data entry rate and missing rate aggregation - D3/D4 Query Tracking: severity distribution from qc_field_status - D6 Protocol Deviation: D6 dimension filtering - 4 frontend table components + ReportsPage 5-tab restructure AI Timeline Enhancement: - SkillRunner outputs totalRules (33 actual rules vs 1 skill) - iitQcCockpitController severity mapping fix (critical->red, warning->yellow) - AiStreamPage expandable issue detail table with Chinese labels - Event label localization (eventLabel from backend) Business-side One-click Batch QC: - DashboardPage batch QC button with SyncOutlined icon - Auto-refresh QcReport cache after batch execution Bug Fixes: - dimension_code -> rule_category in 4 SQL queries - D1 eligibility data source: record_summary full + qc_field_status overlay - Timezone UTC -> Asia/Shanghai (QcReportService toBeijingTime helper) - Pass rate calculation: passed/totalEvents instead of passed/totalRecords Docs: - Update IIT module status with GCP reports and bug fix milestones - Update system status doc v6.6 with IIT progress Tested: Backend compiles, frontend linter clean, batch QC verified Made-with: Cursor
260 lines
7.8 KiB
TypeScript
260 lines
7.8 KiB
TypeScript
/**
|
||
* 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<BatchRequest>,
|
||
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<BatchRequest>,
|
||
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<string, { data: any; forms: Set<string>; 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<string, number> = {};
|
||
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();
|