From 203846968c0c70d5e4fbcd67159e1f2ce3c28e40 Mon Sep 17 00:00:00 2001 From: HaHafeng Date: Thu, 26 Feb 2026 13:28:08 +0800 Subject: [PATCH] feat(iit): Complete CRA Agent V3.0 P0 milestone - autonomous QC pipeline P0-1: Variable list sync from REDCap metadata P0-2: QC rule configuration with JSON Logic + AI suggestion P0-3: Scheduled QC + report generation + eQuery closed loop P0-4: Unified dashboard + AI stream timeline + critical events Backend: - Add IitEquery, IitCriticalEvent Prisma models + migration - Add cronEnabled/cronExpression to IitProject - Implement eQuery service/controller/routes (CRUD + respond/review/close) - Implement DailyQcOrchestrator (report -> eQuery -> critical events -> notify) - Add AI rule suggestion service - Register daily QC cron worker and eQuery auto-review worker - Extend QC cockpit with timeline, trend, critical events APIs - Fix timeline issues field compat (object vs array format) Frontend: - Create IIT business module with 6 pages (Dashboard, AI Stream, eQuery, Reports, Variable List + project config pages) - Migrate IIT config from admin panel to business module - Implement health score, risk heatmap, trend chart, critical event alerts - Register IIT module in App router and top navigation Testing: - Add E2E API test script covering 7 modules (46 assertions, all passing) Tested: E2E API tests 46/46 passed, backend and frontend verified Made-with: Cursor --- .../migration.sql | 67 ++ backend/prisma/schema.prisma | 93 ++ backend/src/index.ts | 3 +- .../admin/iit-projects/iitEqueryController.ts | 138 +++ .../admin/iit-projects/iitEqueryRoutes.ts | 26 + .../admin/iit-projects/iitEqueryService.ts | 308 +++++ .../iit-projects/iitProjectController.ts | 58 + .../admin/iit-projects/iitProjectRoutes.ts | 3 + .../iit-projects/iitQcCockpitController.ts | 180 +++ .../admin/iit-projects/iitQcCockpitRoutes.ts | 9 + .../admin/iit-projects/iitQcRuleController.ts | 27 + .../admin/iit-projects/iitQcRuleRoutes.ts | 3 + .../iit-projects/iitRuleSuggestionService.ts | 161 +++ .../src/modules/admin/iit-projects/index.ts | 3 + backend/src/modules/iit-manager/index.ts | 173 ++- .../services/DailyQcOrchestrator.ts | 279 +++++ backend/tests/e2e-p0-test.ts | 249 ++++ frontend-v2/src/App.tsx | 10 +- .../src/framework/layout/AdminLayout.tsx | 7 +- .../src/framework/modules/moduleRegistry.ts | 23 +- frontend-v2/src/modules/iit/IitLayout.tsx | 205 ++++ .../src/modules/iit/api/iitProjectApi.ts | 564 +++++++++ .../iit/components/RuleTemplateBuilder.tsx | 518 ++++++++ .../modules/iit/components/VariablePicker.tsx | 192 +++ .../modules/iit/config/ProjectDetailPage.tsx | 1048 +++++++++++++++++ .../modules/iit/config/ProjectListPage.tsx | 350 ++++++ frontend-v2/src/modules/iit/index.tsx | 35 + .../src/modules/iit/pages/AiStreamPage.tsx | 212 ++++ .../src/modules/iit/pages/DashboardPage.tsx | 359 ++++++ .../src/modules/iit/pages/EQueryPage.tsx | 387 ++++++ .../src/modules/iit/pages/ReportsPage.tsx | 349 ++++++ .../modules/iit/pages/VariableListPage.tsx | 404 +++++++ .../src/modules/iit/styles/QcCockpitPage.css | 680 +++++++++++ .../src/modules/iit/types/iitProject.ts | 136 +++ .../src/modules/iit/types/qcCockpit.ts | 116 ++ 35 files changed, 7353 insertions(+), 22 deletions(-) create mode 100644 backend/prisma/migrations/20260226_add_equery_critical_events_cron/migration.sql create mode 100644 backend/src/modules/admin/iit-projects/iitEqueryController.ts create mode 100644 backend/src/modules/admin/iit-projects/iitEqueryRoutes.ts create mode 100644 backend/src/modules/admin/iit-projects/iitEqueryService.ts create mode 100644 backend/src/modules/admin/iit-projects/iitRuleSuggestionService.ts create mode 100644 backend/src/modules/iit-manager/services/DailyQcOrchestrator.ts create mode 100644 backend/tests/e2e-p0-test.ts create mode 100644 frontend-v2/src/modules/iit/IitLayout.tsx create mode 100644 frontend-v2/src/modules/iit/api/iitProjectApi.ts create mode 100644 frontend-v2/src/modules/iit/components/RuleTemplateBuilder.tsx create mode 100644 frontend-v2/src/modules/iit/components/VariablePicker.tsx create mode 100644 frontend-v2/src/modules/iit/config/ProjectDetailPage.tsx create mode 100644 frontend-v2/src/modules/iit/config/ProjectListPage.tsx create mode 100644 frontend-v2/src/modules/iit/index.tsx create mode 100644 frontend-v2/src/modules/iit/pages/AiStreamPage.tsx create mode 100644 frontend-v2/src/modules/iit/pages/DashboardPage.tsx create mode 100644 frontend-v2/src/modules/iit/pages/EQueryPage.tsx create mode 100644 frontend-v2/src/modules/iit/pages/ReportsPage.tsx create mode 100644 frontend-v2/src/modules/iit/pages/VariableListPage.tsx create mode 100644 frontend-v2/src/modules/iit/styles/QcCockpitPage.css create mode 100644 frontend-v2/src/modules/iit/types/iitProject.ts create mode 100644 frontend-v2/src/modules/iit/types/qcCockpit.ts diff --git a/backend/prisma/migrations/20260226_add_equery_critical_events_cron/migration.sql b/backend/prisma/migrations/20260226_add_equery_critical_events_cron/migration.sql new file mode 100644 index 00000000..96e67725 --- /dev/null +++ b/backend/prisma/migrations/20260226_add_equery_critical_events_cron/migration.sql @@ -0,0 +1,67 @@ +-- P0-3: eQuery 闭环 + 重大事件归档 + 项目 cron 配置 + +-- 1. eQuery 表(AI 自动生成的电子质疑,具有完整生命周期) +CREATE TABLE IF NOT EXISTS iit_schema.equery ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id TEXT NOT NULL, + record_id TEXT NOT NULL, + event_id TEXT, + form_name TEXT, + field_name TEXT, + qc_log_id TEXT, + report_id TEXT, + query_text TEXT NOT NULL, + expected_action TEXT, + severity TEXT NOT NULL DEFAULT 'warning', + category TEXT, + status TEXT NOT NULL DEFAULT 'pending', + assigned_to TEXT, + responded_at TIMESTAMPTZ, + response_text TEXT, + response_data JSONB, + review_result TEXT, + review_note TEXT, + reviewed_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + closed_by TEXT, + resolution TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_iit_equery_project ON iit_schema.equery(project_id); +CREATE INDEX IF NOT EXISTS idx_iit_equery_project_status ON iit_schema.equery(project_id, status); +CREATE INDEX IF NOT EXISTS idx_iit_equery_record ON iit_schema.equery(record_id); +CREATE INDEX IF NOT EXISTS idx_iit_equery_assigned ON iit_schema.equery(assigned_to); + +-- 2. 重大事件归档表(SAE、重大方案偏离等长期临床资产) +CREATE TABLE IF NOT EXISTS iit_schema.critical_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id TEXT NOT NULL, + record_id TEXT NOT NULL, + event_type TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'critical', + title TEXT NOT NULL, + description TEXT NOT NULL, + detected_at TIMESTAMPTZ NOT NULL, + detected_by TEXT NOT NULL DEFAULT 'ai', + source_qc_log_id TEXT, + source_equery_id TEXT, + source_data JSONB, + status TEXT NOT NULL DEFAULT 'open', + handled_by TEXT, + handled_at TIMESTAMPTZ, + handling_note TEXT, + reported_to_ec BOOLEAN NOT NULL DEFAULT false, + reported_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_iit_critical_event_project ON iit_schema.critical_events(project_id); +CREATE INDEX IF NOT EXISTS idx_iit_critical_event_type ON iit_schema.critical_events(project_id, event_type); +CREATE INDEX IF NOT EXISTS idx_iit_critical_event_status ON iit_schema.critical_events(project_id, status); + +-- 3. 项目表新增 cron 配置字段 +ALTER TABLE iit_schema.projects ADD COLUMN IF NOT EXISTS cron_enabled BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE iit_schema.projects ADD COLUMN IF NOT EXISTS cron_expression TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 24b3003c..d21e8969 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -948,6 +948,8 @@ model IitProject { redcapApiToken String @map("redcap_api_token") redcapUrl String @map("redcap_url") lastSyncAt DateTime? @map("last_sync_at") + cronEnabled Boolean @default(false) @map("cron_enabled") + cronExpression String? @map("cron_expression") status String @default("active") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -1426,6 +1428,97 @@ model IitQcProjectStats { @@schema("iit_schema") } +/// eQuery 表 - AI 自动生成的电子质疑,具有完整生命周期 +model IitEquery { + id String @id @default(uuid()) + projectId String @map("project_id") + + // 来源 + recordId String @map("record_id") + eventId String? @map("event_id") + formName String? @map("form_name") + fieldName String? @map("field_name") + qcLogId String? @map("qc_log_id") + reportId String? @map("report_id") + + // 质疑内容 + queryText String @map("query_text") @db.Text + expectedAction String? @map("expected_action") @db.Text + severity String @default("warning") + category String? + + // 状态机: pending → responded → reviewing → closed / reopened + status String @default("pending") + + // CRC 回复 + assignedTo String? @map("assigned_to") + respondedAt DateTime? @map("responded_at") + responseText String? @map("response_text") @db.Text + responseData Json? @map("response_data") @db.JsonB + + // AI 复核 + reviewResult String? @map("review_result") + reviewNote String? @map("review_note") @db.Text + reviewedAt DateTime? @map("reviewed_at") + + // 关闭 + closedAt DateTime? @map("closed_at") + closedBy String? @map("closed_by") + resolution String? @db.Text + + // 时间线 + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([projectId], map: "idx_iit_equery_project") + @@index([projectId, status], map: "idx_iit_equery_project_status") + @@index([recordId], map: "idx_iit_equery_record") + @@index([assignedTo], map: "idx_iit_equery_assigned") + @@map("equery") + @@schema("iit_schema") +} + +/// 重大事件归档表 - SAE、重大方案偏离等长期临床资产 +model IitCriticalEvent { + id String @id @default(uuid()) + projectId String @map("project_id") + recordId String @map("record_id") + + // 事件分类 + eventType String @map("event_type") + severity String @default("critical") + + // 事件内容 + title String + description String @db.Text + detectedAt DateTime @map("detected_at") + detectedBy String @default("ai") @map("detected_by") + + // 来源追溯 + sourceQcLogId String? @map("source_qc_log_id") + sourceEqueryId String? @map("source_equery_id") + sourceData Json? @map("source_data") @db.JsonB + + // 处理状态 + status String @default("open") + handledBy String? @map("handled_by") + handledAt DateTime? @map("handled_at") + handlingNote String? @map("handling_note") @db.Text + + // 上报追踪 + reportedToEc Boolean @default(false) @map("reported_to_ec") + reportedAt DateTime? @map("reported_at") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([projectId], map: "idx_iit_critical_event_project") + @@index([projectId, eventType], map: "idx_iit_critical_event_type") + @@index([projectId, status], map: "idx_iit_critical_event_status") + @@map("critical_events") + @@schema("iit_schema") +} + model admin_operation_logs { id Int @id @default(autoincrement()) admin_id String diff --git a/backend/src/index.ts b/backend/src/index.ts index bbb39a0e..afc14835 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -110,7 +110,7 @@ import { tenantRoutes, moduleRoutes } from './modules/admin/routes/tenantRoutes. import { userRoutes } from './modules/admin/routes/userRoutes.js'; import { statsRoutes, userOverviewRoute } from './modules/admin/routes/statsRoutes.js'; import { systemKbRoutes } from './modules/admin/system-kb/index.js'; -import { iitProjectRoutes, iitQcRuleRoutes, iitUserMappingRoutes, iitBatchRoutes, iitQcCockpitRoutes } from './modules/admin/iit-projects/index.js'; +import { iitProjectRoutes, iitQcRuleRoutes, iitUserMappingRoutes, iitBatchRoutes, iitQcCockpitRoutes, iitEqueryRoutes } from './modules/admin/iit-projects/index.js'; await fastify.register(tenantRoutes, { prefix: '/api/admin/tenants' }); await fastify.register(moduleRoutes, { prefix: '/api/admin/modules' }); await fastify.register(userRoutes, { prefix: '/api/admin/users' }); @@ -122,6 +122,7 @@ await fastify.register(iitQcRuleRoutes, { prefix: '/api/v1/admin/iit-projects' } await fastify.register(iitUserMappingRoutes, { prefix: '/api/v1/admin/iit-projects' }); await fastify.register(iitBatchRoutes, { prefix: '/api/v1/admin/iit-projects' }); // 一键全量质控/汇总 await fastify.register(iitQcCockpitRoutes, { prefix: '/api/v1/admin/iit-projects' }); // 质控驾驶舱 +await fastify.register(iitEqueryRoutes, { prefix: '/api/v1/admin/iit-projects' }); // eQuery 闭环 logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users, /api/admin/stats, /api/v1/admin/system-kb, /api/v1/admin/iit-projects'); // ============================================ diff --git a/backend/src/modules/admin/iit-projects/iitEqueryController.ts b/backend/src/modules/admin/iit-projects/iitEqueryController.ts new file mode 100644 index 00000000..46bcf497 --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitEqueryController.ts @@ -0,0 +1,138 @@ +/** + * eQuery 控制器 + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { getIitEqueryService } from './iitEqueryService.js'; +import { prisma } from '../../../config/database.js'; +import { logger } from '../../../common/logging/index.js'; + +interface ProjectIdParams { + projectId: string; +} + +interface EqueryIdParams { + projectId: string; + equeryId: string; +} + +export async function listEqueries( + request: FastifyRequest<{ + Params: ProjectIdParams; + Querystring: { status?: string; recordId?: string; severity?: string; page?: string; pageSize?: string }; + }>, + reply: FastifyReply +) { + try { + const { projectId } = request.params; + const query = request.query as any; + const service = getIitEqueryService(prisma); + const result = await service.list({ + projectId, + status: query.status, + recordId: query.recordId, + severity: query.severity, + page: query.page ? parseInt(query.page) : 1, + pageSize: query.pageSize ? parseInt(query.pageSize) : 50, + }); + return reply.send({ success: true, data: result }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error('获取 eQuery 列表失败', { error: msg }); + return reply.status(500).send({ success: false, error: msg }); + } +} + +export async function getEquery( + request: FastifyRequest<{ Params: EqueryIdParams }>, + reply: FastifyReply +) { + try { + const { equeryId } = request.params; + const service = getIitEqueryService(prisma); + const equery = await service.getById(equeryId); + if (!equery) return reply.status(404).send({ success: false, error: 'eQuery 不存在' }); + return reply.send({ success: true, data: equery }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return reply.status(500).send({ success: false, error: msg }); + } +} + +export async function getEqueryStats( + request: FastifyRequest<{ Params: ProjectIdParams }>, + reply: FastifyReply +) { + try { + const { projectId } = request.params; + const service = getIitEqueryService(prisma); + const stats = await service.getStats(projectId); + return reply.send({ success: true, data: stats }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return reply.status(500).send({ success: false, error: msg }); + } +} + +export async function respondEquery( + request: FastifyRequest<{ + Params: EqueryIdParams; + Body: { responseText: string; responseData?: Record }; + }>, + reply: FastifyReply +) { + try { + const { equeryId } = request.params; + const { responseText, responseData } = request.body; + if (!responseText) { + return reply.status(400).send({ success: false, error: '请提供回复内容' }); + } + const service = getIitEqueryService(prisma); + const updated = await service.respond(equeryId, { responseText, responseData }); + return reply.send({ success: true, data: updated }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error('eQuery 回复失败', { error: msg }); + return reply.status(400).send({ success: false, error: msg }); + } +} + +export async function reviewEquery( + request: FastifyRequest<{ + Params: EqueryIdParams; + Body: { passed: boolean; reviewNote?: string }; + }>, + reply: FastifyReply +) { + try { + const { equeryId } = request.params; + const { passed, reviewNote } = request.body; + const service = getIitEqueryService(prisma); + const updated = await service.review(equeryId, { passed, reviewNote }); + return reply.send({ success: true, data: updated }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error('eQuery 复核失败', { error: msg }); + return reply.status(400).send({ success: false, error: msg }); + } +} + +export async function closeEquery( + request: FastifyRequest<{ + Params: EqueryIdParams; + Body: { closedBy: string; resolution?: string }; + }>, + reply: FastifyReply +) { + try { + const { equeryId } = request.params; + const { closedBy, resolution } = request.body; + const service = getIitEqueryService(prisma); + const updated = await service.close(equeryId, closedBy || 'manual', resolution); + return reply.send({ success: true, data: updated }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error('eQuery 关闭失败', { error: msg }); + return reply.status(400).send({ success: false, error: msg }); + } +} diff --git a/backend/src/modules/admin/iit-projects/iitEqueryRoutes.ts b/backend/src/modules/admin/iit-projects/iitEqueryRoutes.ts new file mode 100644 index 00000000..80e141c5 --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitEqueryRoutes.ts @@ -0,0 +1,26 @@ +/** + * eQuery 路由 + */ + +import { FastifyInstance } from 'fastify'; +import * as controller from './iitEqueryController.js'; + +export async function iitEqueryRoutes(fastify: FastifyInstance) { + // 获取项目的 eQuery 列表 + fastify.get('/:projectId/equeries', controller.listEqueries); + + // 获取 eQuery 统计 + fastify.get('/:projectId/equeries/stats', controller.getEqueryStats); + + // 获取单条 eQuery + fastify.get('/:projectId/equeries/:equeryId', controller.getEquery); + + // CRC 回复 eQuery + fastify.post('/:projectId/equeries/:equeryId/respond', controller.respondEquery); + + // AI 复核 eQuery + fastify.post('/:projectId/equeries/:equeryId/review', controller.reviewEquery); + + // 手动关闭 eQuery + fastify.post('/:projectId/equeries/:equeryId/close', controller.closeEquery); +} diff --git a/backend/src/modules/admin/iit-projects/iitEqueryService.ts b/backend/src/modules/admin/iit-projects/iitEqueryService.ts new file mode 100644 index 00000000..c65445d3 --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitEqueryService.ts @@ -0,0 +1,308 @@ +/** + * eQuery 闭环服务 + * + * 状态机: pending → responded → reviewing → closed / reopened + * AI 自动生成 Query → CRC 回复 → AI 复核 → 关闭 / 重开 + */ + +import { PrismaClient } from '@prisma/client'; +import { logger } from '../../../common/logging/index.js'; +import { jobQueue } from '../../../common/jobs/index.js'; + +// ==================== Types ==================== + +export type EqueryStatus = 'pending' | 'responded' | 'reviewing' | 'closed' | 'reopened'; + +export interface CreateEqueryInput { + projectId: string; + recordId: string; + eventId?: string; + formName?: string; + fieldName?: string; + qcLogId?: string; + reportId?: string; + queryText: string; + expectedAction?: string; + severity?: string; + category?: string; + assignedTo?: string; +} + +export interface RespondEqueryInput { + responseText: string; + responseData?: Record; +} + +export interface ReviewEqueryInput { + passed: boolean; + reviewNote?: string; +} + +export interface EqueryListParams { + projectId: string; + status?: string; + recordId?: string; + severity?: string; + assignedTo?: string; + page?: number; + pageSize?: number; +} + +export interface EqueryStats { + total: number; + pending: number; + responded: number; + reviewing: number; + closed: number; + reopened: number; + avgResolutionHours: number | null; +} + +// ==================== Service ==================== + +export class IitEqueryService { + constructor(private prisma: PrismaClient) {} + + /** + * 创建 eQuery(通常由 AI 质控后自动调用) + */ + async create(input: CreateEqueryInput) { + const equery = await this.prisma.iitEquery.create({ + data: { + projectId: input.projectId, + recordId: input.recordId, + eventId: input.eventId, + formName: input.formName, + fieldName: input.fieldName, + qcLogId: input.qcLogId, + reportId: input.reportId, + queryText: input.queryText, + expectedAction: input.expectedAction, + severity: input.severity || 'warning', + category: input.category, + status: 'pending', + assignedTo: input.assignedTo, + }, + }); + + logger.info('eQuery created', { + id: equery.id, + projectId: input.projectId, + recordId: input.recordId, + severity: input.severity, + }); + + return equery; + } + + /** + * 批量创建 eQuery(报告生成后一次性派发) + */ + async createBatch(inputs: CreateEqueryInput[]) { + if (inputs.length === 0) return { count: 0 }; + + const data = inputs.map((input) => ({ + projectId: input.projectId, + recordId: input.recordId, + eventId: input.eventId, + formName: input.formName, + fieldName: input.fieldName, + qcLogId: input.qcLogId, + reportId: input.reportId, + queryText: input.queryText, + expectedAction: input.expectedAction, + severity: input.severity || 'warning', + category: input.category, + status: 'pending' as const, + assignedTo: input.assignedTo, + })); + + const result = await this.prisma.iitEquery.createMany({ data }); + + logger.info('eQuery batch created', { + projectId: inputs[0].projectId, + count: result.count, + }); + + return { count: result.count }; + } + + /** + * 获取 eQuery 列表 + */ + async list(params: EqueryListParams) { + const { projectId, status, recordId, severity, assignedTo, page = 1, pageSize = 50 } = params; + + const where: any = { projectId }; + if (status) where.status = status; + if (recordId) where.recordId = recordId; + if (severity) where.severity = severity; + if (assignedTo) where.assignedTo = assignedTo; + + const [items, total] = await Promise.all([ + this.prisma.iitEquery.findMany({ + where, + orderBy: [{ status: 'asc' }, { createdAt: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.iitEquery.count({ where }), + ]); + + return { items, total, page, pageSize }; + } + + /** + * 获取单条 eQuery + */ + async getById(id: string) { + return this.prisma.iitEquery.findUnique({ where: { id } }); + } + + /** + * CRC 回复 eQuery(pending / reopened → responded) + */ + async respond(id: string, input: RespondEqueryInput) { + const equery = await this.prisma.iitEquery.findUnique({ where: { id } }); + if (!equery) throw new Error('eQuery 不存在'); + if (!['pending', 'reopened'].includes(equery.status)) { + throw new Error(`当前状态 ${equery.status} 不允许回复`); + } + + const updated = await this.prisma.iitEquery.update({ + where: { id }, + data: { + status: 'responded', + responseText: input.responseText, + responseData: input.responseData as any, + respondedAt: new Date(), + }, + }); + + logger.info('eQuery responded', { id, recordId: equery.recordId }); + + // Trigger async AI review + try { + await jobQueue.push('iit_equery_review', { + equeryId: id, + projectId: equery.projectId, + recordId: equery.recordId, + fieldName: equery.fieldName, + responseText: input.responseText, + }); + logger.info('eQuery AI review job queued', { id }); + } catch (err) { + logger.warn('Failed to queue eQuery review job (non-fatal)', { id, error: String(err) }); + } + + return updated; + } + + /** + * AI 复核 eQuery(responded → reviewing → closed / reopened) + */ + async review(id: string, input: ReviewEqueryInput) { + const equery = await this.prisma.iitEquery.findUnique({ where: { id } }); + if (!equery) throw new Error('eQuery 不存在'); + if (equery.status !== 'responded') { + throw new Error(`当前状态 ${equery.status} 不允许复核`); + } + + const now = new Date(); + const newStatus: EqueryStatus = input.passed ? 'closed' : 'reopened'; + + const updated = await this.prisma.iitEquery.update({ + where: { id }, + data: { + status: newStatus, + reviewResult: input.passed ? 'passed' : 'failed', + reviewNote: input.reviewNote, + reviewedAt: now, + ...(input.passed ? { closedAt: now, closedBy: 'ai_review' } : {}), + }, + }); + + logger.info('eQuery reviewed', { + id, + recordId: equery.recordId, + passed: input.passed, + newStatus, + }); + return updated; + } + + /** + * 手动关闭 eQuery + */ + async close(id: string, closedBy: string, resolution?: string) { + const equery = await this.prisma.iitEquery.findUnique({ where: { id } }); + if (!equery) throw new Error('eQuery 不存在'); + if (equery.status === 'closed') throw new Error('eQuery 已关闭'); + + const updated = await this.prisma.iitEquery.update({ + where: { id }, + data: { + status: 'closed', + closedAt: new Date(), + closedBy, + resolution, + }, + }); + + logger.info('eQuery manually closed', { id, closedBy }); + return updated; + } + + /** + * 获取统计 + */ + async getStats(projectId: string): Promise { + const counts = await this.prisma.iitEquery.groupBy({ + by: ['status'], + where: { projectId }, + _count: true, + }); + + const statusMap: Record = {}; + let total = 0; + for (const c of counts) { + statusMap[c.status] = c._count; + total += c._count; + } + + // Average resolution time (hours) for closed equeries + let avgResolutionHours: number | null = null; + const closedEqueries = await this.prisma.iitEquery.findMany({ + where: { projectId, status: 'closed', closedAt: { not: null } }, + select: { createdAt: true, closedAt: true }, + take: 100, + orderBy: { closedAt: 'desc' }, + }); + if (closedEqueries.length > 0) { + const totalHours = closedEqueries.reduce((sum: number, eq: { createdAt: Date; closedAt: Date | null }) => { + const diff = eq.closedAt!.getTime() - eq.createdAt.getTime(); + return sum + diff / (1000 * 60 * 60); + }, 0); + avgResolutionHours = Math.round((totalHours / closedEqueries.length) * 10) / 10; + } + + return { + total, + pending: statusMap['pending'] || 0, + responded: statusMap['responded'] || 0, + reviewing: statusMap['reviewing'] || 0, + closed: statusMap['closed'] || 0, + reopened: statusMap['reopened'] || 0, + avgResolutionHours, + }; + } +} + +let serviceInstance: IitEqueryService | null = null; + +export function getIitEqueryService(prisma: PrismaClient): IitEqueryService { + if (!serviceInstance) { + serviceInstance = new IitEqueryService(prisma); + } + return serviceInstance; +} diff --git a/backend/src/modules/admin/iit-projects/iitProjectController.ts b/backend/src/modules/admin/iit-projects/iitProjectController.ts index 54910dcc..0a1f4122 100644 --- a/backend/src/modules/admin/iit-projects/iitProjectController.ts +++ b/backend/src/modules/admin/iit-projects/iitProjectController.ts @@ -286,6 +286,64 @@ export async function syncMetadata( } } +/** + * 获取字段元数据列表 + */ +export async function listFieldMetadata( + request: FastifyRequest<{ + Params: ProjectIdParams; + Querystring: { formName?: string; search?: string }; + }>, + reply: FastifyReply +) { + try { + const { id } = request.params; + const { formName, search } = request.query as { formName?: string; search?: string }; + + const where: any = { projectId: id }; + if (formName) { + where.formName = formName; + } + if (search) { + where.OR = [ + { fieldName: { contains: search, mode: 'insensitive' } }, + { fieldLabel: { contains: search, mode: 'insensitive' } }, + ]; + } + + const [fields, total] = await Promise.all([ + prisma.iitFieldMetadata.findMany({ + where, + orderBy: [{ formName: 'asc' }, { fieldName: 'asc' }], + }), + prisma.iitFieldMetadata.count({ where }), + ]); + + const forms = await prisma.iitFieldMetadata.findMany({ + where: { projectId: id }, + select: { formName: true }, + distinct: ['formName'], + orderBy: { formName: 'asc' }, + }); + + return reply.send({ + success: true, + data: { + fields, + total, + forms: forms.map(f => f.formName), + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('获取字段元数据失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + /** * 关联知识库 */ diff --git a/backend/src/modules/admin/iit-projects/iitProjectRoutes.ts b/backend/src/modules/admin/iit-projects/iitProjectRoutes.ts index fb682719..ff9c1a3d 100644 --- a/backend/src/modules/admin/iit-projects/iitProjectRoutes.ts +++ b/backend/src/modules/admin/iit-projects/iitProjectRoutes.ts @@ -34,6 +34,9 @@ export async function iitProjectRoutes(fastify: FastifyInstance) { // 同步 REDCap 元数据 fastify.post('/:id/sync-metadata', controller.syncMetadata); + // 获取字段元数据列表 + fastify.get('/:id/field-metadata', controller.listFieldMetadata); + // ==================== 知识库关联 ==================== // 关联知识库 diff --git a/backend/src/modules/admin/iit-projects/iitQcCockpitController.ts b/backend/src/modules/admin/iit-projects/iitQcCockpitController.ts index 2661f843..b6a0b320 100644 --- a/backend/src/modules/admin/iit-projects/iitQcCockpitController.ts +++ b/backend/src/modules/admin/iit-projects/iitQcCockpitController.ts @@ -12,6 +12,7 @@ import { FastifyRequest, FastifyReply } from 'fastify'; import { iitQcCockpitService } from './iitQcCockpitService.js'; import { QcReportService } from '../../iit-manager/services/QcReportService.js'; import { logger } from '../../../common/logging/index.js'; +import { prisma } from '../../../config/database.js'; class IitQcCockpitController { /** @@ -185,6 +186,185 @@ class IitQcCockpitController { }); } } + + /** + * 获取 AI 工作时间线(QC 日志 + Agent Trace 合并) + */ + async getTimeline( + request: FastifyRequest<{ + Params: { projectId: string }; + Querystring: { page?: string; pageSize?: string; date?: string }; + }>, + reply: FastifyReply + ) { + const { projectId } = request.params; + const query = request.query as any; + const page = query.page ? parseInt(query.page) : 1; + const pageSize = query.pageSize ? parseInt(query.pageSize) : 50; + const dateFilter = query.date; + + try { + const dateWhere: any = {}; + if (dateFilter) { + const start = new Date(dateFilter); + const end = new Date(dateFilter); + end.setDate(end.getDate() + 1); + dateWhere.createdAt = { gte: start, lt: end }; + } + + const [qcLogs, totalLogs] = await Promise.all([ + prisma.iitQcLog.findMany({ + where: { projectId, ...dateWhere }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + select: { + id: true, + recordId: true, + eventId: true, + qcType: true, + formName: true, + status: true, + issues: true, + rulesEvaluated: true, + rulesPassed: true, + rulesFailed: true, + triggeredBy: true, + createdAt: true, + }, + }), + 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; + + let description = `扫描受试者 ${log.recordId}`; + if (log.formName) description += ` [${log.formName}]`; + description += ` → 执行 ${log.rulesEvaluated} 条规则 (${log.rulesPassed} 通过`; + if (log.rulesFailed > 0) description += `, ${log.rulesFailed} 失败`; + description += ')'; + if (redCount > 0) description += ` → 发现 ${redCount} 个严重问题`; + if (yellowCount > 0) description += `, ${yellowCount} 个警告`; + + return { + id: log.id, + type: 'qc_check' as const, + time: log.createdAt, + recordId: log.recordId, + formName: log.formName, + status: log.status, + triggeredBy: log.triggeredBy, + description, + details: { + rulesEvaluated: log.rulesEvaluated, + rulesPassed: log.rulesPassed, + rulesFailed: log.rulesFailed, + issuesSummary: { red: redCount, yellow: yellowCount }, + }, + }; + }); + + return reply.send({ + success: true, + data: { items, total: totalLogs, page, pageSize }, + }); + } catch (error: any) { + logger.error('[QcCockpitController] 获取时间线失败', { projectId, error: error.message }); + return reply.status(500).send({ success: false, error: error.message }); + } + } + + /** + * 获取重大事件列表 + */ + async getCriticalEvents( + request: FastifyRequest<{ + Params: { projectId: string }; + Querystring: { status?: string; eventType?: string; page?: string; pageSize?: string }; + }>, + reply: FastifyReply + ) { + const { projectId } = request.params; + const query = request.query as any; + const page = query.page ? parseInt(query.page) : 1; + const pageSize = query.pageSize ? parseInt(query.pageSize) : 50; + + try { + const where: any = { projectId }; + if (query.status) where.status = query.status; + if (query.eventType) where.eventType = query.eventType; + + const [items, total] = await Promise.all([ + prisma.iitCriticalEvent.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.iitCriticalEvent.count({ where }), + ]); + + return reply.send({ + success: true, + data: { items, total, page, pageSize }, + }); + } catch (error: any) { + logger.error('[QcCockpitController] 获取重大事件失败', { projectId, error: error.message }); + return reply.status(500).send({ success: false, error: error.message }); + } + } + + /** + * 获取质控趋势数据(近30天每日通过率) + */ + async getTrend( + request: FastifyRequest<{ + Params: { projectId: string }; + Querystring: { days?: string }; + }>, + reply: FastifyReply + ) { + const { projectId } = request.params; + const days = parseInt((request.query as any).days || '30'); + + try { + const since = new Date(); + since.setDate(since.getDate() - days); + + const logs = await prisma.iitQcLog.findMany({ + where: { projectId, createdAt: { gte: since } }, + select: { createdAt: true, status: true }, + orderBy: { createdAt: 'asc' }, + }); + + // Group by date + const dailyMap = new Map(); + for (const log of logs) { + const dateKey = log.createdAt.toISOString().split('T')[0]; + const entry = dailyMap.get(dateKey) || { total: 0, passed: 0 }; + entry.total++; + if (log.status === 'PASS') entry.passed++; + dailyMap.set(dateKey, entry); + } + + const trend = Array.from(dailyMap.entries()).map(([date, { total, passed }]) => ({ + date, + total, + passed, + passRate: total > 0 ? Math.round((passed / total) * 100) : 0, + })); + + return reply.send({ success: true, data: trend }); + } catch (error: any) { + logger.error('[QcCockpitController] 获取趋势失败', { projectId, error: error.message }); + return reply.status(500).send({ success: false, error: error.message }); + } + } } export const iitQcCockpitController = new IitQcCockpitController(); diff --git a/backend/src/modules/admin/iit-projects/iitQcCockpitRoutes.ts b/backend/src/modules/admin/iit-projects/iitQcCockpitRoutes.ts index 99b1df74..e26847eb 100644 --- a/backend/src/modules/admin/iit-projects/iitQcCockpitRoutes.ts +++ b/backend/src/modules/admin/iit-projects/iitQcCockpitRoutes.ts @@ -242,4 +242,13 @@ export async function iitQcCockpitRoutes(fastify: FastifyInstance) { }, }, }, iitQcCockpitController.refreshReport.bind(iitQcCockpitController)); + + // AI 工作时间线 + fastify.get('/:projectId/qc-cockpit/timeline', iitQcCockpitController.getTimeline.bind(iitQcCockpitController)); + + // 重大事件列表 + fastify.get('/:projectId/qc-cockpit/critical-events', iitQcCockpitController.getCriticalEvents.bind(iitQcCockpitController)); + + // 质控趋势(近N天通过率折线) + fastify.get('/:projectId/qc-cockpit/trend', iitQcCockpitController.getTrend.bind(iitQcCockpitController)); } diff --git a/backend/src/modules/admin/iit-projects/iitQcRuleController.ts b/backend/src/modules/admin/iit-projects/iitQcRuleController.ts index addf37b7..a262f3b3 100644 --- a/backend/src/modules/admin/iit-projects/iitQcRuleController.ts +++ b/backend/src/modules/admin/iit-projects/iitQcRuleController.ts @@ -4,6 +4,7 @@ import { FastifyRequest, FastifyReply } from 'fastify'; import { getIitQcRuleService, CreateRuleInput, UpdateRuleInput, TestRuleInput } from './iitQcRuleService.js'; +import { getIitRuleSuggestionService } from './iitRuleSuggestionService.js'; import { prisma } from '../../../config/database.js'; import { logger } from '../../../common/logging/index.js'; @@ -286,3 +287,29 @@ export async function getRuleStats( }); } } + +/** + * AI 规则建议 + */ +export async function suggestRules( + request: FastifyRequest<{ Params: ProjectIdParams }>, + reply: FastifyReply +) { + try { + const { projectId } = request.params; + const service = getIitRuleSuggestionService(prisma); + const suggestions = await service.suggestRules(projectId); + + return reply.send({ + success: true, + data: suggestions, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('AI 规则建议生成失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} diff --git a/backend/src/modules/admin/iit-projects/iitQcRuleRoutes.ts b/backend/src/modules/admin/iit-projects/iitQcRuleRoutes.ts index ebd9560b..27b829a7 100644 --- a/backend/src/modules/admin/iit-projects/iitQcRuleRoutes.ts +++ b/backend/src/modules/admin/iit-projects/iitQcRuleRoutes.ts @@ -27,6 +27,9 @@ export async function iitQcRuleRoutes(fastify: FastifyInstance) { // 批量导入规则 fastify.post('/:projectId/rules/import', controller.importRules); + // AI 规则建议 + fastify.post('/:projectId/rules/suggest', controller.suggestRules); + // 测试规则逻辑(不需要项目 ID) fastify.post('/rules/test', controller.testRule); } diff --git a/backend/src/modules/admin/iit-projects/iitRuleSuggestionService.ts b/backend/src/modules/admin/iit-projects/iitRuleSuggestionService.ts new file mode 100644 index 00000000..f684e8d0 --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitRuleSuggestionService.ts @@ -0,0 +1,161 @@ +/** + * AI 规则建议服务 + * + * 读取项目的变量元数据和知识库文档,调用 LLM 生成质控规则建议。 + */ + +import { PrismaClient } from '@prisma/client'; +import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js'; +import type { Message } from '../../../common/llm/adapters/types.js'; +import { logger } from '../../../common/logging/index.js'; + +export interface RuleSuggestion { + name: string; + field: string | string[]; + logic: Record; + message: string; + severity: 'error' | 'warning' | 'info'; + category: string; +} + +export class IitRuleSuggestionService { + constructor(private prisma: PrismaClient) {} + + async suggestRules(projectId: string): Promise { + const project = await this.prisma.iitProject.findFirst({ + where: { id: projectId, deletedAt: null }, + }); + if (!project) throw new Error('项目不存在'); + + // 1. Gather variable metadata + const fields = await this.prisma.iitFieldMetadata.findMany({ + where: { projectId }, + orderBy: [{ formName: 'asc' }, { fieldName: 'asc' }], + }); + + if (fields.length === 0) { + throw new Error('请先从 REDCap 同步变量元数据'); + } + + // 2. Gather knowledge base context (protocol summary) + let protocolContext = ''; + if (project.knowledgeBaseId) { + try { + const docs = await this.prisma.ekbDocument.findMany({ + where: { kbId: project.knowledgeBaseId, status: 'completed' }, + select: { filename: true, summary: true, extractedText: true }, + take: 3, + orderBy: { createdAt: 'desc' }, + }); + for (const doc of docs) { + const text = doc.summary || doc.extractedText?.substring(0, 3000) || ''; + if (text) { + protocolContext += `\n### ${doc.filename}\n${text}\n`; + } + } + } catch (err) { + logger.warn('读取知识库失败,仅基于变量生成规则', { error: String(err) }); + } + } + + // 3. Build variable summary for LLM + const variableSummary = fields.map((f) => { + const parts = [`${f.fieldName} (${f.fieldLabel}): type=${f.fieldType}, form=${f.formName}`]; + if (f.validation) parts.push(`validation=${f.validation}`); + if (f.validationMin || f.validationMax) parts.push(`range=[${f.validationMin || ''},${f.validationMax || ''}]`); + if (f.choices) parts.push(`choices=${f.choices.substring(0, 100)}`); + if (f.required) parts.push('required=true'); + return parts.join(', '); + }).join('\n'); + + // 4. Call LLM + const systemPrompt = `You are an expert clinical research data manager. You generate quality control (QC) rules for clinical trial data captured in REDCap. + +Rules must be in JSON Logic format (https://jsonlogic.com). Each rule checks one or more fields. + +Available categories: +- variable_qc: Field-level checks (range, required, format, enum) +- inclusion: Inclusion criteria checks +- exclusion: Exclusion criteria checks +- lab_values: Lab value range checks +- logic_check: Cross-field logic checks +- protocol_deviation: Visit window / time constraint checks +- ae_monitoring: AE reporting timeline checks + +Severity levels: error (blocking), warning (review needed), info (informational) + +Respond ONLY with a JSON array of rule objects. Each object must have these fields: +- name (string): short descriptive name in Chinese +- field (string or string[]): REDCap field name(s) +- logic (object): JSON Logic expression +- message (string): error message in Chinese +- severity: "error" | "warning" | "info" +- category: one of the categories listed above + +Generate 5-10 practical rules. Focus on: +1. Required field checks for key variables +2. Range checks for numeric fields that have validation ranges +3. Logical consistency checks between related fields +4. Date field checks (visit windows, timelines) +Do NOT include explanations, only the JSON array.`; + + const userPrompt = `Project: ${project.name} + +Variable List (${fields.length} fields): +${variableSummary} +${protocolContext ? `\nProtocol / Study Document Context:\n${protocolContext}` : ''} + +Generate QC rules for this project:`; + + const messages: Message[] = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ]; + + try { + const llm = LLMFactory.getAdapter('deepseek-v3'); + const response = await llm.chat(messages, { + temperature: 0.3, + maxTokens: 4000, + }); + + const content = response.content.trim(); + // Extract JSON array from response (handle markdown code fences) + const jsonMatch = content.match(/\[[\s\S]*\]/); + if (!jsonMatch) { + logger.error('LLM 返回非 JSON 格式', { content: content.substring(0, 200) }); + throw new Error('AI 返回格式异常,请重试'); + } + + const rules: RuleSuggestion[] = JSON.parse(jsonMatch[0]); + + // Validate structure + const validRules = rules.filter( + (r) => r.name && r.field && r.logic && r.message && r.severity && r.category + ); + + logger.info('AI 规则建议生成成功', { + projectId, + total: rules.length, + valid: validRules.length, + model: response.model, + }); + + return validRules; + } catch (err) { + if (err instanceof SyntaxError) { + throw new Error('AI 返回格式解析失败,请重试'); + } + throw err; + } + } +} + +let serviceInstance: IitRuleSuggestionService | null = null; + +export function getIitRuleSuggestionService(prisma: PrismaClient): IitRuleSuggestionService { + if (!serviceInstance) { + serviceInstance = new IitRuleSuggestionService(prisma); + } + return serviceInstance; +} diff --git a/backend/src/modules/admin/iit-projects/index.ts b/backend/src/modules/admin/iit-projects/index.ts index 764f4ad7..21b82617 100644 --- a/backend/src/modules/admin/iit-projects/index.ts +++ b/backend/src/modules/admin/iit-projects/index.ts @@ -7,12 +7,15 @@ export { iitQcRuleRoutes } from './iitQcRuleRoutes.js'; export { iitUserMappingRoutes } from './iitUserMappingRoutes.js'; export { iitBatchRoutes } from './iitBatchRoutes.js'; export { iitQcCockpitRoutes } from './iitQcCockpitRoutes.js'; +export { iitEqueryRoutes } from './iitEqueryRoutes.js'; export { IitProjectService, getIitProjectService } from './iitProjectService.js'; export { IitQcRuleService, getIitQcRuleService } from './iitQcRuleService.js'; export { IitUserMappingService, getIitUserMappingService } from './iitUserMappingService.js'; export { iitQcCockpitService, IitQcCockpitService } from './iitQcCockpitService.js'; +export { IitEqueryService, getIitEqueryService } from './iitEqueryService.js'; export * from './iitProjectController.js'; export * from './iitQcRuleController.js'; export * from './iitUserMappingController.js'; export * from './iitBatchController.js'; export * from './iitQcCockpitController.js'; +export * from './iitEqueryController.js'; diff --git a/backend/src/modules/iit-manager/index.ts b/backend/src/modules/iit-manager/index.ts index 45e542d6..fc76e505 100644 --- a/backend/src/modules/iit-manager/index.ts +++ b/backend/src/modules/iit-manager/index.ts @@ -18,6 +18,7 @@ import { PrismaClient } from '@prisma/client'; import { logger } from '../../common/logging/index.js'; import { createHardRuleEngine, QCResult } from './engines/HardRuleEngine.js'; import { RedcapAdapter } from './adapters/RedcapAdapter.js'; +import { dailyQcOrchestrator } from './services/DailyQcOrchestrator.js'; // 初始化 Prisma Client const prisma = new PrismaClient(); @@ -51,10 +52,22 @@ export async function initIitManager(): Promise { // 1. 注册定时轮询任务(每5分钟) // ============================================= // ⏸️ 暂时禁用定时轮询(MVP阶段,Webhook已足够) - // TODO: Phase 2 - 实现定时轮询作为补充机制 // await syncManager.initScheduledJob(); - logger.info('IIT Manager: Scheduled job registration skipped (using Webhook only for MVP)'); + // ============================================= + // 1b. 注册定时全量质控任务(每天 08:00 UTC+8) + // ============================================= + try { + await (jobQueue as any).schedule( + 'iit_daily_qc', + '0 0 * * *', // UTC 00:00 = 北京时间 08:00 + {}, + { tz: 'Asia/Shanghai' } + ); + logger.info('IIT Manager: Daily QC cron registered (08:00 CST)'); + } catch (cronErr: any) { + logger.warn('IIT Manager: Failed to register daily QC cron (non-fatal)', { error: cronErr.message }); + } // ============================================= // 2. 注册Worker:处理定时轮询任务 @@ -85,6 +98,162 @@ export async function initIitManager(): Promise { logger.info('IIT Manager: Worker registered - iit_redcap_poll'); + // ============================================= + // 2b. 注册Worker:定时全量质控(每日 Agent 自主巡查) + // ============================================= + jobQueue.process('iit_daily_qc', async (job: any) => { + const startTime = Date.now(); + logger.info('Worker: iit_daily_qc started', { jobId: job.id }); + + try { + const activeProjects = await prisma.iitProject.findMany({ + where: { status: 'active', deletedAt: null, cronEnabled: true }, + select: { id: true, name: true, redcapUrl: true, redcapApiToken: true }, + }); + + logger.info(`Daily QC: found ${activeProjects.length} active projects`); + + for (const project of activeProjects) { + try { + const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken); + const records = await adapter.exportRecords({ rawOrLabel: 'raw' }); + const recordIds = [...new Set(records.map((r: any) => r.record_id || r[Object.keys(r)[0]]))]; + + // Run HardRuleEngine for each record + const ruleEngine = await createHardRuleEngine(project.id); + let totalErrors = 0; + let totalWarnings = 0; + + for (const recordId of recordIds) { + try { + const recordData = records.filter((r: any) => (r.record_id || r[Object.keys(r)[0]]) === recordId); + const flat = recordData.reduce((acc: any, row: any) => ({ ...acc, ...row }), {}); + const qcResult = await ruleEngine.execute(String(recordId), flat); + + totalErrors += qcResult.summary.failed; + totalWarnings += qcResult.summary.warnings; + + // Save QC log + await prisma.iitQcLog.create({ + data: { + projectId: project.id, + recordId: String(recordId), + qcType: 'full', + status: qcResult.overallStatus, + issues: [ + ...qcResult.errors.map((r: any) => ({ field: r.field, rule: r.ruleName, level: 'RED', message: r.errorMessage || r.message })), + ...qcResult.warnings.map((r: any) => ({ field: r.field, rule: r.ruleName, level: 'YELLOW', message: r.errorMessage || r.message })), + ], + rulesEvaluated: qcResult.summary.totalRules, + rulesSkipped: 0, + rulesPassed: qcResult.summary.passed, + rulesFailed: qcResult.summary.failed, + ruleVersion: new Date().toISOString().split('T')[0], + triggeredBy: 'cron', + }, + }); + } catch (recErr: any) { + logger.warn('Daily QC: record check failed', { projectId: project.id, recordId, error: recErr.message }); + } + } + + logger.info('Daily QC: project QC completed, starting orchestration', { + projectId: project.id, + projectName: project.name, + records: recordIds.length, + totalErrors, + totalWarnings, + }); + + // Orchestrate: report → eQuery → critical events → push + try { + const orchResult = await dailyQcOrchestrator.orchestrate(project.id); + logger.info('Daily QC: orchestration completed', { + projectId: project.id, + ...orchResult, + }); + } catch (orchErr: any) { + logger.error('Daily QC: orchestration failed (non-fatal)', { + projectId: project.id, + error: orchErr.message, + }); + } + } catch (projErr: any) { + logger.error('Daily QC: project failed', { projectId: project.id, error: projErr.message }); + } + } + + logger.info('Worker: iit_daily_qc completed', { + jobId: job.id, + projects: activeProjects.length, + durationMs: Date.now() - startTime, + }); + return { success: true, projects: activeProjects.length }; + } catch (error: any) { + logger.error('Worker: iit_daily_qc failed', { jobId: job.id, error: error.message }); + throw error; + } + }); + + logger.info('IIT Manager: Worker registered - iit_daily_qc'); + + // ============================================= + // 2c. 注册Worker:eQuery AI 自动复核 + // ============================================= + jobQueue.process('iit_equery_review', async (job: { id: string; data: { equeryId: string; projectId: string; recordId: string; fieldName?: string; responseText: string } }) => { + const { equeryId, projectId, recordId, fieldName, responseText } = job.data; + logger.info('Worker: iit_equery_review started', { jobId: job.id, equeryId }); + + try { + // Re-check: get the latest record data and re-run relevant rules + const projectConfig = await prisma.iitProject.findUnique({ where: { id: projectId } }); + if (!projectConfig) { + logger.warn('eQuery review: project not found', { projectId }); + return { status: 'project_not_found' }; + } + + const adapter = new RedcapAdapter(projectConfig.redcapUrl, projectConfig.redcapApiToken); + const recordData = await adapter.getRecordById(recordId); + + let passed = true; + let reviewNote = 'AI 复核:数据已修正,问题已解决。'; + + if (recordData && fieldName) { + // Re-run rules for the specific field + const ruleEngine = await createHardRuleEngine(projectId); + const flat = Array.isArray(recordData) ? recordData.reduce((acc: any, row: any) => ({ ...acc, ...row }), {}) : recordData; + const qcResult = await ruleEngine.execute(recordId, flat); + + // Check if the specific field still has issues + const allIssues = [...qcResult.errors, ...qcResult.warnings]; + const fieldStillFailing = allIssues.some((issue: any) => issue.field === fieldName); + + if (fieldStillFailing) { + passed = false; + const failingIssue = allIssues.find((issue: any) => issue.field === fieldName); + reviewNote = `AI 复核:字段 ${fieldName} 仍存在问题 — ${failingIssue?.errorMessage || failingIssue?.message || '规则未通过'}。CRC 回复: "${responseText}"`; + } + } + + // Update eQuery via the service + const { getIitEqueryService } = await import('../admin/iit-projects/iitEqueryService.js'); + const equeryService = getIitEqueryService(prisma); + await equeryService.review(equeryId, { passed, reviewNote }); + + logger.info('Worker: iit_equery_review completed', { + jobId: job.id, + equeryId, + passed, + }); + return { status: 'success', passed }; + } catch (error: any) { + logger.error('Worker: iit_equery_review failed', { jobId: job.id, equeryId, error: error.message }); + throw error; + } + }); + + logger.info('IIT Manager: Worker registered - iit_equery_review'); + // ============================================= // 3. 注册Worker:处理质控任务 + 双产出(质控日志 + 录入汇总) // ============================================= diff --git a/backend/src/modules/iit-manager/services/DailyQcOrchestrator.ts b/backend/src/modules/iit-manager/services/DailyQcOrchestrator.ts new file mode 100644 index 00000000..b1befd57 --- /dev/null +++ b/backend/src/modules/iit-manager/services/DailyQcOrchestrator.ts @@ -0,0 +1,279 @@ +/** + * DailyQcOrchestrator - 每日全量质控编排器 + * + * 在 SkillRunner / HardRuleEngine 执行完质控后: + * 1. 调用 QcReportService 生成完整报告 + * 2. 从报告中提取严重/警告问题 → 批量创建 eQuery + * 3. 将 SAE / 重大方案偏离写入 iit_critical_events + * 4. 与上次报告对比(新增 / 已解决) + * 5. 通过企微推送摘要 + */ + +import { PrismaClient } from '@prisma/client'; +import { logger } from '../../../common/logging/index.js'; +import { QcReportService, type QcReport, type ReportIssue } from './QcReportService.js'; +import { getIitEqueryService, type CreateEqueryInput } from '../../admin/iit-projects/iitEqueryService.js'; +import { wechatService } from './WechatService.js'; + +const prisma = new PrismaClient(); + +export interface OrchestratorResult { + reportId: string; + equeriesCreated: number; + criticalEventsArchived: number; + newIssues: number; + resolvedIssues: number; + pushSent: boolean; +} + +class DailyQcOrchestratorClass { + /** + * 主方法:质控后编排全部后续动作 + */ + async orchestrate(projectId: string): Promise { + const startTime = Date.now(); + logger.info('[DailyQcOrchestrator] Starting orchestration', { projectId }); + + // Step 1: Generate report + const report = await QcReportService.getReport(projectId, { + forceRefresh: true, + reportType: 'daily', + expirationHours: 24, + }); + + // Save report to DB and get ID + const savedReport = await prisma.iitQcReport.create({ + data: { + projectId, + reportType: 'daily', + summary: report.summary as any, + issues: JSON.parse(JSON.stringify({ + critical: report.criticalIssues, + warning: report.warningIssues, + topIssues: report.topIssues, + grouped: report.groupedIssues, + })), + llmReport: report.llmFriendlyXml, + }, + }); + + // Step 2: Create eQueries from issues + const equeriesCreated = await this.dispatchEqueries(projectId, report, savedReport.id); + + // Step 3: Archive critical events + const criticalEventsArchived = await this.archiveCriticalEvents(projectId, report); + + // Step 4: Compare with previous report + const { newIssues, resolvedIssues } = await this.compareWithPrevious(projectId, report); + + // Step 5: Push notification + const pushSent = await this.pushNotification(projectId, report, equeriesCreated, newIssues, resolvedIssues); + + const duration = Date.now() - startTime; + logger.info('[DailyQcOrchestrator] Orchestration completed', { + projectId, + reportId: savedReport.id, + equeriesCreated, + criticalEventsArchived, + newIssues, + resolvedIssues, + pushSent, + durationMs: duration, + }); + + return { + reportId: savedReport.id, + equeriesCreated, + criticalEventsArchived, + newIssues, + resolvedIssues, + pushSent, + }; + } + + /** + * Step 2: 从报告中的严重/警告问题自动派发 eQuery + */ + private async dispatchEqueries(projectId: string, report: QcReport, reportId: string): Promise { + const issues = [...report.criticalIssues, ...report.warningIssues]; + if (issues.length === 0) return 0; + + // Deduplicate: skip if there's already an open eQuery for the same record+field + const existingEqueries = await prisma.iitEquery.findMany({ + where: { + projectId, + status: { in: ['pending', 'responded', 'reviewing', 'reopened'] }, + }, + select: { recordId: true, fieldName: true }, + }); + const existingKeys = new Set(existingEqueries.map((e: { recordId: string; fieldName: string | null }) => `${e.recordId}:${e.fieldName || ''}`)); + + const newEqueries: CreateEqueryInput[] = []; + for (const issue of issues) { + const key = `${issue.recordId}:${issue.field || ''}`; + if (existingKeys.has(key)) continue; + existingKeys.add(key); + + newEqueries.push({ + projectId, + recordId: issue.recordId, + fieldName: issue.field, + reportId, + queryText: `[${issue.severity === 'critical' ? '严重' : '警告'}] ${issue.message}`, + expectedAction: `请核实受试者 ${issue.recordId} 的 ${issue.field || '相关'} 数据并修正或提供说明`, + severity: issue.severity === 'critical' ? 'error' : 'warning', + category: issue.ruleName, + }); + } + + if (newEqueries.length === 0) return 0; + + const equeryService = getIitEqueryService(prisma); + const result = await equeryService.createBatch(newEqueries); + return result.count; + } + + /** + * Step 3: 将 SAE / 重大方案偏离写入 critical_events + */ + private async archiveCriticalEvents(projectId: string, report: QcReport): Promise { + const criticalIssues = report.criticalIssues.filter((issue) => { + const lowerMsg = (issue.message || '').toLowerCase(); + const lowerRule = (issue.ruleName || '').toLowerCase(); + return ( + lowerMsg.includes('sae') || + lowerMsg.includes('严重不良') || + lowerMsg.includes('方案偏离') || + lowerRule.includes('ae_monitoring') || + lowerRule.includes('protocol_deviation') + ); + }); + + if (criticalIssues.length === 0) return 0; + + // Deduplicate against existing events + const existing = await prisma.iitCriticalEvent.findMany({ + where: { projectId, status: 'open' }, + select: { recordId: true, title: true }, + }); + const existingKeys = new Set(existing.map((e: { recordId: string; title: string }) => `${e.recordId}:${e.title}`)); + + let archived = 0; + for (const issue of criticalIssues) { + const title = `${issue.ruleName}: ${issue.message}`.substring(0, 200); + const key = `${issue.recordId}:${title}`; + if (existingKeys.has(key)) continue; + + await prisma.iitCriticalEvent.create({ + data: { + projectId, + recordId: issue.recordId, + eventType: issue.ruleName?.includes('ae') ? 'SAE' : 'PROTOCOL_DEVIATION', + severity: 'critical', + title, + description: `受试者 ${issue.recordId}: ${issue.message}. 字段: ${issue.field || 'N/A'}`, + detectedAt: new Date(issue.detectedAt), + detectedBy: 'ai', + sourceQcLogId: issue.ruleId, + status: 'open', + }, + }); + archived++; + } + + return archived; + } + + /** + * Step 4: 与上一份报告对比 + */ + private async compareWithPrevious( + projectId: string, + currentReport: QcReport + ): Promise<{ newIssues: number; resolvedIssues: number }> { + const previousReports = await prisma.iitQcReport.findMany({ + where: { projectId, reportType: 'daily' }, + orderBy: { generatedAt: 'desc' }, + take: 2, + select: { issues: true }, + }); + + if (previousReports.length < 2) { + return { newIssues: currentReport.criticalIssues.length + currentReport.warningIssues.length, resolvedIssues: 0 }; + } + + const prevIssues = previousReports[1].issues as any; + const prevKeys = new Set(); + for (const issue of [...(prevIssues?.critical || []), ...(prevIssues?.warning || [])]) { + prevKeys.add(`${issue.recordId}:${issue.field || ''}:${issue.ruleName || ''}`); + } + + const currentIssues = [...currentReport.criticalIssues, ...currentReport.warningIssues]; + const currentKeys = new Set(); + let newIssues = 0; + for (const issue of currentIssues) { + const key = `${issue.recordId}:${issue.field || ''}:${issue.ruleName || ''}`; + currentKeys.add(key); + if (!prevKeys.has(key)) newIssues++; + } + + let resolvedIssues = 0; + for (const key of prevKeys) { + if (!currentKeys.has(key)) resolvedIssues++; + } + + return { newIssues, resolvedIssues }; + } + + /** + * Step 5: 企微推送摘要 + */ + private async pushNotification( + projectId: string, + report: QcReport, + equeriesCreated: number, + newIssues: number, + resolvedIssues: number + ): Promise { + try { + const project = await prisma.iitProject.findUnique({ + where: { id: projectId }, + select: { name: true }, + }); + + const { summary } = report; + const now = new Date().toLocaleString('zh-CN'); + + const markdown = [ + `# AI 监查日报 - ${project?.name || projectId}`, + `> ${now}`, + '', + `**质控通过率**: ${summary.passRate}%`, + `**总受试者**: ${summary.totalRecords} | **已完成**: ${summary.completedRecords}`, + `**严重问题**: ${summary.criticalIssues} | **警告**: ${summary.warningIssues}`, + '', + `📊 **对比上期**: 新增 ${newIssues} 个问题,已解决 ${resolvedIssues} 个`, + `📋 **新派发 eQuery**: ${equeriesCreated} 条`, + '', + summary.criticalIssues > 0 + ? `⚠️ 有 ${summary.criticalIssues} 个严重问题需要关注,请登录平台查看详情` + : '✅ 无严重问题', + '', + '[查看详情](点击进入 CRA 质控平台)', + ].join('\n'); + + const piUserId = process.env.WECHAT_TEST_USER_ID || 'FengZhiBo'; + await wechatService.sendMarkdownMessage(piUserId, markdown); + + return true; + } catch (err) { + logger.warn('[DailyQcOrchestrator] Push notification failed (non-fatal)', { + projectId, + error: String(err), + }); + return false; + } + } +} + +export const dailyQcOrchestrator = new DailyQcOrchestratorClass(); diff --git a/backend/tests/e2e-p0-test.ts b/backend/tests/e2e-p0-test.ts new file mode 100644 index 00000000..ecd4f81b --- /dev/null +++ b/backend/tests/e2e-p0-test.ts @@ -0,0 +1,249 @@ +/** + * P0 端到端 API 测试脚本 + * + * 测试完整流水线: + * 变量清单 → 规则配置 → 质控报告 → eQuery 闭环 → 驾驶舱 → AI 时间线 → 重大事件 + * + * 运行方式: npx tsx tests/e2e-p0-test.ts + */ + +const BASE = 'http://localhost:3001/api/v1/admin/iit-projects'; +const PROJECT_ID = 'test0102-pd-study'; + +let passCount = 0; +let failCount = 0; +const results: { name: string; ok: boolean; detail?: string }[] = []; + +async function api(method: string, path: string, body?: any) { + const url = `${BASE}${path}`; + const opts: RequestInit = { method }; + if (body) { + opts.headers = { 'Content-Type': 'application/json' }; + opts.body = JSON.stringify(body); + } + const res = await fetch(url, opts); + const json = await res.json().catch(() => null); + return { status: res.status, data: json }; +} + +function assert(name: string, condition: boolean, detail?: string) { + if (condition) { + passCount++; + results.push({ name, ok: true }); + console.log(` ✅ ${name}`); + } else { + failCount++; + results.push({ name, ok: false, detail }); + console.log(` ❌ ${name}${detail ? ` — ${detail}` : ''}`); + } +} + +// ========== Test Suites ========== + +async function testFieldMetadata() { + console.log('\n📋 [1/7] 变量清单 API'); + + const { status, data } = await api('GET', `/${PROJECT_ID}/field-metadata`); + assert('GET field-metadata 返回 200', status === 200, `status=${status}`); + assert('返回 fields 数组', Array.isArray(data?.data?.fields), JSON.stringify(data?.data)?.substring(0, 100)); + assert('fields 数量 > 0', (data?.data?.fields?.length || 0) > 0, `count=${data?.data?.fields?.length}`); + assert('返回 forms 数组', Array.isArray(data?.data?.forms), `forms=${JSON.stringify(data?.data?.forms)?.substring(0, 80)}`); + + // Search + const { data: searchData } = await api('GET', `/${PROJECT_ID}/field-metadata?search=age`); + assert('search=age 返回结果', searchData?.data?.fields !== undefined); +} + +async function testQcRules() { + console.log('\n📐 [2/7] 规则配置 API'); + + const { status, data } = await api('GET', `/${PROJECT_ID}/rules`); + assert('GET rules 返回 200', status === 200, `status=${status}`); + assert('返回 rules 数组', Array.isArray(data?.data), `type=${typeof data?.data}`); + + // Create a test rule + const newRule = { + name: 'E2E_test_rule_auto', + category: 'logic_check', + field: 'age', + logic: { '>': [{ var: 'age' }, 120] }, + message: 'E2E 测试:年龄超过 120', + severity: 'warning', + }; + const { status: createStatus, data: createData } = await api('POST', `/${PROJECT_ID}/rules`, newRule); + assert('POST rules 创建规则 201/200', [200, 201].includes(createStatus), `status=${createStatus}`); + + const ruleId = createData?.data?.id; + if (ruleId) { + // Delete the test rule + const { status: delStatus } = await api('DELETE', `/${PROJECT_ID}/rules/${ruleId}`); + assert('DELETE rule 删除成功', [200, 204].includes(delStatus), `status=${delStatus}`); + } + + // AI suggest (LLM 依赖,200 或 400/500 均可接受) + const { status: suggestStatus } = await api('POST', `/${PROJECT_ID}/rules/suggest`, {}); + assert('POST rules/suggest AI 建议可达 (非网络错误)', [200, 400, 500].includes(suggestStatus), `status=${suggestStatus}`); +} + +async function testQcCockpit() { + console.log('\n📊 [3/7] 质控驾驶舱 API'); + + const { status, data } = await api('GET', `/${PROJECT_ID}/qc-cockpit`); + assert('GET qc-cockpit 返回 200', status === 200, `status=${status}`); + assert('返回 stats 对象', data?.data?.stats !== undefined, `keys=${Object.keys(data?.data || {})}`); + assert('stats 包含 totalRecords', typeof data?.data?.stats?.totalRecords === 'number'); + assert('stats 包含 passRate', typeof data?.data?.stats?.passRate === 'number'); + assert('返回 heatmap 对象', data?.data?.heatmap !== undefined); +} + +async function testQcReport() { + console.log('\n📄 [4/7] 质控报告 API'); + + // Get report (may be cached or generated) + const { status, data } = await api('GET', `/${PROJECT_ID}/qc-cockpit/report`); + assert('GET report 返回 200', status === 200, `status=${status}`); + + if (data?.data) { + assert('报告包含 summary', data.data.summary !== undefined); + assert('报告包含 criticalIssues', Array.isArray(data.data.criticalIssues)); + assert('报告包含 warningIssues', Array.isArray(data.data.warningIssues)); + assert('报告包含 formStats', Array.isArray(data.data.formStats)); + assert('报告包含 llmFriendlyXml', typeof data.data.llmFriendlyXml === 'string'); + } + + // Refresh report + const { status: refreshStatus } = await api('POST', `/${PROJECT_ID}/qc-cockpit/report/refresh`); + assert('POST report/refresh 返回 200', refreshStatus === 200, `status=${refreshStatus}`); +} + +async function testEquery() { + console.log('\n📨 [5/7] eQuery 闭环 API'); + + // Stats + const { status: statsStatus, data: statsData } = await api('GET', `/${PROJECT_ID}/equeries/stats`); + assert('GET equeries/stats 返回 200', statsStatus === 200, `status=${statsStatus}`); + assert('stats 包含 total', typeof statsData?.data?.total === 'number'); + assert('stats 包含 pending', typeof statsData?.data?.pending === 'number'); + + // List + const { status: listStatus, data: listData } = await api('GET', `/${PROJECT_ID}/equeries`); + assert('GET equeries 返回 200', listStatus === 200, `status=${listStatus}`); + assert('返回 items 数组', Array.isArray(listData?.data?.items)); + + // Create a test eQuery via direct DB insert (simulate AI dispatch) + // Instead, we test the respond/review flow if there are existing equeries + // If none exist, we test with filter params + const { status: filteredStatus } = await api('GET', `/${PROJECT_ID}/equeries?status=pending&severity=error`); + assert('GET equeries 带过滤参数返回 200', filteredStatus === 200); + + // Test respond endpoint (will fail gracefully if no eQuery exists) + const { status: respondStatus } = await api('POST', `/${PROJECT_ID}/equeries/nonexistent-id/respond`, { + responseText: 'E2E test response', + }); + assert('POST respond 对不存在的 eQuery 返回 400/404/500', [400, 404, 500].includes(respondStatus), `status=${respondStatus}`); + + // Test close endpoint + const { status: closeStatus } = await api('POST', `/${PROJECT_ID}/equeries/nonexistent-id/close`, { + closedBy: 'e2e-test', + }); + assert('POST close 对不存在的 eQuery 返回 400/404/500', [400, 404, 500].includes(closeStatus), `status=${closeStatus}`); + + // Test review endpoint + const { status: reviewStatus } = await api('POST', `/${PROJECT_ID}/equeries/nonexistent-id/review`, { + passed: true, + reviewNote: 'E2E test review', + }); + assert('POST review 对不存在的 eQuery 返回 400/404/500', [400, 404, 500].includes(reviewStatus), `status=${reviewStatus}`); +} + +async function testTimeline() { + console.log('\n⏱️ [6/7] AI 工作时间线 API'); + + const { status, data } = await api('GET', `/${PROJECT_ID}/qc-cockpit/timeline`); + assert('GET timeline 返回 200', status === 200, `status=${status}`); + assert('返回 items 数组', Array.isArray(data?.data?.items)); + assert('返回 total 数字', typeof data?.data?.total === 'number'); + + if (data?.data?.items?.length > 0) { + const item = data.data.items[0]; + assert('timeline item 包含 description', typeof item.description === 'string'); + assert('timeline item 包含 recordId', typeof item.recordId === 'string'); + assert('timeline item 包含 status', typeof item.status === 'string'); + assert('timeline item 包含 details.rulesEvaluated', typeof item.details?.rulesEvaluated === 'number'); + } + + // Date filter + const today = new Date().toISOString().split('T')[0]; + const { status: dateStatus } = await api('GET', `/${PROJECT_ID}/qc-cockpit/timeline?date=${today}`); + assert('GET timeline 带日期过滤返回 200', dateStatus === 200); +} + +async function testTrendAndCriticalEvents() { + console.log('\n📈 [7/7] 趋势 + 重大事件 API'); + + // Trend + const { status: trendStatus, data: trendData } = await api('GET', `/${PROJECT_ID}/qc-cockpit/trend?days=30`); + assert('GET trend 返回 200', trendStatus === 200, `status=${trendStatus}`); + assert('trend 返回数组', Array.isArray(trendData?.data)); + if (trendData?.data?.length > 0) { + assert('trend item 包含 date + passRate', trendData.data[0].date && typeof trendData.data[0].passRate === 'number'); + } + + // Critical events + const { status: ceStatus, data: ceData } = await api('GET', `/${PROJECT_ID}/qc-cockpit/critical-events`); + assert('GET critical-events 返回 200', ceStatus === 200, `status=${ceStatus}`); + assert('返回 items 数组', Array.isArray(ceData?.data?.items)); + assert('返回 total 数字', typeof ceData?.data?.total === 'number'); + + // With status filter + const { status: ceFilterStatus } = await api('GET', `/${PROJECT_ID}/qc-cockpit/critical-events?status=open`); + assert('GET critical-events 带状态过滤返回 200', ceFilterStatus === 200); +} + +// ========== Main ========== + +async function main() { + console.log('='.repeat(60)); + console.log(' P0 端到端 API 测试'); + console.log(` 项目: ${PROJECT_ID}`); + console.log(` 后端: ${BASE}`); + console.log('='.repeat(60)); + + // Health check + try { + const healthRes = await fetch('http://localhost:3001/health'); + if (!healthRes.ok) throw new Error(`status ${healthRes.status}`); + console.log('\n🟢 后端服务已启动'); + } catch { + console.error('\n🔴 后端服务未启动!请先运行 npm run dev'); + process.exit(1); + } + + await testFieldMetadata(); + await testQcRules(); + await testQcCockpit(); + await testQcReport(); + await testEquery(); + await testTimeline(); + await testTrendAndCriticalEvents(); + + // Summary + console.log('\n' + '='.repeat(60)); + console.log(` 测试结果: ${passCount} 通过 / ${failCount} 失败 / ${passCount + failCount} 总计`); + if (failCount === 0) { + console.log(' 🎉 全部通过!'); + } else { + console.log(' ⚠️ 有失败项,请检查:'); + results.filter((r) => !r.ok).forEach((r) => { + console.log(` ❌ ${r.name}${r.detail ? ` — ${r.detail}` : ''}`); + }); + } + console.log('='.repeat(60)); + + process.exit(failCount > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error('测试脚本异常:', err); + process.exit(2); +}); diff --git a/frontend-v2/src/App.tsx b/frontend-v2/src/App.tsx index be58c183..939a1d95 100644 --- a/frontend-v2/src/App.tsx +++ b/frontend-v2/src/App.tsx @@ -24,10 +24,7 @@ import UserDetailPage from './modules/admin/pages/UserDetailPage' // 系统知识库管理 import SystemKbListPage from './modules/admin/pages/SystemKbListPage' import SystemKbDetailPage from './modules/admin/pages/SystemKbDetailPage' -// IIT 项目管理 -import IitProjectListPage from './modules/admin/pages/IitProjectListPage' -import IitProjectDetailPage from './modules/admin/pages/IitProjectDetailPage' -import IitQcCockpitPage from './modules/admin/pages/IitQcCockpitPage' +// IIT 项目管理 - 已迁移到 modules/iit/ 业务模块 // 运营日志 import ActivityLogsPage from './pages/admin/ActivityLogsPage' // 个人中心页面 @@ -121,10 +118,7 @@ function App() { {/* 系统知识库 */} } /> } /> - {/* IIT 项目管理 */} - } /> - } /> - } /> + {/* IIT 项目管理 - 已迁移到 /iit/* 业务模块 */} {/* 运营日志 */} } /> {/* 系统配置 */} diff --git a/frontend-v2/src/framework/layout/AdminLayout.tsx b/frontend-v2/src/framework/layout/AdminLayout.tsx index 62c0bea5..9744e566 100644 --- a/frontend-v2/src/framework/layout/AdminLayout.tsx +++ b/frontend-v2/src/framework/layout/AdminLayout.tsx @@ -14,7 +14,7 @@ import { BellOutlined, BookOutlined, FileTextOutlined, - ExperimentOutlined, + } from '@ant-design/icons' import type { MenuProps } from 'antd' import { useAuth } from '../auth' @@ -91,11 +91,6 @@ const AdminLayout = () => { icon: , label: '系统知识库', }, - { - key: '/admin/iit-projects', - icon: , - label: 'IIT 项目管理', - }, { key: '/admin/tenants', icon: , diff --git a/frontend-v2/src/framework/modules/moduleRegistry.ts b/frontend-v2/src/framework/modules/moduleRegistry.ts index 947480a8..d3e631a4 100644 --- a/frontend-v2/src/framework/modules/moduleRegistry.ts +++ b/frontend-v2/src/framework/modules/moduleRegistry.ts @@ -7,7 +7,8 @@ import { ClearOutlined, BarChartOutlined, LineChartOutlined, - AuditOutlined + AuditOutlined, + MedicineBoxOutlined, } from '@ant-design/icons' /** @@ -18,9 +19,10 @@ export const MODULE_CODE_MAP: Record = { 'literature-platform': 'ASL', 'knowledge-base': 'PKB', 'data-cleaning': 'DC', - 'statistical-analysis': 'SSA', // 暂未实现 - 'statistical-tools': 'ST', // 暂未实现 + 'statistical-analysis': 'SSA', + 'statistical-tools': 'ST', 'review-system': 'RVW', + 'iit-cra': 'IIT', }; /** @@ -103,10 +105,21 @@ export const MODULES: ModuleDefinition[] = [ path: '/rvw', icon: AuditOutlined, component: lazy(() => import('@/modules/rvw')), - placeholder: false, // RVW模块已开发 + placeholder: false, requiredVersion: 'basic', description: '智能期刊审稿系统(稿约评审+方法学评审)', - moduleCode: 'RVW', // 后端模块代码 + moduleCode: 'RVW', + }, + { + id: 'iit-cra', + name: 'CRA质控', + path: '/iit', + icon: MedicineBoxOutlined, + component: lazy(() => import('@/modules/iit')), + placeholder: false, + requiredVersion: 'basic', + description: '统一数字 CRA 质控平台(替代 CRA 的自主 AI Agent)', + moduleCode: 'IIT', }, ] diff --git a/frontend-v2/src/modules/iit/IitLayout.tsx b/frontend-v2/src/modules/iit/IitLayout.tsx new file mode 100644 index 00000000..cccce2f2 --- /dev/null +++ b/frontend-v2/src/modules/iit/IitLayout.tsx @@ -0,0 +1,205 @@ +import React, { Suspense } from 'react'; +import { Outlet, useNavigate, useLocation } from 'react-router-dom'; +import { Layout, Menu, Spin, Button, Tag, Tooltip } from 'antd'; +import { + DashboardOutlined, + ThunderboltOutlined, + FileSearchOutlined, + AlertOutlined, + SettingOutlined, + LinkOutlined, + DatabaseOutlined, +} from '@ant-design/icons'; +import type { MenuProps } from 'antd'; + +const { Sider, Content, Header } = Layout; + +const siderMenuItems: MenuProps['items'] = [ + { + type: 'group', + label: '全局与宏观概览', + children: [ + { + key: 'dashboard', + icon: , + label: '项目健康度大盘', + }, + { + key: 'variables', + icon: , + label: '变量清单', + }, + { + key: 'reports', + icon: , + label: '定期报告与关键事件', + }, + ], + }, + { + type: 'group', + label: 'AI 监查过程与细节', + children: [ + { + key: 'stream', + icon: , + label: 'AI 实时工作流水', + }, + ], + }, + { + type: 'group', + label: '人工介入与协作', + children: [ + { + key: 'equery', + icon: , + label: '待处理 eQuery', + }, + ], + }, +]; + +const viewTitles: Record = { + dashboard: '项目健康度大盘', + variables: '变量清单', + stream: 'AI 实时工作流水', + equery: '待处理电子质疑 (eQuery)', + reports: '定期报告与关键事件', +}; + +const IitLayout: React.FC = () => { + const navigate = useNavigate(); + const location = useLocation(); + + const pathSegments = location.pathname.split('/'); + const currentView = pathSegments[pathSegments.length - 1] || 'dashboard'; + const headerTitle = viewTitles[currentView] || 'CRA 质控平台'; + + const handleMenuClick: MenuProps['onClick'] = ({ key }) => { + navigate(`/iit/${key}`); + }; + + return ( + + + {/* Logo / 系统标题 */} +
+ + + AI 监查系统 + +
+ + {/* 当前项目信息 */} +
+
+ 当前监控项目 +
+
+
IIT-2026-001
+
非小细胞肺癌真实世界研究
+
+
+ + {/* 导航菜单 */} + + + {/* 底部:项目设置入口 */} +
+ + + +
+ EDC 直连 + 统一视图,全员可见 +
+
+ + + + {/* 顶部标题栏 */} +
+

+ {headerTitle} +

+
+ } color="processing">EDC 实时直连中 +
+
+ + {/* 主内容区 */} + + + + + }> + + + +
+ + ); +}; + +export default IitLayout; diff --git a/frontend-v2/src/modules/iit/api/iitProjectApi.ts b/frontend-v2/src/modules/iit/api/iitProjectApi.ts new file mode 100644 index 00000000..60120b5e --- /dev/null +++ b/frontend-v2/src/modules/iit/api/iitProjectApi.ts @@ -0,0 +1,564 @@ +/** + * IIT 项目管理 API + */ + +import apiClient from '@/common/api/axios'; +import type { + IitProject, + CreateProjectRequest, + UpdateProjectRequest, + TestConnectionRequest, + TestConnectionResult, + QCRule, + CreateRuleRequest, + RuleStats, + IitUserMapping, + CreateUserMappingRequest, + UpdateUserMappingRequest, + RoleOption, + KnowledgeBaseOption, +} from '../types/iitProject'; +import type { + QcCockpitData, + RecordDetail, +} from '../types/qcCockpit'; + +const BASE_URL = '/api/v1/admin/iit-projects'; + +// ==================== 项目 CRUD ==================== + +/** 获取项目列表 */ +export async function listProjects(params?: { + status?: string; + search?: string; +}): Promise { + const response = await apiClient.get(BASE_URL, { params }); + return response.data.data; +} + +/** 获取项目详情 */ +export async function getProject(id: string): Promise { + const response = await apiClient.get(`${BASE_URL}/${id}`); + return response.data.data; +} + +/** 创建项目 */ +export async function createProject(data: CreateProjectRequest): Promise { + const response = await apiClient.post(BASE_URL, data); + return response.data.data; +} + +/** 更新项目 */ +export async function updateProject( + id: string, + data: UpdateProjectRequest +): Promise { + const response = await apiClient.put(`${BASE_URL}/${id}`, data); + return response.data.data; +} + +/** 删除项目 */ +export async function deleteProject(id: string): Promise { + await apiClient.delete(`${BASE_URL}/${id}`); +} + +// ==================== REDCap 连接 ==================== + +/** 测试 REDCap 连接(新配置) */ +export async function testConnection( + data: TestConnectionRequest +): Promise { + const response = await apiClient.post(`${BASE_URL}/test-connection`, data); + return response.data.data; +} + +/** 测试项目的 REDCap 连接 */ +export async function testProjectConnection( + projectId: string +): Promise { + const response = await apiClient.post(`${BASE_URL}/${projectId}/test-connection`); + return response.data.data; +} + +/** 同步 REDCap 元数据 */ +export async function syncMetadata(projectId: string): Promise<{ + success: boolean; + fieldCount: number; +}> { + const response = await apiClient.post(`${BASE_URL}/${projectId}/sync-metadata`); + return response.data.data; +} + +// ==================== 字段元数据 ==================== + +/** 字段元数据类型 */ +export interface FieldMetadata { + id: string; + projectId: string; + fieldName: string; + fieldLabel: string; + fieldType: string; + formName: string; + sectionHeader?: string | null; + validation?: string | null; + validationMin?: string | null; + validationMax?: string | null; + choices?: string | null; + required: boolean; + branching?: string | null; + alias?: string | null; + ruleSource?: string | null; + syncedAt: string; +} + +/** 获取字段元数据列表 */ +export async function listFieldMetadata( + projectId: string, + params?: { formName?: string; search?: string } +): Promise<{ fields: FieldMetadata[]; total: number; forms: string[] }> { + const response = await apiClient.get(`${BASE_URL}/${projectId}/field-metadata`, { params }); + return response.data.data; +} + +// ==================== 知识库关联 ==================== + +/** 关联知识库 */ +export async function linkKnowledgeBase( + projectId: string, + knowledgeBaseId: string +): Promise { + await apiClient.post(`${BASE_URL}/${projectId}/knowledge-base`, { knowledgeBaseId }); +} + +/** 解除知识库关联 */ +export async function unlinkKnowledgeBase(projectId: string): Promise { + await apiClient.delete(`${BASE_URL}/${projectId}/knowledge-base`); +} + +/** 获取可用知识库列表 */ +export async function listKnowledgeBases(): Promise { + const response = await apiClient.get('/api/v1/admin/system-kb'); + return response.data.data; +} + +// ==================== 质控规则 ==================== + +/** 获取项目的质控规则列表 */ +export async function listRules(projectId: string): Promise { + const response = await apiClient.get(`${BASE_URL}/${projectId}/rules`); + return response.data.data; +} + +/** 获取规则统计 */ +export async function getRuleStats(projectId: string): Promise { + const response = await apiClient.get(`${BASE_URL}/${projectId}/rules/stats`); + return response.data.data; +} + +/** 获取单条规则 */ +export async function getRule(projectId: string, ruleId: string): Promise { + const response = await apiClient.get(`${BASE_URL}/${projectId}/rules/${ruleId}`); + return response.data.data; +} + +/** 添加规则 */ +export async function addRule( + projectId: string, + data: CreateRuleRequest +): Promise { + const response = await apiClient.post(`${BASE_URL}/${projectId}/rules`, data); + return response.data.data; +} + +/** 更新规则 */ +export async function updateRule( + projectId: string, + ruleId: string, + data: Partial +): Promise { + const response = await apiClient.put(`${BASE_URL}/${projectId}/rules/${ruleId}`, data); + return response.data.data; +} + +/** 删除规则 */ +export async function deleteRule(projectId: string, ruleId: string): Promise { + await apiClient.delete(`${BASE_URL}/${projectId}/rules/${ruleId}`); +} + +/** 批量导入规则 */ +export async function importRules( + projectId: string, + rules: CreateRuleRequest[] +): Promise<{ count: number; rules: QCRule[] }> { + const response = await apiClient.post(`${BASE_URL}/${projectId}/rules/import`, { + rules, + }); + return response.data.data; +} + +/** 测试规则逻辑 */ +export async function testRule( + logic: Record, + testData: Record +): Promise<{ passed: boolean; result: unknown }> { + const response = await apiClient.post(`${BASE_URL}/rules/test`, { logic, testData }); + return response.data.data; +} + +/** AI 规则建议 */ +export async function suggestRules( + projectId: string +): Promise { + const response = await apiClient.post(`${BASE_URL}/${projectId}/rules/suggest`); + return response.data.data; +} + +// ==================== eQuery ==================== + +export interface Equery { + id: string; + projectId: string; + recordId: string; + eventId?: string; + formName?: string; + fieldName?: string; + queryText: string; + expectedAction?: string; + severity: string; + category?: string; + status: string; + assignedTo?: string; + respondedAt?: string; + responseText?: string; + reviewResult?: string; + reviewNote?: string; + reviewedAt?: string; + closedAt?: string; + closedBy?: string; + resolution?: string; + createdAt: string; + updatedAt: string; +} + +export interface EqueryListResult { + items: Equery[]; + total: number; + page: number; + pageSize: number; +} + +export interface EqueryStats { + total: number; + pending: number; + responded: number; + reviewing: number; + closed: number; + reopened: number; + avgResolutionHours: number | null; +} + +/** 获取 eQuery 列表 */ +export async function listEqueries( + projectId: string, + params?: { status?: string; recordId?: string; severity?: string; page?: number; pageSize?: number } +): Promise { + const response = await apiClient.get(`${BASE_URL}/${projectId}/equeries`, { params }); + return response.data.data; +} + +/** 获取 eQuery 统计 */ +export async function getEqueryStats(projectId: string): Promise { + const response = await apiClient.get(`${BASE_URL}/${projectId}/equeries/stats`); + return response.data.data; +} + +/** 获取单条 eQuery */ +export async function getEquery(projectId: string, equeryId: string): Promise { + const response = await apiClient.get(`${BASE_URL}/${projectId}/equeries/${equeryId}`); + return response.data.data; +} + +/** CRC 回复 eQuery */ +export async function respondEquery( + projectId: string, + equeryId: string, + data: { responseText: string } +): Promise { + const response = await apiClient.post(`${BASE_URL}/${projectId}/equeries/${equeryId}/respond`, data); + return response.data.data; +} + +/** 管理员手动复核 eQuery */ +export async function reviewEquery( + projectId: string, + equeryId: string, + data: { passed: boolean; reviewNote?: string } +): Promise { + const response = await apiClient.post(`${BASE_URL}/${projectId}/equeries/${equeryId}/review`, data); + return response.data.data; +} + +/** 手动关闭 eQuery */ +export async function closeEquery( + projectId: string, + equeryId: string, + data: { closedBy: string; resolution?: string } +): Promise { + const response = await apiClient.post(`${BASE_URL}/${projectId}/equeries/${equeryId}/close`, data); + return response.data.data; +} + +// ==================== 用户映射 ==================== + +/** 获取角色选项 */ +export async function getRoleOptions(): Promise { + const response = await apiClient.get(`${BASE_URL}/roles`); + return response.data.data; +} + +/** 获取项目的用户映射列表 */ +export async function listUserMappings( + projectId: string, + params?: { role?: string; search?: string } +): Promise { + const response = await apiClient.get(`${BASE_URL}/${projectId}/users`, { params }); + return response.data.data; +} + +/** 获取用户映射统计 */ +export async function getUserMappingStats(projectId: string): Promise<{ + total: number; + byRole: Record; +}> { + const response = await apiClient.get(`${BASE_URL}/${projectId}/users/stats`); + return response.data.data; +} + +/** 创建用户映射 */ +export async function createUserMapping( + projectId: string, + data: CreateUserMappingRequest +): Promise { + const response = await apiClient.post(`${BASE_URL}/${projectId}/users`, data); + return response.data.data; +} + +/** 更新用户映射 */ +export async function updateUserMapping( + projectId: string, + mappingId: string, + data: UpdateUserMappingRequest +): Promise { + const response = await apiClient.put( + `${BASE_URL}/${projectId}/users/${mappingId}`, + data + ); + return response.data.data; +} + +/** 删除用户映射 */ +export async function deleteUserMapping( + projectId: string, + mappingId: string +): Promise { + await apiClient.delete(`${BASE_URL}/${projectId}/users/${mappingId}`); +} + +// ==================== 批量操作 ==================== + +/** 一键全量质控 */ +export async function batchQualityCheck(projectId: string): Promise<{ + success: boolean; + message: string; + stats: { + totalRecords: number; + passed: number; + failed: number; + warnings: number; + passRate: string; + }; + durationMs: number; +}> { + const response = await apiClient.post(`${BASE_URL}/${projectId}/batch-qc`); + return response.data; +} + +/** 一键全量数据汇总 */ +export async function batchSummary(projectId: string): Promise<{ + success: boolean; + message: string; + stats: { + totalRecords: number; + summariesUpdated: number; + totalForms: number; + avgCompletionRate: string; + }; + durationMs: number; +}> { + const response = await apiClient.post(`${BASE_URL}/${projectId}/batch-summary`); + return response.data; +} + +// ==================== 质控驾驶舱 ==================== + +/** 获取质控驾驶舱数据 */ +export async function getQcCockpitData(projectId: string): Promise { + const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit`); + return response.data.data; +} + +/** 获取记录质控详情 */ +export async function getQcRecordDetail( + projectId: string, + recordId: string, + formName: string +): Promise { + const response = await apiClient.get( + `${BASE_URL}/${projectId}/qc-cockpit/records/${recordId}`, + { params: { formName } } + ); + return response.data.data; +} + +// ==================== AI 时间线 ==================== + +export interface TimelineItem { + id: string; + type: 'qc_check'; + time: string; + recordId: string; + formName?: string; + status: string; + triggeredBy: string; + description: string; + details: { + rulesEvaluated: number; + rulesPassed: number; + rulesFailed: number; + issuesSummary: { red: number; yellow: number }; + }; +} + +/** 获取 AI 工作时间线 */ +export async function getTimeline( + projectId: string, + params?: { page?: number; pageSize?: number; date?: string } +): Promise<{ items: TimelineItem[]; total: number; page: number; pageSize: number }> { + const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/timeline`, { params }); + return response.data.data; +} + +// ==================== 重大事件 ==================== + +export interface CriticalEvent { + id: string; + projectId: string; + recordId: string; + eventType: string; + severity: string; + title: string; + description: string; + detectedAt: string; + detectedBy: string; + status: string; + handledBy?: string; + handledAt?: string; + handlingNote?: string; + reportedToEc: boolean; + reportedAt?: string; + createdAt: string; + updatedAt: string; +} + +/** 获取重大事件列表 */ +export async function getCriticalEvents( + projectId: string, + params?: { status?: string; eventType?: string; page?: number; pageSize?: number } +): Promise<{ items: CriticalEvent[]; total: number }> { + const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/critical-events`, { params }); + return response.data.data; +} + +// ==================== 趋势 ==================== + +export interface TrendPoint { + date: string; + total: number; + passed: number; + passRate: number; +} + +/** 获取质控趋势 */ +export async function getTrend(projectId: string, days: number = 30): Promise { + const response = await apiClient.get(`${BASE_URL}/${projectId}/qc-cockpit/trend`, { params: { days } }); + return response.data.data; +} + +/** 质控报告类型 */ +export interface QcReport { + projectId: string; + reportType: 'daily' | 'weekly' | 'on_demand'; + generatedAt: string; + expiresAt: string | null; + summary: { + totalRecords: number; + completedRecords: number; + criticalIssues: number; + warningIssues: number; + pendingQueries: number; + passRate: number; + lastQcTime: string | null; + }; + criticalIssues: Array<{ + recordId: string; + ruleId: string; + ruleName: string; + severity: 'critical' | 'warning' | 'info'; + message: string; + field?: string; + actualValue?: any; + expectedValue?: any; + detectedAt: string; + }>; + warningIssues: Array<{ + recordId: string; + ruleId: string; + ruleName: string; + severity: 'critical' | 'warning' | 'info'; + message: string; + field?: string; + detectedAt: string; + }>; + formStats: Array<{ + formName: string; + formLabel: string; + totalChecks: number; + passed: number; + failed: number; + passRate: number; + }>; + llmFriendlyXml: string; +} + +/** 获取质控报告 */ +export async function getQcReport( + projectId: string, + format: 'json' | 'xml' = 'json' +): Promise { + const response = await apiClient.get( + `${BASE_URL}/${projectId}/qc-cockpit/report`, + { + params: { format }, + responseType: format === 'xml' ? 'text' : 'json', + } + ); + return format === 'xml' ? response.data : response.data.data; +} + +/** 刷新质控报告 */ +export async function refreshQcReport(projectId: string): Promise { + const response = await apiClient.post( + `${BASE_URL}/${projectId}/qc-cockpit/report/refresh` + ); + return response.data.data; +} diff --git a/frontend-v2/src/modules/iit/components/RuleTemplateBuilder.tsx b/frontend-v2/src/modules/iit/components/RuleTemplateBuilder.tsx new file mode 100644 index 00000000..c8244ce1 --- /dev/null +++ b/frontend-v2/src/modules/iit/components/RuleTemplateBuilder.tsx @@ -0,0 +1,518 @@ +/** + * RuleTemplateBuilder - 可视化规则构建器 + * + * 根据规则分类提供不同表单模板,自动生成 JSON Logic。 + * 支持"可视化模式"与"高级 JSON 模式"切换。 + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { + Form, + Select, + InputNumber, + Input, + Switch, + Card, + Space, + Tag, + Typography, + Alert, + Divider, + Row, + Col, +} from 'antd'; +import { CodeOutlined, FormOutlined } from '@ant-design/icons'; +import VariablePicker from './VariablePicker'; +import type { VariableInfo } from './VariablePicker'; + +const { Text } = Typography; +const { TextArea } = Input; + +// ==================== Types ==================== + +type RuleCategory = + | 'lab_values' + | 'inclusion' + | 'exclusion' + | 'logic_check' + | 'variable_qc' + | 'protocol_deviation' + | 'ae_monitoring'; + +interface RuleTemplateBuilderProps { + projectId: string; + category: RuleCategory; + /** Current JSON Logic value (object or JSON string) */ + value?: Record | string; + onChange?: (logic: Record) => void; + /** Current field(s) selection — synced from parent form */ + fields?: string | string[]; + onFieldsChange?: (fields: string | string[], variables: VariableInfo[]) => void; + /** Auto-generate error message */ + onMessageSuggest?: (msg: string) => void; +} + +// ==================== Check type options per category ==================== + +const VARIABLE_CHECK_TYPES = [ + { value: 'range', label: '数值范围' }, + { value: 'required', label: '必填检查' }, + { value: 'format', label: '格式校验' }, + { value: 'enum', label: '枚举值检查' }, +]; + +const COMPARISON_OPERATORS = [ + { value: '==', label: '等于 (=)' }, + { value: '!=', label: '不等于 (!=)' }, + { value: '>', label: '大于 (>)' }, + { value: '>=', label: '大于等于 (>=)' }, + { value: '<', label: '小于 (<)' }, + { value: '<=', label: '小于等于 (<=)' }, + { value: 'in', label: '属于 (in)' }, +]; + +// ==================== JSON Logic Generators ==================== + +function buildRangeLogic(fieldName: string, min?: number, max?: number): Record { + const conditions: Record[] = []; + if (min !== undefined && min !== null) { + conditions.push({ '>=': [{ var: fieldName }, min] }); + } + if (max !== undefined && max !== null) { + conditions.push({ '<=': [{ var: fieldName }, max] }); + } + if (conditions.length === 0) return { '!!': [{ var: fieldName }] }; + if (conditions.length === 1) return conditions[0]; + return { and: conditions }; +} + +function buildRequiredLogic(fieldName: string): Record { + return { '!!': [{ var: fieldName }] }; +} + +function buildEnumLogic(fieldName: string, allowedValues: (string | number)[]): Record { + return { in: [{ var: fieldName }, allowedValues] }; +} + +function buildComparisonLogic(fieldName: string, operator: string, compareValue: string | number): Record { + if (operator === 'in') { + const vals = typeof compareValue === 'string' + ? compareValue.split(',').map(v => v.trim()) + : [compareValue]; + return { in: [{ var: fieldName }, vals] }; + } + return { [operator]: [{ var: fieldName }, compareValue] }; +} + +function buildWindowLogic(dateField1: string, dateField2: string, maxDays: number): Record { + return { + '<=': [ + { '-': [{ var: dateField2 }, { var: dateField1 }] }, + maxDays, + ], + }; +} + +function buildAeTimeLimitLogic(aeDateField: string, reportDateField: string, limitDays: number): Record { + return { + '<=': [ + { '-': [{ var: reportDateField }, { var: aeDateField }] }, + limitDays, + ], + }; +} + +// ==================== Component ==================== + +const RuleTemplateBuilder: React.FC = ({ + projectId, + category, + value, + onChange, + fields, + onFieldsChange, + onMessageSuggest, +}) => { + const [advancedMode, setAdvancedMode] = useState(false); + const [jsonText, setJsonText] = useState(''); + const [jsonError, setJsonError] = useState(''); + + // Template state for variable_qc / lab_values + const [checkType, setCheckType] = useState('range'); + const [rangeMin, setRangeMin] = useState(); + const [rangeMax, setRangeMax] = useState(); + const [enumValues, setEnumValues] = useState(''); + + // Template state for inclusion / exclusion + const [operator, setOperator] = useState('=='); + const [compareValue, setCompareValue] = useState(''); + + // Template state for protocol_deviation + const [windowDays, setWindowDays] = useState(7); + + // Template state for ae_monitoring + const [limitDays, setLimitDays] = useState(1); + + // Selected variable info + const [selectedVars, setSelectedVars] = useState([]); + + // Sync external value to jsonText when in advanced mode + useEffect(() => { + if (value) { + const obj = typeof value === 'string' ? value : JSON.stringify(value, null, 2); + setJsonText(typeof obj === 'string' ? obj : ''); + } + }, [value]); + + const emitLogic = useCallback( + (logic: Record) => { + onChange?.(logic); + setJsonText(JSON.stringify(logic, null, 2)); + setJsonError(''); + }, + [onChange] + ); + + // Build logic from visual form whenever inputs change (non-advanced mode) + useEffect(() => { + if (advancedMode) return; + + const fieldName = Array.isArray(fields) ? fields[0] : fields; + if (!fieldName) return; + + let logic: Record | null = null; + let suggestedMessage = ''; + + const varLabel = selectedVars[0]?.fieldLabel || fieldName; + + switch (category) { + case 'lab_values': + case 'variable_qc': { + switch (checkType) { + case 'range': + logic = buildRangeLogic(fieldName, rangeMin, rangeMax); + suggestedMessage = rangeMin !== undefined && rangeMax !== undefined + ? `${varLabel} 必须在 ${rangeMin} ~ ${rangeMax} 之间` + : rangeMin !== undefined + ? `${varLabel} 必须 >= ${rangeMin}` + : rangeMax !== undefined + ? `${varLabel} 必须 <= ${rangeMax}` + : `${varLabel} 不可为空`; + break; + case 'required': + logic = buildRequiredLogic(fieldName); + suggestedMessage = `${varLabel} 不可为空`; + break; + case 'enum': { + const vals = enumValues.split(',').map((v) => v.trim()).filter(Boolean); + logic = buildEnumLogic(fieldName, vals); + suggestedMessage = `${varLabel} 必须为以下值之一: ${vals.join(', ')}`; + break; + } + case 'format': + logic = buildRequiredLogic(fieldName); + suggestedMessage = `${varLabel} 格式不正确`; + break; + } + break; + } + case 'inclusion': + case 'exclusion': { + logic = buildComparisonLogic(fieldName, operator, compareValue); + const opLabel = COMPARISON_OPERATORS.find(o => o.value === operator)?.label || operator; + suggestedMessage = category === 'inclusion' + ? `纳入标准: ${varLabel} ${opLabel} ${compareValue}` + : `排除标准: ${varLabel} ${opLabel} ${compareValue}`; + break; + } + case 'protocol_deviation': { + const field2 = Array.isArray(fields) && fields[1] ? fields[1] : ''; + if (field2) { + logic = buildWindowLogic(fieldName, field2, windowDays); + suggestedMessage = `${fieldName} 与 ${field2} 间隔不得超过 ${windowDays} 天`; + } + break; + } + case 'ae_monitoring': { + const reportField = Array.isArray(fields) && fields[1] ? fields[1] : ''; + if (reportField) { + logic = buildAeTimeLimitLogic(fieldName, reportField, limitDays); + suggestedMessage = `AE 发生后需在 ${limitDays} 天内上报`; + } + break; + } + case 'logic_check': { + // Generic — user should use advanced mode for complex logic + logic = buildComparisonLogic(fieldName, operator, compareValue); + suggestedMessage = `逻辑检查: ${varLabel} ${operator} ${compareValue}`; + break; + } + } + + if (logic) { + emitLogic(logic); + } + if (suggestedMessage) { + onMessageSuggest?.(suggestedMessage); + } + // Intentionally not including all deps to avoid infinite loops — rebuild only on user-input changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [category, checkType, rangeMin, rangeMax, enumValues, operator, compareValue, windowDays, limitDays, fields, advancedMode]); + + const handleAdvancedChange = (text: string) => { + setJsonText(text); + try { + const parsed = JSON.parse(text); + setJsonError(''); + onChange?.(parsed); + } catch { + setJsonError('JSON 格式错误'); + } + }; + + const handleFieldsChange = (val: string | string[], vars: VariableInfo[]) => { + setSelectedVars(vars); + onFieldsChange?.(val, vars); + }; + + // ==================== Visual templates per category ==================== + + const renderVariableQcTemplate = () => ( + <> + + setEnumValues(e.target.value)} + placeholder="例如: 1, 2, 3" + /> + + )} + + ); + + const renderInclusionExclusionTemplate = () => ( + + + + setCompareValue(e.target.value)} + placeholder="例如: 18 或 男,女" + /> + + + + ); + + const renderProtocolDeviationTemplate = () => ( + <> + + + + + + setWindowDays(v ?? 7)} + min={1} + style={{ width: 200 }} + addonAfter="天" + /> + + + ); + + const renderAeMonitoringTemplate = () => ( + <> + + + + + + setLimitDays(v ?? 1)} + min={1} + style={{ width: 200 }} + addonAfter="天" + /> + + + ); + + const renderVisualTemplate = () => { + // For protocol_deviation and ae_monitoring, the variable picker is inside the template + const needsExternalPicker = !['protocol_deviation', 'ae_monitoring'].includes(category); + + return ( + <> + {needsExternalPicker && ( + + + + )} + + {selectedVars.length > 0 && needsExternalPicker && ( +
+ + {selectedVars.map((v) => ( + + {v.fieldLabel} ({v.fieldType}) + {v.validation && | {v.validation}} + + ))} + +
+ )} + + {(category === 'variable_qc' || category === 'lab_values') && renderVariableQcTemplate()} + {(category === 'inclusion' || category === 'exclusion') && renderInclusionExclusionTemplate()} + {category === 'protocol_deviation' && renderProtocolDeviationTemplate()} + {category === 'ae_monitoring' && renderAeMonitoringTemplate()} + {category === 'logic_check' && renderInclusionExclusionTemplate()} + + ); + }; + + return ( + + {advancedMode ? : } + {advancedMode ? '高级模式(JSON Logic)' : '可视化规则配置'} + + } + extra={ + + 高级 + + + } + bodyStyle={{ padding: 16 }} + > + {advancedMode ? ( + <> + + + +