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:
@@ -32,13 +32,29 @@ export interface ReportSummary {
|
||||
pendingQueries: number;
|
||||
passRate: number;
|
||||
lastQcTime: string | null;
|
||||
healthScore?: number;
|
||||
healthGrade?: string;
|
||||
dimensionBreakdown?: DimensionPassRate[];
|
||||
}
|
||||
|
||||
export interface DimensionPassRate {
|
||||
code: string;
|
||||
label: string;
|
||||
passRate: number;
|
||||
total: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 问题项
|
||||
* 问题项 — V3.1 五级坐标
|
||||
*/
|
||||
export interface ReportIssue {
|
||||
recordId: string;
|
||||
eventId?: string;
|
||||
formName?: string;
|
||||
instanceId?: number;
|
||||
fieldName?: string;
|
||||
dimensionCode?: string;
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
@@ -47,6 +63,7 @@ export interface ReportIssue {
|
||||
actualValue?: any;
|
||||
expectedValue?: any;
|
||||
evidence?: Record<string, any>;
|
||||
semanticLabel?: string;
|
||||
detectedAt: string;
|
||||
}
|
||||
|
||||
@@ -62,6 +79,25 @@ export interface FormStats {
|
||||
passRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* V3.1: 事件级概览
|
||||
*/
|
||||
export interface EventOverview {
|
||||
eventId: string;
|
||||
eventLabel?: string;
|
||||
status: string;
|
||||
fieldsTotal: number;
|
||||
fieldsPassed: number;
|
||||
fieldsFailed: number;
|
||||
fieldsWarning: number;
|
||||
d1Issues: number;
|
||||
d2Issues: number;
|
||||
d3Issues: number;
|
||||
d5Issues: number;
|
||||
d6Issues: number;
|
||||
d7Issues: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 质控报告
|
||||
*/
|
||||
@@ -74,10 +110,11 @@ export interface QcReport {
|
||||
criticalIssues: ReportIssue[];
|
||||
warningIssues: ReportIssue[];
|
||||
formStats: FormStats[];
|
||||
topIssues: TopIssue[]; // V2.1: Top 问题统计
|
||||
groupedIssues: GroupedIssues[]; // V2.1: 按受试者分组
|
||||
llmFriendlyXml: string; // V2.1: LLM 友好格式
|
||||
legacyXml?: string; // V2.1: 兼容旧格式
|
||||
eventOverview: EventOverview[];
|
||||
topIssues: TopIssue[];
|
||||
groupedIssues: GroupedIssues[];
|
||||
llmFriendlyXml: string;
|
||||
legacyXml?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,12 +218,13 @@ class QcReportServiceClass {
|
||||
return {
|
||||
projectId: cached.projectId,
|
||||
reportType: cached.reportType as 'daily' | 'weekly' | 'on_demand',
|
||||
generatedAt: cached.generatedAt.toISOString(),
|
||||
generatedAt: this.toBeijingTime(cached.generatedAt),
|
||||
expiresAt: cached.expiresAt?.toISOString() || null,
|
||||
summary: cached.summary as unknown as ReportSummary,
|
||||
criticalIssues: (issuesData.critical || []) as ReportIssue[],
|
||||
warningIssues: (issuesData.warning || []) as ReportIssue[],
|
||||
formStats: (issuesData.formStats || []) as FormStats[],
|
||||
eventOverview: (issuesData.eventOverview || []) as EventOverview[],
|
||||
topIssues: (issuesData.topIssues || []) as TopIssue[],
|
||||
groupedIssues: (issuesData.groupedIssues || []) as GroupedIssues[],
|
||||
llmFriendlyXml: cached.llmReport,
|
||||
@@ -233,22 +271,26 @@ class QcReportServiceClass {
|
||||
// 3. 获取问题列表
|
||||
const { criticalIssues, warningIssues } = await this.getIssues(projectId);
|
||||
|
||||
// 4. 获取表单统计
|
||||
const formStats = await this.getFormStats(projectId);
|
||||
// 4. 获取表单统计 + 事件概览
|
||||
const [formStats, eventOverview] = await Promise.all([
|
||||
this.getFormStats(projectId),
|
||||
this.getEventOverview(projectId),
|
||||
]);
|
||||
|
||||
// 5. V2.1: 计算 Top Issues 和分组
|
||||
// 5. 计算 Top Issues 和分组
|
||||
const allIssues = [...criticalIssues, ...warningIssues];
|
||||
const topIssues = this.calculateTopIssues(allIssues);
|
||||
const groupedIssues = this.groupIssuesByRecord(criticalIssues);
|
||||
|
||||
// 6. V2.1: 生成双格式 XML 报告
|
||||
// 6. 生成双格式 XML 报告
|
||||
const llmFriendlyXml = this.buildLlmXmlReport(
|
||||
projectId,
|
||||
project.name || projectId,
|
||||
summary,
|
||||
criticalIssues,
|
||||
warningIssues,
|
||||
formStats
|
||||
formStats,
|
||||
eventOverview,
|
||||
);
|
||||
|
||||
const legacyXml = this.buildLegacyXmlReport(
|
||||
@@ -257,7 +299,7 @@ class QcReportServiceClass {
|
||||
summary,
|
||||
criticalIssues,
|
||||
warningIssues,
|
||||
formStats
|
||||
formStats,
|
||||
);
|
||||
|
||||
const generatedAt = new Date();
|
||||
@@ -277,12 +319,13 @@ class QcReportServiceClass {
|
||||
return {
|
||||
projectId,
|
||||
reportType,
|
||||
generatedAt: generatedAt.toISOString(),
|
||||
generatedAt: this.toBeijingTime(generatedAt),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
summary,
|
||||
criticalIssues,
|
||||
warningIssues,
|
||||
formStats,
|
||||
eventOverview,
|
||||
topIssues,
|
||||
groupedIssues,
|
||||
llmFriendlyXml,
|
||||
@@ -291,204 +334,211 @@ class QcReportServiceClass {
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合质控统计
|
||||
*
|
||||
* V3.1: 修复记录数统计和 issues 格式兼容性
|
||||
* V3.1: 从 qc_project_stats + qc_field_status 聚合统计
|
||||
*/
|
||||
private async aggregateStats(projectId: string): Promise<ReportSummary> {
|
||||
// 获取记录汇总(用于 completedRecords 统计)
|
||||
const recordSummaries = await prisma.iitRecordSummary.findMany({
|
||||
where: { projectId },
|
||||
});
|
||||
const [projectStats, recordSummaries, fieldCounts, pendingEqs, lastField] = await Promise.all([
|
||||
prisma.iitQcProjectStats.findUnique({ where: { projectId } }),
|
||||
prisma.iitRecordSummary.findMany({ where: { projectId } }),
|
||||
prisma.$queryRaw<Array<{ severity: string; cnt: bigint }>>`
|
||||
SELECT
|
||||
CASE WHEN severity = 'critical' THEN 'critical' ELSE 'warning' END AS severity,
|
||||
COUNT(*) AS cnt
|
||||
FROM iit_schema.qc_field_status
|
||||
WHERE project_id = ${projectId} AND status IN ('FAIL', 'WARNING')
|
||||
GROUP BY 1
|
||||
`,
|
||||
prisma.iitEquery.count({ where: { projectId, status: { in: ['pending', 'reopened'] } } }),
|
||||
prisma.$queryRaw<Array<{ last_qc_at: Date }>>`
|
||||
SELECT last_qc_at FROM iit_schema.qc_field_status
|
||||
WHERE project_id = ${projectId}
|
||||
ORDER BY last_qc_at DESC LIMIT 1
|
||||
`,
|
||||
]);
|
||||
|
||||
const completedRecords = recordSummaries.filter(r =>
|
||||
r.completionRate && (r.completionRate as number) >= 100
|
||||
const totalRecords = projectStats?.totalRecords ?? recordSummaries.length;
|
||||
const completedRecords = recordSummaries.filter(
|
||||
(r: any) => r.completionRate && r.completionRate >= 100,
|
||||
).length;
|
||||
|
||||
// V3.1: 获取每个 record+event 的最新质控日志(避免重复)
|
||||
const latestQcLogs = await prisma.$queryRaw<Array<{
|
||||
record_id: string;
|
||||
event_id: string | null;
|
||||
form_name: string | null;
|
||||
status: string;
|
||||
issues: any;
|
||||
created_at: Date;
|
||||
}>>`
|
||||
SELECT DISTINCT ON (record_id, COALESCE(event_id, ''))
|
||||
record_id, event_id, form_name, status, issues, created_at
|
||||
FROM iit_schema.qc_logs
|
||||
WHERE project_id = ${projectId}
|
||||
ORDER BY record_id, COALESCE(event_id, ''), created_at DESC
|
||||
`;
|
||||
|
||||
// V3.1: 从质控日志获取独立 record_id 数量
|
||||
const uniqueRecordIds = new Set(latestQcLogs.map(log => log.record_id));
|
||||
const totalRecords = uniqueRecordIds.size;
|
||||
|
||||
// V3.1: 统计问题数量(按 recordId + ruleId 去重)
|
||||
const seenCritical = new Set<string>();
|
||||
const seenWarning = new Set<string>();
|
||||
let pendingQueries = 0;
|
||||
|
||||
for (const log of latestQcLogs) {
|
||||
// V3.1: 兼容两种 issues 格式
|
||||
const rawIssues = log.issues as any;
|
||||
let issues: any[] = [];
|
||||
|
||||
if (Array.isArray(rawIssues)) {
|
||||
issues = rawIssues;
|
||||
} else if (rawIssues && Array.isArray(rawIssues.items)) {
|
||||
issues = rawIssues.items;
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
const ruleId = issue.ruleId || issue.ruleName || 'unknown';
|
||||
const key = `${log.record_id}:${ruleId}`;
|
||||
const severity = issue.severity || issue.level;
|
||||
|
||||
if (severity === 'critical' || severity === 'RED' || severity === 'error') {
|
||||
seenCritical.add(key);
|
||||
} else if (severity === 'warning' || severity === 'YELLOW') {
|
||||
seenWarning.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (log.status === 'UNCERTAIN' || log.status === 'PENDING') {
|
||||
pendingQueries++;
|
||||
}
|
||||
let criticalIssues = 0;
|
||||
let warningIssues = 0;
|
||||
for (const fc of fieldCounts) {
|
||||
const n = Number(fc.cnt);
|
||||
if (fc.severity === 'critical') criticalIssues = n;
|
||||
else warningIssues = n;
|
||||
}
|
||||
|
||||
const criticalIssues = seenCritical.size;
|
||||
const warningIssues = seenWarning.size;
|
||||
|
||||
// V3.2: 按 record 级别计算通过率(每个 record 取最严重状态)
|
||||
const statusPriority: Record<string, number> = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0, 'GREEN': 0 };
|
||||
const recordWorstStatus = new Map<string, string>();
|
||||
for (const log of latestQcLogs) {
|
||||
const existing = recordWorstStatus.get(log.record_id);
|
||||
const currentPrio = statusPriority[log.status] ?? 0;
|
||||
const existingPrio = existing ? (statusPriority[existing] ?? 0) : -1;
|
||||
if (currentPrio > existingPrio) {
|
||||
recordWorstStatus.set(log.record_id, log.status);
|
||||
}
|
||||
}
|
||||
const passedRecords = [...recordWorstStatus.values()].filter(
|
||||
s => s === 'PASS' || s === 'GREEN'
|
||||
).length;
|
||||
const passRate = totalRecords > 0
|
||||
? Math.round((passedRecords / totalRecords) * 100 * 10) / 10
|
||||
const passedRecords = projectStats?.passedRecords ?? 0;
|
||||
const passRate = totalRecords > 0
|
||||
? Math.round((passedRecords / totalRecords) * 1000) / 10
|
||||
: 0;
|
||||
|
||||
// 获取最后质控时间
|
||||
const lastQcLog = await prisma.iitQcLog.findFirst({
|
||||
where: { projectId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
const DIMENSION_LABELS: Record<string, string> = {
|
||||
D1: '数据一致性', D2: '数据完整性', D3: '数据准确性',
|
||||
D5: '时效性', D6: '方案依从', D7: '安全性',
|
||||
};
|
||||
|
||||
const dimensionBreakdown: DimensionPassRate[] = [];
|
||||
if (projectStats) {
|
||||
const ps = projectStats as any;
|
||||
for (const [code, label] of Object.entries(DIMENSION_LABELS)) {
|
||||
const rateField = `${code.toLowerCase()}PassRate`;
|
||||
const rate = (ps[rateField] as number) ?? 100;
|
||||
dimensionBreakdown.push({ code, label, passRate: rate, total: 0, failed: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalRecords,
|
||||
completedRecords,
|
||||
criticalIssues,
|
||||
warningIssues,
|
||||
pendingQueries,
|
||||
pendingQueries: pendingEqs,
|
||||
passRate,
|
||||
lastQcTime: lastQcLog?.createdAt?.toISOString() || null,
|
||||
lastQcTime: (lastField as any)?.[0]?.last_qc_at
|
||||
? this.toBeijingTime(new Date((lastField as any)[0].last_qc_at))
|
||||
: null,
|
||||
healthScore: ((projectStats as any)?.healthScore as number) ?? undefined,
|
||||
healthGrade: ((projectStats as any)?.healthGrade as string) ?? undefined,
|
||||
dimensionBreakdown,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取问题列表
|
||||
* V3.1: 从 qc_field_status 获取问题列表(五级坐标)
|
||||
*/
|
||||
private async getIssues(projectId: string): Promise<{
|
||||
criticalIssues: ReportIssue[];
|
||||
warningIssues: ReportIssue[];
|
||||
}> {
|
||||
// V3.1: 获取每个 record+event 的最新质控日志
|
||||
const latestQcLogs = await prisma.$queryRaw<Array<{
|
||||
record_id: string;
|
||||
event_id: string | null;
|
||||
form_name: string | null;
|
||||
status: string;
|
||||
issues: any;
|
||||
created_at: Date;
|
||||
const fieldRows = await prisma.$queryRaw<Array<{
|
||||
record_id: string; event_id: string; form_name: string; instance_id: number;
|
||||
field_name: string; status: string; rule_id: string | null; rule_name: string | null;
|
||||
rule_category: string | null; severity: string | null; message: string | null;
|
||||
actual_value: string | null; expected_value: string | null; last_qc_at: Date | null;
|
||||
}>>`
|
||||
SELECT DISTINCT ON (record_id, COALESCE(event_id, ''))
|
||||
record_id, event_id, form_name, status, issues, created_at
|
||||
FROM iit_schema.qc_logs
|
||||
WHERE project_id = ${projectId}
|
||||
ORDER BY record_id, COALESCE(event_id, ''), created_at DESC
|
||||
SELECT record_id, event_id, form_name, instance_id, field_name, status,
|
||||
rule_id, rule_name, rule_category, severity, message,
|
||||
actual_value, expected_value, last_qc_at
|
||||
FROM iit_schema.qc_field_status
|
||||
WHERE project_id = ${projectId} AND status IN ('FAIL', 'WARNING')
|
||||
ORDER BY last_qc_at DESC
|
||||
`;
|
||||
|
||||
const semanticMap = await this.getSemanticLabelMap(projectId);
|
||||
|
||||
const criticalIssues: ReportIssue[] = [];
|
||||
const warningIssues: ReportIssue[] = [];
|
||||
|
||||
for (const log of latestQcLogs) {
|
||||
// V2.1: 兼容两种 issues 格式
|
||||
// 新格式: { items: [...], summary: {...} }
|
||||
// 旧格式: [...]
|
||||
const rawIssues = log.issues as any;
|
||||
let issues: any[] = [];
|
||||
|
||||
if (Array.isArray(rawIssues)) {
|
||||
// 旧格式:直接是数组
|
||||
issues = rawIssues;
|
||||
} else if (rawIssues && Array.isArray(rawIssues.items)) {
|
||||
// 新格式:对象包含 items 数组
|
||||
issues = rawIssues.items;
|
||||
for (const row of fieldRows) {
|
||||
const reportIssue: ReportIssue = {
|
||||
recordId: row.record_id,
|
||||
eventId: row.event_id,
|
||||
formName: row.form_name,
|
||||
instanceId: row.instance_id,
|
||||
fieldName: row.field_name,
|
||||
dimensionCode: row.rule_category || undefined,
|
||||
ruleId: row.rule_id || 'unknown',
|
||||
ruleName: row.rule_name || 'Unknown',
|
||||
severity: this.normalizeSeverity(row.severity || row.status),
|
||||
message: row.message || '',
|
||||
field: row.field_name,
|
||||
actualValue: row.actual_value,
|
||||
expectedValue: row.expected_value,
|
||||
semanticLabel: semanticMap.get(row.field_name),
|
||||
detectedAt: row.last_qc_at?.toISOString() || new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (reportIssue.severity === 'critical') {
|
||||
criticalIssues.push(reportIssue);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
// V2.1: 构建自包含的 LLM 友好消息
|
||||
const llmMessage = this.buildSelfContainedMessage(issue);
|
||||
|
||||
const reportIssue: ReportIssue = {
|
||||
recordId: log.record_id,
|
||||
ruleId: issue.ruleId || issue.ruleName || 'unknown',
|
||||
ruleName: issue.ruleName || issue.message || 'Unknown Rule',
|
||||
severity: this.normalizeSeverity(issue.severity || issue.level),
|
||||
message: llmMessage, // V2.1: 使用自包含消息
|
||||
field: issue.field,
|
||||
actualValue: issue.actualValue,
|
||||
expectedValue: issue.expectedValue || this.extractExpectedFromMessage(issue.message),
|
||||
evidence: issue.evidence,
|
||||
detectedAt: log.created_at.toISOString(),
|
||||
};
|
||||
|
||||
if (reportIssue.severity === 'critical') {
|
||||
criticalIssues.push(reportIssue);
|
||||
} else if (reportIssue.severity === 'warning') {
|
||||
warningIssues.push(reportIssue);
|
||||
}
|
||||
warningIssues.push(reportIssue);
|
||||
}
|
||||
}
|
||||
|
||||
// V3.1: 按 recordId + ruleId 去重,保留最新的问题(避免多事件重复)
|
||||
const deduplicateCritical = this.deduplicateIssues(criticalIssues);
|
||||
const deduplicateWarning = this.deduplicateIssues(warningIssues);
|
||||
|
||||
return { criticalIssues: deduplicateCritical, warningIssues: deduplicateWarning };
|
||||
return { criticalIssues, warningIssues };
|
||||
}
|
||||
|
||||
/**
|
||||
* V3.1: 按 recordId + ruleId 去重问题
|
||||
*
|
||||
* 同一记录的同一规则只保留一条(最新的,因为数据已按时间倒序排列)
|
||||
* V3.1: 事件级概览(从 qc_event_status)
|
||||
*/
|
||||
private deduplicateIssues(issues: ReportIssue[]): ReportIssue[] {
|
||||
const seen = new Map<string, ReportIssue>();
|
||||
|
||||
for (const issue of issues) {
|
||||
const key = `${issue.recordId}:${issue.ruleId}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, issue);
|
||||
private async getEventOverview(projectId: string): Promise<EventOverview[]> {
|
||||
const rows = await prisma.$queryRaw<Array<{
|
||||
event_id: string;
|
||||
event_label: string | null;
|
||||
status: string;
|
||||
fields_total: number;
|
||||
fields_passed: number;
|
||||
fields_failed: number;
|
||||
fields_warning: number;
|
||||
d1_issues: number;
|
||||
d2_issues: number;
|
||||
d3_issues: number;
|
||||
d5_issues: number;
|
||||
d6_issues: number;
|
||||
d7_issues: number;
|
||||
}>>`
|
||||
SELECT
|
||||
event_id,
|
||||
MAX(event_label) AS event_label,
|
||||
CASE
|
||||
WHEN SUM(CASE WHEN status = 'FAIL' THEN 1 ELSE 0 END) > 0 THEN 'FAIL'
|
||||
WHEN SUM(CASE WHEN status = 'WARNING' THEN 1 ELSE 0 END) > 0 THEN 'WARNING'
|
||||
ELSE 'PASS'
|
||||
END AS status,
|
||||
COALESCE(SUM(fields_total), 0)::int AS fields_total,
|
||||
COALESCE(SUM(fields_passed), 0)::int AS fields_passed,
|
||||
COALESCE(SUM(fields_failed), 0)::int AS fields_failed,
|
||||
COALESCE(SUM(fields_warning), 0)::int AS fields_warning,
|
||||
COALESCE(SUM(d1_issues), 0)::int AS d1_issues,
|
||||
COALESCE(SUM(d2_issues), 0)::int AS d2_issues,
|
||||
COALESCE(SUM(d3_issues), 0)::int AS d3_issues,
|
||||
COALESCE(SUM(d5_issues), 0)::int AS d5_issues,
|
||||
COALESCE(SUM(d6_issues), 0)::int AS d6_issues,
|
||||
COALESCE(SUM(d7_issues), 0)::int AS d7_issues
|
||||
FROM iit_schema.qc_event_status
|
||||
WHERE project_id = ${projectId}
|
||||
GROUP BY event_id
|
||||
ORDER BY event_id
|
||||
`;
|
||||
|
||||
return rows.map(r => ({
|
||||
eventId: r.event_id,
|
||||
eventLabel: r.event_label || undefined,
|
||||
status: r.status,
|
||||
fieldsTotal: r.fields_total,
|
||||
fieldsPassed: r.fields_passed,
|
||||
fieldsFailed: r.fields_failed,
|
||||
fieldsWarning: r.fields_warning,
|
||||
d1Issues: r.d1_issues,
|
||||
d2Issues: r.d2_issues,
|
||||
d3Issues: r.d3_issues,
|
||||
d5Issues: r.d5_issues,
|
||||
d6Issues: r.d6_issues,
|
||||
d7Issues: r.d7_issues,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* V3.1: 从 IitFieldMapping 获取语义标签映射
|
||||
*/
|
||||
private async getSemanticLabelMap(projectId: string): Promise<Map<string, string>> {
|
||||
const map = new Map<string, string>();
|
||||
try {
|
||||
const mappings = await prisma.$queryRaw<Array<{
|
||||
actual_name: string; semantic_label: string | null;
|
||||
}>>`
|
||||
SELECT actual_name, semantic_label FROM iit_schema.field_mapping
|
||||
WHERE project_id = ${projectId} AND semantic_label IS NOT NULL
|
||||
`;
|
||||
for (const m of mappings) {
|
||||
if (m.semantic_label) map.set(m.actual_name, m.semantic_label);
|
||||
}
|
||||
// 如果已存在,跳过(因为按时间倒序,第一个就是最新的)
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
|
||||
return Array.from(seen.values());
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -552,36 +602,29 @@ class QcReportServiceClass {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表单统计
|
||||
* V3.1: 从 qc_field_status 获取表单统计
|
||||
*/
|
||||
private async getFormStats(projectId: string): Promise<FormStats[]> {
|
||||
// 获取表单标签映射
|
||||
const project = await prisma.iitProject.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { fieldMappings: true },
|
||||
});
|
||||
const formLabels: Record<string, string> =
|
||||
const formLabels: Record<string, string> =
|
||||
((project?.fieldMappings as any)?.formLabels) || {};
|
||||
|
||||
// 按表单统计
|
||||
const formStatsRaw = await prisma.$queryRaw<Array<{
|
||||
form_name: string;
|
||||
total: bigint;
|
||||
passed: bigint;
|
||||
failed: bigint;
|
||||
}>>`
|
||||
SELECT
|
||||
SELECT
|
||||
form_name,
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status = 'PASS' OR status = 'GREEN') as passed,
|
||||
COUNT(*) FILTER (WHERE status = 'FAIL' OR status = 'RED') as failed
|
||||
FROM (
|
||||
SELECT DISTINCT ON (record_id, form_name)
|
||||
record_id, form_name, status
|
||||
FROM iit_schema.qc_logs
|
||||
WHERE project_id = ${projectId} AND form_name IS NOT NULL
|
||||
ORDER BY record_id, form_name, created_at DESC
|
||||
) latest_logs
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'PASS') AS passed,
|
||||
COUNT(*) FILTER (WHERE status IN ('FAIL', 'WARNING')) AS failed
|
||||
FROM iit_schema.qc_field_status
|
||||
WHERE project_id = ${projectId}
|
||||
GROUP BY form_name
|
||||
ORDER BY form_name
|
||||
`;
|
||||
@@ -596,7 +639,7 @@ class QcReportServiceClass {
|
||||
totalChecks: total,
|
||||
passed,
|
||||
failed,
|
||||
passRate: total > 0 ? Math.round((passed / total) * 100 * 10) / 10 : 0,
|
||||
passRate: total > 0 ? Math.round((passed / total) * 1000) / 10 : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -673,81 +716,102 @@ class QcReportServiceClass {
|
||||
summary: ReportSummary,
|
||||
criticalIssues: ReportIssue[],
|
||||
warningIssues: ReportIssue[],
|
||||
formStats: FormStats[]
|
||||
formStats: FormStats[],
|
||||
eventOverview?: EventOverview[],
|
||||
): string {
|
||||
const now = new Date().toISOString();
|
||||
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
||||
|
||||
// V2.1: 计算 Top Issues
|
||||
const allIssues = [...criticalIssues, ...warningIssues];
|
||||
const topIssues = this.calculateTopIssues(allIssues);
|
||||
|
||||
// V2.1: 按受试者分组
|
||||
const groupedCritical = this.groupIssuesByRecord(criticalIssues);
|
||||
const failedRecordCount = groupedCritical.length;
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<qc_context project_id="${projectId}" project_name="${this.escapeXml(projectName)}" generated="${now}">
|
||||
const hs = summary.healthScore != null ? ` health_score="${summary.healthScore}" health_grade="${summary.healthGrade}"` : '';
|
||||
|
||||
<!-- 1. 宏观统计 (Aggregate) -->
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<qc_context project_id="${projectId}" project_name="${this.escapeXml(projectName)}" generated="${now}"${hs}>
|
||||
|
||||
<!-- 1. 宏观统计 -->
|
||||
<summary>
|
||||
- 状态: ${failedRecordCount}/${summary.totalRecords} 记录存在严重违规 (${summary.totalRecords > 0 ? Math.round((failedRecordCount / summary.totalRecords) * 100) : 0}% Fail)
|
||||
- 健康度: ${summary.healthScore ?? 'N/A'} (${summary.healthGrade ?? '-'})
|
||||
- 状态: ${failedRecordCount}/${summary.totalRecords} 记录存在严重违规
|
||||
- 通过率: ${summary.passRate}%
|
||||
- 严重问题: ${summary.criticalIssues} | 警告: ${summary.warningIssues}
|
||||
${topIssues.length > 0 ? ` - Top ${Math.min(3, topIssues.length)} 问题:
|
||||
${topIssues.slice(0, 3).map((t, i) => ` ${i + 1}. ${t.ruleName} (${t.count}人)`).join('\n')}` : ''}
|
||||
${topIssues.length > 0 ? ` - Top ${Math.min(3, topIssues.length)} 问题:\n${topIssues.slice(0, 3).map((t, i) => ` ${i + 1}. ${t.ruleName} (${t.count}人)`).join('\n')}` : ''}
|
||||
</summary>
|
||||
|
||||
`;
|
||||
|
||||
// V3.1: 严重问题详情(按受试者分组,显示所有问题)
|
||||
// dimension_summary
|
||||
if (summary.dimensionBreakdown && summary.dimensionBreakdown.length > 0) {
|
||||
xml += ` <!-- 2. 维度通过率 -->\n`;
|
||||
xml += ` <dimension_summary>\n`;
|
||||
for (const d of summary.dimensionBreakdown) {
|
||||
xml += ` <dimension code="${d.code}" label="${this.escapeXml(d.label)}" pass_rate="${d.passRate.toFixed(1)}%" />\n`;
|
||||
}
|
||||
xml += ` </dimension_summary>\n\n`;
|
||||
}
|
||||
|
||||
// event_overview
|
||||
if (eventOverview && eventOverview.length > 0) {
|
||||
xml += ` <!-- 3. 事件概览 -->\n`;
|
||||
xml += ` <event_overview count="${eventOverview.length}">\n`;
|
||||
for (const ev of eventOverview) {
|
||||
xml += ` <event id="${this.escapeXml(ev.eventId)}" status="${ev.status}" fields_total="${ev.fieldsTotal}" fields_failed="${ev.fieldsFailed}" />\n`;
|
||||
}
|
||||
xml += ` </event_overview>\n\n`;
|
||||
}
|
||||
|
||||
// critical issues (grouped by record, five-level coordinates)
|
||||
if (groupedCritical.length > 0) {
|
||||
xml += ` <!-- 2. 严重问题详情 (按受试者分组) -->\n`;
|
||||
xml += ` <!-- 4. 严重问题详情 -->\n`;
|
||||
xml += ` <critical_issues record_count="${groupedCritical.length}" issue_count="${criticalIssues.length}">\n\n`;
|
||||
|
||||
|
||||
for (const group of groupedCritical) {
|
||||
xml += ` <record id="${group.recordId}">\n`;
|
||||
xml += ` **严重违规 (${group.issueCount}项)**:\n`;
|
||||
|
||||
// V3.1: 显示所有问题,不再限制
|
||||
for (let i = 0; i < group.issues.length; i++) {
|
||||
const issue = group.issues[i];
|
||||
const llmLine = this.buildLlmIssueLine(issue, i + 1);
|
||||
xml += ` ${llmLine}\n`;
|
||||
xml += this.buildV31IssueLine(issue, i + 1, ' ');
|
||||
}
|
||||
|
||||
xml += ` </record>\n\n`;
|
||||
}
|
||||
|
||||
xml += ` </critical_issues>\n\n`;
|
||||
} else {
|
||||
xml += ` <critical_issues record_count="0" issue_count="0" />\n\n`;
|
||||
}
|
||||
|
||||
// V3.1: 警告问题(显示所有)
|
||||
// warning issues
|
||||
const groupedWarning = this.groupIssuesByRecord(warningIssues);
|
||||
if (groupedWarning.length > 0) {
|
||||
xml += ` <!-- 3. 警告问题 -->\n`;
|
||||
xml += ` <!-- 5. 警告问题 -->\n`;
|
||||
xml += ` <warning_issues record_count="${groupedWarning.length}" issue_count="${warningIssues.length}">\n`;
|
||||
|
||||
for (const group of groupedWarning) {
|
||||
xml += ` <record id="${group.recordId}">\n`;
|
||||
xml += ` **警告 (${group.issueCount}项)**:\n`;
|
||||
for (let i = 0; i < group.issues.length; i++) {
|
||||
const issue = group.issues[i];
|
||||
const llmLine = this.buildLlmIssueLine(issue, i + 1);
|
||||
xml += ` ${llmLine}\n`;
|
||||
xml += this.buildV31IssueLine(group.issues[i], i + 1, ' ');
|
||||
}
|
||||
xml += ` </record>\n`;
|
||||
}
|
||||
|
||||
xml += ` </warning_issues>\n\n`;
|
||||
}
|
||||
|
||||
xml += `</qc_context>`;
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* V3.1: 五级坐标 + 维度标注的问题行
|
||||
*/
|
||||
private buildV31IssueLine(issue: ReportIssue, index: number, indent: string): string {
|
||||
const dim = issue.dimensionCode ? `[${issue.dimensionCode}] ` : '';
|
||||
const label = issue.semanticLabel || issue.fieldName || issue.field || '';
|
||||
const loc = [issue.eventId, issue.formName, issue.fieldName].filter(Boolean).join(' > ');
|
||||
const actual = issue.actualValue != null ? ` 当前值=${issue.actualValue}` : '';
|
||||
const expected = issue.expectedValue ? ` (标准: ${issue.expectedValue})` : '';
|
||||
|
||||
return `${indent}${index}. ${dim}${loc ? `[${loc}] ` : ''}${label}: ${issue.message}${actual}${expected}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2.1: 构建 LLM 友好的单行问题描述
|
||||
*
|
||||
@@ -785,7 +849,7 @@ ${topIssues.slice(0, 3).map((t, i) => ` ${i + 1}. ${t.ruleName} (${t.count}
|
||||
warningIssues: ReportIssue[],
|
||||
formStats: FormStats[]
|
||||
): string {
|
||||
const now = new Date().toISOString();
|
||||
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<qc_report project_id="${projectId}" project_name="${this.escapeXml(projectName)}" generated="${now}">
|
||||
@@ -874,6 +938,20 @@ ${topIssues.slice(0, 3).map((t, i) => ` ${i + 1}. ${t.ruleName} (${t.count}
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC Date → 北京时间字符串(yyyy/M/d HH:mm:ss 格式,可被 new Date() 解析回来)
|
||||
*/
|
||||
private toBeijingTime(date: Date): string {
|
||||
return date.toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai', hour12: false }).replace(' ', 'T') + '+08:00';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 toBeijingTime 输出反解回 Date 对象
|
||||
*/
|
||||
private parseBeijingTime(str: string): Date {
|
||||
return new Date(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* XML 转义
|
||||
*/
|
||||
@@ -900,12 +978,13 @@ ${topIssues.slice(0, 3).map((t, i) => ` ${i + 1}. ${t.ruleName} (${t.count}
|
||||
critical: report.criticalIssues,
|
||||
warning: report.warningIssues,
|
||||
formStats: report.formStats,
|
||||
topIssues: report.topIssues, // V2.1
|
||||
groupedIssues: report.groupedIssues, // V2.1
|
||||
legacyXml: report.legacyXml, // V2.1
|
||||
eventOverview: report.eventOverview,
|
||||
topIssues: report.topIssues,
|
||||
groupedIssues: report.groupedIssues,
|
||||
legacyXml: report.legacyXml,
|
||||
} as any,
|
||||
llmReport: report.llmFriendlyXml,
|
||||
generatedAt: new Date(report.generatedAt),
|
||||
generatedAt: this.parseBeijingTime(report.generatedAt),
|
||||
expiresAt: report.expiresAt ? new Date(report.expiresAt) : null,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user