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

@@ -0,0 +1,236 @@
/**
* QcAggregator — V3.1 异步防抖聚合引擎
*
* 职责:
* 1. 从 qc_field_status 聚合到 qc_event_status事件级
* 2. 从 qc_event_status 聚合到 record_summary记录级
* 3. 提供受试者粒度和全项目粒度两种聚合入口
*
* 设计原则:
* - 纯 SQL INSERT...ON CONFLICT 一次性聚合,无应用层循环
* - 执行阶段只写 qc_field_status聚合阶段延迟批量完成
* - 受试者级防抖singletonKey = aggregate_${projectId}_${recordId}
*/
import { PrismaClient, Prisma } from '@prisma/client';
import { logger } from '../../../common/logging/index.js';
import { HealthScoreEngine } from './HealthScoreEngine.js';
const prisma = new PrismaClient();
export interface AggregateResult {
eventStatusRows: number;
recordSummaryRows: number;
durationMs: number;
}
/**
* 全项目聚合field_status → event_status → record_summary
*
* 适用于 executeBatch 完成后一次性刷新。
*/
export async function aggregateDeferred(
projectId: string,
): Promise<AggregateResult> {
const start = Date.now();
const eventRows = await aggregateEventStatus(projectId);
const recordRows = await aggregateRecordSummary(projectId);
// HealthScoreEngine — 仅全项目聚合时触发
try {
const hsEngine = new HealthScoreEngine(projectId);
const hsResult = await hsEngine.calculate();
logger.info('[QcAggregator] HealthScore refreshed', {
projectId,
healthScore: hsResult.healthScore,
healthGrade: hsResult.healthGrade,
});
} catch (err: any) {
logger.warn('[QcAggregator] HealthScoreEngine failed (non-fatal)', {
projectId, error: err.message,
});
}
const result: AggregateResult = {
eventStatusRows: eventRows,
recordSummaryRows: recordRows,
durationMs: Date.now() - start,
};
logger.info('[QcAggregator] aggregateDeferred done', {
projectId,
...result,
});
return result;
}
/**
* 单受试者聚合:只重算该 record 下的 event_status 和 record_summary
*
* 适用于 executeSingle 完成后由 pg-boss 防抖触发。
*/
export async function aggregateForRecord(
projectId: string,
recordId: string,
): Promise<AggregateResult> {
const start = Date.now();
const eventRows = await aggregateEventStatus(projectId, recordId);
const recordRows = await aggregateRecordSummary(projectId, recordId);
const result: AggregateResult = {
eventStatusRows: eventRows,
recordSummaryRows: recordRows,
durationMs: Date.now() - start,
};
logger.info('[QcAggregator] aggregateForRecord done', {
projectId,
recordId,
...result,
});
return result;
}
// ============================================================
// Step 1: qc_field_status → qc_event_status
// ============================================================
async function aggregateEventStatus(
projectId: string,
recordId?: string,
): Promise<number> {
const whereClause = recordId
? Prisma.sql`WHERE fs.project_id = ${projectId} AND fs.record_id = ${recordId}`
: Prisma.sql`WHERE fs.project_id = ${projectId}`;
const rows: number = await prisma.$executeRaw`
INSERT INTO iit_schema.qc_event_status
(id, project_id, record_id, event_id, status,
fields_total, fields_passed, fields_failed, fields_warning,
d1_issues, d2_issues, d3_issues, d5_issues, d6_issues, d7_issues,
triggered_by, last_qc_at, created_at, updated_at)
SELECT
gen_random_uuid(),
fs.project_id,
fs.record_id,
fs.event_id,
CASE
WHEN COUNT(*) FILTER (WHERE fs.status = 'FAIL') > 0 THEN 'FAIL'
WHEN COUNT(*) FILTER (WHERE fs.status = 'WARNING') > 0 THEN 'WARNING'
ELSE 'PASS'
END,
COUNT(*)::int,
COUNT(*) FILTER (WHERE fs.status = 'PASS')::int,
COUNT(*) FILTER (WHERE fs.status = 'FAIL')::int,
COUNT(*) FILTER (WHERE fs.status = 'WARNING')::int,
COUNT(*) FILTER (WHERE fs.rule_category = 'D1' AND fs.status = 'FAIL')::int,
COUNT(*) FILTER (WHERE fs.rule_category = 'D2' AND fs.status = 'FAIL')::int,
COUNT(*) FILTER (WHERE fs.rule_category = 'D3' AND fs.status = 'FAIL')::int,
COUNT(*) FILTER (WHERE fs.rule_category = 'D5' AND fs.status = 'FAIL')::int,
COUNT(*) FILTER (WHERE fs.rule_category = 'D6' AND fs.status = 'FAIL')::int,
COUNT(*) FILTER (WHERE fs.rule_category = 'D7' AND fs.status = 'FAIL')::int,
'aggregation',
NOW(),
NOW(),
NOW()
FROM iit_schema.qc_field_status fs
${whereClause}
GROUP BY fs.project_id, fs.record_id, fs.event_id
ON CONFLICT (project_id, record_id, event_id)
DO UPDATE SET
status = EXCLUDED.status,
fields_total = EXCLUDED.fields_total,
fields_passed = EXCLUDED.fields_passed,
fields_failed = EXCLUDED.fields_failed,
fields_warning = EXCLUDED.fields_warning,
d1_issues = EXCLUDED.d1_issues,
d2_issues = EXCLUDED.d2_issues,
d3_issues = EXCLUDED.d3_issues,
d5_issues = EXCLUDED.d5_issues,
d6_issues = EXCLUDED.d6_issues,
d7_issues = EXCLUDED.d7_issues,
last_qc_at = NOW(),
updated_at = NOW()
`;
return rows;
}
// ============================================================
// Step 2: qc_event_status → record_summary
// ============================================================
async function aggregateRecordSummary(
projectId: string,
recordId?: string,
): Promise<number> {
const whereClause = recordId
? Prisma.sql`WHERE es.project_id = ${projectId} AND es.record_id = ${recordId}`
: Prisma.sql`WHERE es.project_id = ${projectId}`;
const rows: number = await prisma.$executeRaw`
UPDATE iit_schema.record_summary rs
SET
events_total = agg.events_total,
events_passed = agg.events_passed,
events_failed = agg.events_failed,
events_warning = agg.events_warning,
fields_total = agg.fields_total,
fields_passed = agg.fields_passed,
fields_failed = agg.fields_failed,
d1_issues = agg.d1_issues,
d2_issues = agg.d2_issues,
d3_issues = agg.d3_issues,
d5_issues = agg.d5_issues,
d6_issues = agg.d6_issues,
d7_issues = agg.d7_issues,
top_issues = agg.top_issues,
latest_qc_status = agg.worst_status,
latest_qc_at = NOW(),
updated_at = NOW()
FROM (
SELECT
es.project_id,
es.record_id,
COUNT(*)::int AS events_total,
COUNT(*) FILTER (WHERE es.status = 'PASS')::int AS events_passed,
COUNT(*) FILTER (WHERE es.status = 'FAIL')::int AS events_failed,
COUNT(*) FILTER (WHERE es.status = 'WARNING')::int AS events_warning,
COALESCE(SUM(es.fields_total), 0)::int AS fields_total,
COALESCE(SUM(es.fields_passed), 0)::int AS fields_passed,
COALESCE(SUM(es.fields_failed), 0)::int AS fields_failed,
COALESCE(SUM(es.d1_issues), 0)::int AS d1_issues,
COALESCE(SUM(es.d2_issues), 0)::int AS d2_issues,
COALESCE(SUM(es.d3_issues), 0)::int AS d3_issues,
COALESCE(SUM(es.d5_issues), 0)::int AS d5_issues,
COALESCE(SUM(es.d6_issues), 0)::int AS d6_issues,
COALESCE(SUM(es.d7_issues), 0)::int AS d7_issues,
CASE
WHEN COUNT(*) FILTER (WHERE es.status = 'FAIL') > 0 THEN 'FAIL'
WHEN COUNT(*) FILTER (WHERE es.status = 'WARNING') > 0 THEN 'WARNING'
ELSE 'PASS'
END AS worst_status,
COALESCE(
jsonb_agg(
jsonb_build_object(
'eventId', es.event_id,
'status', es.status,
'failedFields', es.fields_failed
)
) FILTER (WHERE es.status IN ('FAIL', 'WARNING')),
'[]'::jsonb
) AS top_issues
FROM iit_schema.qc_event_status es
${whereClause}
GROUP BY es.project_id, es.record_id
) agg
WHERE rs.project_id = agg.project_id
AND rs.record_id = agg.record_id
`;
return rows;
}