import { PrismaClient } from '@prisma/client'; import { RedcapAdapter } from '../src/modules/iit-manager/adapters/RedcapAdapter.js'; import { QcExecutor } from '../src/modules/iit-manager/engines/QcExecutor.js'; import { dailyQcOrchestrator } from '../src/modules/iit-manager/services/DailyQcOrchestrator.js'; import { QcReportService } from '../src/modules/iit-manager/services/QcReportService.js'; import { createToolsService } from '../src/modules/iit-manager/services/ToolsService.js'; import { iitQcCockpitService } from '../src/modules/admin/iit-projects/iitQcCockpitService.js'; import { getChatOrchestrator } from '../src/modules/iit-manager/services/ChatOrchestrator.js'; const prisma = new PrismaClient(); type StageResult = { name: string; ok: boolean; checks: Array<{ name: string; ok: boolean; value?: unknown }>; detail?: Record; }; function check(name: string, condition: boolean, value?: unknown) { return { name, ok: condition, value }; } function assertStage(stage: StageResult) { const failed = stage.checks.filter((c) => !c.ok); if (failed.length > 0) { const reason = failed.map((f) => f.name).join(', '); throw new Error(`[${stage.name}] 断言失败: ${reason}`); } } async function main() { const projectId = process.argv[2]; const withChat = process.argv.includes('--with-chat') || process.env.E2E_WITH_CHAT === '1'; const strictGuards = process.argv.includes('--strict-guards') || process.env.E2E_REQUIRE_GUARD_TYPES === '1'; if (!projectId) { throw new Error('Usage: npx tsx scripts/e2e_iit_full_flow.ts [--with-chat] [--strict-guards]'); } const summary: { projectId: string; withChat: boolean; strictGuards: boolean; startedAt: string; stages: StageResult[]; endedAt?: string; } = { projectId, withChat, strictGuards, startedAt: new Date().toISOString(), stages: [], }; // Stage 1: REDCap 结构 + 数据同步能力 { const project = await prisma.iitProject.findUnique({ where: { id: projectId }, select: { redcapUrl: true, redcapApiToken: true }, }); if (!project?.redcapUrl || !project?.redcapApiToken) { throw new Error('项目未配置 REDCap 连接信息'); } const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken); const [metadata, events, formEventMapping, recordsByEvent] = await Promise.all([ adapter.exportMetadata(), adapter.getEvents(), adapter.getFormEventMapping(), adapter.getAllRecordsByEvent(), ]); const uniqueRecords = new Set(recordsByEvent.map((r) => r.recordId)); const stage: StageResult = { name: 'Stage1_REDCap', ok: true, checks: [ check('metadata_non_empty', metadata.length > 0, metadata.length), check('records_by_event_non_empty', recordsByEvent.length > 0, recordsByEvent.length), check('unique_records_non_empty', uniqueRecords.size > 0, uniqueRecords.size), check('events_loaded_or_non_longitudinal', events.length > 0 || formEventMapping.length === 0, { events: events.length, formEventMapping: formEventMapping.length, }), ], detail: { metadataCount: metadata.length, eventCount: events.length, formEventMappingCount: formEventMapping.length, recordEventRows: recordsByEvent.length, uniqueRecordCount: uniqueRecords.size, }, }; assertStage(stage); summary.stages.push(stage); } // Stage 2: 规则配置加载与覆盖 { const skill = await prisma.iitSkill.findFirst({ where: { projectId, skillType: 'qc_process', isActive: true }, select: { config: true }, }); const rules = ((skill?.config as any)?.rules || []) as Array<{ id: string; name?: string; category?: string; field?: string | string[]; metadata?: Record; }>; const multiFieldRules = rules.filter((r) => Array.isArray(r.field)).length; const categorySet = new Set(rules.map((r) => String(r.category || '')).filter(Boolean)); const guardCandidates = rules.filter((r) => /访视日期.*知情同意|早于知情同意|SF-?MPQ.*CMSS.*不一致|评估日期.*访视日期.*不一致|所有纳入标准.*检查|纳入标准.*满足|入组状态.*排除标准.*冲突/i.test(String(r.name || '')), ); const guardConfigured = guardCandidates.filter((r) => String((r.metadata as any)?.guardType || '').trim().length > 0); const stage: StageResult = { name: 'Stage2_Rules', ok: true, checks: [ check('active_qc_rules_exists', rules.length > 0, rules.length), check('multi_field_rules_exists', multiFieldRules > 0, multiFieldRules), check('has_D1_or_legacy_inclusion_exclusion', categorySet.has('D1') || categorySet.has('inclusion') || categorySet.has('exclusion'), Array.from(categorySet)), check( strictGuards ? 'guardtype_coverage_required' : 'guardtype_coverage_info', strictGuards ? guardConfigured.length === guardCandidates.length : true, { configured: guardConfigured.length, candidates: guardCandidates.length }, ), ], detail: { ruleCount: rules.length, multiFieldRuleCount: multiFieldRules, categories: Array.from(categorySet).sort(), guardTypeCoverage: { strictMode: strictGuards, configured: guardConfigured.length, candidates: guardCandidates.length, }, }, }; assertStage(stage); summary.stages.push(stage); } // Stage 3: 质控执行 + 报告编排 { const executor = new QcExecutor(projectId); const batch = await executor.executeBatch({ triggeredBy: 'manual', skipSoftRules: true }); const orchestrate = await dailyQcOrchestrator.orchestrate(projectId, { skipPush: true }); const [fieldCountRows, eventCountRows, summaryCountRows, projectStats] = await Promise.all([ prisma.$queryRaw>` SELECT COUNT(*)::bigint AS cnt FROM iit_schema.qc_field_status WHERE project_id = ${projectId} `, prisma.$queryRaw>` SELECT COUNT(*)::bigint AS cnt FROM iit_schema.qc_event_status WHERE project_id = ${projectId} `, prisma.$queryRaw>` SELECT COUNT(*)::bigint AS cnt FROM iit_schema.record_summary WHERE project_id = ${projectId} `, prisma.iitQcProjectStats.findUnique({ where: { projectId } }), ]); const fieldStatusCount = Number(fieldCountRows[0]?.cnt || 0n); const eventStatusCount = Number(eventCountRows[0]?.cnt || 0n); const recordSummaryCount = Number(summaryCountRows[0]?.cnt || 0n); const stage: StageResult = { name: 'Stage3_Execution', ok: true, checks: [ check('batch_has_records', batch.totalRecords > 0, batch.totalRecords), check('field_status_written', fieldStatusCount > 0, fieldStatusCount), check('event_status_written', eventStatusCount > 0, eventStatusCount), check('record_summary_written', recordSummaryCount > 0, recordSummaryCount), check('project_stats_exists', !!projectStats, projectStats ? true : false), ], detail: { batch, orchestrate, db: { fieldStatusCount, eventStatusCount, recordSummaryCount, projectStats: projectStats ? { totalRecords: projectStats.totalRecords, passedRecords: projectStats.passedRecords, failedRecords: projectStats.failedRecords, } : null, }, }, }; assertStage(stage); summary.stages.push(stage); } // Stage 4: 多消费者一致性(驾驶舱 / 报告 / 工具 / 可选Chat) { const [report, cockpitData, equeryLog, tools] = await Promise.all([ QcReportService.getReport(projectId), iitQcCockpitService.getCockpitData(projectId), iitQcCockpitService.getEqueryLogReport(projectId), createToolsService(projectId), ]); const toolSummary = await tools.execute('read_report', { section: 'summary' }, 'e2e-script'); const toolPassRate = Number((toolSummary as any)?.data?.passRate ?? NaN); const reportPassRate = Number(report.summary.passRate); const cockpitPassRate = Number(cockpitData.stats.passRate); const passRateConsistent = Number.isFinite(toolPassRate) && Math.abs(reportPassRate - cockpitPassRate) < 0.0001 && Math.abs(reportPassRate - toolPassRate) < 0.0001; let chatResult: string | null = null; if (withChat) { const orchestrator = await getChatOrchestrator(projectId); chatResult = await orchestrator.handleMessage('e2e-user', '当前项目总体通过率是多少?'); } const stage: StageResult = { name: 'Stage4_Consumption', ok: true, checks: [ check('report_summary_exists', report.summary.totalRecords >= 0, report.summary.totalRecords), check('cockpit_stats_exists', cockpitData.stats.totalRecords >= 0, cockpitData.stats.totalRecords), check('equery_log_summary_exists', equeryLog.summary.total >= 0, equeryLog.summary.total), check('pass_rate_consistent_report_cockpit_tool', passRateConsistent, { reportPassRate, cockpitPassRate, toolPassRate, }), check('chat_has_conclusion_or_skipped', !withChat || !!chatResult, chatResult || 'skipped'), ], detail: { reportPassRate, cockpitPassRate, toolPassRate, chatResult: withChat ? chatResult : 'skipped', }, }; assertStage(stage); summary.stages.push(stage); } summary.endedAt = new Date().toISOString(); console.log(JSON.stringify(summary, null, 2)); } main() .then(async () => { await prisma.$disconnect(); process.exit(0); }) .catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); });