diff --git a/数据库Web可视化/app.py b/数据库Web可视化/app.py index c7651bf..b4e1027 100644 --- a/数据库Web可视化/app.py +++ b/数据库Web可视化/app.py @@ -170,40 +170,53 @@ def near_pinyin_match(left: str, candidates: set[str]) -> bool: return any(len(candidate) >= 6 and edit_distance_with_cap(left, candidate, cap=1) <= 1 for candidate in candidates) +def match_patient_names(pacs_name: Any, upp_name: Any) -> tuple[str, str]: + pacs_text = str(pacs_name or "") + upp_text = str(upp_name or "") + if not pacs_text or not upp_text: + return "", "" + + pacs_latin = normalize_latin_name(pacs_text) + upp_latin = normalize_latin_name(upp_text) + pacs_cjk = normalize_cjk_name(pacs_text) + upp_cjk = normalize_cjk_name(upp_text) + + if pacs_cjk and upp_cjk and pacs_cjk == upp_cjk: + return "same", "中文姓名一致" + if pacs_latin and upp_latin and pacs_latin == upp_latin: + return "same", "拼音/拉丁姓名一致" + if upp_latin and upp_latin in pinyin_name_candidates(pacs_text): + return "same", "DICOM中文姓名与UPP拼音匹配" + if upp_latin and near_pinyin_match(upp_latin, pinyin_name_candidates(pacs_text)): + return "same", "DICOM中文姓名与UPP拼音近似匹配" + if pacs_latin and pacs_latin in pinyin_name_candidates(upp_text): + return "same", "UPP中文姓名与DICOM拼音匹配" + if pacs_latin and near_pinyin_match(pacs_latin, pinyin_name_candidates(upp_text)): + return "same", "UPP中文姓名与DICOM拼音近似匹配" + return "different", f"DICOM患者:{pacs_text};UPP患者:{upp_text}" + + def enrich_patient_match(row: dict[str, Any]) -> dict[str, Any]: pacs_name = row.get("pacs_patient_name") or "" - upp_name = row.get("upp_patient_name") or "" - if not pacs_name or not upp_name: + upp_names = [row.get("upp_patient_name"), *(row.get("upp_patient_names") or [])] + upp_names = list(dict.fromkeys([str(name) for name in upp_names if name])) + if not pacs_name or not upp_names: row["patient_name_match"] = "" row["patient_name_match_note"] = "" return row - pacs_latin = normalize_latin_name(pacs_name) - upp_latin = normalize_latin_name(upp_name) - pacs_cjk = normalize_cjk_name(pacs_name) - upp_cjk = normalize_cjk_name(upp_name) - - if pacs_cjk and upp_cjk and pacs_cjk == upp_cjk: - row["patient_name_match"] = "same" - row["patient_name_match_note"] = "中文姓名一致" - elif pacs_latin and upp_latin and pacs_latin == upp_latin: - row["patient_name_match"] = "same" - row["patient_name_match_note"] = "拼音/拉丁姓名一致" - elif upp_latin and upp_latin in pinyin_name_candidates(pacs_name): - row["patient_name_match"] = "same" - row["patient_name_match_note"] = "DICOM中文姓名与UPP拼音匹配" - elif upp_latin and near_pinyin_match(upp_latin, pinyin_name_candidates(pacs_name)): - row["patient_name_match"] = "same" - row["patient_name_match_note"] = "DICOM中文姓名与UPP拼音近似匹配" - elif pacs_latin and pacs_latin in pinyin_name_candidates(upp_name): - row["patient_name_match"] = "same" - row["patient_name_match_note"] = "UPP中文姓名与DICOM拼音匹配" - elif pacs_latin and near_pinyin_match(pacs_latin, pinyin_name_candidates(upp_name)): - row["patient_name_match"] = "same" - row["patient_name_match_note"] = "UPP中文姓名与DICOM拼音近似匹配" - else: - row["patient_name_match"] = "different" - row["patient_name_match_note"] = f"DICOM患者:{pacs_name};UPP患者:{upp_name}" + mismatch_notes = [] + for upp_name in upp_names: + status, note = match_patient_names(pacs_name, upp_name) + if status == "same": + row["patient_name_match"] = "same" + row["patient_name_match_note"] = note + row["upp_patient_name"] = row.get("upp_patient_name") or upp_name + return row + if note: + mismatch_notes.append(note) + row["patient_name_match"] = "different" + row["patient_name_match_note"] = ";".join(mismatch_notes[:3]) return row @@ -338,9 +351,46 @@ def relation_cte() -> str: FROM public.{UPP_ASSET_TABLE_SQL} u ), u AS ( - SELECT DISTINCT ON (ct_key) * + SELECT + ct_key, + (array_agg(ct_number ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE ct_number IS NOT NULL))[1] AS ct_number, + bool_or(COALESCE(list_present, false)) AS list_present, + bool_or(COALESCE(stl_present, false)) AS stl_present, + (array_agg(patient_name ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(patient_name, '') IS NOT NULL))[1] AS patient_name, + to_jsonb(array_remove(array_agg(DISTINCT NULLIF(patient_name, '')), NULL)) AS patient_names, + (array_agg(patient_sex ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(patient_sex, '') IS NOT NULL))[1] AS patient_sex, + (array_agg(patient_age ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(patient_age, '') IS NOT NULL))[1] AS patient_age, + (array_agg(patient_id_masked ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(patient_id_masked, '') IS NOT NULL))[1] AS patient_id_masked, + max(exam_date) AS exam_date, + max(task_created_at) AS task_created_at, + (array_agg(exam_description ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(exam_description, '') IS NOT NULL))[1] AS exam_description, + (array_agg(exam_device ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(exam_device, '') IS NOT NULL))[1] AS exam_device, + array_to_string(array_remove(array_agg(DISTINCT NULLIF(algorithm_model, '')), NULL), '、') AS algorithm_model, + to_jsonb(array_remove(array_agg(DISTINCT NULLIF(algorithm_model, '')), NULL)) AS algorithm_models, + array_to_string(array_remove(array_agg(DISTINCT NULLIF(upp_status, '')), NULL), '、') AS upp_status, + COALESCE(sum(COALESCE(list_record_count, 0)), 0)::int AS list_record_count, + (array_agg(processed_stl_dir ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(processed_stl_dir, '') IS NOT NULL))[1] AS processed_stl_dir, + COALESCE(sum(COALESCE(stl_file_count, 0)), 0)::int AS stl_file_count, + COALESCE(sum(COALESCE(stl_total_bytes, 0)), 0)::bigint AS stl_total_bytes, + max(updated_at) AS updated_at, + (array_agg(selected_list_record ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE selected_list_record IS NOT NULL))[1] AS selected_list_record, + jsonb_agg(list_records ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE list_records IS NOT NULL) AS list_records, + jsonb_agg(stl_candidates ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE stl_candidates IS NOT NULL) AS stl_candidates, + count(*)::int AS upp_asset_count, + jsonb_agg( + jsonb_build_object( + 'ct_number', ct_number, + 'algorithm_model', algorithm_model, + 'upp_status', upp_status, + 'file_count', stl_file_count, + 'total_bytes', stl_total_bytes, + 'processed_stl_dir', processed_stl_dir, + 'updated_at', updated_at + ) + ORDER BY updated_at DESC NULLS LAST + ) AS upp_assets FROM u_raw - ORDER BY ct_key, stl_present DESC, list_present DESC, updated_at DESC NULLS LAST + GROUP BY ct_key ), s_raw AS ( SELECT @@ -348,25 +398,85 @@ def relation_cte() -> str: upper(s.ct_number) AS ct_key FROM public.{UPP_STL_TABLE_SQL} s ), - s AS ( - SELECT DISTINCT ON (ct_key) * + s_segment_names AS ( + SELECT + ct_key, + COALESCE(jsonb_agg(DISTINCT name.value) FILTER (WHERE name.value IS NOT NULL), '[]'::jsonb) AS segment_names FROM s_raw - ORDER BY ct_key, file_count DESC, updated_at DESC NULLS LAST + CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(s_raw.segment_names, '[]'::jsonb)) AS name(value) + GROUP BY ct_key + ), + s_segment_families AS ( + SELECT + ct_key, + COALESCE(jsonb_agg(DISTINCT family.value) FILTER (WHERE family.value IS NOT NULL), '[]'::jsonb) AS segment_families + FROM s_raw + CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(s_raw.segment_families, '[]'::jsonb)) AS family(value) + GROUP BY ct_key + ), + s_segment_categories AS ( + SELECT + ct_key, + COALESCE(jsonb_agg(DISTINCT category.value) FILTER (WHERE category.value IS NOT NULL), '[]'::jsonb) AS segment_categories + FROM s_raw + CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(s_raw.segment_categories, '[]'::jsonb)) AS category(value) + GROUP BY ct_key + ), + s_segments AS ( + SELECT + s_raw.ct_key, + COALESCE(s_segment_names.segment_names, '[]'::jsonb) AS segment_names, + COALESCE(s_segment_families.segment_families, '[]'::jsonb) AS segment_families, + COALESCE(s_segment_categories.segment_categories, '[]'::jsonb) AS segment_categories + FROM (SELECT DISTINCT ct_key FROM s_raw) s_raw + LEFT JOIN s_segment_names ON s_segment_names.ct_key = s_raw.ct_key + LEFT JOIN s_segment_families ON s_segment_families.ct_key = s_raw.ct_key + LEFT JOIN s_segment_categories ON s_segment_categories.ct_key = s_raw.ct_key + ), + s AS ( + SELECT + s_raw.ct_key, + (array_agg(s_raw.ct_number ORDER BY s_raw.updated_at DESC NULLS LAST) FILTER (WHERE s_raw.ct_number IS NOT NULL))[1] AS ct_number, + COALESCE(sum(COALESCE(s_raw.file_count, 0)), 0)::int AS file_count, + COALESCE(sum(COALESCE(s_raw.total_bytes, 0)), 0)::bigint AS total_bytes, + COALESCE((array_agg(s_segments.segment_names) FILTER (WHERE s_segments.segment_names IS NOT NULL))[1], '[]'::jsonb) AS segment_names, + COALESCE((array_agg(s_segments.segment_families) FILTER (WHERE s_segments.segment_families IS NOT NULL))[1], '[]'::jsonb) AS segment_families, + COALESCE((array_agg(s_segments.segment_categories) FILTER (WHERE s_segments.segment_categories IS NOT NULL))[1], '[]'::jsonb) AS segment_categories, + jsonb_agg(s_raw.file_names ORDER BY s_raw.updated_at DESC NULLS LAST) FILTER (WHERE s_raw.file_names IS NOT NULL) AS file_names, + jsonb_agg(s_raw.files ORDER BY s_raw.updated_at DESC NULLS LAST) FILTER (WHERE s_raw.files IS NOT NULL) AS stl_files, + max(s_raw.updated_at) AS updated_at, + count(*)::int AS stl_asset_count, + jsonb_agg( + jsonb_build_object( + 'ct_number', s_raw.ct_number, + 'file_count', s_raw.file_count, + 'total_bytes', s_raw.total_bytes, + 'updated_at', s_raw.updated_at, + 'segment_categories', s_raw.segment_categories, + 'segment_families', s_raw.segment_families + ) + ORDER BY s_raw.updated_at DESC NULLS LAST + ) AS stl_assets + FROM s_raw + LEFT JOIN s_segments ON s_segments.ct_key = s_raw.ct_key + GROUP BY s_raw.ct_key ), keys AS ( SELECT ct_key FROM p UNION SELECT ct_key FROM u + UNION + SELECT ct_key FROM s ), relation AS ( SELECT k.ct_key, p.ct_number AS pacs_ct_number, - u.ct_number AS stl_ct_number, + COALESCE(s.ct_number, u.ct_number) AS stl_ct_number, u.ct_number AS upp_ct_number, s.ct_number AS stl_file_ct_number, p.ct_number IS NOT NULL AS pacs_present, - u.ct_number IS NOT NULL AS stl_asset_present, + u.ct_number IS NOT NULL OR s.ct_number IS NOT NULL AS stl_asset_present, u.ct_number IS NOT NULL AS upp_present, COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL AS stl_present, CASE @@ -408,6 +518,7 @@ def relation_cte() -> str: CASE WHEN ps.body_parts ? 'pelvis' THEN '盆腔' END ], NULL)) AS dicom_body_parts, u.patient_name AS upp_patient_name, + COALESCE(u.patient_names, '[]'::jsonb) AS upp_patient_names, u.patient_sex AS upp_patient_sex, u.patient_age, u.patient_id_masked, @@ -416,6 +527,7 @@ def relation_cte() -> str: u.exam_description, u.exam_device, u.algorithm_model, + COALESCE(u.algorithm_models, '[]'::jsonb) AS algorithm_models, u.upp_status, u.list_present, u.list_record_count, @@ -426,14 +538,18 @@ def relation_cte() -> str: u.selected_list_record, u.list_records, u.stl_candidates, + COALESCE(u.upp_asset_count, 0) AS upp_asset_count, + COALESCE(u.upp_assets, '[]'::jsonb) AS upp_assets, s.file_count AS stl_file_count_agg, s.total_bytes AS stl_total_bytes_agg, s.segment_names, s.segment_families, s.segment_categories, s.file_names, - s.files AS stl_files, + s.stl_files, s.updated_at AS stl_updated_at, + COALESCE(s.stl_asset_count, 0) AS stl_asset_count, + COALESCE(s.stl_assets, '[]'::jsonb) AS stl_assets, CASE WHEN COALESCE(NULLIF(p.source_patient_name, ''), NULLIF(p.patient_name_dicom, '')) IS NULL OR NULLIF(u.patient_name, '') IS NULL THEN '' @@ -663,12 +779,14 @@ def relations( SELECT ct_key, pacs_ct_number, stl_ct_number, pacs_present, stl_asset_present, list_present, stl_present, relation_status, match_type, - pacs_patient_name, upp_patient_name, patient_name_match, patient_id, patient_id_masked, + pacs_patient_name, upp_patient_name, upp_patient_names, patient_name_match, patient_id, patient_id_masked, study_date, study_time, exam_date, task_created_at, study_description, exam_description, algorithm_model, upp_status, pacs_series_count, dicom_file_count, annotated_series, undetermined_series, completed, list_present, list_record_count, stl_file_count, stl_file_count_agg, - body_parts, dicom_body_parts, dicom_annotation_labels, segment_categories, segment_families, + body_parts, dicom_body_parts, dicom_annotation_labels, algorithm_models, + upp_asset_count, upp_assets, stl_asset_count, stl_assets, + segment_categories, segment_families, pacs_updated_at, upp_updated_at, stl_updated_at FROM relation {where} diff --git a/数据库Web可视化/static/app.js b/数据库Web可视化/static/app.js index 3a64e43..4dd7f27 100644 --- a/数据库Web可视化/static/app.js +++ b/数据库Web可视化/static/app.js @@ -149,6 +149,26 @@ function patientNameMismatch(row) { return row.pacs_present && row.stl_asset_present && row.patient_name_match === "different"; } +function stlFileCount(row) { + return Number(row.stl_file_count_agg || row.stl_file_count || 0); +} + +function stlAssetCount(row) { + return Number(row.stl_asset_count || row.upp_asset_count || 0); +} + +function assetSummary(row) { + const assets = asList(row.upp_assets).length ? asList(row.upp_assets) : asList(row.stl_assets); + return assets + .slice(0, 6) + .map((asset) => { + const model = asset.algorithm_model || "STL"; + const count = Number(asset.file_count || 0); + return `${model}${count ? ` ${count}个` : ""}`; + }) + .join(";"); +} + function setDbStatus(data) { app.viewerUrl = data.viewer_url || app.viewerUrl; const pill = $("dbStatus"); @@ -205,14 +225,15 @@ function renderList() { card.className = "relation-card"; card.classList.toggle("active", app.active?.ct_key === row.ct_key); card.title = cardTitle(row); - const stlCount = Number(row.stl_file_count || row.stl_file_count_agg || 0); + const stlCount = stlFileCount(row); + const assetCount = stlAssetCount(row); const labels = dicomParts(row); card.innerHTML = `
${escapeHtml(row.ct_key)} ${escapeHtml(row.pacs_patient_name || row.upp_patient_name || "无姓名")} · ${escapeHtml(row.patient_id || row.patient_id_masked || "无ID")} - DICOM ${Number(row.pacs_series_count || 0)} 序列${stlCount ? ` · STL ${stlCount} 个` : ""} + DICOM ${Number(row.pacs_series_count || 0)} 序列${stlCount ? ` · STL ${stlCount} 个` : ""}${assetCount > 1 ? ` · ${assetCount} 组` : ""}
${escapeHtml(statusLabel(row.relation_status))} @@ -310,7 +331,7 @@ function renderDetail(row) { "nodeStl", row.stl_asset_present ? (row.stl_present ? "ok" : "warn") : "missing", row.stl_ct_number || "缺失", - `${row.algorithm_model || "无重建模型"} · ${Number(row.stl_file_count || row.stl_file_count_agg || 0)} STL`, + `${row.algorithm_model || "无重建模型"} · ${stlFileCount(row)} STL${stlAssetCount(row) > 1 ? ` · ${stlAssetCount(row)} 组资产` : ""}`, ); renderDl("pacsDetails", [ @@ -330,12 +351,15 @@ function renderDetail(row) { ["患者", row.upp_patient_name], ["STL状态", row.stl_present ? "存在" : "缺失"], ["重建模型", row.algorithm_model], + ["STL资产", `${stlAssetCount(row)} 组`], + ["资产明细", assetSummary(row)], + ["UPP记录", row.upp_asset_count ? `${Number(row.upp_asset_count)} 条` : ""], ["UPP状态", row.upp_status], ["姓名核对", row.patient_name_match === "different" ? row.patient_name_match_note : row.patient_name_match === "same" ? row.patient_name_match_note || "匹配" : ""], ["年龄/性别", [row.patient_age, row.upp_patient_sex].filter(Boolean).join(" / ")], ["检查时间", fmtDate(row.exam_date)], ["任务时间", fmtDate(row.task_created_at)], - ["文件数", Number(row.stl_file_count || row.stl_file_count_agg || 0)], + ["文件数", stlFileCount(row)], ["总大小", fmtBytes(row.stl_total_bytes || row.stl_total_bytes_agg)], ["目录", row.processed_stl_dir], ["分割分类", uniq(asList(row.segment_categories)).join("、")],