feat(iit): Complete V3.1 QC engine + GCP business reports + AI timeline + bug fixes
V3.1 QC Engine: - QcExecutor unified entry + D1-D7 dimension engines + three-level aggregation - HealthScoreEngine + CompletenessEngine + ProtocolDeviationEngine + QcAggregator - B4 flexible cron scheduling (project-level cronExpression + pg-boss dispatcher) - Prisma migrations for qc_field_status, event_status, project_stats GCP Business Reports (Phase A - 4 reports): - D1 Eligibility: record_summary full list + qc_field_status D1 overlay - D2 Completeness: data entry rate and missing rate aggregation - D3/D4 Query Tracking: severity distribution from qc_field_status - D6 Protocol Deviation: D6 dimension filtering - 4 frontend table components + ReportsPage 5-tab restructure AI Timeline Enhancement: - SkillRunner outputs totalRules (33 actual rules vs 1 skill) - iitQcCockpitController severity mapping fix (critical->red, warning->yellow) - AiStreamPage expandable issue detail table with Chinese labels - Event label localization (eventLabel from backend) Business-side One-click Batch QC: - DashboardPage batch QC button with SyncOutlined icon - Auto-refresh QcReport cache after batch execution Bug Fixes: - dimension_code -> rule_category in 4 SQL queries - D1 eligibility data source: record_summary full + qc_field_status overlay - Timezone UTC -> Asia/Shanghai (QcReportService toBeijingTime helper) - Pass rate calculation: passed/totalEvents instead of passed/totalRecords Docs: - Update IIT module status with GCP reports and bug fix milestones - Update system status doc v6.6 with IIT progress Tested: Backend compiles, frontend linter clean, batch QC verified Made-with: Cursor
This commit is contained in:
@@ -17,6 +17,8 @@ import { PrismaClient } from '@prisma/client';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { RedcapAdapter } from '../../iit-manager/adapters/RedcapAdapter.js';
|
||||
import { createSkillRunner } from '../../iit-manager/engines/SkillRunner.js';
|
||||
import { QcExecutor } from '../../iit-manager/engines/QcExecutor.js';
|
||||
import { QcReportService } from '../../iit-manager/services/QcReportService.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -44,130 +46,53 @@ export class IitBatchController {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
logger.info('🔄 开始事件级全量质控', { projectId });
|
||||
logger.info('[V3.1] Batch QC started', { projectId });
|
||||
|
||||
// 1. 获取项目配置
|
||||
const project = await prisma.iitProject.findUnique({
|
||||
where: { id: projectId }
|
||||
where: { id: projectId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return reply.status(404).send({ error: '项目不存在' });
|
||||
}
|
||||
|
||||
// 2. 使用 SkillRunner 执行事件级质控
|
||||
const runner = await createSkillRunner(projectId);
|
||||
const results = await runner.runByTrigger('manual');
|
||||
const executor = new QcExecutor(projectId);
|
||||
const batchResult = await executor.executeBatch({ triggeredBy: 'manual' });
|
||||
|
||||
if (results.length === 0) {
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: '项目暂无记录或未配置质控规则',
|
||||
stats: { totalRecords: 0, totalEvents: 0 }
|
||||
});
|
||||
const { totalRecords, totalEvents, passed, failed, warnings, fieldStatusWrites, executionTimeMs } = batchResult;
|
||||
const passRate = totalEvents > 0 ? `${((passed / totalEvents) * 100).toFixed(1)}%` : '0%';
|
||||
|
||||
// 自动刷新 QcReport 缓存,使业务端立即看到最新数据
|
||||
try {
|
||||
await QcReportService.refreshReport(projectId);
|
||||
logger.info('[V3.1] QcReport cache refreshed after batch QC', { projectId });
|
||||
} catch (reportErr: any) {
|
||||
logger.warn('[V3.1] QcReport refresh failed (non-blocking)', { projectId, error: reportErr.message });
|
||||
}
|
||||
|
||||
// 3. V3.2: 按 record 级别聚合状态(每个 record 取最严重状态)
|
||||
const statusPriority: Record<string, number> = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0 };
|
||||
const recordWorstStatus = new Map<string, string>();
|
||||
|
||||
for (const result of results) {
|
||||
const existing = recordWorstStatus.get(result.recordId);
|
||||
const currentPrio = statusPriority[result.overallStatus] ?? 0;
|
||||
const existingPrio = existing ? (statusPriority[existing] ?? 0) : -1;
|
||||
if (currentPrio > existingPrio) {
|
||||
recordWorstStatus.set(result.recordId, result.overallStatus);
|
||||
}
|
||||
}
|
||||
|
||||
// V3.2: 用本次批量质控结果更新 record_summary(覆盖旧状态)
|
||||
for (const [recordId, worstStatus] of recordWorstStatus) {
|
||||
await prisma.iitRecordSummary.upsert({
|
||||
where: { projectId_recordId: { projectId, recordId } },
|
||||
create: {
|
||||
projectId,
|
||||
recordId,
|
||||
lastUpdatedAt: new Date(),
|
||||
latestQcStatus: worstStatus,
|
||||
latestQcAt: new Date(),
|
||||
formStatus: {},
|
||||
updateCount: 1
|
||||
},
|
||||
update: {
|
||||
latestQcStatus: worstStatus,
|
||||
latestQcAt: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// V3.2: 清理该项目旧版本日志(event_id 为 NULL 的遗留数据)
|
||||
const deletedLegacy = await prisma.iitQcLog.deleteMany({
|
||||
where: { projectId, eventId: null }
|
||||
});
|
||||
if (deletedLegacy.count > 0) {
|
||||
logger.info('🧹 清理旧版质控日志', { projectId, deletedCount: deletedLegacy.count });
|
||||
}
|
||||
|
||||
// V3.2: record 级别统计
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
let warningCount = 0;
|
||||
|
||||
for (const status of recordWorstStatus.values()) {
|
||||
if (status === 'PASS') passCount++;
|
||||
else if (status === 'FAIL') failCount++;
|
||||
else warningCount++;
|
||||
}
|
||||
|
||||
const totalRecords = recordWorstStatus.size;
|
||||
|
||||
// 4. 更新项目统计表(record 级别)
|
||||
await prisma.iitQcProjectStats.upsert({
|
||||
where: { projectId },
|
||||
create: {
|
||||
projectId,
|
||||
totalRecords,
|
||||
passedRecords: passCount,
|
||||
failedRecords: failCount,
|
||||
warningRecords: warningCount
|
||||
},
|
||||
update: {
|
||||
totalRecords,
|
||||
passedRecords: passCount,
|
||||
failedRecords: failCount,
|
||||
warningRecords: warningCount
|
||||
}
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
logger.info('✅ 事件级全量质控完成', {
|
||||
projectId,
|
||||
totalRecords,
|
||||
totalEventCombinations: results.length,
|
||||
passCount,
|
||||
failCount,
|
||||
warningCount,
|
||||
durationMs
|
||||
logger.info('[V3.1] Batch QC completed', {
|
||||
projectId, totalRecords, totalEvents, passed, failed, warnings, durationMs,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: '事件级全量质控完成',
|
||||
message: '事件级全量质控完成(V3.1 QcExecutor)',
|
||||
stats: {
|
||||
totalRecords,
|
||||
totalEventCombinations: results.length,
|
||||
passed: passCount,
|
||||
failed: failCount,
|
||||
warnings: warningCount,
|
||||
passRate: totalRecords > 0
|
||||
? `${((passCount / totalRecords) * 100).toFixed(1)}%`
|
||||
: '0%'
|
||||
totalEventCombinations: totalEvents,
|
||||
passed,
|
||||
failed,
|
||||
warnings,
|
||||
fieldStatusWrites,
|
||||
passRate,
|
||||
},
|
||||
durationMs
|
||||
durationMs,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 事件级全量质控失败', { projectId, error: error.message });
|
||||
logger.error('Batch QC failed', { projectId, error: error.message });
|
||||
return reply.status(500).send({ error: `质控失败: ${error.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface UpdateProjectInput {
|
||||
knowledgeBaseId?: string;
|
||||
status?: string;
|
||||
isDemo?: boolean;
|
||||
cronEnabled?: boolean;
|
||||
cronExpression?: string;
|
||||
}
|
||||
|
||||
export interface TestConnectionResult {
|
||||
@@ -226,10 +228,22 @@ export class IitProjectService {
|
||||
knowledgeBaseId: input.knowledgeBaseId,
|
||||
status: input.status,
|
||||
isDemo: input.isDemo,
|
||||
cronEnabled: input.cronEnabled,
|
||||
cronExpression: input.cronExpression,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// B4: 刷新 cron 调度
|
||||
if (input.cronEnabled !== undefined || input.cronExpression !== undefined) {
|
||||
try {
|
||||
const { refreshProjectCronSchedule } = await import('../../iit-manager/index.js');
|
||||
await refreshProjectCronSchedule(id);
|
||||
} catch (err: any) {
|
||||
logger.warn('刷新 cron 调度失败 (non-fatal)', { projectId: id, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('更新 IIT 项目成功', { projectId: project.id });
|
||||
return project;
|
||||
}
|
||||
|
||||
@@ -59,25 +59,26 @@ class IitQcCockpitController {
|
||||
async getRecordDetail(
|
||||
request: FastifyRequest<{
|
||||
Params: { projectId: string; recordId: string };
|
||||
Querystring: { formName?: string };
|
||||
Querystring: { formName?: string; eventId?: string };
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { projectId, recordId } = request.params;
|
||||
const { formName = 'default' } = request.query;
|
||||
const { eventId, formName } = request.query;
|
||||
const resolvedEventId = eventId || formName || undefined;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const data = await iitQcCockpitService.getRecordDetail(
|
||||
projectId,
|
||||
recordId,
|
||||
formName
|
||||
resolvedEventId
|
||||
);
|
||||
|
||||
logger.info('[QcCockpitController] 获取记录详情成功', {
|
||||
projectId,
|
||||
recordId,
|
||||
formName,
|
||||
eventId: resolvedEventId,
|
||||
issueCount: data.issues.length,
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
@@ -236,16 +237,17 @@ class IitQcCockpitController {
|
||||
prisma.iitQcLog.count({ where: { projectId, ...dateWhere } }),
|
||||
]);
|
||||
|
||||
// Transform to timeline items
|
||||
const items = qcLogs.map((log) => {
|
||||
const rawIssues = log.issues as any;
|
||||
const issues: any[] = Array.isArray(rawIssues) ? rawIssues : (rawIssues?.items || []);
|
||||
const redCount = issues.filter((i: any) => i.level === 'RED').length;
|
||||
const yellowCount = issues.filter((i: any) => i.level === 'YELLOW').length;
|
||||
const redCount = issues.filter((i: any) => i.severity === 'critical' || i.level === 'RED').length;
|
||||
const yellowCount = issues.filter((i: any) => i.severity === 'warning' || i.level === 'YELLOW').length;
|
||||
const eventLabel = rawIssues?.eventLabel || '';
|
||||
const totalRules = rawIssues?.summary?.totalRules || log.rulesEvaluated || 0;
|
||||
|
||||
let description = `扫描受试者 ${log.recordId}`;
|
||||
if (log.formName) description += ` [${log.formName}]`;
|
||||
description += ` → 执行 ${log.rulesEvaluated} 条规则 (${log.rulesPassed} 通过`;
|
||||
if (eventLabel) description += `「${eventLabel}」`;
|
||||
description += ` → 执行 ${totalRules} 条规则 (${log.rulesPassed} 通过`;
|
||||
if (log.rulesFailed > 0) description += `, ${log.rulesFailed} 失败`;
|
||||
description += ')';
|
||||
if (redCount > 0) description += ` → 发现 ${redCount} 个严重问题`;
|
||||
@@ -256,15 +258,17 @@ class IitQcCockpitController {
|
||||
type: 'qc_check' as const,
|
||||
time: log.createdAt,
|
||||
recordId: log.recordId,
|
||||
eventLabel,
|
||||
formName: log.formName,
|
||||
status: log.status,
|
||||
triggeredBy: log.triggeredBy,
|
||||
description,
|
||||
details: {
|
||||
rulesEvaluated: log.rulesEvaluated,
|
||||
rulesEvaluated: totalRules,
|
||||
rulesPassed: log.rulesPassed,
|
||||
rulesFailed: log.rulesFailed,
|
||||
issuesSummary: { red: redCount, yellow: yellowCount },
|
||||
issues,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -365,6 +369,210 @@ class IitQcCockpitController {
|
||||
return reply.status(500).send({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V3.1: D1-D7 各维度详细统计
|
||||
*/
|
||||
async getDimensions(
|
||||
request: FastifyRequest<{ Params: { projectId: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const { projectId } = request.params;
|
||||
try {
|
||||
const stats = await iitQcCockpitService.getStats(projectId);
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
healthScore: stats.healthScore,
|
||||
healthGrade: stats.healthGrade,
|
||||
dimensions: stats.dimensionBreakdown,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('[QcCockpitController] getDimensions failed', { projectId, error: error.message });
|
||||
return reply.status(500).send({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V3.1: 按受试者返回缺失率
|
||||
*/
|
||||
async getCompleteness(
|
||||
request: FastifyRequest<{ Params: { projectId: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const { projectId } = request.params;
|
||||
try {
|
||||
const records = await prisma.$queryRaw<Array<{
|
||||
record_id: string; fields_total: number; fields_passed: number; d2_issues: number;
|
||||
}>>`
|
||||
SELECT record_id, fields_total, fields_passed, d2_issues
|
||||
FROM iit_schema.record_summary
|
||||
WHERE project_id = ${projectId}
|
||||
ORDER BY record_id
|
||||
`;
|
||||
|
||||
const data = records.map(r => ({
|
||||
recordId: r.record_id,
|
||||
fieldsTotal: r.fields_total,
|
||||
fieldsFilled: r.fields_passed,
|
||||
fieldsMissing: r.d2_issues,
|
||||
missingRate: r.fields_total > 0
|
||||
? Math.round((r.d2_issues / r.fields_total) * 1000) / 10
|
||||
: 0,
|
||||
}));
|
||||
|
||||
return reply.send({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error('[QcCockpitController] getCompleteness failed', { projectId, error: error.message });
|
||||
return reply.status(500).send({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V3.1: 字段级质控结果(分页,支持筛选)
|
||||
*/
|
||||
async getFieldStatus(
|
||||
request: FastifyRequest<{
|
||||
Params: { projectId: string };
|
||||
Querystring: { recordId?: string; eventId?: string; status?: string; page?: string; pageSize?: string };
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const { projectId } = request.params;
|
||||
const query = request.query as any;
|
||||
const page = parseInt(query.page || '1');
|
||||
const pageSize = Math.min(parseInt(query.pageSize || '50'), 200);
|
||||
|
||||
try {
|
||||
const where: any = { projectId };
|
||||
if (query.recordId) where.recordId = query.recordId;
|
||||
if (query.eventId) where.eventId = query.eventId;
|
||||
if (query.status) where.status = query.status;
|
||||
|
||||
const conditions = [`project_id = '${projectId}'`];
|
||||
if (query.recordId) conditions.push(`record_id = '${query.recordId}'`);
|
||||
if (query.eventId) conditions.push(`event_id = '${query.eventId}'`);
|
||||
if (query.status) conditions.push(`status = '${query.status}'`);
|
||||
const whereClause = conditions.join(' AND ');
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
prisma.$queryRawUnsafe<any[]>(
|
||||
`SELECT * FROM iit_schema.qc_field_status WHERE ${whereClause} ORDER BY last_qc_at DESC LIMIT ${pageSize} OFFSET ${offset}`,
|
||||
),
|
||||
prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
|
||||
`SELECT COUNT(*) AS cnt FROM iit_schema.qc_field_status WHERE ${whereClause}`,
|
||||
),
|
||||
]);
|
||||
const total = Number(countResult[0]?.cnt ?? 0);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: { items, total, page, pageSize },
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('[QcCockpitController] getFieldStatus failed', { projectId, error: error.message });
|
||||
return reply.status(500).send({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* V3.1: 获取 D6 方案偏离列表
|
||||
*/
|
||||
async getDeviations(
|
||||
request: FastifyRequest<{ Params: { projectId: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { projectId } = request.params;
|
||||
try {
|
||||
const deviations = await iitQcCockpitService.getDeviations(projectId);
|
||||
return reply.send({ success: true, data: deviations });
|
||||
} catch (error: any) {
|
||||
logger.error('[QcCockpitController] getDeviations failed', { projectId, error: error.message });
|
||||
return reply.status(500).send({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GCP 业务报表 API
|
||||
// ============================================================
|
||||
|
||||
async getEligibilityReport(
|
||||
request: FastifyRequest<{ Params: { projectId: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const { projectId } = request.params;
|
||||
try {
|
||||
const data = await iitQcCockpitService.getEligibilityReport(projectId);
|
||||
return reply.send({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error('[QcCockpitController] getEligibilityReport failed', { projectId, error: error.message });
|
||||
return reply.status(500).send({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getCompletenessReport(
|
||||
request: FastifyRequest<{ Params: { projectId: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const { projectId } = request.params;
|
||||
try {
|
||||
const data = await iitQcCockpitService.getCompletenessReport(projectId);
|
||||
return reply.send({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error('[QcCockpitController] getCompletenessReport failed', { projectId, error: error.message });
|
||||
return reply.status(500).send({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getCompletenessFields(
|
||||
request: FastifyRequest<{
|
||||
Params: { projectId: string };
|
||||
Querystring: { recordId: string; eventId: string };
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const { projectId } = request.params;
|
||||
const { recordId, eventId } = request.query as any;
|
||||
if (!recordId || !eventId) {
|
||||
return reply.status(400).send({ success: false, error: 'recordId and eventId are required' });
|
||||
}
|
||||
try {
|
||||
const data = await iitQcCockpitService.getCompletenessFields(projectId, recordId, eventId);
|
||||
return reply.send({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error('[QcCockpitController] getCompletenessFields failed', { projectId, error: error.message });
|
||||
return reply.status(500).send({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getEqueryLogReport(
|
||||
request: FastifyRequest<{ Params: { projectId: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const { projectId } = request.params;
|
||||
try {
|
||||
const data = await iitQcCockpitService.getEqueryLogReport(projectId);
|
||||
return reply.send({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error('[QcCockpitController] getEqueryLogReport failed', { projectId, error: error.message });
|
||||
return reply.status(500).send({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getDeviationReport(
|
||||
request: FastifyRequest<{ Params: { projectId: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const { projectId } = request.params;
|
||||
try {
|
||||
const data = await iitQcCockpitService.getDeviationReport(projectId);
|
||||
return reply.send({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error('[QcCockpitController] getDeviationReport failed', { projectId, error: error.message });
|
||||
return reply.status(500).send({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const iitQcCockpitController = new IitQcCockpitController();
|
||||
|
||||
@@ -243,6 +243,43 @@ export async function iitQcCockpitRoutes(fastify: FastifyInstance) {
|
||||
},
|
||||
}, iitQcCockpitController.refreshReport.bind(iitQcCockpitController));
|
||||
|
||||
// V3.1: D1-D7 维度统计
|
||||
fastify.get('/:projectId/qc-cockpit/dimensions', {
|
||||
schema: {
|
||||
description: 'D1-D7 各维度详细统计',
|
||||
tags: ['IIT Admin - 质控驾驶舱'],
|
||||
params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
|
||||
},
|
||||
}, iitQcCockpitController.getDimensions.bind(iitQcCockpitController));
|
||||
|
||||
// V3.1: 按受试者缺失率
|
||||
fastify.get('/:projectId/qc-cockpit/completeness', {
|
||||
schema: {
|
||||
description: '按受试者返回缺失率',
|
||||
tags: ['IIT Admin - 质控驾驶舱'],
|
||||
params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
|
||||
},
|
||||
}, iitQcCockpitController.getCompleteness.bind(iitQcCockpitController));
|
||||
|
||||
// V3.1: 字段级质控结果(分页)
|
||||
fastify.get('/:projectId/qc-cockpit/field-status', {
|
||||
schema: {
|
||||
description: '字段级质控结果(分页,支持 recordId/eventId/status 筛选)',
|
||||
tags: ['IIT Admin - 质控驾驶舱'],
|
||||
params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
recordId: { type: 'string' },
|
||||
eventId: { type: 'string' },
|
||||
status: { type: 'string', enum: ['PASS', 'FAIL', 'WARNING'] },
|
||||
page: { type: 'string', default: '1' },
|
||||
pageSize: { type: 'string', default: '50' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, iitQcCockpitController.getFieldStatus.bind(iitQcCockpitController));
|
||||
|
||||
// AI 工作时间线
|
||||
fastify.get('/:projectId/qc-cockpit/timeline', iitQcCockpitController.getTimeline.bind(iitQcCockpitController));
|
||||
|
||||
@@ -251,4 +288,59 @@ export async function iitQcCockpitRoutes(fastify: FastifyInstance) {
|
||||
|
||||
// 质控趋势(近N天通过率折线)
|
||||
fastify.get('/:projectId/qc-cockpit/trend', iitQcCockpitController.getTrend.bind(iitQcCockpitController));
|
||||
|
||||
// V3.1: D6 方案偏离列表
|
||||
fastify.get('/:projectId/qc-cockpit/deviations', iitQcCockpitController.getDeviations.bind(iitQcCockpitController));
|
||||
|
||||
// ============================================================
|
||||
// GCP 业务报表路由
|
||||
// ============================================================
|
||||
|
||||
fastify.get('/:projectId/qc-cockpit/report/eligibility', {
|
||||
schema: {
|
||||
description: 'D1 筛选入选表 — 入排合规性评估',
|
||||
tags: ['IIT Admin - GCP 报表'],
|
||||
params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
|
||||
},
|
||||
}, iitQcCockpitController.getEligibilityReport.bind(iitQcCockpitController));
|
||||
|
||||
fastify.get('/:projectId/qc-cockpit/report/completeness', {
|
||||
schema: {
|
||||
description: 'D2 数据完整性总览 — L1 项目 + L2 受试者 + L3 事件级统计',
|
||||
tags: ['IIT Admin - GCP 报表'],
|
||||
params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
|
||||
},
|
||||
}, iitQcCockpitController.getCompletenessReport.bind(iitQcCockpitController));
|
||||
|
||||
fastify.get('/:projectId/qc-cockpit/report/completeness/fields', {
|
||||
schema: {
|
||||
description: 'D2 字段级懒加载 — 按 recordId + eventId 返回缺失字段清单',
|
||||
tags: ['IIT Admin - GCP 报表'],
|
||||
params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
recordId: { type: 'string' },
|
||||
eventId: { type: 'string' },
|
||||
},
|
||||
required: ['recordId', 'eventId'],
|
||||
},
|
||||
},
|
||||
}, iitQcCockpitController.getCompletenessFields.bind(iitQcCockpitController));
|
||||
|
||||
fastify.get('/:projectId/qc-cockpit/report/equery-log', {
|
||||
schema: {
|
||||
description: 'D3/D4 eQuery 全生命周期跟踪 — 统计 + 分组 + 全量明细',
|
||||
tags: ['IIT Admin - GCP 报表'],
|
||||
params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
|
||||
},
|
||||
}, iitQcCockpitController.getEqueryLogReport.bind(iitQcCockpitController));
|
||||
|
||||
fastify.get('/:projectId/qc-cockpit/report/deviations', {
|
||||
schema: {
|
||||
description: 'D6 方案偏离报表 — 结构化超窗数据 + 汇总统计',
|
||||
tags: ['IIT Admin - GCP 报表'],
|
||||
params: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
|
||||
},
|
||||
}, iitQcCockpitController.getDeviationReport.bind(iitQcCockpitController));
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user