iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0

摘要
Day 8 已完成第二條 Reference 規則 LAB-REF-002,確認 DiagnosticReport.result 必須指向 Bundle 中存在的 Observation。Day 9 實作第三條 Reference 規則 LAB-REF-003DiagnosticReport.subject 與它引用的 Observation.subject 必須對應同一位 Patient。

為什麼第三條規則要檢查「同一位病人」?

Day 7 處理的是:

Observation.subject → Patient

也就是檢驗結果要能找到它所屬的病人。

Day 8 處理的是:

DiagnosticReport.result → Observation

也就是檢驗報告列出的檢驗結果,必須真的存在於同一份待驗證 Bundle 裡。

但這兩條都通過,還是不代表整份報告一定合理。

例如一份 Bundle 裡可以出現這種資料:

DiagnosticReport.subject → Patient/patient-a
DiagnosticReport.result  → Observation/obs-1
Observation/obs-1.subject → Patient/patient-b

這時候每一個 reference 可能都找得到 Resource。

Patient/patient-a 存在。

Observation/obs-1 也存在。

Patient/patient-b 也存在。

但從交換情境來看,這份資料仍然有問題:

報告說自己屬於 A 病人,但報告裡的檢驗結果其實屬於 B 病人。

LAB-REF-003 要回答的問題是:

這份 DiagnosticReport 引用的 Observation,是否真的屬於同一位 Patient?

這種錯誤通常不是 JSON parser 或單一 Resource Profile 能完整判斷,因為問題發生在多個 Resource 的組合語意。

今天的實作範圍

今天新增的範圍只有:

  • LabRef003ReportObservationPatientRule
  • LabRef003ReportObservationPatientRuleTests
  • 3 份 LAB-REF-003 專用 fixture
  • Day 9 journal

今天沒有把規則接回 BundleParseService,也沒有做 UI、契約載入、LOINC、UCUM 或 Quality Gate。

原因和 Day 8 一樣。

如果今天同時做規則、服務層、畫面和契約設定,很容易每一層都只完成一半。

先讓第三條 Reference 規則可以被單元測試穩定驗證,會比提早接畫面更重要。

今天完成後,三條 Reference 規則就補齊了:

Observation.subject → Patient
DiagnosticReport.result → Observation
DiagnosticReport.subject 與 Observation.subject → 同一 Patient

第三條規則要處理什麼?

LAB-REF-003 同時看三個欄位:

DiagnosticReport.subject.reference
DiagnosticReport.result[*].reference
Observation.subject.reference

MVP 目前支援兩種 Bundle 內 reference:

reference 形式 範例 Day 9 行為
Resource logical reference Patient/patient-1Observation/obs-1 若可解析且指向同一 Patient,回 PASS
Bundle entry fullUrl urn:uuid:b23e4567-e89b-12d3-a456-426614174030 若 fullUrl 對應同一 Patient,回 PASS

MVP 目前不解析外部 HTTP reference:

reference 形式 範例 Day 9 行為
外部 FHIR Server reference https://example.org/fhir/Patient/external-patient NOT_EVALUATED
外部 FHIR Server reference https://example.org/fhir/Observation/external-observation NOT_EVALUATED

原因和 Day 7、Day 8 一樣。

外部 reference 需要連到外部 FHIR Server 才能確認 Resource 是否存在,也才能進一步比對病人是否一致。

Day 9 還沒有外部查詢能力,所以不能說通過,也不能直接說資料錯誤。

比較誠實的狀態仍然是:

NOT_EVALUATED

Day 9 的結果分類

LAB-REF-003 的分類順序如下:

Bundle 沒有 DiagnosticReport → NOT_APPLICABLE
DiagnosticReport.subject.reference 空白 → FAIL
DiagnosticReport 沒有 result → FAIL
result.reference 空白 → FAIL
result.reference 是外部 HTTP reference → NOT_EVALUATED
Bundle 內找不到 result 指向的 Observation → FAIL
Observation.subject.reference 空白 → FAIL
Observation.subject.reference 是外部 HTTP reference → NOT_EVALUATED
Report subject 與 Observation subject 指向同一 Patient → PASS
Report subject 與 Observation subject 指向不同 Patient → FAIL

這裡有四個容易混淆的地方。

第一,沒有 DiagnosticReport 時不是 FAIL

因為這條規則的適用對象不存在,所以回:

NOT_APPLICABLE

第二,有 DiagnosticReport 但沒有 subjectresult 時是 FAIL

因為 LAB-REF-003 要比對報告與檢驗結果是否屬於同一位病人。

如果報告沒有病人或沒有檢驗結果,這份資料在本專案的檢驗資料交換情境中是不完整的。

第三,外部 HTTP reference 是 NOT_EVALUATED

因為系統目前沒有外部查詢能力,不能把沒查過的外部 Resource 當成通過。

第四,只確認 reference 找得到還不夠。

LAB-REF-003 的重點是「同一位病人」:

DiagnosticReport
  ├─ subject → Patient A
  └─ result  → Observation
                 └─ subject → Patient A

如果 Report 和 Observation 指向不同 Patient,即使兩個 Patient 都存在於 Bundle 中,也必須回 FAIL

測試資料

Day 9 新增或複用以下 fixture:

fixture 用途 LAB-REF-003 預期
valid-internal-reference.json Report 與 Observation 都用 logical id 指向同一 Patient PASS
report-subject-full-url-observation-subject-id.json Report subject 用 Patient fullUrl,Observation subject 用 logical id PASS
mismatched-report-observation-patient.json Report 與 Observation 指向不同 Patient FAIL
missing-report-result-reference.json Report result 指向不存在的 Observation FAIL
missing-report-result-field.json DiagnosticReport 沒有 result FAIL
external-report-result-reference.json Report result 指向外部 HTTP Observation NOT_EVALUATED
external-observation-subject-reference.json Observation subject 指向外部 HTTP Patient NOT_EVALUATED
unsupported-resource-in-bundle.json Bundle 沒有 DiagnosticReport NOT_APPLICABLE

這些 fixture 的目的不是追求案例數量。

它們的目的,是先把 LAB-REF-003 的狀態分類固定下來:

PASS
FAIL
NOT_APPLICABLE
NOT_EVALUATED

後續做情境測試包時,才有穩定的規則行為可以組合。

實作

Day 9 新增:

src/main/java/com/twlab/qualitygate/validation
└─ LabRef003ReportObservationPatientRule.java

規則仍然實作同一個介面:

public class LabRef003ReportObservationPatientRule implements ContractRule {

  public static final String RULE_CODE = "LAB-REF-003";

  @Override
  public String ruleCode() {
    return RULE_CODE;
  }

  @Override
  public List<RuleResult> validate(Bundle bundle) {
    // ...
  }
}

這代表 Day 7 建立的 ContractRule 介面仍然可以繼續使用。

第三條規則也不需要新的資料模型。

核心邏輯分成三步:

建立 Observation reference → Observation 對照表
        ↓
建立 Patient reference alias 對照表
        ↓
逐一檢查 DiagnosticReport.subject 與 Observation.subject 是否指向同一 Patient

Observation 對照表包含:

Observation/{id}
Bundle.entry.fullUrl

概念如下:

private Map<String, Observation> collectObservationsByReference(Bundle bundle) {
  Map<String, Observation> observationsByReference = new HashMap<>();
  for (Bundle.BundleEntryComponent entry : bundle.getEntry()) {
    Resource resource = entry.getResource();
    if (resource instanceof Observation observation) {
      String id = observation.getIdElement().getIdPart();
      if (id != null && !id.isBlank()) {
        observationsByReference.put("Observation/" + id, observation);
      }
      String fullUrl = entry.getFullUrl();
      if (fullUrl != null && !fullUrl.isBlank()) {
        observationsByReference.put(fullUrl, observation);
      }
    }
  }
  return observationsByReference;
}

Day 8 的 LAB-REF-002 只需要知道 Observation 是否存在,所以用 Set<String> 就夠了。

Day 9 的 LAB-REF-003 還要讀取被引用 Observation 的 subject,所以改成:

Map<String, Observation>

接著建立 Patient alias。

這段是為了讓 logical id 和 fullUrl 可以對應到同一位 Patient:

Patient/patient-full-url-match
urn:uuid:b23e4567-e89b-12d3-a456-426614174030

如果這兩種 reference 都指到同一個 Bundle entry Patient,就應該被視為同一位病人。

概念如下:

private Map<String, String> collectPatientReferenceAliases(Bundle bundle) {
  Map<String, String> patientReferenceAliases = new HashMap<>();
  for (Bundle.BundleEntryComponent entry : bundle.getEntry()) {
    Resource resource = entry.getResource();
    if (resource != null && "Patient".equals(resource.fhirType())) {
      String id = resource.getIdElement().getIdPart();
      String canonical = id == null || id.isBlank() ? entry.getFullUrl() : "Patient/" + id;
      if (canonical == null || canonical.isBlank()) {
        continue;
      }
      patientReferenceAliases.put(canonical, canonical);
      String fullUrl = entry.getFullUrl();
      if (fullUrl != null && !fullUrl.isBlank()) {
        patientReferenceAliases.put(fullUrl, canonical);
      }
    }
  }
  return patientReferenceAliases;
}

最後比對 Report subject 和 Observation subject(以下簡化示意):

String canonicalReportSubject = patientReferenceAliases.getOrDefault(reportSubject, reportSubject);
String canonicalObservationSubject = patientReferenceAliases.getOrDefault(observationSubject, observationSubject);

if (canonicalReportSubject.equals(canonicalObservationSubject)) {
  return pass(path, actual);
}

return fail(path, actual, "DiagnosticReport.subject and referenced Observation.subject point to different Patients.");

這裡的重點不是把 reference 字串寫得一模一樣。

而是要知道它們是否對應同一個 Bundle 內 Patient。

所以這兩種組合都可以通過:

DiagnosticReport.subject.reference = Patient/patient-1
Observation.subject.reference      = Patient/patient-1

以及:

DiagnosticReport.subject.reference = urn:uuid:b23e4567-e89b-12d3-a456-426614174030
Observation.subject.reference      = Patient/patient-full-url-match

為什麼今天還不抽共用 Reference helper?

Day 7、Day 8、Day 9 的規則都有 reference 解析邏輯。

但三條規則的需求不完全一樣:

  • LAB-REF-001:Observation → Patient 是否存在
  • LAB-REF-002:DiagnosticReport → Observation 是否存在
  • LAB-REF-003:Report subject 與 Observation subject 是否一致

第三條規則完成後,才比較看得出真正共用的是什麼:

收集 Resource logical reference
收集 Bundle.entry.fullUrl
辨識 external HTTP reference
建立 path
輸出一致的 RuleResult

所以 Day 9 先不抽 helper。

等三條 Reference 規則都穩定後,再整理共用 reference helper,會比現在先猜一個抽象安全。

八種測試案例

Day 9 新增:

src/test/java/com/twlab/qualitygate/validation/LabRef003ReportObservationPatientRuleTests.java

測試一:Report 與 Observation 都用 logical id 指向同一 Patient

assertThat(results.get(0).ruleCode()).isEqualTo("LAB-REF-003");
assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.PASS);
assertThat(results.get(0).path()).isEqualTo("DiagnosticReport/report-valid-ref.result[0].reference");

這代表最基本的同病人 reference 可以通過。

測試二:Report subject 使用 Patient fullUrl,Observation subject 使用 logical id

assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.PASS);
assertThat(results.get(0).actual()).contains("urn:uuid:b23e4567-e89b-12d3-a456-426614174030");
assertThat(results.get(0).actual()).contains("Patient/patient-full-url-match");

這代表規則不是只做字串相等。

它能把同一個 Patient 的 entry.fullUrlPatient/{id} 視為同一個目標。

測試三:Report 與 Observation 指向不同 Patient

assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.FAIL);
assertThat(results.get(0).severity()).isEqualTo("error");
assertThat(results.get(0).actual())
    .isEqualTo("DiagnosticReport.subject=Patient/patient-report; Observation.subject=Patient/patient-observation");

這代表合作方可以得到明確的阻擋原因。

問題不是 Resource 不存在,而是報告與檢驗結果對應到不同病人。

測試四:Report result 指向不存在的 Observation

assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.FAIL);
assertThat(results.get(0).actual()).isEqualTo("Observation/obs-not-in-bundle");

這和 Day 8 的缺失類似。

如果 Report 引用的 Observation 不在 Bundle 裡,Day 9 也無法繼續比對病人一致性。

測試五:DiagnosticReport 沒有 result

assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.FAIL);
assertThat(results.get(0).path()).isEqualTo("DiagnosticReport/report-no-result.result");
assertThat(results.get(0).actual()).isEqualTo("N/A");

這代表本專案的檢驗報告情境要求 Report 必須能追溯到 Observation。

測試六:Report result 指向外部 HTTP Observation reference

assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.NOT_EVALUATED);
assertThat(results.get(0).severity()).isEqualTo("warning");
assertThat(results.get(0).actual()).isEqualTo("https://example.org/fhir/Observation/external-observation");

這代表系統承認 MVP 還不支援外部 Observation 查詢。

測試七:Observation subject 指向外部 HTTP Patient reference

assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.NOT_EVALUATED);
assertThat(results.get(0).severity()).isEqualTo("warning");
assertThat(results.get(0).actual()).isEqualTo("https://example.org/fhir/Patient/external-patient");

這代表即使 Report result 指向 Bundle 內 Observation,只要 Observation 的 Patient 要到外部查詢,Day 9 仍然回 NOT_EVALUATED

測試八:Bundle 沒有 DiagnosticReport

assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.NOT_APPLICABLE);
assertThat(results.get(0).severity()).isEqualTo("information");

這代表規則沒有適用對象時,不會誤判成資料錯誤。

這八段 assertion 就是 Day 9 測試的核心,不需要再另外截測試檔畫面。

自動化驗證

指令:

./mvnw test

測試結果:

Tests run: 35, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

https://ithelp.ithome.com.tw/upload/images/20260809/20177913nlofROB6CX.png

Day 9 從 Day 8 的 27 個測試增加到 35 個測試。

新增的 8 個測試都集中在 LAB-REF-003

  • Report 與 Observation 同一 Patient logical reference:PASS
  • Patient fullUrl 與 logical id alias:PASS
  • Report 與 Observation 不同 Patient:FAIL
  • Report result 指向不存在 Observation:FAIL
  • DiagnosticReport 沒有 result:FAIL
  • 外部 HTTP Observation reference:NOT_EVALUATED
  • 外部 HTTP Patient reference:NOT_EVALUATED
  • Bundle 沒有 DiagnosticReport:NOT_APPLICABLE

Day 9 的可展示成果仍然不是 UI。

它是用單元測試把第三條交換契約規則的行為固定下來。

常見錯誤 & 排查

  1. 只檢查 Observation 存不存在

LAB-REF-003 不是 LAB-REF-002 的重複。

LAB-REF-002 問的是:

DiagnosticReport.result 指向的 Observation 找得到嗎?

LAB-REF-003 問的是:

DiagnosticReport.subject 和 Observation.subject 是同一位 Patient 嗎?

所以 Observation 找得到,不代表 LAB-REF-003 一定通過。

  1. 把 fullUrl 和 logical id 當成不同病人

Bundle 內可能同時出現:

Patient/patient-full-url-match
urn:uuid:b23e4567-e89b-12d3-a456-426614174030

如果這兩個 reference 都對應同一個 Patient entry,就應該視為同一位病人。

Day 9 透過 Patient alias map 處理這件事。

  1. 對外部 HTTP reference 回 FAIL

外部 HTTP reference 不一定是錯。

它只是超出 MVP 目前能力,因為系統沒有去外部 FHIR Server 查詢。

所以 Day 9 回:

NOT_EVALUATED
  1. 把沒有 DiagnosticReport 當成 FAIL

LAB-REF-003 的適用對象是 DiagnosticReport

如果 Bundle 裡根本沒有 DiagnosticReport,這條規則沒有可以檢查的對象。

所以回:

NOT_APPLICABLE
  1. 太早抽共用 ReferenceResolver

Day 9 完成後,三條 Reference 規則都已經存在。

這時候才適合回頭整理共用 helper。

如果 Day 7 或 Day 8 就先抽,可能會只抽到「存在性檢查」,但 Day 9 其實需要的是「reference alias 與同一目標比對」。

這和「能不能交換」有什麼關係?

Day 7 的問題是:

Observation 找得到 Patient 嗎?

Day 8 的問題是:

DiagnosticReport 找得到它列出的 Observation 嗎?

Day 9 的問題是:

DiagnosticReport 和它列出的 Observation 是同一位 Patient 嗎?

三條規則加起來,才比較像一條完整的檢驗資料鏈:

Patient
  ↑
Observation
  ↑
DiagnosticReport

如果 DiagnosticReport.subject 是 A 病人,但 Observation.subject 是 B 病人,合作方收到的不是「少一筆資料」。

它收到的是一份可能把報告和檢驗結果配錯病人的資料。

這類問題不一定會被 JSON parser 擋下來。

也不應該硬說是 TW Core 官方 Profile 的錯。

它比較適合被歸類為:

Exchange Contract: FAILED

這也是資料品質閘門要補上的地方:

標準驗證告訴我們 Resource 結構是否合理。
交換契約規則告訴我們這份資料是否符合合作方接收條件。

今天完成了什麼

  • 新增 LabRef003ReportObservationPatientRule
  • 新增 LabRef003ReportObservationPatientRuleTests
  • LAB-REF-003 支援 Patient/{id}Observation/{id} reference。
  • LAB-REF-003 支援 Bundle.entry.fullUrl reference。
  • LAB-REF-003 可比對 Report subject 與 Observation subject 是否同一 Patient。
  • LAB-REF-003 對不同 Patient 回 FAIL
  • LAB-REF-003 對缺少 DiagnosticReport.resultFAIL
  • LAB-REF-003 對不存在的 Bundle 內 Observation 回 FAIL
  • LAB-REF-003 對外部 HTTP reference 回 NOT_EVALUATED
  • LAB-REF-003 對沒有 DiagnosticReport 的 Bundle 回 NOT_APPLICABLE
  • 新增 3 份 LAB-REF-003 專用 fixture。
  • 新增 8 個單元測試。
  • ./mvnw test 通過,測試數從 27 增加到 35。

Day 9 尚未處理:

  • 將交換契約規則接回 BundleParseService
  • 在首頁顯示 RuleResult
  • LOINC/UCUM 契約允許集合。
  • 契約 YAML/JSON 載入。
  • 契約啟用/停用規則。
  • Quality Gate 的 Passed / Warning / Blocked 判定。
  • 契約 v1.0/v1.1 比較。

目前的規則進度:

contract-rule
├─ LAB-REF-001  Observation.subject → Patient            完成
├─ LAB-REF-002  DiagnosticReport.result → Observation    完成
├─ LAB-REF-003  Report 與 Observation 對應同一 Patient    完成
├─ LAB-CODE-001 Observation.code 契約允許 LOINC           未開始
├─ LAB-UNIT-001 Quantity 必須具有可讀 unit                 未開始
└─ LAB-UNIT-002 UCUM system / code 契約允許集合             未開始

下一步預計先整理三條 Reference 規則的共用部分。

原因是現在已經看得到三條規則真正重複的是:

Resource logical reference
Bundle.entry.fullUrl
external HTTP reference
path 顯示格式
RuleResult 狀態分類

完成這個小重構後,再進入 LAB-CODE-001,開始處理 LOINC 契約允許集合。

Repository:twcore-data-quality-gate


上一篇
Day8 - 完成第二條交換契約 Reference 規則
下一篇
Day10 - 完成 Reference 共用整理與 LOINC 契約允許集合規則
系列文
醫療資料通過標準驗證,就真的能交換嗎?——30 天打造 TW Core 資料品質閘門10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言