feat(iit): Complete V3.1 QC engine + GCP business reports + AI timeline + bug fixes
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
This commit is contained in:
@@ -17,6 +17,8 @@ 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();
|
||||
|
||||
@@ -44,130 +46,53 @@ export class IitBatchController {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
logger.info('🔄 开始事件级全量质控', { projectId });
|
||||
logger.info('[V3.1] Batch QC started', { projectId });
|
||||
|
||||
// 1. 获取项目配置
|
||||
const project = await prisma.iitProject.findUnique({
|
||||
where: { id: projectId }
|
||||
where: { id: projectId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return reply.status(404).send({ error: '项目不存在' });
|
||||
}
|
||||
|
||||
// 2. 使用 SkillRunner 执行事件级质控
|
||||
const runner = await createSkillRunner(projectId);
|
||||
const results = await runner.runByTrigger('manual');
|
||||
const executor = new QcExecutor(projectId);
|
||||
const batchResult = await executor.executeBatch({ triggeredBy: 'manual' });
|
||||
|
||||
if (results.length === 0) {
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: '项目暂无记录或未配置质控规则',
|
||||
stats: { totalRecords: 0, totalEvents: 0 }
|
||||
});
|
||||
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 });
|
||||
}
|
||||
|
||||
// 3. V3.2: 按 record 级别聚合状态(每个 record 取最严重状态)
|
||||
const statusPriority: Record<string, number> = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0 };
|
||||
const recordWorstStatus = new Map<string, string>();
|
||||
|
||||
for (const result of results) {
|
||||
const existing = recordWorstStatus.get(result.recordId);
|
||||
const currentPrio = statusPriority[result.overallStatus] ?? 0;
|
||||
const existingPrio = existing ? (statusPriority[existing] ?? 0) : -1;
|
||||
if (currentPrio > existingPrio) {
|
||||
recordWorstStatus.set(result.recordId, result.overallStatus);
|
||||
}
|
||||
}
|
||||
|
||||
// V3.2: 用本次批量质控结果更新 record_summary(覆盖旧状态)
|
||||
for (const [recordId, worstStatus] of recordWorstStatus) {
|
||||
await prisma.iitRecordSummary.upsert({
|
||||
where: { projectId_recordId: { projectId, recordId } },
|
||||
create: {
|
||||
projectId,
|
||||
recordId,
|
||||
lastUpdatedAt: new Date(),
|
||||
latestQcStatus: worstStatus,
|
||||
latestQcAt: new Date(),
|
||||
formStatus: {},
|
||||
updateCount: 1
|
||||
},
|
||||
update: {
|
||||
latestQcStatus: worstStatus,
|
||||
latestQcAt: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// V3.2: 清理该项目旧版本日志(event_id 为 NULL 的遗留数据)
|
||||
const deletedLegacy = await prisma.iitQcLog.deleteMany({
|
||||
where: { projectId, eventId: null }
|
||||
});
|
||||
if (deletedLegacy.count > 0) {
|
||||
logger.info('🧹 清理旧版质控日志', { projectId, deletedCount: deletedLegacy.count });
|
||||
}
|
||||
|
||||
// V3.2: record 级别统计
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
let warningCount = 0;
|
||||
|
||||
for (const status of recordWorstStatus.values()) {
|
||||
if (status === 'PASS') passCount++;
|
||||
else if (status === 'FAIL') failCount++;
|
||||
else warningCount++;
|
||||
}
|
||||
|
||||
const totalRecords = recordWorstStatus.size;
|
||||
|
||||
// 4. 更新项目统计表(record 级别)
|
||||
await prisma.iitQcProjectStats.upsert({
|
||||
where: { projectId },
|
||||
create: {
|
||||
projectId,
|
||||
totalRecords,
|
||||
passedRecords: passCount,
|
||||
failedRecords: failCount,
|
||||
warningRecords: warningCount
|
||||
},
|
||||
update: {
|
||||
totalRecords,
|
||||
passedRecords: passCount,
|
||||
failedRecords: failCount,
|
||||
warningRecords: warningCount
|
||||
}
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
logger.info('✅ 事件级全量质控完成', {
|
||||
projectId,
|
||||
totalRecords,
|
||||
totalEventCombinations: results.length,
|
||||
passCount,
|
||||
failCount,
|
||||
warningCount,
|
||||
durationMs
|
||||
logger.info('[V3.1] Batch QC completed', {
|
||||
projectId, totalRecords, totalEvents, passed, failed, warnings, durationMs,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: '事件级全量质控完成',
|
||||
message: '事件级全量质控完成(V3.1 QcExecutor)',
|
||||
stats: {
|
||||
totalRecords,
|
||||
totalEventCombinations: results.length,
|
||||
passed: passCount,
|
||||
failed: failCount,
|
||||
warnings: warningCount,
|
||||
passRate: totalRecords > 0
|
||||
? `${((passCount / totalRecords) * 100).toFixed(1)}%`
|
||||
: '0%'
|
||||
totalEventCombinations: totalEvents,
|
||||
passed,
|
||||
failed,
|
||||
warnings,
|
||||
fieldStatusWrites,
|
||||
passRate,
|
||||
},
|
||||
durationMs
|
||||
durationMs,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 事件级全量质控失败', { projectId, error: error.message });
|
||||
logger.error('Batch QC failed', { projectId, error: error.message });
|
||||
return reply.status(500).send({ error: `质控失败: ${error.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user