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:
236
backend/src/modules/iit-manager/engines/QcAggregator.ts
Normal file
236
backend/src/modules/iit-manager/engines/QcAggregator.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user