Files
AIclinicalresearch/backend/src/modules/admin/iit-projects/iitBatchController.ts
HaHafeng 2030ebe28f 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
2026-03-01 22:49:49 +08:00

260 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { 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();
interface BatchRequest {
Params: { projectId: string };
}
export class IitBatchController {
/**
* 一键全量质控(事件级)
*
* POST /api/v1/admin/iit-projects/:projectId/batch-qc
*
* 功能:
* 1. 使用 SkillRunner 进行事件级质控
* 2. 每个 record+event 组合独立质控
* 3. 规则根据 applicableEvents/applicableForms 动态过滤
* 4. 质控日志自动保存到 iit_qc_logs含 eventId
*/
async batchQualityCheck(
request: FastifyRequest<BatchRequest>,
reply: FastifyReply
) {
const { projectId } = request.params;
const startTime = Date.now();
try {
logger.info('[V3.1] Batch QC started', { projectId });
const project = await prisma.iitProject.findUnique({
where: { id: projectId },
select: { id: true },
});
if (!project) {
return reply.status(404).send({ error: '项目不存在' });
}
const executor = new QcExecutor(projectId);
const batchResult = await executor.executeBatch({ triggeredBy: 'manual' });
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 });
}
const durationMs = Date.now() - startTime;
logger.info('[V3.1] Batch QC completed', {
projectId, totalRecords, totalEvents, passed, failed, warnings, durationMs,
});
return reply.send({
success: true,
message: '事件级全量质控完成V3.1 QcExecutor',
stats: {
totalRecords,
totalEventCombinations: totalEvents,
passed,
failed,
warnings,
fieldStatusWrites,
passRate,
},
durationMs,
});
} catch (error: any) {
logger.error('Batch QC failed', { 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();