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:
354
backend/src/modules/admin/iit-projects/iitBatchController.ts
Normal file
354
backend/src/modules/admin/iit-projects/iitBatchController.ts
Normal 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();
|
||||
Reference in New Issue
Block a user