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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动重开 eQuery(closed → 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user