摘要
Day 15 已經把代表案例整理成 Expected / Actual 測試,Day 16 已經把契約 v1.0 / v1.1 的基本比較結果接回畫面。Day 17 補上「升級後新增阻擋」的最小解讀區,讓使用者看到同一份 Bundle 從 v1.0PASSED變成 v1.1BLOCKED時,可以直接知道是哪條規則、哪個欄位、實際值是什麼、期待條件是什麼,以及應該怎麼修正。
Day 16 已經可以在畫面上回答:
同一份 Bundle 在契約 v1.0 與 v1.1 下,Quality Gate 結果差在哪裡?
例如:
v1.0 → PASSED
v1.1 → BLOCKED by LAB-UNIT-002
但這還不是使用者真正能拿去修資料的答案。
因為 LAB-UNIT-002 只是一個規則代碼。
如果畫面只停在 failed rule codes,使用者還要回頭查:
LAB-UNIT-002 到底檢查哪個欄位?
目前資料寫了什麼?
合作契約期待什麼?
要怎麼改才可能重新通過?
所以 Day 17 要補上的問題是:
契約升級後新增的阻擋,能不能直接轉成可修正的 Expected / Actual 證據?
這和交換很有關係。
資料品質閘門不只要說「不能交換」,還要說清楚:
不能交換的理由在哪個欄位上。
Day 15 的 Expected / Actual 原本主要存在於測試裡。
Day 17 則把同一套概念搬到畫面上,讓它變成使用者可以理解的修正線索。
今天新增或修改的範圍有:
ContractComparisonResult
index.html
ParseControllerTests
20260818.md
成果集中在比較結果的解讀層。
Day 17 的目標只有一個:
把 v1.1 新增失敗規則展開成 path、actual、expected、evidence、suggestion。
Day 16 的 ContractComparisonResult 很薄:
public record ContractComparisonResult(
ValidationResult v1Result,
ValidationResult v1_1Result
) {
}
它只保存兩版驗證結果。
這樣足夠做比較表,但不適合讓 Thymeleaf 自己處理「v1.1 新增了哪些失敗規則」。
所以 Day 17 在這個 record 裡補兩個查詢方法:
public boolean hasGateOutcomeChange() {
return v1Result.gateOutcome() != v1_1Result.gateOutcome();
}
第一個方法只回答 Gate 有沒有變:
v1.0 PASSED → v1.1 BLOCKED
第二個方法找出 v1.1 新增的失敗規則:
public List<RuleResult> newlyFailedV11RuleResults() {
Set<String> v1FailedRuleCodes = v1Result.contractRuleResults().stream()
.filter(ruleResult -> ruleResult.outcome() == RuleOutcome.FAIL)
.map(RuleResult::ruleCode)
.collect(Collectors.toSet());
return v1_1Result.contractRuleResults().stream()
.filter(ruleResult -> ruleResult.outcome() == RuleOutcome.FAIL)
.filter(ruleResult -> !v1FailedRuleCodes.contains(ruleResult.ruleCode()))
.toList();
}
這裡只做最小集合差異:
v1.1 FAIL 規則 - v1.0 已經 FAIL 的規則
也就是:
只列出升級到 v1.1 後才新增的阻擋。
這不是完整版本治理。
它不判斷這是不是預期 breaking change,也不判斷這是不是非預期回歸。
目前只是把已經存在的 RuleResult 過濾出來,讓畫面可以顯示更完整的修正證據。
Day 16 的畫面已經有:
Contract comparison
Day 17 在這個區塊下方新增:
升級後新增阻擋
這個區塊只有在兩個條件同時成立時顯示:
1. v1.0 與 v1.1 的 Quality Gate 結果不同
2. v1.1 有新增的 FAIL 規則
Thymeleaf 條件是:
<div th:if="${comparison.hasGateOutcomeChange()
&& !comparison.newlyFailedV11RuleResults().isEmpty()}">
所以合法資料不會看到這個區塊。
原本 v1.0 就已經失敗的規則,也不會被誤標成「升級後新增阻擋」。
畫面欄位直接沿用 RuleResult:
| 欄位 | 來源 |
|---|---|
| Rule code | RuleResult.ruleCode |
| Path | RuleResult.path |
| Actual | RuleResult.actual |
| Expected | RuleResult.expected |
| Evidence | RuleResult.evidence |
| Suggestion | RuleResult.suggestion |
這裡沒有新增新的資料模型。
原因是 Day 8 開始設計 RuleResult 時,就已經把 rule code、path、actual、expected、evidence、suggestion 放進結果物件。
Day 17 做的事情只是把這些欄位放到版本差異旁邊。
今天使用 Day 16 整理過的乾淨 fixture:
twcore-valid-wrong-ucum-system.json
這份資料的 Patient、Observation 與 DiagnosticReport 已經補齊目前 TW Core Profile validation 需要的最小欄位。
Reference 鏈、LOINC code 與可讀 unit 也都是乾淨的。
唯一刻意留下的問題是 valueQuantity.system:
"valueQuantity": {
"value": 95,
"unit": "mg/dL",
"system": "http://example.org/local-units",
"code": "mg/dL"
}
所以比較表仍然會呈現:
| 契約版本 | Gate 結果 | 失敗規則 |
|---|---|---|
| v1.0 | PASSED | None |
| v1.1 | BLOCKED | LAB-UNIT-002 |
但 Day 17 會再往下補出「升級後新增阻擋」:
| Rule code | Path | Actual | Expected |
|---|---|---|---|
LAB-UNIT-002 |
Observation/obs-wrong-ucum-system.valueQuantity.system/code |
http://example.org/local-units|mg/dL |
Observation.valueQuantity.system must be http://unitsofmeasure.org and code must be allowed by the exchange contract. |
Suggestion 也會直接顯示中文修正方向:
請使用合作契約允許的 UCUM 條件:system 必須是 http://unitsofmeasure.org,code 目前允許 mg/dL 或 mmol/L。

這個結果說明:
同一份 Bundle 不是單純被 v1.1 擋下。
它是因為 v1.1 開始檢查 UCUM system/code,並且目前 system 寫成 http://example.org/local-units。
把 system 從 http://example.org/local-units 改成 http://unitsofmeasure.org 後,
同一份 Bundle 在 v1.1 也會從 BLOCKED 回到 PASSED。

Day 17 另一個要確認的是合法 Bundle。
使用:
valid-twcore-contract-bundle.json
預期結果仍然是:
| 契約版本 | Gate 結果 | 失敗規則 |
|---|---|---|
| v1.0 | PASSED | None |
| v1.1 | PASSED | None |
這個案例不應該出現:
升級後新增阻擋
原因是兩版 Gate 沒有改變。
如果合法資料也顯示升級警告,使用者會誤以為契約 v1.1 本身造成問題。
所以 Day 17 的畫面邏輯必須同時做到兩件事:
有新增阻擋時,清楚顯示修正證據。
沒有新增阻擋時,不額外顯示不必要的資訊。

Day 16 的 ParseControllerTests 已經確認比較表會出現在首頁:
Contract comparison
v1.0
v1.1
LAB-UNIT-002
Day 17 補的是畫面解讀證據。
UCUM system 錯誤案例必須在 response HTML 裡看得到:
mockMvc.perform(post("/parse")
.param("bundleJson", fixture("twcore-valid-wrong-ucum-system.json")))
.andExpect(status().isOk())
.andExpect(content().string(containsString("升級後新增阻擋")))
.andExpect(content().string(containsString("LAB-UNIT-002")))
.andExpect(content().string(containsString(
"Observation/obs-wrong-ucum-system.valueQuantity.system/code")))
.andExpect(content().string(containsString(
"http://example.org/local-units|mg/dL")))
.andExpect(content().string(containsString(
"Observation.valueQuantity.system must be http://unitsofmeasure.org")))
.andExpect(content().string(containsString(
"請使用合作契約允許的 UCUM 條件")));
另外合法 Bundle 要確認不會出現新增阻擋區塊:
mockMvc.perform(post("/parse")
.param("bundleJson", fixture("valid-twcore-contract-bundle.json")))
.andExpect(status().isOk())
.andExpect(content().string(containsString("Contract comparison")))
.andExpect(content().string(not(containsString("升級後新增阻擋"))));
這兩個測試分別保護兩種行為:
指令:
./mvnw test
測試結果:
Tests run: 59, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

Day 17 沒有增加測試總數。
原因是今天修改的是 Day 16 已新增的 controller 測試內容:
也就是說,測試數維持 59,但測試內容變得更精準。
如果 v1.0 本來就因為 LAB-REF-001 失敗,v1.1 也因為 LAB-REF-001 失敗,這不是升級後新增阻擋。
Day 17 的邏輯必須先取出 v1.0 已經失敗的規則:
Set<String> v1FailedRuleCodes = ...
再從 v1.1 的失敗規則裡排除它們。
這通常代表畫面只檢查 comparison != null,沒有檢查 Gate 是否改變。
正確條件應該包含:
comparison.hasGateOutcomeChange()
這樣會退回 Day 16 的狀態。
Day 17 的重點是讓使用者不用再查規則表,就能知道:
Actual:目前資料值
Expected:契約期待條件
Suggestion:中文修正方向
ContractComparisonResult 新增 hasGateOutcomeChange()。ContractComparisonResult 新增 newlyFailedV11RuleResults()。Contract comparison 下方新增「升級後新增阻擋」區塊。LAB-UNIT-002 的完整修正證據。ParseControllerTests 補強畫面內容斷言。./mvnw test 通過,測試數維持 59。Day 17 尚未處理:
COMPATIBLE / EXPECTED_BREAKING_CHANGE / UNEXPECTED_REGRESSION 三分類。目前的 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 完成最小版
│ ├─ homepage comparison display 完成最小版
│ └─ upgrade blocker evidence display 完成最小版
└─ Scenario test pack
├─ minimal expected / actual table 完成最小版
├─ v1.0 / v1.1 representative cases 完成 4 例
└─ NOT_APPLICABLE scenario fixture 完成 1 例
下一步預計處理:
如何解讀版本差異
從錯誤案例到修正版資料
Docker Compose 與 CI quality gate
Repository:twcore-data-quality-gate