iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0

摘要
Day 13 已經把六條交換契約規則接回 Bundle 解析流程與首頁結果頁,使用者可以在同一個畫面看到 JSON、FHIR R4、TW Core、交換契約四層結果,以及最小 Quality Gate。Day 14 切出契約版本比較的第一個可測試骨架:同一份 Bundle 在契約 v1.0 與 v1.1 下,可以因為 v1.1 新增 UCUM system/code 要求而得到不同 Gate 結果。

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

Day 13 已經可以回答:

這份 Bundle 在目前契約下能不能通過 Quality Gate?

Day 14 開始回答另一個問題:

如果合作方契約升級,同一份 Bundle 的結果會不會改變?

今天的例子是 UCUM system/code。

在 v1.0 中,契約只要求:

Observation.valueQuantity.unit 必須有可讀單位

所以這份資料可以通過:

unit = mg/dL

但在 v1.1 中,契約多要求:

system = http://unitsofmeasure.org
code 必須在允許 UCUM code 集合

同一份資料就會被阻擋:

system = http://example.org/local-units

這就是契約版本比較核心:

不是資料本身突然變壞,而是交換契約變嚴格了。

今天的實作範圍

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

  • ContractVersion
  • ContractComparisonResult
  • ContractComparisonService
  • BundleParseService
  • ContractComparisonServiceTests
  • observation-quantity-wrong-ucum-system.json

今天的成果主要在 service 與 test 層,首頁仍維持 Day 13 的單一版本驗證畫面。

為什麼要先做 ContractVersion?

Day 13 的 BundleParseService 會固定執行六條交換契約規則:

LAB-REF-001
LAB-REF-002
LAB-REF-003
LAB-CODE-001
LAB-UNIT-001
LAB-UNIT-002

這很適合單一契約版本。
但如果要展示契約 v1.0 / v1.1 的差異,就需要回答一個問題:

哪個契約版本啟用哪些規則?

Day 14 先用最小 enum 表示這件事:

public enum ContractVersion {
  V1_0(Set.of(
      LabRef001ObservationSubjectRule.RULE_CODE,
      LabRef002DiagnosticReportResultRule.RULE_CODE,
      LabRef003ReportObservationPatientRule.RULE_CODE,
      LabCode001ObservationLoincRule.RULE_CODE,
      LabUnit001ObservationQuantityUnitRule.RULE_CODE
  )),
  V1_1(Set.of(
      LabRef001ObservationSubjectRule.RULE_CODE,
      LabRef002DiagnosticReportResultRule.RULE_CODE,
      LabRef003ReportObservationPatientRule.RULE_CODE,
      LabCode001ObservationLoincRule.RULE_CODE,
      LabUnit001ObservationQuantityUnitRule.RULE_CODE,
      LabUnit002ObservationUcumCodeRule.RULE_CODE
  ));
}

這裡的版本差異刻意只有一個:

v1.1 比 v1.0 多啟用 LAB-UNIT-002

也就是 v1.1 開始要求:

Observation.valueQuantity.system 必須是 http://unitsofmeasure.org
Observation.valueQuantity.code 必須在契約允許的 UCUM code 集合中

BundleParseService 的變化

Day 13 的 parse 方法只有一種行為:

parse(bundleJson) → 執行全部六條規則

Day 14 保留這個預設行為,但把它明確視為最新契約 v1.1:

public ValidationResult parse(String bundleJson) {
  return parse(bundleJson, ContractVersion.V1_1);
}

這樣 Day 13 的首頁與既有測試都不用改語意。
原本呼叫 parse(bundleJson) 的地方,仍然會看到六條規則。
接著新增可以指定契約版本的 overload:

public ValidationResult parse(String bundleJson, ContractVersion contractVersion)

真正執行交換規則時,會依照契約版本過濾:

private List<RuleResult> validateContractRules(Bundle bundle, ContractVersion contractVersion) {
  return contractRules.stream()
      .filter(rule -> contractVersion.enables(rule.ruleCode()))
      .flatMap(rule -> rule.validate(bundle).stream())
      .toList();
}

這個改法有兩個好處。

第一,Day 13 的單一版本驗證流程不被破壞。
首頁仍然可以把 v1.1 當成目前預設契約。

第二,Day 14 的比較服務可以用同一套解析與 Gate 計算邏輯。
不需要複製 JSON parse、FHIR parse、TW Core validation 或 Quality Gate 的程式。

ContractComparisonService 做什麼?

Day 14 新增一個很薄的比較服務:

public ContractComparisonResult compare(String bundleJson) {
  return new ContractComparisonResult(
      bundleParseService.parse(bundleJson, ContractVersion.V1_0),
      bundleParseService.parse(bundleJson, ContractVersion.V1_1)
  );
}

它不做 Change Manifest。
它也不判斷:

COMPATIBLE
EXPECTED_BREAKING_CHANGE
UNEXPECTED_REGRESSION

目前只做最小比較:

同一份 Bundle
        ↓
用 v1.0 跑一次
        ↓
用 v1.1 跑一次
        ↓
回傳兩份 ValidationResult

ContractComparisonResult 也刻意維持很薄:

public record ContractComparisonResult(
    ValidationResult v1Result,
    ValidationResult v1_1Result
) {
}

也就是說,Day 14 的成果不是完整治理平台。
它只是先把「同一份資料套不同契約版本」這件事接起來。

為什麼要修改 UCUM 錯誤案例?

一開始測試使用:

observation-quantity-wrong-ucum-system.json

但第一次跑測試時發現一個問題:

v1.0 沒有執行 LAB-UNIT-002,仍然是 BLOCKED

原因不是契約版本邏輯錯。
原因是原本這份 fixture 只有 Observation,沒有 Patient 與 DiagnosticReport。
因此即使 v1.0 不檢查 UCUM system/code,它仍然會被 Reference 規則阻擋。

這不是我們今天要展示的差異。
今天需要的是:

除了 LAB-UNIT-002 以外,其他規則都通過

所以 Day 14 把這份 fixture 補成完整三 Resource Bundle:

Patient
Observation
DiagnosticReport

並保留唯一錯誤:

"valueQuantity": {
  "value": 95,
  "unit": "mg/dL",
  "system": "http://example.org/local-units",
  "code": "mg/dL"
}

這樣測試語意才乾淨:

v1.0:不啟用 LAB-UNIT-002,所以不因 UCUM system/code 阻擋
v1.1:啟用 LAB-UNIT-002,所以因 UCUM system/code 阻擋

契約版本比較測試

Day 14 新增:

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

核心測試是:

ContractComparisonResult comparison =
    service.compare(fixture("observation-quantity-wrong-ucum-system.json"));

assertThat(comparison.v1Result().gateOutcome()).isEqualTo(GateOutcome.PASSED);
assertThat(comparison.v1Result().contractRuleResults())
    .extracting(RuleResult::ruleCode)
    .doesNotContain(LabUnit002ObservationUcumCodeRule.RULE_CODE);

assertThat(comparison.v1_1Result().gateOutcome()).isEqualTo(GateOutcome.BLOCKED);
assertThat(comparison.v1_1Result().contractRuleResults())
    .anySatisfy(ruleResult -> {
      assertThat(ruleResult.ruleCode()).isEqualTo(LabUnit002ObservationUcumCodeRule.RULE_CODE);
      assertThat(ruleResult.outcome()).isEqualTo(RuleOutcome.FAIL);
    });

這個測試確認三件事。

第一,同一份 Bundle 可以分別用 v1.0 與 v1.1 驗證。

第二,v1.0 的規則結果中不包含:

LAB-UNIT-002

第三,v1.1 的規則結果中包含:

LAB-UNIT-002 → FAIL

所以整體 Gate 會從:

v1.0 → PASSED
v1.1 → BLOCKED
契約版本 啟用 LAB-UNIT-002 Gate 結果 原因
v1.0 PASSED 只檢查可讀 unit
v1.1 BLOCKED UCUM system 不符

Day 14 的重點不是宣稱 v1.1 比 v1.0 好,而是讓系統能重現「契約變嚴格後,同一份資料結果改變」這件事。

自動化驗證

指令:

./mvnw test

測試結果:

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

https://ithelp.ithome.com.tw/upload/images/20260815/20177913SvSsECZvM0.png

Day 14 從 Day 13 的 52 個測試增加到 53 個測試。
新增的 1 個測試集中在契約版本比較服務:

  • 同一份 UCUM system 錯誤 Bundle,在 v1.0 下 PASSED
  • 同一份 UCUM system 錯誤 Bundle,在 v1.1 下 BLOCKED
  • v1.0 不執行 LAB-UNIT-002
  • v1.1 執行 LAB-UNIT-002 且得到 FAIL

常見錯誤 & 排查

  1. 用不乾淨的 fixture 做版本差異測試

這次第一次測試失敗,就是因為 UCUM 錯誤案例本身還缺 Patient 與 DiagnosticReport reference 鏈。
如果同一份資料同時有多個錯誤,就很難證明:

v1.0 / v1.1 差異只來自 LAB-UNIT-002

所以版本比較案例要盡量一次只保留一個差異點。

  1. 直接把 v1.0 當成「少跑一條測試」而不是契約版本

Day 14 沒有在測試裡手動刪掉某條規則。
而是新增:

ContractVersion.V1_0
ContractVersion.V1_1

再讓 BundleParseService 根據版本決定規則集合。
這樣之後要接 UI、API 或契約檔載入時,才有一個明確的版本入口。

  1. 讓比較服務重新實作驗證流程

ContractComparisonService 不重新 parse JSON,也不重新計算 Quality Gate。
它只呼叫:

bundleParseService.parse(bundleJson, ContractVersion.V1_0)
bundleParseService.parse(bundleJson, ContractVersion.V1_1)

這可以避免比較流程和單一驗證流程出現不一致。

今天完成了什麼

  • 新增 ContractVersion
  • ContractVersion.V1_0 啟用五條規則,不含 LAB-UNIT-002
  • ContractVersion.V1_1 啟用六條規則,包含 LAB-UNIT-002
  • BundleParseService.parse(String) 保持既有預設行為,預設使用 v1.1。
  • BundleParseService 新增可指定契約版本的 parse(String, ContractVersion)
  • BundleParseService 依契約版本過濾交換規則。
  • 新增 ContractComparisonResult
  • 新增 ContractComparisonService
  • 新增 ContractComparisonServiceTests
  • 修正 observation-quantity-wrong-ucum-system.json,讓它只保留 UCUM system/code 差異。
  • ./mvnw test 通過,測試數從 52 增加到 53。

Day 14 尚未處理:

  • 契約 YAML/JSON 載入。
  • 契約啟用/停用規則的外部設定。
  • Change Manifest。
  • COMPATIBLE / EXPECTED_BREAKING_CHANGE / UNEXPECTED_REGRESSION 三分類。
  • 首頁顯示 v1.0 / v1.1 比較。
  • Docker Compose。
  • GitHub Actions。

目前的 MVP 進度:

validation-flow
├─ JSON parse                                  完成
├─ FHIR R4 parse                               完成
├─ FHIR R4 validation                          完成
├─ TW Core validation / safe NOT_EVALUATED      完成
├─ Exchange contract rules
│  ├─ LAB-REF-001                              完成並接回畫面
│  ├─ LAB-REF-002                              完成並接回畫面
│  ├─ LAB-REF-003                              完成並接回畫面
│  ├─ LAB-CODE-001                             完成並接回畫面
│  ├─ LAB-UNIT-001                             完成並接回畫面
│  └─ LAB-UNIT-002                             完成並接回畫面
├─ Quality Gate                                完成最小版
└─ Contract comparison
   ├─ ContractVersion                          完成最小版
   ├─ v1.0 / v1.1 rule selection                完成最小版
   └─ comparison service test                   完成最小版

下一步預計處理:

首頁/API 顯示比較
契約檔載入

Repository:twcore-data-quality-gate


上一篇
Day13 - 把六條交換規則接成可操作 Quality Gate
下一篇
Day15 - 情境測試包最小化
系列文
醫療資料通過標準驗證,就真的能交換嗎?——30 天打造 TW Core 資料品質閘門16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言