摘要
Day 6 已確認 TW Core Profile validation chain 可以實際執行,並且能把 TW Core Profile issues 和 FHIR R4 OperationOutcome 分開顯示。Day 7 建立交換契約規則的最小資料模型,並實作第一條 Reference 規則LAB-REF-001的測試骨架。
Day 6 已經回答:
系統能不能用 TW Core Profile 驗證 Bundle 內的 Patient、Observation、DiagnosticReport?
Day 7 開始處理下一個問題:
就算資料能被標準 Profile 驗證,合作方真的能接受這份檢驗資料嗎?
TW Core Profile validation 檢查的是 Resource 是否符合指定 Profile。
交換契約規則檢查的是某個交換情境下,合作方需要的資料關係與阻擋條件。
例如本專案的第一條交換契約規則:
LAB-REF-001:Observation.subject 必須指向 Bundle 中存在的 Patient。
這不是要取代 TW Core,也不是把專案規則包裝成官方要求。
它回答的是更接近交換流程的問題:
這筆 Observation 說自己屬於某個 Patient,那個 Patient 在這份待驗證 Bundle 裡找得到嗎?
Day 7 只做 Reference 規則入口。
今天新增的範圍只有:
RuleOutcome
RuleResult
ContractRule
LabRef001ObservationSubjectRule
LabRef001ObservationSubjectRuleTests
目標是先把交換規則的資料結構與第一條規則行為固定下來,避免直接把六條規則、UI 和契約設定一起做,導致每一層都只完成一半。
LAB-REF-001 只看 Observation.subject.reference。
MVP 目前支援兩種 Bundle 內 reference:
| reference 形式 | 範例 | Day 7 行為 |
|---|---|---|
| Resource logical reference | Patient/patient-valid-ref |
若 Bundle 內有該 Patient,回 PASS |
| Bundle entry fullUrl | urn:uuid:423e4567-e89b-12d3-a456-426614174010 |
若 fullUrl 對應 Patient,回 PASS |
MVP 目前不解析外部 HTTP reference:
| reference 形式 | 範例 | Day 7 行為 |
|---|---|---|
| 外部 FHIR Server reference | https://example.org/fhir/Patient/external-patient |
回 NOT_EVALUATED |
原因是外部 reference 需要連到外部 FHIR Server 才能確認 Patient 是否存在。
Day 7 還沒有外部查詢能力,所以不能說通過,也不能說資料錯誤。
比較誠實的狀態是:
NOT_EVALUATED
這延續前幾天的原則:
沒有真的檢查,就不能標示 PASS。
Day 7 沿用 Day 5 先準備好的三份 fixture:
| fixture | 用途 | LAB-REF-001 預期 |
|---|---|---|
valid-internal-reference.json |
Observation 指向 Bundle 內存在的 Patient | PASS |
missing-internal-reference.json |
Observation 指向不存在於 Bundle 的 Patient | FAIL |
external-http-reference.json |
Observation 指向外部 HTTP Patient reference | NOT_EVALUATED |
這三份資料在 Day 5 只是 reference exploration fixture。
Day 7 開始,它們正式成為第一條交換契約規則的測試素材。
Day 7 新增最小交換契約規則資料模型:
src/main/java/com/twlab/qualitygate/validation
├─ RuleOutcome.java
├─ RuleResult.java
├─ ContractRule.java
└─ LabRef001ObservationSubjectRule.java
RuleOutcome
public enum RuleOutcome {
PASS,
FAIL,
NOT_APPLICABLE,
NOT_EVALUATED
}
這裡先不用 ParseStatus。
原因是 ParseStatus 是前面 JSON、FHIR R4、TW Core 這些驗證層的狀態;RuleOutcome 是單條交換契約規則的結果。
兩者相似,但語意不同。
RuleResult
public record RuleResult(
String ruleCode,
RuleOutcome outcome,
String severity,
String path,
String actual,
String expected,
String evidence,
String suggestion
) {}
這個欄位設計對應 30 天計畫裡的規則輸出需求:
| 欄位 | 意義 |
|---|---|
ruleCode |
規則編號,例如 LAB-REF-001 |
outcome |
PASS / FAIL / NOT_APPLICABLE / NOT_EVALUATED |
severity |
未來 Quality Gate 可用的嚴重度 |
path |
問題位置 |
actual |
實際 reference |
expected |
預期條件 |
evidence |
判定依據 |
suggestion |
中文修正方向 |
ContractRule
public interface ContractRule {
String ruleCode();
List<RuleResult> validate(Bundle bundle);
}
這裡回傳 List<RuleResult>,不是單一 RuleResult。
原因是同一個 Bundle 可能有多個 Observation。
如果未來一份 Bundle 有三筆 Observation,LAB-REF-001 就應該能分別回報三筆結果,而不是把所有 Observation 混成一個總結。
LabRef001ObservationSubjectRule
核心邏輯分成三步:
讀取 Bundle 內所有 Patient
↓
建立可接受的 Patient reference 集合
↓
逐一檢查 Observation.subject.reference
Patient reference 集合包含:
Patient/{id}
Bundle.entry.fullUrl
如果 Observation 沒有 subject.reference,回 FAIL。
如果 reference 是 http:// 或 https://,回 NOT_EVALUATED。
如果 reference 可以在 Bundle 內找到 Patient,回 PASS。
如果 reference 是 Bundle 內形式,但找不到 Patient,回 FAIL。
以下是為了文章閱讀而簡化的核心片段,省略 path 建立與 RuleResult helper 細節。
核心程式碼可以分成兩段來看:
private Set<String> collectPatientReferences(Bundle bundle) {
Set<String> patientReferences = new HashSet<>();
for (Bundle.BundleEntryComponent entry : bundle.getEntry()) {
Resource resource = entry.getResource();
if (resource instanceof Patient patient) {
String id = patient.getIdElement().getIdPart();
if (id != null && !id.isBlank()) {
patientReferences.add("Patient/" + id);
}
String fullUrl = entry.getFullUrl();
if (fullUrl != null && !fullUrl.isBlank()) {
patientReferences.add(fullUrl);
}
}
}
return patientReferences;
}
這段先把 Bundle 內所有 Patient 轉成可比對的 reference 集合。
同一個 Patient 會同時保留兩種定位方式:
Patient/{id}
entry.fullUrl
接著逐一檢查 Observation:
private RuleResult validateObservation(
Observation observation,
Set<String> patientReferences
) {
String actual = observation.hasSubject()
? observation.getSubject().getReference()
: null;
if (actual == null || actual.isBlank()) {
return fail(path, "N/A", "Observation.subject.reference is required.");
}
if (isExternalReference(actual)) {
return notEvaluated(path, actual);
}
if (patientReferences.contains(actual)) {
return pass(path, actual);
}
return fail(path, actual, "Patient reference not found in this Bundle.");
}
這段的重點不是語法,而是分類順序:
沒有 subject.reference → FAIL
外部 HTTP reference → NOT_EVALUATED
Bundle 內找得到 Patient → PASS
Bundle 內找不到 Patient → FAIL
Day 7 新增:
src/test/java/com/twlab/qualitygate/validation/LabRef001ObservationSubjectRuleTests.java
測試一:Observation 指向 Bundle 內 Patient
assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.PASS);
assertThat(results.get(0).actual()).isEqualTo("Patient/patient-valid-ref");
這代表最基本的內部 reference 可以通過。
測試二:Observation 指向不存在的 Patient
assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.FAIL);
assertThat(results.get(0).severity()).isEqualTo("error");
assertThat(results.get(0).actual()).isEqualTo("Patient/patient-not-in-bundle");
這代表合作方可以得到明確的阻擋原因,而不是只看到「FHIR 格式合法」。
測試三:Observation 指向外部 HTTP Patient reference
assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.NOT_EVALUATED);
assertThat(results.get(0).severity()).isEqualTo("warning");
這代表系統承認 MVP 還不支援外部查詢。
它不把外部 reference 當成通過,也不直接說資料錯。
這三段 assertion 就是 Day 7 測試的核心,不需要再另外截測試檔畫面。
指令:
./mvnw test
測試結果:
Tests run: 21, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

Day 7 從原本 18 個測試增加到 21 個測試。
新增的 3 個測試都集中在 LAB-REF-001:
PASS
FAIL
NOT_EVALUATED
Day 7 的可展示成果不是 UI,而是先用單元測試把第一條交換契約規則的行為固定下來。
LAB-REF-001 是交換契約規則,不是 TW Core Profile validator 的一部分。
Day 7 沒有修改:
HapiTwCoreProfileValidator
TwCoreValidationService
TwCoreValidationResult
這樣可以維持分層:
FHIR R4 / TW Core Profile / Exchange Contract
各層回答不同問題。
FAIL
外部 HTTP reference 不一定是錯。
它只是超出 MVP 目前能力,因為系統沒有去外部 FHIR Server 查詢。
所以 Day 7 回:
NOT_EVALUATED
Bundle 內 reference 不一定只使用 Patient/{id}。
後續也可能透過 Bundle.entry.fullUrl 對應,例如 urn:uuid:...。
所以 Day 7 在建立 Patient reference 集合時,同時保留:
Patient/{id}
entry.fullUrl
Day 7 先不接 UI。
原因是現在還只有第一條規則,如果太早設計畫面,後面 LAB-REF-002、LAB-REF-003、LOINC、UCUM 都可能讓畫面模型重做。
先用單元測試固定規則行為,比較符合三小時內的停止線。
Day 6 看到的是:
FHIR R4 validation: PASSED
TW Core validation: FAILED
Day 7 開始準備下一種情境:
FHIR R4 validation: PASSED
TW Core validation: PASSED 或 FAILED
Exchange Contract: FAILED
也就是說,即使前面的標準驗證層可以執行,交換情境仍然可能因為 reference 關係不完整而被阻擋。
LAB-REF-001 是第一個例子。
Observation 不能只寫自己是檢驗結果,還要能指向這份 Bundle 裡可解析的 Patient。
這也是資料品質閘門要補上的地方:
標準驗證告訴我們 Resource 結構是否合理。
交換契約規則告訴我們這份資料是否符合合作方接收條件。
RuleOutcome。RuleResult。ContractRule。LabRef001ObservationSubjectRule。LAB-REF-001 支援 Patient/{id} reference。LAB-REF-001 支援 Bundle.entry.fullUrl reference。LAB-REF-001 對不存在的 Bundle 內 Patient 回 FAIL。LAB-REF-001 對外部 HTTP reference 回 NOT_EVALUATED。./mvnw test 通過,測試數從 18 增加到 21。Day 7 尚未處理:
BundleParseService。RuleResult。LAB-REF-002:DiagnosticReport.result 必須指向 Bundle 中存在的 Observation。LAB-REF-003:DiagnosticReport 與 Observation 必須對應同一 Patient。後續分層預計會變成:
quality-gate
├─ parser
├─ fhir-r4-validator
├─ resource-inventory
├─ tw-core-package-probe
├─ tw-core-validator
├─ contract-rule
│ ├─ LAB-REF-001
│ ├─ LAB-REF-002
│ └─ LAB-REF-003
└─ report
Repository:twcore-data-quality-gate