feat(iit): Implement real-time quality control system

Summary:

- Add 4 new database tables: iit_field_metadata, iit_qc_logs, iit_record_summary, iit_qc_project_stats

- Implement pg-boss debounce mechanism in WebhookController

- Refactor QC Worker for dual output: QC logs + record summary

- Enhance HardRuleEngine to support form-based rule filtering

- Create QcService for QC data queries

- Optimize ChatService with new intents: query_enrollment, query_qc_status

- Add admin batch operations: one-click full QC + one-click full summary

- Create IIT Admin management module: project config, QC rules, user mapping

Status: Code complete, pending end-to-end testing
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-07 21:56:11 +08:00
parent 0c590854b5
commit 5db4a7064c
74 changed files with 13383 additions and 2129 deletions

View File

@@ -0,0 +1,354 @@
/**
* IIT 批量操作 Controller
*
* 功能:
* - 一键全量质控
* - 一键全量数据汇总
*
* 用途:
* - 运营管理端手动触发
* - 未来可作为 AI 工具暴露
*/
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';
const prisma = new PrismaClient();
interface BatchRequest {
Params: { projectId: string };
}
export class IitBatchController {
/**
* 一键全量质控
*
* POST /api/v1/admin/iit-projects/:projectId/batch-qc
*
* 功能:
* 1. 获取 REDCap 中所有记录
* 2. 对每条记录执行质控
* 3. 存储质控日志到 iit_qc_logs
* 4. 更新项目统计到 iit_qc_project_stats
*/
async batchQualityCheck(
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 = await adapter.exportRecords({});
if (!allRecords || allRecords.length === 0) {
return reply.send({
success: true,
message: '项目暂无记录',
stats: { totalRecords: 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];
let passCount = 0;
let failCount = 0;
let warningCount = 0;
for (const [recordId, recordData] of recordMap.entries()) {
const qcResult = engine.execute(recordId, recordData);
// 存储质控日志
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
}))
];
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'
}
});
// 更新录入汇总表的质控状态
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()
}
});
// 统计
if (qcResult.overallStatus === 'PASS') passCount++;
else if (qcResult.overallStatus === 'FAIL') failCount++;
else warningCount++;
}
// 5. 更新项目统计表
await prisma.iitQcProjectStats.upsert({
where: { projectId },
create: {
projectId,
totalRecords: recordMap.size,
passedRecords: passCount,
failedRecords: failCount,
warningRecords: warningCount
},
update: {
totalRecords: recordMap.size,
passedRecords: passCount,
failedRecords: failCount,
warningRecords: warningCount
}
});
const durationMs = Date.now() - startTime;
logger.info('✅ 全量质控完成', {
projectId,
totalRecords: recordMap.size,
passCount,
failCount,
warningCount,
durationMs
});
return reply.send({
success: true,
message: '全量质控完成',
stats: {
totalRecords: recordMap.size,
passed: passCount,
failed: failCount,
warnings: warningCount,
passRate: `${((passCount / recordMap.size) * 100).toFixed(1)}%`
},
durationMs
});
} catch (error: any) {
logger.error('❌ 全量质控失败', { 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();