摘要
Day 12 已完成第六條核心交換契約規則LAB-UNIT-002,六條規則都已能獨立測試。Day 13 接著把這六條規則接回 Bundle 解析流程與首頁結果頁,讓使用者貼上或上傳 Bundle 後,可以在同一個畫面看到 JSON、FHIR R4、TW Core、交換契約四層結果,以及最小 Quality Gate 判定。
Day 12 結束時,系統內部已經有六條核心交換規則:
Observation.subject → Patient
DiagnosticReport.result → Observation
DiagnosticReport.subject 與 Observation.subject → 同一 Patient
Observation.code → 契約允許 LOINC
Observation.valueQuantity.unit → 可讀單位存在
Observation.valueQuantity.system/code → 契約允許 UCUM 單位集合
開發者可以透過單元測試確認規則行為,但使用者在瀏覽器貼上 Bundle 時,還看不到:
哪一條交換契約規則通過?
哪一條交換契約規則失敗?
失敗的 JSON path 是哪裡?
實際值是什麼?
系統期待的條件是什麼?
最後 Quality Gate 是 Passed、Warning 還是 Blocked?
所以 Day 13 先簡單包裝前端,確認「使用者可操作」。
今天新增或修改的範圍有:
GateOutcome
ValidationResult
BundleParseService
ContractRule 的 Spring component 註冊index.html
BundleParseServiceTests
ParseControllerTests
完成後,首頁可以展示:
Quality Gate
JSON parse
FHIR R4 parse
Resource Type Gate
FHIR R4 validation
TW Core validation
Exchange contract rule results
讓四層驗證結果第一次在同一個可操作畫面裡串起來。
原本每一條交換規則會回傳 RuleResult:
PASS
FAIL
NOT_APPLICABLE
NOT_EVALUATED
但使用者不只需要知道單條規則結果。
使用者還需要一個整體判斷:
這份 Bundle 最後能不能通過資料品質閘門?
所以 Day 13 新增:
public enum GateOutcome {
PASSED,
PASS_WITH_WARNINGS,
BLOCKED
}
這不是新的醫療規則,它是把 JSON、FHIR R4、TW Core 與交換契約規則整合後的最小總結。
Day 13 的 Quality Gate 只做最小分類:
PASSED
PASS_WITH_WARNINGS
BLOCKED
分類條件如下:
JSON parse 失敗 → BLOCKED
FHIR R4 parse 失敗 → BLOCKED
Resource type 不是 Bundle → BLOCKED
FHIR R4 validation 有 error/fatal → BLOCKED
TW Core validation 明確 FAILED → BLOCKED
任一交換契約規則 FAIL → BLOCKED
沒有阻擋,但 TW Core validation 是 NOT_EVALUATED → PASS_WITH_WARNINGS
沒有阻擋,但任一交換契約規則是 NOT_EVALUATED → PASS_WITH_WARNINGS
其餘情況 → PASSED
這裡有一個重要邊界,TW Core NOT_EVALUATED 不等於通過。
但它也不直接等於阻擋,原因是本專案的 90 小時停止線已經明確定義:
TW Core package 若無法穩定載入,TW Core 層必須安全降級為 NOT_EVALUATED。
所以 Day 13 的處理方式是:
TW Core NOT_EVALUATED → PASS_WITH_WARNINGS
TW Core FAILED → BLOCKED
這樣可以避免兩種錯誤:
把尚未評估冒充成通過
把真正 Profile error 放行
Day 13 以前,BundleParseService 的主要責任是:
讀入字串
↓
檢查 JSON parse
↓
檢查 FHIR R4 parse
↓
確認是否為 Bundle
↓
執行 FHIR R4 validation
↓
執行 TW Core validation 或安全降級
Day 13 之後,多了交換契約規則聚合:
讀入字串
↓
檢查 JSON parse
↓
檢查 FHIR R4 parse
↓
確認是否為 Bundle
↓
執行 FHIR R4 validation
↓
執行 TW Core validation 或安全降級
↓
依固定順序執行六條 ContractRule
↓
計算 Quality Gate
六條規則固定依這個順序執行:
LAB-REF-001
LAB-REF-002
LAB-REF-003
LAB-CODE-001
LAB-UNIT-001
LAB-UNIT-002
固定順序很重要,如果順序每次由 Spring 注入順序決定,頁面顯示與測試就可能不穩定。
所以今天在 BundleParseService 中明確排序:
private Map<String, Integer> ruleOrder() {
return Map.of(
LabRef001ObservationSubjectRule.RULE_CODE, 1,
LabRef002DiagnosticReportResultRule.RULE_CODE, 2,
LabRef003ReportObservationPatientRule.RULE_CODE, 3,
LabCode001ObservationLoincRule.RULE_CODE, 4,
LabUnit001ObservationQuantityUnitRule.RULE_CODE, 5,
LabUnit002ObservationUcumCodeRule.RULE_CODE, 6
);
}
這讓畫面、測試與文章說明可以使用同一個順序。
今天刻意保留一個安全邊界:
JSON parse 失敗
FHIR R4 parse 失敗
輸入不是 Bundle
這三種情況不執行交換契約規則,原因是交換契約規則的輸入前提是:
已經取得一個 FHIR R4 Bundle
如果 JSON 本身不能解析,或 FHIR parser 無法把輸入轉成 Bundle,規則沒有可靠的資料結構可檢查。
這時 contractRuleResults 應該是空集合,不能顯示成 PASS,也不能顯示成 NOT_APPLICABLE。
因為這不是規則不適用,而是規則根本還沒有被安全執行。
Day 13 擴充 ValidationResult,新增兩個欄位:
List<RuleResult> contractRuleResults
GateOutcome gateOutcome
contractRuleResults 負責保存六條交換契約規則的詳細結果。gateOutcome 負責保存整體 Quality Gate 判斷。
這樣 controller 與 Thymeleaf template 不需要重新計算規則。
畫面只需要讀取 ValidationResult:
result.gateOutcome
result.contractRuleResults
這也讓後續要做 API、JSON 匯出或 Demo 劇本時,有同一份結果模型可以使用。
Day 13 的頁面仍然是單頁流程。
使用者可以:
上傳 Bundle JSON
或直接貼上 JSON
送出後,頁面會先顯示整體結果:
Quality Gate
JSON parse
FHIR R4 parse
Resource Type Gate
FHIR R4 validation
TW Core validation
Resource type
Resource count
Error message
接著顯示 Resource summary、OperationOutcome issues、TW Core Profile issues。


最後新增交換契約規則表格。
表格欄位如下:
| 欄位 | 用途 |
|---|---|
| Rule code | 規則編號,例如 LAB-REF-001 |
| Outcome | PASS、FAIL、NOT_APPLICABLE、NOT_EVALUATED |
| Severity | information、warning、error |
| Path | 規則檢查的 FHIR path |
| Actual | 實際資料值 |
| Expected | 預期條件 |
| Evidence | 判斷原因 |
| Suggestion | 中文修正方向 |

這個表格是 Day 13 最重要的畫面成果。
它讓使用者可以直接看到:
格式驗證通過,不代表交換契約一定通過。
Day 13 能用四種資料測試 Quality Gate。
第一,四層都通過的 Bundle。
使用:
valid-twcore-contract-bundle.json
這份資料補齊目前 TW Core Profile validation 會要求的最小欄位,並使用合法的 urn:uuid fullUrl reference。
預期畫面會顯示:
Quality Gate → PASSED
JSON parse → PASSED
FHIR R4 parse → PASSED
FHIR R4 validation → PASSED
TW Core validation → PASSED
六條 Exchange contract rules → PASS

這個案例用來證明 Day 13 的最小 Quality Gate 可以真正到達 PASSED,而不是只能展示被阻擋的案例。
第二,交換契約合法、但 TW Core 不完整的最小 lab Bundle。
使用:
valid-minimal-lab-bundle.json
這份資料是交換契約規則的正例,但不是完整 TW Core Profile 正例。
預期:
Quality Gate → BLOCKED
FHIR R4 parse / validation → PASSED
TW Core validation → FAILED
六條 Exchange contract rules → PASS


這個案例用來說明:
交換契約規則可以全部通過,但只要 TW Core 層明確失敗,整體 Gate 仍然必須阻擋。
第三,Reference 錯誤。
使用:
missing-internal-reference.json
這份資料中的 Observation subject 指向 Bundle 內不存在的 Patient。
預期:
Quality Gate → BLOCKED
LAB-REF-001 → FAIL
LAB-REF-003 → FAIL
LAB-REF-001 失敗,是因為 Observation.subject 找不到 Bundle 內對應的 Patient。LAB-REF-003 也會失敗,因為 DiagnosticReport 指向的 Observation subject 和 DiagnosticReport subject 不是同一個 Patient。


第四,UCUM system/code 錯誤。
使用:
observation-quantity-wrong-ucum-system.json
這份資料有可讀的 unit,但 valueQuantity.system 不是 http://unitsofmeasure.org。
預期:
Quality Gate → BLOCKED
LAB-UNIT-001 → PASS
LAB-UNIT-002 → FAIL


這個案例很適合用來說明 Day 11 與 Day 12 的差別:
有 unit,不代表 UCUM system/code 合約檢查會通過。
Day 13 修改:
src/test/java/com/twlab/qualitygate/validation/BundleParseServiceTests.java
新增或調整的測試重點有四個。
測試一:合法 Bundle 會回六條規則結果
assertThat(result.contractRuleResults())
.extracting(RuleResult::ruleCode)
.containsExactly(
LabRef001ObservationSubjectRule.RULE_CODE,
LabRef002DiagnosticReportResultRule.RULE_CODE,
LabRef003ReportObservationPatientRule.RULE_CODE,
LabCode001ObservationLoincRule.RULE_CODE,
LabUnit001ObservationQuantityUnitRule.RULE_CODE,
LabUnit002ObservationUcumCodeRule.RULE_CODE
);
這個測試確認規則不只是各自存在,而是真的被 BundleParseService 串起來。
測試二:Reference 規則失敗會阻擋 Gate
assertThat(result.gateOutcome()).isEqualTo(GateOutcome.BLOCKED);
assertThat(result.contractRuleResults())
.anySatisfy(ruleResult -> {
assertThat(ruleResult.ruleCode()).isEqualTo(LabRef001ObservationSubjectRule.RULE_CODE);
assertThat(ruleResult.outcome()).isEqualTo(RuleOutcome.FAIL);
});
測試三:UCUM 規則失敗會阻擋 Gate
assertThat(result.gateOutcome()).isEqualTo(GateOutcome.BLOCKED);
assertThat(result.contractRuleResults())
.anySatisfy(ruleResult -> {
assertThat(ruleResult.ruleCode()).isEqualTo(LabUnit002ObservationUcumCodeRule.RULE_CODE);
assertThat(ruleResult.outcome()).isEqualTo(RuleOutcome.FAIL);
});
測試四:invalid JSON 不執行交換規則
assertThat(result.contractRuleResults()).isEmpty();
assertThat(result.gateOutcome()).isEqualTo(GateOutcome.BLOCKED);
這個測試確認早退流程不會誤顯示規則通過。
確認送出 Bundle 後會顯示:
Quality Gate
Exchange contract rule results
LAB-REF-001
LAB-UNIT-002
這代表瀏覽器流程已經能看到交換契約規則結果,而不是只有 service 層能取得。
指令:
./mvnw test
測試結果:
Tests run: 52, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

Day 13 從 Day 12 的 50 個測試增加到 52 個測試。
新增的 2 個主要測試集中在整合層:
BLOCKED
BLOCKED
同時既有 controller 測試也更新成 Day 13 畫面。
NOT_EVALUATED 當成 PASS
NOT_EVALUATED 的意思是尚未評估,它不等於通過。
所以 TW Core 無法穩定執行時,不能顯示成:
PASSED
Day 13 的做法是讓整體 Gate 回:
PASS_WITH_WARNINGS
這比較符合降級處理原則。
FAILED 當成 warningTW Core NOT_EVALUATED 可以是 warning。
但 TW Core 明確 FAILED 表示 Profile validation 已經執行,且存在 error/fatal issue。
這時 Quality Gate 必須是:
BLOCKED
交換契約規則需要 Bundle 物件。
如果 JSON 不能解析,或 FHIR parser 不能產生 Bundle,規則就沒有可靠輸入。
這時應該讓:
contractRuleResults = []
Quality Gate = BLOCKED
如果直接使用 Spring 注入的 List<ContractRule>,不同環境可能讓顯示順序變得不明確。
Day 13 明確用 rule code 排序,確保畫面與測試都固定。
Day 13 的目標是「可操作核心畫面」。
所以只讓 BundleParseService 回傳 RuleResult 還不夠。
一定要在 index.html 顯示:
Quality Gate
Exchange contract rule results
Day 12 以前,我們已經能用規則回答:
Reference 鏈是否接得起來?
Observation.code 是否在合作方允許 LOINC 集合?
Quantity 是否具有可讀 unit?
Quantity 是否具有合作方允許的 UCUM system/code?
但這些答案都還停留在單元測試。
Day 13 把這些答案放回使用者流程。
現在使用者可以貼上一份 Bundle (UCUM system 錯誤案例),直接看到:
JSON: PASSED
FHIR R4: PASSED
TW Core: NOT_EVALUATED
Exchange Contract:
LAB-REF-001: PASS
LAB-REF-002: PASS
LAB-REF-003: PASS
LAB-CODE-001: PASS
LAB-UNIT-001: PASS
LAB-UNIT-002: FAIL
Quality Gate: BLOCKED
這正是本系列要展示的核心:
資料通過格式驗證,不代表一定能交換。
FHIR R4 與 TW Core 比較接近標準結構與 Profile 層。
交換契約規則則處理合作方實際接收條件。
Quality Gate 把這些層次整合成使用者可以理解的總結。
GateOutcome。ValidationResult 新增 contractRuleResults。ValidationResult 新增 gateOutcome。ContractRule 註冊為 Spring component。BundleParseService 在 Bundle parse 成功後執行六條交換契約規則。BundleParseService 固定規則執行與顯示順序。PASSED、PASS_WITH_WARNINGS、BLOCKED。NOT_EVALUATED 進入 warning,不冒充通過。FAILED 會阻擋 Gate。Quality Gate、LAB-REF-001、LAB-UNIT-002。valid-twcore-contract-bundle.json 作為四層皆通過的展示案例。BLOCKED。BLOCKED。./mvnw test 通過,測試數從 50 增加到 52。Day 13 尚未處理:
目前的 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 完成最小版
下一步預計處理:
契約 v1.0 / v1.1 基本比較
Docker Compose
GitHub Actions
Repository:twcore-data-quality-gate