feat(iit): Implement event-level QC architecture V3.1 with dynamic rule filtering, report deduplication and AI intent enhancement
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,19 +2,21 @@
|
||||
* 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 { createHardRuleEngine } from '../../iit-manager/engines/HardRuleEngine.js';
|
||||
import { createSkillRunner } from '../../iit-manager/engines/SkillRunner.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -24,15 +26,15 @@ interface BatchRequest {
|
||||
|
||||
export class IitBatchController {
|
||||
/**
|
||||
* 一键全量质控
|
||||
* 一键全量质控(事件级)
|
||||
*
|
||||
* POST /api/v1/admin/iit-projects/:projectId/batch-qc
|
||||
*
|
||||
* 功能:
|
||||
* 1. 获取 REDCap 中所有记录
|
||||
* 2. 对每条记录执行质控
|
||||
* 3. 存储质控日志到 iit_qc_logs
|
||||
* 4. 更新项目统计到 iit_qc_project_stats
|
||||
* 1. 使用 SkillRunner 进行事件级质控
|
||||
* 2. 每个 record+event 组合独立质控
|
||||
* 3. 规则根据 applicableEvents/applicableForms 动态过滤
|
||||
* 4. 质控日志自动保存到 iit_qc_logs(含 eventId)
|
||||
*/
|
||||
async batchQualityCheck(
|
||||
request: FastifyRequest<BatchRequest>,
|
||||
@@ -42,7 +44,7 @@ export class IitBatchController {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
logger.info('🔄 开始全量质控', { projectId });
|
||||
logger.info('🔄 开始事件级全量质控', { projectId });
|
||||
|
||||
// 1. 获取项目配置
|
||||
const project = await prisma.iitProject.findUnique({
|
||||
@@ -53,141 +55,111 @@ export class IitBatchController {
|
||||
return reply.status(404).send({ error: '项目不存在' });
|
||||
}
|
||||
|
||||
// 2. 从 REDCap 获取所有记录
|
||||
const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken);
|
||||
const allRecords = await adapter.exportRecords({});
|
||||
// 2. 使用 SkillRunner 执行事件级质控
|
||||
const runner = await createSkillRunner(projectId);
|
||||
const results = await runner.runByTrigger('manual');
|
||||
|
||||
if (!allRecords || allRecords.length === 0) {
|
||||
if (results.length === 0) {
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: '项目暂无记录',
|
||||
stats: { totalRecords: 0 }
|
||||
message: '项目暂无记录或未配置质控规则',
|
||||
stats: { totalRecords: 0, totalEvents: 0 }
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 按 record_id 分组
|
||||
const recordMap = new Map<string, any>();
|
||||
for (const record of allRecords) {
|
||||
const recordId = record.record_id || record.id;
|
||||
if (recordId) {
|
||||
// 合并同一记录的多个事件数据
|
||||
const existing = recordMap.get(recordId) || {};
|
||||
recordMap.set(recordId, { ...existing, ...record });
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 执行质控
|
||||
const engine = await createHardRuleEngine(projectId);
|
||||
const ruleVersion = new Date().toISOString().split('T')[0];
|
||||
|
||||
// 3. 统计(按 record+event 组合)
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
let warningCount = 0;
|
||||
let uncertainCount = 0;
|
||||
|
||||
for (const [recordId, recordData] of recordMap.entries()) {
|
||||
const qcResult = engine.execute(recordId, recordData);
|
||||
const uniqueRecords = new Set<string>();
|
||||
|
||||
// 存储质控日志
|
||||
const issues = [
|
||||
...qcResult.errors.map((e: any) => ({
|
||||
field: e.field,
|
||||
rule: e.ruleName,
|
||||
level: 'RED',
|
||||
message: e.message
|
||||
})),
|
||||
...qcResult.warnings.map((w: any) => ({
|
||||
field: w.field,
|
||||
rule: w.ruleName,
|
||||
level: 'YELLOW',
|
||||
message: w.message
|
||||
}))
|
||||
];
|
||||
for (const result of results) {
|
||||
uniqueRecords.add(result.recordId);
|
||||
|
||||
if (result.overallStatus === 'PASS') passCount++;
|
||||
else if (result.overallStatus === 'FAIL') failCount++;
|
||||
else if (result.overallStatus === 'WARNING') warningCount++;
|
||||
else uncertainCount++;
|
||||
|
||||
await prisma.iitQcLog.create({
|
||||
data: {
|
||||
projectId,
|
||||
recordId,
|
||||
qcType: 'holistic', // 全案质控
|
||||
status: qcResult.overallStatus,
|
||||
issues,
|
||||
rulesEvaluated: qcResult.summary.totalRules,
|
||||
rulesSkipped: 0,
|
||||
rulesPassed: qcResult.summary.passed,
|
||||
rulesFailed: qcResult.summary.failed,
|
||||
ruleVersion,
|
||||
triggeredBy: 'manual'
|
||||
}
|
||||
// 更新录入汇总表(取最差状态)
|
||||
const existingSummary = await prisma.iitRecordSummary.findUnique({
|
||||
where: { projectId_recordId: { projectId, recordId: result.recordId } }
|
||||
});
|
||||
|
||||
// 更新录入汇总表的质控状态
|
||||
await prisma.iitRecordSummary.upsert({
|
||||
where: {
|
||||
projectId_recordId: { projectId, recordId }
|
||||
},
|
||||
create: {
|
||||
projectId,
|
||||
recordId,
|
||||
lastUpdatedAt: new Date(),
|
||||
latestQcStatus: qcResult.overallStatus,
|
||||
latestQcAt: new Date(),
|
||||
formStatus: {},
|
||||
updateCount: 1
|
||||
},
|
||||
update: {
|
||||
latestQcStatus: qcResult.overallStatus,
|
||||
latestQcAt: new Date()
|
||||
}
|
||||
});
|
||||
const statusPriority: Record<string, number> = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0 };
|
||||
const currentPriority = statusPriority[result.overallStatus] || 0;
|
||||
const existingPriority = statusPriority[existingSummary?.latestQcStatus || 'PASS'] || 0;
|
||||
|
||||
// 统计
|
||||
if (qcResult.overallStatus === 'PASS') passCount++;
|
||||
else if (qcResult.overallStatus === 'FAIL') failCount++;
|
||||
else warningCount++;
|
||||
// 只更新为更严重的状态
|
||||
if (!existingSummary || currentPriority > existingPriority) {
|
||||
await prisma.iitRecordSummary.upsert({
|
||||
where: { projectId_recordId: { projectId, recordId: result.recordId } },
|
||||
create: {
|
||||
projectId,
|
||||
recordId: result.recordId,
|
||||
lastUpdatedAt: new Date(),
|
||||
latestQcStatus: result.overallStatus,
|
||||
latestQcAt: new Date(),
|
||||
formStatus: {},
|
||||
updateCount: 1
|
||||
},
|
||||
update: {
|
||||
latestQcStatus: result.overallStatus,
|
||||
latestQcAt: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 更新项目统计表
|
||||
// 4. 更新项目统计表
|
||||
await prisma.iitQcProjectStats.upsert({
|
||||
where: { projectId },
|
||||
create: {
|
||||
projectId,
|
||||
totalRecords: recordMap.size,
|
||||
totalRecords: uniqueRecords.size,
|
||||
passedRecords: passCount,
|
||||
failedRecords: failCount,
|
||||
warningRecords: warningCount
|
||||
warningRecords: warningCount + uncertainCount
|
||||
},
|
||||
update: {
|
||||
totalRecords: recordMap.size,
|
||||
totalRecords: uniqueRecords.size,
|
||||
passedRecords: passCount,
|
||||
failedRecords: failCount,
|
||||
warningRecords: warningCount
|
||||
warningRecords: warningCount + uncertainCount
|
||||
}
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
logger.info('✅ 全量质控完成', {
|
||||
logger.info('✅ 事件级全量质控完成', {
|
||||
projectId,
|
||||
totalRecords: recordMap.size,
|
||||
uniqueRecords: uniqueRecords.size,
|
||||
totalEventCombinations: results.length,
|
||||
passCount,
|
||||
failCount,
|
||||
warningCount,
|
||||
uncertainCount,
|
||||
durationMs
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: '全量质控完成',
|
||||
message: '事件级全量质控完成',
|
||||
stats: {
|
||||
totalRecords: recordMap.size,
|
||||
totalRecords: uniqueRecords.size,
|
||||
totalEventCombinations: results.length,
|
||||
passed: passCount,
|
||||
failed: failCount,
|
||||
warnings: warningCount,
|
||||
passRate: `${((passCount / recordMap.size) * 100).toFixed(1)}%`
|
||||
uncertain: uncertainCount,
|
||||
passRate: `${((passCount / results.length) * 100).toFixed(1)}%`
|
||||
},
|
||||
durationMs
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 全量质控失败', { projectId, error: error.message });
|
||||
logger.error('❌ 事件级全量质控失败', { projectId, error: error.message });
|
||||
return reply.status(500).send({ error: `质控失败: ${error.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user