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

View File

@@ -30,6 +30,8 @@ export interface UpdateProjectInput {
knowledgeBaseId?: string;
status?: string;
isDemo?: boolean;
cronEnabled?: boolean;
cronExpression?: string;
}
export interface TestConnectionResult {
@@ -226,10 +228,22 @@ export class IitProjectService {
knowledgeBaseId: input.knowledgeBaseId,
status: input.status,
isDemo: input.isDemo,
cronEnabled: input.cronEnabled,
cronExpression: input.cronExpression,
updatedAt: new Date(),
},
});
// B4: 刷新 cron 调度
if (input.cronEnabled !== undefined || input.cronExpression !== undefined) {
try {
const { refreshProjectCronSchedule } = await import('../../iit-manager/index.js');
await refreshProjectCronSchedule(id);
} catch (err: any) {
logger.warn('刷新 cron 调度失败 (non-fatal)', { projectId: id, error: err.message });
}
}
logger.info('更新 IIT 项目成功', { projectId: project.id });
return project;
}

View File

@@ -59,25 +59,26 @@ class IitQcCockpitController {
async getRecordDetail(
request: FastifyRequest<{
Params: { projectId: string; recordId: string };
Querystring: { formName?: string };
Querystring: { formName?: string; eventId?: string };
}>,
reply: FastifyReply
) {
const { projectId, recordId } = request.params;
const { formName = 'default' } = request.query;
const { eventId, formName } = request.query;
const resolvedEventId = eventId || formName || undefined;
const startTime = Date.now();
try {
const data = await iitQcCockpitService.getRecordDetail(
projectId,
recordId,
formName
resolvedEventId
);
logger.info('[QcCockpitController] 获取记录详情成功', {
projectId,
recordId,
formName,
eventId: resolvedEventId,
issueCount: data.issues.length,
durationMs: Date.now() - startTime,
});
@@ -236,16 +237,17 @@ class IitQcCockpitController {
prisma.iitQcLog.count({ where: { projectId, ...dateWhere } }),
]);
// Transform to timeline items
const items = qcLogs.map((log) => {
const rawIssues = log.issues as any;
const issues: any[] = Array.isArray(rawIssues) ? rawIssues : (rawIssues?.items || []);
const redCount = issues.filter((i: any) => i.level === 'RED').length;
const yellowCount = issues.filter((i: any) => i.level === 'YELLOW').length;
const redCount = issues.filter((i: any) => i.severity === 'critical' || i.level === 'RED').length;
const yellowCount = issues.filter((i: any) => i.severity === 'warning' || i.level === 'YELLOW').length;
const eventLabel = rawIssues?.eventLabel || '';
const totalRules = rawIssues?.summary?.totalRules || log.rulesEvaluated || 0;
let description = `扫描受试者 ${log.recordId}`;
if (log.formName) description += ` [${log.formName}]`;
description += ` → 执行 ${log.rulesEvaluated} 条规则 (${log.rulesPassed} 通过`;
if (eventLabel) description += `${eventLabel}`;
description += ` → 执行 ${totalRules} 条规则 (${log.rulesPassed} 通过`;
if (log.rulesFailed > 0) description += `, ${log.rulesFailed} 失败`;
description += ')';
if (redCount > 0) description += ` → 发现 ${redCount} 个严重问题`;
@@ -256,15 +258,17 @@ class IitQcCockpitController {
type: 'qc_check' as const,
time: log.createdAt,
recordId: log.recordId,
eventLabel,
formName: log.formName,
status: log.status,
triggeredBy: log.triggeredBy,
description,
details: {
rulesEvaluated: log.rulesEvaluated,
rulesEvaluated: totalRules,
rulesPassed: log.rulesPassed,
rulesFailed: log.rulesFailed,
issuesSummary: { red: redCount, yellow: yellowCount },
issues,
},
};
});
@@ -365,6 +369,210 @@ class IitQcCockpitController {
return reply.status(500).send({ success: false, error: error.message });
}
}
/**
* V3.1: D1-D7 各维度详细统计
*/
async getDimensions(
request: FastifyRequest<{ Params: { projectId: string } }>,
reply: FastifyReply,
) {
const { projectId } = request.params;
try {
const stats = await iitQcCockpitService.getStats(projectId);
return reply.send({
success: true,
data: {
healthScore: stats.healthScore,
healthGrade: stats.healthGrade,
dimensions: stats.dimensionBreakdown,
},
});
} catch (error: any) {
logger.error('[QcCockpitController] getDimensions failed', { projectId, error: error.message });
return reply.status(500).send({ success: false, error: error.message });
}
}
/**
* V3.1: 按受试者返回缺失率
*/
async getCompleteness(
request: FastifyRequest<{ Params: { projectId: string } }>,
reply: FastifyReply,
) {
const { projectId } = request.params;
try {
const records = await prisma.$queryRaw<Array<{
record_id: string; fields_total: number; fields_passed: number; d2_issues: number;
}>>`
SELECT record_id, fields_total, fields_passed, d2_issues
FROM iit_schema.record_summary
WHERE project_id = ${projectId}
ORDER BY record_id
`;
const data = records.map(r => ({
recordId: r.record_id,
fieldsTotal: r.fields_total,
fieldsFilled: r.fields_passed,
fieldsMissing: r.d2_issues,
missingRate: r.fields_total > 0
? Math.round((r.d2_issues / r.fields_total) * 1000) / 10
: 0,
}));
return reply.send({ success: true, data });
} catch (error: any) {
logger.error('[QcCockpitController] getCompleteness failed', { projectId, error: error.message });
return reply.status(500).send({ success: false, error: error.message });
}
}
/**
* V3.1: 字段级质控结果(分页,支持筛选)
*/
async getFieldStatus(
request: FastifyRequest<{
Params: { projectId: string };
Querystring: { recordId?: string; eventId?: string; status?: string; page?: string; pageSize?: string };
}>,
reply: FastifyReply,
) {
const { projectId } = request.params;
const query = request.query as any;
const page = parseInt(query.page || '1');
const pageSize = Math.min(parseInt(query.pageSize || '50'), 200);
try {
const where: any = { projectId };
if (query.recordId) where.recordId = query.recordId;
if (query.eventId) where.eventId = query.eventId;
if (query.status) where.status = query.status;
const conditions = [`project_id = '${projectId}'`];
if (query.recordId) conditions.push(`record_id = '${query.recordId}'`);
if (query.eventId) conditions.push(`event_id = '${query.eventId}'`);
if (query.status) conditions.push(`status = '${query.status}'`);
const whereClause = conditions.join(' AND ');
const offset = (page - 1) * pageSize;
const [items, countResult] = await Promise.all([
prisma.$queryRawUnsafe<any[]>(
`SELECT * FROM iit_schema.qc_field_status WHERE ${whereClause} ORDER BY last_qc_at DESC LIMIT ${pageSize} OFFSET ${offset}`,
),
prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
`SELECT COUNT(*) AS cnt FROM iit_schema.qc_field_status WHERE ${whereClause}`,
),
]);
const total = Number(countResult[0]?.cnt ?? 0);
return reply.send({
success: true,
data: { items, total, page, pageSize },
});
} catch (error: any) {
logger.error('[QcCockpitController] getFieldStatus failed', { projectId, error: error.message });
return reply.status(500).send({ success: false, error: error.message });
}
}
/**
* V3.1: 获取 D6 方案偏离列表
*/
async getDeviations(
request: FastifyRequest<{ Params: { projectId: string } }>,
reply: FastifyReply
) {
const { projectId } = request.params;
try {
const deviations = await iitQcCockpitService.getDeviations(projectId);
return reply.send({ success: true, data: deviations });
} catch (error: any) {
logger.error('[QcCockpitController] getDeviations failed', { projectId, error: error.message });
return reply.status(500).send({ success: false, error: error.message });
}
}
// ============================================================
// GCP 业务报表 API
// ============================================================
async getEligibilityReport(
request: FastifyRequest<{ Params: { projectId: string } }>,
reply: FastifyReply,
) {
const { projectId } = request.params;
try {
const data = await iitQcCockpitService.getEligibilityReport(projectId);
return reply.send({ success: true, data });
} catch (error: any) {
logger.error('[QcCockpitController] getEligibilityReport failed', { projectId, error: error.message });
return reply.status(500).send({ success: false, error: error.message });
}
}
async getCompletenessReport(
request: FastifyRequest<{ Params: { projectId: string } }>,
reply: FastifyReply,
) {
const { projectId } = request.params;
try {
const data = await iitQcCockpitService.getCompletenessReport(projectId);
return reply.send({ success: true, data });
} catch (error: any) {
logger.error('[QcCockpitController] getCompletenessReport failed', { projectId, error: error.message });
return reply.status(500).send({ success: false, error: error.message });
}
}
async getCompletenessFields(
request: FastifyRequest<{
Params: { projectId: string };
Querystring: { recordId: string; eventId: string };
}>,
reply: FastifyReply,
) {
const { projectId } = request.params;
const { recordId, eventId } = request.query as any;
if (!recordId || !eventId) {
return reply.status(400).send({ success: false, error: 'recordId and eventId are required' });
}
try {
const data = await iitQcCockpitService.getCompletenessFields(projectId, recordId, eventId);
return reply.send({ success: true, data });
} catch (error: any) {
logger.error('[QcCockpitController] getCompletenessFields failed', { projectId, error: error.message });
return reply.status(500).send({ success: false, error: error.message });
}
}
async getEqueryLogReport(
request: FastifyRequest<{ Params: { projectId: string } }>,
reply: FastifyReply,
) {
const { projectId } = request.params;
try {
const data = await iitQcCockpitService.getEqueryLogReport(projectId);
return reply.send({ success: true, data });
} catch (error: any) {
logger.error('[QcCockpitController] getEqueryLogReport failed', { projectId, error: error.message });
return reply.status(500).send({ success: false, error: error.message });
}
}
async getDeviationReport(
request: FastifyRequest<{ Params: { projectId: string } }>,
reply: FastifyReply,
) {
const { projectId } = request.params;
try {
const data = await iitQcCockpitService.getDeviationReport(projectId);
return reply.send({ success: true, data });
} catch (error: any) {
logger.error('[QcCockpitController] getDeviationReport failed', { projectId, error: error.message });
return reply.status(500).send({ success: false, error: error.message });
}
}
}
export const iitQcCockpitController = new IitQcCockpitController();

View File

@@ -243,6 +243,43 @@ export async function iitQcCockpitRoutes(fastify: FastifyInstance) {
},
}, iitQcCockpitController.refreshReport.bind(iitQcCockpitController));
// V3.1: D1-D7 维度统计
fastify.get('/:projectId/qc-cockpit/dimensions', {
schema: {
description: 'D1-D7 各维度详细统计',
tags: ['IIT Admin - 质控驾驶舱'],
params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
},
}, iitQcCockpitController.getDimensions.bind(iitQcCockpitController));
// V3.1: 按受试者缺失率
fastify.get('/:projectId/qc-cockpit/completeness', {
schema: {
description: '按受试者返回缺失率',
tags: ['IIT Admin - 质控驾驶舱'],
params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
},
}, iitQcCockpitController.getCompleteness.bind(iitQcCockpitController));
// V3.1: 字段级质控结果(分页)
fastify.get('/:projectId/qc-cockpit/field-status', {
schema: {
description: '字段级质控结果(分页,支持 recordId/eventId/status 筛选)',
tags: ['IIT Admin - 质控驾驶舱'],
params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
querystring: {
type: 'object',
properties: {
recordId: { type: 'string' },
eventId: { type: 'string' },
status: { type: 'string', enum: ['PASS', 'FAIL', 'WARNING'] },
page: { type: 'string', default: '1' },
pageSize: { type: 'string', default: '50' },
},
},
},
}, iitQcCockpitController.getFieldStatus.bind(iitQcCockpitController));
// AI 工作时间线
fastify.get('/:projectId/qc-cockpit/timeline', iitQcCockpitController.getTimeline.bind(iitQcCockpitController));
@@ -251,4 +288,59 @@ export async function iitQcCockpitRoutes(fastify: FastifyInstance) {
// 质控趋势近N天通过率折线
fastify.get('/:projectId/qc-cockpit/trend', iitQcCockpitController.getTrend.bind(iitQcCockpitController));
// V3.1: D6 方案偏离列表
fastify.get('/:projectId/qc-cockpit/deviations', iitQcCockpitController.getDeviations.bind(iitQcCockpitController));
// ============================================================
// GCP 业务报表路由
// ============================================================
fastify.get('/:projectId/qc-cockpit/report/eligibility', {
schema: {
description: 'D1 筛选入选表 — 入排合规性评估',
tags: ['IIT Admin - GCP 报表'],
params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
},
}, iitQcCockpitController.getEligibilityReport.bind(iitQcCockpitController));
fastify.get('/:projectId/qc-cockpit/report/completeness', {
schema: {
description: 'D2 数据完整性总览 — L1 项目 + L2 受试者 + L3 事件级统计',
tags: ['IIT Admin - GCP 报表'],
params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
},
}, iitQcCockpitController.getCompletenessReport.bind(iitQcCockpitController));
fastify.get('/:projectId/qc-cockpit/report/completeness/fields', {
schema: {
description: 'D2 字段级懒加载 — 按 recordId + eventId 返回缺失字段清单',
tags: ['IIT Admin - GCP 报表'],
params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
querystring: {
type: 'object',
properties: {
recordId: { type: 'string' },
eventId: { type: 'string' },
},
required: ['recordId', 'eventId'],
},
},
}, iitQcCockpitController.getCompletenessFields.bind(iitQcCockpitController));
fastify.get('/:projectId/qc-cockpit/report/equery-log', {
schema: {
description: 'D3/D4 eQuery 全生命周期跟踪 — 统计 + 分组 + 全量明细',
tags: ['IIT Admin - GCP 报表'],
params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
},
}, iitQcCockpitController.getEqueryLogReport.bind(iitQcCockpitController));
fastify.get('/:projectId/qc-cockpit/report/deviations', {
schema: {
description: 'D6 方案偏离报表 — 结构化超窗数据 + 汇总统计',
tags: ['IIT Admin - GCP 报表'],
params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
},
}, iitQcCockpitController.getDeviationReport.bind(iitQcCockpitController));
}

File diff suppressed because it is too large Load Diff