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:
2026-03-01 22:49:49 +08:00
parent 0b29fe88b5
commit 2030ebe28f
50 changed files with 8687 additions and 1492 deletions

View File

@@ -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}` });
}
}