feat(iit): harden QC pipeline consistency and release artifacts

Implement IIT quality workflow hardening across eQuery deduplication, guard metadata validation, timeline/readability improvements, and chat evidence fallbacks, then synchronize release and development documentation for deployment handoff.

Includes migration/scripts for open eQuery dedupe guards, orchestration/status semantics, report/tool readability fixes, and updated module status plus deployment checklist.

Made-with: Cursor
This commit is contained in:
2026-03-08 21:54:35 +08:00
parent ac724266c1
commit a666649fd4
57 changed files with 28637 additions and 316 deletions

View File

@@ -136,3 +136,23 @@ export async function closeEquery(
return reply.status(400).send({ success: false, error: msg });
}
}
export async function reopenEquery(
request: FastifyRequest<{
Params: EqueryIdParams;
Body: { reviewNote?: string };
}>,
reply: FastifyReply
) {
try {
const { equeryId } = request.params;
const { reviewNote } = request.body || {};
const service = getIitEqueryService(prisma);
const updated = await service.reopen(equeryId, { 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 });
}
}

View File

@@ -23,4 +23,7 @@ export async function iitEqueryRoutes(fastify: FastifyInstance) {
// 手动关闭 eQuery
fastify.post('/:projectId/equeries/:equeryId/close', controller.closeEquery);
// 手动重开 eQuery
fastify.post('/:projectId/equeries/:equeryId/reopen', controller.reopenEquery);
}

View File

@@ -38,6 +38,10 @@ export interface ReviewEqueryInput {
reviewNote?: string;
}
export interface ReopenEqueryInput {
reviewNote?: string;
}
export interface EqueryListParams {
projectId: string;
status?: string;
@@ -101,30 +105,91 @@ export class IitEqueryService {
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,
const payload = inputs.map((input) => ({
project_id: input.projectId,
record_id: input.recordId,
event_id: input.eventId ?? null,
form_name: input.formName ?? null,
field_name: input.fieldName ?? null,
qc_log_id: input.qcLogId ?? null,
report_id: input.reportId ?? null,
query_text: input.queryText,
expected_action: input.expectedAction ?? null,
severity: input.severity || 'warning',
category: input.category,
status: 'pending' as const,
assignedTo: input.assignedTo,
category: input.category ?? null,
status: 'pending',
assigned_to: input.assignedTo ?? null,
}));
const result = await this.prisma.iitEquery.createMany({ data });
const inserted = await this.prisma.$executeRawUnsafe(
`
WITH payload AS (
SELECT *
FROM jsonb_to_recordset($1::jsonb) AS x(
project_id text,
record_id text,
event_id text,
form_name text,
field_name text,
qc_log_id text,
report_id text,
query_text text,
expected_action text,
severity text,
category text,
status text,
assigned_to text
)
)
INSERT INTO iit_schema.equery (
project_id,
record_id,
event_id,
form_name,
field_name,
qc_log_id,
report_id,
query_text,
expected_action,
severity,
category,
status,
assigned_to
)
SELECT
project_id,
record_id,
event_id,
form_name,
field_name,
qc_log_id,
report_id,
query_text,
expected_action,
severity,
category,
status,
assigned_to
FROM payload
ON CONFLICT (
project_id,
record_id,
(COALESCE(event_id, '')),
(COALESCE(category, ''))
)
WHERE status IN ('pending', 'responded', 'reviewing', 'reopened')
DO NOTHING
`,
JSON.stringify(payload),
);
logger.info('eQuery batch created', {
projectId: inputs[0].projectId,
count: result.count,
count: Number(inserted),
skipped: inputs.length - Number(inserted),
});
return { count: result.count };
return { count: Number(inserted) };
}
/**
@@ -139,7 +204,7 @@ export class IitEqueryService {
if (severity) where.severity = severity;
if (assignedTo) where.assignedTo = assignedTo;
const [items, total] = await Promise.all([
const [items, total, eventRows] = await Promise.all([
this.prisma.iitEquery.findMany({
where,
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
@@ -147,9 +212,28 @@ export class IitEqueryService {
take: pageSize,
}),
this.prisma.iitEquery.count({ where }),
this.prisma.$queryRaw<Array<{ event_id: string; event_label: string | null }>>`
SELECT event_id, MAX(event_label) AS event_label
FROM iit_schema.qc_event_status
WHERE project_id = ${projectId}
AND event_id IS NOT NULL
GROUP BY event_id
`.catch(() => [] as Array<{ event_id: string; event_label: string | null }>),
]);
return { items, total, page, pageSize };
const eventLabelMap = new Map<string, string>();
for (const row of eventRows) {
if (row.event_id && row.event_label) {
eventLabelMap.set(row.event_id, row.event_label);
}
}
const itemsWithLabels = items.map((item) => ({
...item,
eventLabel: item.eventId ? (eventLabelMap.get(item.eventId) || null) : null,
}));
return { items: itemsWithLabels, total, page, pageSize };
}
/**
@@ -253,6 +337,31 @@ export class IitEqueryService {
return updated;
}
/**
* 手动重开 eQueryclosed → reopened
*/
async reopen(id: string, input?: ReopenEqueryInput) {
const equery = await this.prisma.iitEquery.findUnique({ where: { id } });
if (!equery) throw new Error('eQuery 不存在');
if (equery.status !== 'closed') {
throw new Error(`当前状态 ${equery.status} 不允许重开`);
}
const updated = await this.prisma.iitEquery.update({
where: { id },
data: {
status: 'reopened',
closedAt: null,
closedBy: null,
resolution: null,
reviewNote: input?.reviewNote || equery.reviewNote,
},
});
logger.info('eQuery reopened', { id, recordId: equery.recordId });
return updated;
}
/**
* 获取统计
*/

View File

@@ -15,6 +15,45 @@ import { logger } from '../../../common/logging/index.js';
import { prisma } from '../../../config/database.js';
class IitQcCockpitController {
private fallbackEventLabel(eventId: string): string {
if (!eventId) return '—';
return `访视(${eventId})`;
}
private async buildEventLabelMap(projectId: string): Promise<Map<string, string>> {
const map = new Map<string, string>();
const [project, rows] = await Promise.all([
prisma.iitProject.findUnique({
where: { id: projectId },
select: { cachedRules: true },
}),
prisma.$queryRaw<Array<{ event_id: string; event_label: string | null }>>`
SELECT event_id, MAX(event_label) AS event_label
FROM iit_schema.qc_event_status
WHERE project_id = ${projectId}
AND event_id IS NOT NULL
GROUP BY event_id
`.catch(() => [] as Array<{ event_id: string; event_label: string | null }>),
]);
for (const row of rows) {
if (row.event_id && row.event_label) {
map.set(row.event_id, row.event_label);
}
}
const cached = project?.cachedRules as any;
if (cached?.eventLabels && typeof cached.eventLabels === 'object') {
for (const [eventId, label] of Object.entries(cached.eventLabels)) {
if (typeof label === 'string' && label.trim() && !map.has(eventId)) {
map.set(eventId, label.trim());
}
}
}
return map;
}
/**
* 获取质控驾驶舱数据
*/
@@ -196,7 +235,7 @@ class IitQcCockpitController {
async getTimeline(
request: FastifyRequest<{
Params: { projectId: string };
Querystring: { page?: string; pageSize?: string; date?: string };
Querystring: { page?: string; pageSize?: string; date?: string; startDate?: string; endDate?: string };
}>,
reply: FastifyReply
) {
@@ -205,12 +244,36 @@ class IitQcCockpitController {
const page = query.page ? parseInt(query.page) : 1;
const pageSize = query.pageSize ? parseInt(query.pageSize) : 20;
const dateFilter = query.date;
const startDate = query.startDate;
const endDate = query.endDate;
try {
let dateClause = '';
const dateConditions: string[] = [];
const dateParams: string[] = [];
let dateParamIdx = 2;
// 兼容旧参数 date同时支持区间参数 startDate/endDate
if (dateFilter) {
dateClause = `AND fs.last_qc_at >= '${dateFilter}'::date AND fs.last_qc_at < ('${dateFilter}'::date + INTERVAL '1 day')`;
dateConditions.push(
`fs.last_qc_at >= $${dateParamIdx}::date AND fs.last_qc_at < ($${dateParamIdx}::date + INTERVAL '1 day')`,
);
dateParams.push(dateFilter);
dateParamIdx += 1;
} else {
if (startDate) {
dateConditions.push(`fs.last_qc_at >= $${dateParamIdx}::date`);
dateParams.push(startDate);
dateParamIdx += 1;
}
if (endDate) {
dateConditions.push(`fs.last_qc_at < ($${dateParamIdx}::date + INTERVAL '1 day')`);
dateParams.push(endDate);
dateParamIdx += 1;
}
}
const dateClause = dateConditions.length > 0 ? ` AND ${dateConditions.join(' AND ')}` : '';
const limitPlaceholder = `$${dateParamIdx}`;
const offsetPlaceholder = `$${dateParamIdx + 1}`;
// 1. 获取有问题的受试者摘要(分页)
const recordSummaries = await prisma.$queryRawUnsafe<Array<{
@@ -233,23 +296,24 @@ class IitQcCockpitController {
${dateClause}
GROUP BY fs.record_id
ORDER BY MAX(fs.last_qc_at) DESC
LIMIT $2 OFFSET $3`,
projectId, pageSize, (page - 1) * pageSize
LIMIT ${limitPlaceholder} OFFSET ${offsetPlaceholder}`,
projectId, ...dateParams, pageSize, (page - 1) * pageSize
);
// 2. 总受试者数
const countResult = await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
`SELECT COUNT(DISTINCT record_id) AS cnt
FROM iit_schema.qc_field_status
WHERE project_id = $1 AND status IN ('FAIL', 'WARNING')
FROM iit_schema.qc_field_status fs
WHERE fs.project_id = $1 AND fs.status IN ('FAIL', 'WARNING')
${dateClause}`,
projectId
projectId, ...dateParams
);
const totalRecords = Number(countResult[0]?.cnt || 0);
// 3. 获取这些受试者的问题详情LEFT JOIN 获取字段/事件中文名)
const recordIds = recordSummaries.map(r => r.record_id);
let issues: any[] = [];
const eventLabelMap = await this.buildEventLabelMap(projectId);
if (recordIds.length > 0) {
issues = await prisma.$queryRawUnsafe<any[]>(
`SELECT
@@ -280,7 +344,7 @@ class IitQcCockpitController {
issuesByRecord.get(key)!.push(issue);
}
// 5. 同时获取通过的受试者(无问题的),补充到时间线
// 5. 同时获取通过的受试者(按同一时间窗)
const passedRecords = await prisma.$queryRawUnsafe<Array<{
record_id: string;
total_fields: bigint;
@@ -294,15 +358,12 @@ class IitQcCockpitController {
MAX(fs.triggered_by) AS triggered_by
FROM iit_schema.qc_field_status fs
WHERE fs.project_id = $1
AND fs.record_id NOT IN (
SELECT DISTINCT record_id FROM iit_schema.qc_field_status
WHERE project_id = $1 AND status IN ('FAIL', 'WARNING')
)
${dateClause}
GROUP BY fs.record_id
HAVING COUNT(*) FILTER (WHERE fs.status IN ('FAIL', 'WARNING')) = 0
ORDER BY MAX(fs.last_qc_at) DESC
LIMIT 10`,
projectId
projectId, ...dateParams
);
const items = recordSummaries.map(rec => {
@@ -328,7 +389,7 @@ class IitQcCockpitController {
field: i.field_name || '',
fieldLabel: i.field_label || '',
eventId: i.event_id || '',
eventLabel: i.event_label || '',
eventLabel: i.event_label || eventLabelMap.get(i.event_id || '') || this.fallbackEventLabel(i.event_id || ''),
formName: i.form_name || '',
message: i.message || '',
severity: i.severity || 'warning',
@@ -429,28 +490,34 @@ class IitQcCockpitController {
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' },
});
// 使用与 Dashboard/Report 一致的主口径qc_project_stats返回当前快照趋势点
const [projectStats, lastQcRow] = await Promise.all([
prisma.iitQcProjectStats.findUnique({
where: { projectId },
select: {
totalRecords: true,
passedRecords: true,
},
}),
prisma.$queryRaw<Array<{ last_qc_at: Date | null }>>`
SELECT MAX(last_qc_at) AS last_qc_at
FROM iit_schema.qc_field_status
WHERE project_id = ${projectId}
`,
]);
// Group by date
const dailyMap = new Map<string, { total: number; passed: number }>();
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 lastQcAt = lastQcRow?.[0]?.last_qc_at ? new Date(lastQcRow[0].last_qc_at) : null;
const total = projectStats?.totalRecords ?? 0;
const passed = projectStats?.passedRecords ?? 0;
const trend = Array.from(dailyMap.entries()).map(([date, { total, passed }]) => ({
date,
total,
passed,
passRate: total > 0 ? Math.round((passed / total) * 100) : 0,
}));
const trend = (lastQcAt && lastQcAt >= since)
? [{
date: lastQcAt.toISOString().split('T')[0],
total,
passed,
passRate: total > 0 ? Math.round((passed / total) * 100) : 0,
}]
: [];
return reply.send({ success: true, data: trend });
} catch (error: any) {

View File

@@ -142,6 +142,47 @@ export interface RecordDetail {
// ============================================================
class IitQcCockpitService {
/**
* 统一构建 event_id -> event_label 映射
* 优先级project.cachedRules.eventLabels > REDCap events > fallback 格式化
*/
private async buildEventLabelMap(projectId: string): Promise<Map<string, string>> {
const map = new Map<string, string>();
const project = await prisma.iitProject.findUnique({
where: { id: projectId },
select: { cachedRules: true, redcapUrl: true, redcapApiToken: true },
});
const cached = project?.cachedRules as any;
if (cached?.eventLabels && typeof cached.eventLabels === 'object') {
for (const [eid, label] of Object.entries(cached.eventLabels)) {
if (typeof label === 'string' && label.trim()) {
map.set(eid, label.trim());
}
}
}
if (project?.redcapUrl && project?.redcapApiToken) {
try {
const redcap = new RedcapAdapter(project.redcapUrl, project.redcapApiToken);
const events = await redcap.getEvents();
for (const ev of events) {
if (ev.unique_event_name && ev.event_name && !map.has(ev.unique_event_name)) {
map.set(ev.unique_event_name, ev.event_name);
}
}
} catch {
// non-fatal: keep fallback mapping
}
}
return map;
}
private resolveEventLabel(eventId: string, eventLabelMap: Map<string, string>): string {
return eventLabelMap.get(eventId) || formatFormName(eventId);
}
/**
* 获取质控驾驶舱完整数据
*/
@@ -179,7 +220,7 @@ class IitQcCockpitService {
* V3.1: 从 qc_project_stats + qc_field_status 获取统计
*/
async getStats(projectId: string): Promise<QcStats> {
const [projectStats, fieldIssues, pendingEqs, d6Count] = await Promise.all([
const [projectStats, fieldIssues, fieldSeverityTotals, pendingEqs, d6Count] = await Promise.all([
prisma.iitQcProjectStats.findUnique({ where: { projectId } }),
prisma.$queryRaw<Array<{ rule_name: string; severity: string; cnt: bigint }>>`
SELECT rule_name, severity, COUNT(*) AS cnt
@@ -189,6 +230,12 @@ class IitQcCockpitService {
ORDER BY cnt DESC
LIMIT 10
`,
prisma.$queryRaw<Array<{ severity: string; cnt: bigint }>>`
SELECT COALESCE(severity, 'warning') AS severity, COUNT(*) AS cnt
FROM iit_schema.qc_field_status
WHERE project_id = ${projectId} AND status IN ('FAIL', 'WARNING')
GROUP BY COALESCE(severity, 'warning')
`,
prisma.iitEquery.count({ where: { projectId, status: { in: ['pending', 'reopened'] } } }),
prisma.$queryRaw<[{ cnt: bigint }]>`
SELECT COUNT(*) AS cnt FROM iit_schema.qc_field_status
@@ -204,7 +251,7 @@ class IitQcCockpitService {
const healthScore = ((projectStats as any)?.healthScore as number) ?? 0;
const healthGrade = ((projectStats as any)?.healthGrade as string) ?? 'N/A';
const passRate = totalRecords > 0 ? Math.round((passedRecords / totalRecords) * 1000) / 10 : 100;
const passRate = totalRecords > 0 ? Math.round((passedRecords / totalRecords) * 1000) / 10 : 0;
const qualityScore = Math.round(healthScore);
const DIMENSION_LABELS: Record<string, string> = {
@@ -223,12 +270,17 @@ class IitQcCockpitService {
}
let criticalCount = 0;
for (const row of fieldSeverityTotals) {
if ((row.severity || '').toLowerCase() === 'critical') {
criticalCount += Number(row.cnt);
}
}
const topIssues: QcStats['topIssues'] = [];
for (const row of fieldIssues) {
const cnt = Number(row.cnt);
const sev: 'critical' | 'warning' | 'info' = row.severity === 'critical' ? 'critical' : 'warning';
if (sev === 'critical') criticalCount += cnt;
if (topIssues.length < 5) {
topIssues.push({ issue: row.rule_name || 'Unknown', count: cnt, severity: sev });
}
@@ -376,7 +428,7 @@ class IitQcCockpitService {
form_name: string; status: string; detected_at: Date | null;
}>>`
SELECT field_name, rule_name, message, severity, rule_category,
event_id, form_name, status, detected_at
event_id, form_name, status, last_qc_at AS detected_at
FROM iit_schema.qc_field_status
WHERE project_id = ${projectId}
AND record_id = ${recordId}
@@ -491,14 +543,14 @@ class IitQcCockpitService {
*/
async getDeviations(projectId: string): Promise<Array<{
recordId: string; eventId: string; fieldName: string; fieldLabel: string;
message: string; severity: string; dimensionCode: string; detectedAt: string | null;
eventLabel: string; message: string; severity: string; dimensionCode: string; detectedAt: string | null;
}>> {
const [rows, semanticRows] = await Promise.all([
const [rows, semanticRows, eventLabelMap] = await Promise.all([
prisma.$queryRaw<Array<{
record_id: string; event_id: string; field_name: string;
message: string; severity: string; rule_category: string; detected_at: Date | null;
}>>`
SELECT record_id, event_id, field_name, message, severity, rule_category, detected_at
SELECT record_id, event_id, field_name, message, severity, rule_category, last_qc_at AS detected_at
FROM iit_schema.qc_field_status
WHERE project_id = ${projectId} AND rule_category = 'D6' AND status IN ('FAIL', 'WARNING')
ORDER BY record_id, event_id
@@ -507,6 +559,7 @@ class IitQcCockpitService {
SELECT actual_name, semantic_label FROM iit_schema.field_mapping
WHERE project_id = ${projectId} AND semantic_label IS NOT NULL
`.catch(() => [] as any[]),
this.buildEventLabelMap(projectId),
]);
const semanticMap = new Map<string, string>();
@@ -517,6 +570,7 @@ class IitQcCockpitService {
return rows.map(r => ({
recordId: r.record_id,
eventId: r.event_id,
eventLabel: this.resolveEventLabel(r.event_id, eventLabelMap),
fieldName: r.field_name,
fieldLabel: semanticMap.get(r.field_name) || r.field_name,
message: r.message,
@@ -549,7 +603,7 @@ class IitQcCockpitService {
}>>`
SELECT record_id, rule_id, rule_name, field_name, status, actual_value, expected_value, message
FROM iit_schema.qc_field_status
WHERE project_id = ${projectId} AND rule_category = 'D1'
WHERE project_id = ${projectId} AND rule_category = 'D1' AND status IN ('PASS', 'FAIL', 'WARNING')
ORDER BY record_id, rule_id
`,
prisma.$queryRaw<Array<{ record_id: string }>>`
@@ -568,32 +622,69 @@ class IitQcCockpitService {
const d1Rules = rules.filter(r =>
r.category === 'D1' || r.category === 'inclusion' || r.category === 'exclusion',
);
const d1RuleById = new Map<string, { id: string; name: string; field: string }>();
for (const rule of d1Rules) {
d1RuleById.set(rule.id, { id: rule.id, name: rule.name, field: rule.field });
}
const ruleType = (ruleId: string): 'inclusion' | 'exclusion' =>
ruleId.startsWith('exc_') ? 'exclusion' : 'inclusion';
const subjectIssueMap = new Map<string, Map<string, { status: string; actualValue: string | null; expectedValue: string | null; message: string | null }>>();
const subjectIssueMap = new Map<string, Map<string, Array<{
status: string;
actualValue: string | null;
expectedValue: string | null;
message: string | null;
}>>>();
const discoveredRuleMap = new Map<string, { ruleId: string; ruleName: string; fieldName: string }>();
for (const row of d1Rows) {
if (!row.rule_id) continue;
if (!subjectIssueMap.has(row.record_id)) subjectIssueMap.set(row.record_id, new Map());
subjectIssueMap.get(row.record_id)!.set(row.rule_id, {
const recMap = subjectIssueMap.get(row.record_id)!;
if (!recMap.has(row.rule_id)) recMap.set(row.rule_id, []);
recMap.get(row.rule_id)!.push({
status: row.status,
actualValue: row.actual_value,
expectedValue: row.expected_value,
message: row.message,
});
if (!discoveredRuleMap.has(row.rule_id)) {
discoveredRuleMap.set(row.rule_id, {
ruleId: row.rule_id,
ruleName: row.rule_name || row.rule_id,
fieldName: row.field_name || '',
});
}
}
const allRecordIds = allRecordRows.map(r => r.record_id);
const allRecordIds = Array.from(new Set([
...allRecordRows.map(r => r.record_id),
...d1Rows.map(r => r.record_id),
])).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const totalSubjects = allRecordIds.length;
const criteria = d1Rules.map(rule => {
const ruleUniverse = discoveredRuleMap.size > 0
? Array.from(discoveredRuleMap.values()).map(r => ({
id: r.ruleId,
name: d1RuleById.get(r.ruleId)?.name || r.ruleName,
field: d1RuleById.get(r.ruleId)?.field || r.fieldName,
}))
: d1Rules.map(r => ({ id: r.id, name: r.name, field: r.field }));
const criteria = ruleUniverse.map(rule => {
let passCount = 0;
let failCount = 0;
let warningOrUncheckedCount = 0;
for (const recordId of allRecordIds) {
const issues = subjectIssueMap.get(recordId);
const res = issues?.get(rule.id);
if (res && res.status === 'FAIL') failCount++;
const issueRows = subjectIssueMap.get(recordId)?.get(rule.id) || [];
const hasFail = issueRows.some(r => r.status === 'FAIL');
const hasWarning = issueRows.some(r => r.status === 'WARNING');
const hasPass = issueRows.some(r => r.status === 'PASS');
if (hasFail) failCount++;
else if (hasWarning || !hasPass) warningOrUncheckedCount++;
else passCount++;
}
return {
@@ -603,27 +694,49 @@ class IitQcCockpitService {
fieldName: rule.field,
fieldLabel: labelMap.get(rule.field) || rule.field,
passCount,
failCount,
failCount: failCount + warningOrUncheckedCount,
};
});
const subjects = allRecordIds.map(recordId => {
const issues = subjectIssueMap.get(recordId) || new Map();
const criteriaResults = d1Rules.map(rule => {
const res = issues.get(rule.id);
const issues = subjectIssueMap.get(recordId) || new Map<string, Array<{
status: string;
actualValue: string | null;
expectedValue: string | null;
message: string | null;
}>>();
const criteriaResults = ruleUniverse.map(rule => {
const resRows = issues.get(rule.id) || [];
const hasFail = resRows.some(r => r.status === 'FAIL');
const hasWarning = resRows.some(r => r.status === 'WARNING');
const hasPass = resRows.some(r => r.status === 'PASS');
const firstRes = resRows[0];
const status: 'PASS' | 'FAIL' | 'NOT_CHECKED' = hasFail
? 'FAIL'
: hasWarning
? 'NOT_CHECKED'
: hasPass
? 'PASS'
: 'NOT_CHECKED';
return {
ruleId: rule.id,
ruleName: rule.name,
type: ruleType(rule.id),
status: (res?.status === 'FAIL' ? 'FAIL' : 'PASS') as 'PASS' | 'FAIL' | 'NOT_CHECKED',
actualValue: res?.actualValue || null,
expectedValue: res?.expectedValue || null,
message: res?.message || null,
status,
actualValue: firstRes?.actualValue || null,
expectedValue: firstRes?.expectedValue || null,
message: firstRes?.message || null,
};
});
const failedCriteria = criteriaResults.filter(c => c.status === 'FAIL').map(c => c.ruleId);
const overallStatus: 'eligible' | 'ineligible' | 'incomplete' =
failedCriteria.length > 0 ? 'ineligible' : 'eligible';
const unchecked = criteriaResults.filter(c => c.status === 'NOT_CHECKED').map(c => c.ruleId);
const overallStatus: 'eligible' | 'ineligible' | 'incomplete' = failedCriteria.length > 0
? 'ineligible'
: unchecked.length > 0
? 'incomplete'
: 'eligible';
return { recordId, overallStatus, failedCriteria, criteriaResults };
});
@@ -635,7 +748,7 @@ class IitQcCockpitService {
totalScreened: totalSubjects,
eligible,
ineligible,
incomplete: 0,
incomplete: subjects.filter(s => s.overallStatus === 'incomplete').length,
eligibilityRate: totalSubjects > 0 ? Math.round((eligible / totalSubjects) * 10000) / 100 : 0,
},
criteria,
@@ -647,7 +760,7 @@ class IitQcCockpitService {
* D2 完整性总览 — L1 项目 + L2 受试者 + L3 事件级统计
*/
async getCompletenessReport(projectId: string) {
const [byRecord, byRecordEvent, eventLabelRows] = await Promise.all([
const [byRecord, byRecordEvent, eventLabelRows, activeEventRows, resolvedEventLabelMap] = await Promise.all([
prisma.$queryRaw<Array<{
record_id: string; total: bigint; missing: bigint;
}>>`
@@ -672,10 +785,20 @@ class IitQcCockpitService {
SELECT DISTINCT event_id, event_label FROM iit_schema.qc_event_status
WHERE project_id = ${projectId} AND event_label IS NOT NULL
`.catch(() => [] as any[]),
prisma.$queryRaw<Array<{ record_id: string; event_id: string }>>`
SELECT DISTINCT record_id, event_id
FROM iit_schema.qc_event_status
WHERE project_id = ${projectId}
`.catch(() => [] as any[]),
this.buildEventLabelMap(projectId),
]);
const eventLabelMap = new Map<string, string>();
for (const r of eventLabelRows) if (r.event_label) eventLabelMap.set(r.event_id, r.event_label);
const eventLabelMap = new Map<string, string>(resolvedEventLabelMap);
for (const r of eventLabelRows) {
if (r.event_label && !eventLabelMap.has(r.event_id)) {
eventLabelMap.set(r.event_id, r.event_label);
}
}
const eventsByRecord = new Map<string, Array<{ eventId: string; eventLabel: string; fieldsTotal: number; fieldsMissing: number; missingRate: number }>>();
for (const r of byRecordEvent) {
@@ -684,7 +807,7 @@ class IitQcCockpitService {
if (!eventsByRecord.has(r.record_id)) eventsByRecord.set(r.record_id, []);
eventsByRecord.get(r.record_id)!.push({
eventId: r.event_id,
eventLabel: eventLabelMap.get(r.event_id) || r.event_id,
eventLabel: this.resolveEventLabel(r.event_id, eventLabelMap),
fieldsTotal: total,
fieldsMissing: missing,
missingRate: total > 0 ? Math.round((missing / total) * 10000) / 100 : 0,
@@ -694,6 +817,14 @@ class IitQcCockpitService {
let totalRequired = 0;
let totalMissing = 0;
const uniqueEvents = new Set<string>();
const d2UniqueEvents = new Set<string>();
const activeEventsByRecord = new Map<string, Set<string>>();
for (const row of activeEventRows) {
if (!activeEventsByRecord.has(row.record_id)) activeEventsByRecord.set(row.record_id, new Set());
activeEventsByRecord.get(row.record_id)!.add(row.event_id);
uniqueEvents.add(row.event_id);
}
const bySubject = byRecord.map(r => {
const total = Number(r.total);
@@ -701,14 +832,16 @@ class IitQcCockpitService {
totalRequired += total;
totalMissing += missing;
const events = eventsByRecord.get(r.record_id) || [];
events.forEach(e => uniqueEvents.add(e.eventId));
events.forEach(e => d2UniqueEvents.add(e.eventId));
const activeEventCount = activeEventsByRecord.get(r.record_id)?.size || 0;
return {
recordId: r.record_id,
fieldsTotal: total,
fieldsFilled: total - missing,
fieldsMissing: missing,
missingRate: total > 0 ? Math.round((missing / total) * 10000) / 100 : 0,
activeEvents: events.length,
activeEvents: activeEventCount,
d2CoveredEvents: events.length,
byEvent: events,
};
});
@@ -724,6 +857,7 @@ class IitQcCockpitService {
overallMissingRate: totalRequired > 0 ? Math.round((totalMissing / totalRequired) * 10000) / 100 : 0,
subjectsChecked: byRecord.length,
eventsChecked: uniqueEvents.size,
d2EventsChecked: d2UniqueEvents.size,
isStale,
},
bySubject,
@@ -734,7 +868,7 @@ class IitQcCockpitService {
* D2 字段级懒加载 — 按 recordId + eventId 返回 L4 表单 + L5 字段清单
*/
async getCompletenessFields(projectId: string, recordId: string, eventId: string) {
const [missingRows, fieldMappingRows] = await Promise.all([
const [missingRows, fieldMappingRows, resolvedEventLabelMap] = await Promise.all([
prisma.$queryRaw<Array<{
form_name: string; field_name: string; message: string | null;
}>>`
@@ -749,6 +883,7 @@ class IitQcCockpitService {
SELECT actual_name, semantic_label, form_name, field_type FROM iit_schema.field_mapping
WHERE project_id = ${projectId} AND semantic_label IS NOT NULL
`.catch(() => [] as any[]),
this.buildEventLabelMap(projectId),
]);
const labelMap = new Map<string, string>();
@@ -780,12 +915,7 @@ class IitQcCockpitService {
const totalByForm = new Map<string, number>();
for (const r of allFieldsCount) totalByForm.set(r.form_name, Number(r.cnt));
const eventLabelRows = await prisma.$queryRaw<Array<{ event_label: string | null }>>`
SELECT event_label FROM iit_schema.qc_event_status
WHERE project_id = ${projectId} AND event_id = ${eventId}
LIMIT 1
`.catch(() => [] as any[]);
const eventLabel = (eventLabelRows as any)[0]?.event_label || eventId;
const eventLabel = this.resolveEventLabel(eventId, resolvedEventLabelMap);
const byForm = [...formGroups.entries()].map(([formName, missingFields]) => ({
formName,
@@ -802,7 +932,7 @@ class IitQcCockpitService {
* D3/D4 eQuery 全生命周期跟踪
*/
async getEqueryLogReport(projectId: string) {
const [equeries, fieldRows] = await Promise.all([
const [equeries, fieldRows, eventLabelMap] = await Promise.all([
prisma.iitEquery.findMany({
where: { projectId },
orderBy: { createdAt: 'desc' },
@@ -811,6 +941,7 @@ class IitQcCockpitService {
SELECT actual_name, semantic_label FROM iit_schema.field_mapping
WHERE project_id = ${projectId} AND semantic_label IS NOT NULL
`.catch(() => [] as any[]),
this.buildEventLabelMap(projectId),
]);
const labelMap = new Map<string, string>();
@@ -850,6 +981,7 @@ class IitQcCockpitService {
id: eq.id,
recordId: eq.recordId,
eventId: eq.eventId || null,
eventLabel: eq.eventId ? this.resolveEventLabel(eq.eventId, eventLabelMap) : null,
formName: eq.formName || null,
fieldName: eq.fieldName || null,
fieldLabel: eq.fieldName ? (labelMap.get(eq.fieldName) || eq.fieldName) : null,
@@ -902,13 +1034,13 @@ class IitQcCockpitService {
* D6 方案偏离报表 — 结构化超窗数据
*/
async getDeviationReport(projectId: string) {
const [rows, fieldRows] = await Promise.all([
const [rows, fieldRows, eventLabelMap] = await Promise.all([
prisma.$queryRaw<Array<{
id: string; record_id: string; event_id: string; field_name: string;
rule_name: string | null; message: string | null; severity: string | null;
actual_value: string | null; detected_at: Date | null;
}>>`
SELECT id, record_id, event_id, field_name, rule_name, message, severity, actual_value, detected_at
SELECT id, record_id, event_id, field_name, rule_name, message, severity, actual_value, last_qc_at AS detected_at
FROM iit_schema.qc_field_status
WHERE project_id = ${projectId} AND rule_category = 'D6' AND status IN ('FAIL', 'WARNING')
ORDER BY record_id, event_id
@@ -917,6 +1049,7 @@ class IitQcCockpitService {
SELECT actual_name, semantic_label FROM iit_schema.field_mapping
WHERE project_id = ${projectId} AND semantic_label IS NOT NULL
`.catch(() => [] as any[]),
this.buildEventLabelMap(projectId),
]);
const labelMap = new Map<string, string>();
@@ -943,7 +1076,7 @@ class IitQcCockpitService {
id: r.id,
recordId: r.record_id,
eventId: r.event_id,
eventLabel: parsed.eventLabel || r.event_id,
eventLabel: parsed.eventLabel || this.resolveEventLabel(r.event_id, eventLabelMap),
fieldName: r.field_name,
fieldLabel: labelMap.get(r.field_name) || r.field_name,
deviationType,