iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0

摘要
Day 9 已完成三條 Reference 規則,確認 Observation.subjectDiagnosticReport.result,以及 DiagnosticReport.subjectObservation.subject 的同病人關係。Day 10 先整理這三條規則重複的 Bundle 內 reference 解析邏輯,再實作第四條交換契約規則 LAB-CODE-001Observation.code 必須包含合作方契約允許的 LOINC coding。

為什麼 LOINC 規則需要放在 Reference 規則之後?

前三條 Reference 規則處理的是資料鏈是否接得起來:

Patient
  ↑
Observation
  ↑
DiagnosticReport

也就是先確認:

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

但 Reference 都正確,仍然不代表合作方一定能接收這筆檢驗資料。

例如一筆 Observation 可以正確指向 Patient,也可以被 DiagnosticReport 正確引用,但它的檢驗代碼可能不是合作契約允許的代碼:

Observation.code.coding.system = http://loinc.org
Observation.code.coding.code   = 9999-9

FHIR parser 可能可以解析這筆資料。

TW Core Profile 也不一定會知道某個合作方只接受哪些檢驗項目。

但從交換情境來看,合作方會問的是:

這筆 Observation.code 是否屬於本次交換契約允許的 LOINC 集合?

這就是 LAB-CODE-001 要處理的問題。

它不是完整 terminology validation。

它是交換契約允許集合檢查。

今天的實作範圍

今天新增或修改的範圍只有:

  • BundleReferenceIndex
  • LabRef001ObservationSubjectRule
  • LabRef002DiagnosticReportResultRule
  • LabRef003ReportObservationPatientRule
  • LabCode001ObservationLoincRule
  • LabCode001ObservationLoincRuleTests
  • 4 份 LAB-CODE-001 專用 fixture
  • Day 10 journal

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

今天完成後,六條核心交換規則的進度變成:

Observation.subject → Patient
DiagnosticReport.result → Observation
DiagnosticReport.subject 與 Observation.subject → 同一 Patient
Observation.code → 契約允許 LOINC

為什麼今天先抽 Reference 共用 helper?

Day 7、Day 8、Day 9 的 Reference 規則都需要處理同一批 MVP 邊界:

Resource logical reference,例如 Patient/patient-1
Bundle.entry.fullUrl,例如 urn:uuid:...
外部 HTTP reference,例如 https://example.org/fhir/Patient/external
沒有 id 時的 UNKNOWN path 顯示

Day 9 之前先不抽 helper,是因為三條規則還沒全部完成。

第三條規則完成後,才比較清楚真正共用的是:

收集 Resource logical reference
收集 Bundle.entry.fullUrl
辨識 external HTTP reference
建立 Patient fullUrl 與 logical id alias
建立穩定 path 顯示

所以 Day 10 新增:

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

這個 helper 是 package-private。

它不是新的 public API,也不改 ContractRuleRuleResultRuleOutcome

它只是把三條 Reference 規則裡重複的 Bundle reference indexing 收斂到同一個地方。

整理後的 helper 角色大致如下:

final class BundleReferenceIndex {

  static Set<String> referencesTo(Bundle bundle, Class<? extends Resource> resourceType, String resourceName) {
    // 建立 Resource/{id} 與 Bundle.entry.fullUrl reference 集合
  }

  static Map<String, Observation> observationsByReference(Bundle bundle) {
    // 讓 DiagnosticReport.result 可以找到 Bundle 內 Observation
  }

  static Map<String, String> patientReferenceAliases(Bundle bundle) {
    // 讓 Patient/{id} 與同一個 Patient entry.fullUrl 對應到同一個 canonical reference
  }

  static boolean isExternalReference(String reference) {
    return reference != null && (reference.startsWith("http://") || reference.startsWith("https://"));
  }
}

LAB-CODE-001 要處理什麼?

LAB-CODE-001 只看:

Observation.code.coding[*].system
Observation.code.coding[*].code

MVP 目前固定允許兩個 LOINC code:

LOINC code 用途
2345-7 Glucose 類示範案例
718-7 Hemoglobin 類示範案例

規則判斷條件如下:

system = http://loinc.org
code   in [2345-7, 718-7]

只要 Observation.code.coding[*] 任一筆符合條件,就回 PASS

如果 Observation 存在,但沒有任何一筆 coding 符合條件,就回 FAIL

目標是展示「合作方契約允許集合」這一層,就足以說明格式驗證之外還有交換契約規則。

Day 10 的結果分類

LAB-CODE-001 的分類順序如下:

Bundle 沒有 Observation → NOT_APPLICABLE
Observation.code 沒有 coding → FAIL
Observation.code.coding 缺 code → FAIL
Observation.code.coding 不是 http://loinc.org → FAIL
Observation.code.coding 是 LOINC 但不在允許集合 → FAIL
Observation.code.coding 包含允許 LOINC → PASS

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

第一,沒有 Observation 時不是 FAIL

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

NOT_APPLICABLE

第二,有 Observation 但沒有可用的 code.coding 時是 FAIL

因為 LAB-CODE-001 要確認檢驗代碼是否屬於合作契約允許集合。

如果 Observation 存在卻沒有 coding,合作方無法用契約集合判斷這是哪一個檢驗項目。

第三,Day 10 沒有把不允許的 LOINC 回成 NOT_EVALUATED

因為目前規則已經有固定允許集合:

2345-7
718-7

只要 Observation 存在,就能判斷它是否符合這個集合。

所以不在集合內是 FAIL,不是尚未評估。

測試資料

Day 10 新增或複用以下 fixture:

fixture 用途 LAB-CODE-001 預期
valid-loinc-code.json Observation.code 包含 http://loinc.org 與允許 code 2345-7 PASS
loinc-code-not-allowed.json Observation.code 是 LOINC,但 code 9999-9 不在允許集合 FAIL
observation-code-without-coding.json Observation 有 code text,但沒有 coding FAIL
observation-coding-without-code.json Observation coding 有 system,但缺少 code FAIL
unsupported-resource-in-bundle.json Bundle 沒有 Observation NOT_APPLICABLE

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

它們的目的,是先把 LAB-CODE-001 的最小狀態分類固定下來:

PASS
FAIL
NOT_APPLICABLE

後續加入契約 YAML/JSON 載入時,才有穩定的規則行為可以比對。

實作

Day 10 新增:

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

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

public class LabCode001ObservationLoincRule implements ContractRule {

  public static final String RULE_CODE = "LAB-CODE-001";

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

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

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

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

核心邏輯分成三步:

找出 Bundle 內所有 Observation
        ↓
逐一讀取 Observation.code.coding
        ↓
確認是否存在允許的 http://loinc.org coding

允許集合目前先寫在 class 裡:

private static final String LOINC_SYSTEM = "http://loinc.org";
private static final Set<String> ALLOWED_LOINC_CODES = Set.of("2345-7", "718-7");

判斷邏輯如下:

private boolean hasAllowedLoincCoding(Observation observation) {
  if (!observation.hasCode() || !observation.getCode().hasCoding()) {
    return false;
  }
  return observation.getCode().getCoding().stream()
      .anyMatch(coding -> LOINC_SYSTEM.equals(coding.getSystem())
          && coding.hasCode()
          && ALLOWED_LOINC_CODES.contains(coding.getCode()));
}

這裡特別先檢查:

coding.hasCode()

原因是測試時發現,如果 coding 缺少 code,直接拿 null 去查 immutable Set 可能會造成不必要的例外。

規則應該回 FAIL,不應該讓單筆缺欄位資料把測試跑成 error。

五種測試案例

Day 10 新增:

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

測試一:Observation 有契約允許的 LOINC code

assertThat(results.get(0).ruleCode()).isEqualTo("LAB-CODE-001");
assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.PASS);
assertThat(results.get(0).path()).isEqualTo("Observation/obs-valid-loinc.code.coding");
assertThat(results.get(0).actual()).contains("http://loinc.org|2345-7");

這代表最基本的合作契約允許代碼可以通過。

測試二:Observation 有 LOINC,但不在契約允許集合

assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.FAIL);
assertThat(results.get(0).severity()).isEqualTo("error");
assertThat(results.get(0).actual()).contains("http://loinc.org|9999-9");

這代表問題不是 coding 格式不存在,而是 code 不符合本次交換契約。

測試三:Observation.code 沒有 coding

assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.FAIL);
assertThat(results.get(0).path()).isEqualTo("Observation/obs-no-coding.code.coding");
assertThat(results.get(0).actual()).isEqualTo("N/A");

這代表只有 code.text 不足以通過契約允許集合檢查。

測試四:Observation coding 缺少 code

assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.FAIL);
assertThat(results.get(0).actual()).contains("http://loinc.org|N/A");

這代表缺少 code 時會得到規則失敗,而不是程式例外。

測試五:Bundle 沒有 Observation

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

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

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

自動化驗證

指令:

./mvnw test

測試結果:

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

https://ithelp.ithome.com.tw/upload/images/20260811/20177913Ifd5Nbe7lt.png

Day 10 從 Day 9 的 35 個測試增加到 40 個測試。

新增的 5 個測試都集中在 LAB-CODE-001

  • Observation 有允許 LOINC:PASS
  • Observation 有不允許 LOINC:FAIL
  • Observation.code 沒有 coding:FAIL
  • Observation coding 缺 code:FAIL
  • Bundle 沒有 Observation:NOT_APPLICABLE

常見錯誤 & 排查

  1. 把 LOINC 規則誤當成完整 terminology validation

LAB-CODE-001 不是完整 LOINC terminology validation。

它沒有查 terminology server,也沒有做 ValueSet expansion。

它只檢查:

Observation.code.coding 是否包含契約允許的 http://loinc.org code

所以文章裡應該稱為「契約允許集合檢查」,不要稱為完整術語驗證。

  1. 只看 code,不看 system

LOINC code 不能只看:

2345-7

還要確認 system 是:

http://loinc.org

否則不同 coding system 裡剛好相同的 code 字串可能被誤判。

  1. 把不在集合內的 LOINC 回 NOT_EVALUATED

Day 10 已經有固定允許集合。

所以如果 code 不在:

2345-7
718-7

就應該回:

FAIL
  1. 只有 code.text 就通過

Observation.code.text 可以提供人類可讀文字,但 Day 10 的契約檢查是 coding 集合。

所以只有:

"code": {
  "text": "Glucose"
}

仍然回 FAIL

  1. 缺少 coding.code 時丟程式例外

缺少 coding.code 是資料問題,不應該造成規則引擎 error。

Day 10 測試中已固定這個行為:

http://loinc.org|N/A → FAIL

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

前三條規則回答的是:

資料鏈是否接得起來?

Day 10 的 LAB-CODE-001 回答的是:

接起來的 Observation,是否是合作方願意接收的檢驗項目?

也就是說,就算資料長這樣:

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

還是可能因為:

Observation.code.coding = http://loinc.org|9999-9

而被交換契約擋下。

這不是 JSON 格式錯誤。

也不一定是 TW Core Profile 錯誤。

它比較適合被歸類為:

Exchange Contract: FAILED

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

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

今天完成了什麼

  • 新增 BundleReferenceIndex
  • 重構 LAB-REF-001LAB-REF-002LAB-REF-003 使用共用 reference helper。
  • 新增 LabCode001ObservationLoincRule
  • 新增 LabCode001ObservationLoincRuleTests
  • LAB-CODE-001 支援固定允許 LOINC:2345-7718-7
  • LAB-CODE-001 對允許 LOINC 回 PASS
  • LAB-CODE-001 對不允許 LOINC 回 FAIL
  • LAB-CODE-001 對沒有 coding 或 coding 缺 code 回 FAIL
  • LAB-CODE-001 對沒有 Observation 的 Bundle 回 NOT_APPLICABLE
  • 新增 4 份 LAB-CODE-001 專用 fixture。
  • 新增 5 個單元測試。
  • ./mvnw test 通過,測試數從 35 增加到 40。

Day 10 尚未處理:

  • 將交換契約規則接回 BundleParseService
  • 在首頁顯示 RuleResult
  • 契約 YAML/JSON 載入。
  • 契約啟用/停用規則。
  • LAB-UNIT-001 Quantity 可讀 unit。
  • LAB-UNIT-002 UCUM system/code 契約允許集合。
  • 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 契約允許集合             未開始

下一步預計進入 LAB-UNIT-001

原因是目前已經能證明:

Reference 鏈正確
Observation.code 包含合作方允許 LOINC

但還不能判斷 Quantity 類型的檢驗數值是否有可讀單位。

完成 LAB-UNIT-001 後,再處理 LAB-UNIT-002,確認 UCUM system 與 code 是否符合合作契約。

Repository:twcore-data-quality-gate


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

尚未有邦友留言

立即登入留言