摘要
Day 7 已建立交換契約規則的最小資料模型,並完成第一條 Reference 規則LAB-REF-001。Day 8 實作第二條 Reference 規則LAB-REF-002:DiagnosticReport.result必須指向 Bundle 中存在的Observation。
Day 7 處理的是:
Observation.subject → Patient
也就是檢驗結果要能找到它所屬的病人。
Day 8 往檢驗報告的方向前進:
DiagnosticReport.result → Observation
也就是一份檢驗報告列出的檢驗結果,必須真的存在於同一份待驗證 Bundle 裡。
FHIR Resource 的 JSON 格式合法,不代表跨 Resource 關係一定完整。
例如一份 DiagnosticReport 可以寫:
"result": [
{
"reference": "Observation/obs-not-in-bundle"
}
]
這在語法上可能仍是一段可解析的 FHIR JSON。
但對交換情境來說,合作方收到這份 Bundle 後,如果找不到 Observation/obs-not-in-bundle,報告就無法被完整解讀。
LAB-REF-002 要回答的問題是:
這份 DiagnosticReport 說自己包含某筆 Observation,那筆 Observation 在這份 Bundle 裡找得到嗎?
今天新增的範圍只有:
LabRef002DiagnosticReportResultRule
LabRef002DiagnosticReportResultRuleTests
LAB-REF-002 專用 fixture如果今天同時做契約載入、UI 和規則引擎註冊,很容易變成每一層都只做一半。
現在先讓第二條規則可以被單元測試穩定驗證,會比提早接畫面更重要。
今天完成後,資料鏈從 Day 7 的 Observation -> Patient,擴展成 DiagnosticReport -> Observation -> Patient。
LAB-REF-002 只看 DiagnosticReport.result.reference。
MVP 目前支援兩種 Bundle 內 reference:
| reference 形式 | 範例 | Day 8 行為 |
|---|---|---|
| Resource logical reference | Observation/obs-valid-ref |
若 Bundle 內有該 Observation,回 PASS |
| Bundle entry fullUrl | urn:uuid:223e4567-e89b-12d3-a456-426614174001 |
若 fullUrl 對應 Observation,回 PASS |
MVP 目前不解析外部 HTTP reference:
| reference 形式 | 範例 | Day 8 行為 |
|---|---|---|
| 外部 FHIR Server reference | https://example.org/fhir/Observation/external-observation |
回 NOT_EVALUATED |
原因和 Day 7 一樣。
外部 reference 需要連到外部 FHIR Server 才能確認 Observation 是否存在。
Day 8 還沒有外部查詢能力,所以不能說通過,也不能直接說資料錯誤。
比較誠實的狀態仍然是:
NOT_EVALUATED
LAB-REF-002 的分類順序如下:
Bundle 沒有 DiagnosticReport → NOT_APPLICABLE
DiagnosticReport 沒有 result → FAIL
result.reference 空白 → FAIL
外部 HTTP reference → NOT_EVALUATED
Bundle 內找得到 Observation → PASS
Bundle 內找不到 Observation → FAIL
這裡有三個容易混淆的地方。
第一,沒有 DiagnosticReport 時不是 FAIL。
因為這條規則的適用對象不存在,所以回:
NOT_APPLICABLE
第二,有 DiagnosticReport 但沒有 result 時是 FAIL。
因為這份報告沒有列出可追溯的檢驗結果,對本專案的檢驗資料交換情境來說是不完整的。
第三,外部 HTTP reference 是 NOT_EVALUATED。
因為系統目前沒有外部查詢能力,不能把沒查過的外部 Observation 當成通過。
Day 8 新增或複用以下 fixture:
| fixture | 用途 | LAB-REF-002 預期 |
|---|---|---|
valid-internal-reference.json |
Report result 指向 Bundle 內 Observation logical id | PASS |
valid-report-result-full-url-reference.json |
Report result 指向 Bundle entry fullUrl | PASS |
missing-report-result-reference.json |
Report result 指向不存在的 Observation | FAIL |
missing-report-result-field.json |
DiagnosticReport 沒有 result | FAIL |
external-report-result-reference.json |
Report result 指向外部 HTTP Observation | NOT_EVALUATED |
unsupported-resource-in-bundle.json |
Bundle 沒有 DiagnosticReport | NOT_APPLICABLE |
這些 fixture 的目的不是追求案例數量。
它們的目的,是先把 LAB-REF-002 的狀態分類固定下來:
PASS
FAIL
NOT_APPLICABLE
NOT_EVALUATED
後續做情境測試包時,才有穩定的規則行為可以組合。
Day 8 新增:
src/main/java/com/twlab/qualitygate/validation
└─ LabRef002DiagnosticReportResultRule.java
規則仍然實作同一個介面:
public class LabRef002DiagnosticReportResultRule implements ContractRule {
public static final String RULE_CODE = "LAB-REF-002";
@Override
public String ruleCode() {
return RULE_CODE;
}
@Override
public List<RuleResult> validate(Bundle bundle) {
// ...
}
}
這代表 Day 7 建立的 ContractRule 介面可以繼續使用。
第二條規則不需要新的資料模型。
核心邏輯也分成三步:
讀取 Bundle 內所有 Observation
↓
建立可接受的 Observation reference 集合
↓
逐一檢查 DiagnosticReport.result.reference
Observation reference 集合包含:
Observation/{id}
Bundle.entry.fullUrl
建立 reference 集合的概念如下:
private Set<String> collectObservationReferences(Bundle bundle) {
Set<String> observationReferences = new HashSet<>();
for (Bundle.BundleEntryComponent entry : bundle.getEntry()) {
Resource resource = entry.getResource();
if (resource instanceof Observation observation) {
String id = observation.getIdElement().getIdPart();
if (id != null && !id.isBlank()) {
observationReferences.add("Observation/" + id);
}
String fullUrl = entry.getFullUrl();
if (fullUrl != null && !fullUrl.isBlank()) {
observationReferences.add(fullUrl);
}
}
}
return observationReferences;
}
這段和 Day 7 的 Patient reference 集合很像。
但 Day 8 先不抽成共用 helper。
原因是 Reference 類規則還有 LAB-REF-003。
等第三條規則完成後,再看三條規則真正重複的是什麼更安全。
接著檢查 DiagnosticReport.result:
private List<RuleResult> validateReport(
DiagnosticReport report,
Set<String> observationReferences
) {
if (!report.hasResult()) {
String path = "DiagnosticReport/" + idOrUnknown(report) + ".result";
return List.of(fail(path, "N/A", "DiagnosticReport.result is required."));
}
List<RuleResult> results = new ArrayList<>();
List<Reference> reportResults = report.getResult();
for (int i = 0; i < reportResults.size(); i++) {
Reference reference = reportResults.get(i);
String actual = reference.getReference();
String path = "DiagnosticReport/" + idOrUnknown(report) + ".result[" + i + "].reference";
results.add(validateReference(path, actual, observationReferences));
}
return results;
}
這裡回傳 List<RuleResult>。
原因是同一份 DiagnosticReport 可能有多個 result。
如果一份報告引用三筆 Observation,規則應該能分別回報三筆結果,而不是把所有 result 混成一個總結。
最後的判斷順序如下:
private RuleResult validateReference(
String path,
String actual,
Set<String> observationReferences
) {
if (actual == null || actual.isBlank()) {
return fail(path, "N/A", "DiagnosticReport.result.reference is required.");
}
if (isExternalReference(actual)) {
return notEvaluated(path, actual);
}
if (observationReferences.contains(actual)) {
return pass(path, actual);
}
return fail(path, actual, "Observation reference not found in this Bundle.");
}
這段的重點不是語法,而是分類順序:
沒有 result.reference → FAIL
外部 HTTP reference → NOT_EVALUATED
Bundle 內找得到 Observation → PASS
Bundle 內找不到 Observation → FAIL
Day 8 新增:
src/test/java/com/twlab/qualitygate/validation/LabRef002DiagnosticReportResultRuleTests.java
測試一:DiagnosticReport 指向 Bundle 內 Observation logical id
assertThat(results.get(0).ruleCode()).isEqualTo("LAB-REF-002");
assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.PASS);
assertThat(results.get(0).actual()).isEqualTo("Observation/obs-valid-ref");
這代表最基本的內部 Observation reference 可以通過。
測試二:DiagnosticReport 指向 Bundle entry fullUrl
assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.PASS);
assertThat(results.get(0).actual()).isEqualTo("urn:uuid:223e4567-e89b-12d3-a456-426614174001");
這代表規則不是只靠 Resource id。
它也能處理 Bundle 內常見的 urn:uuid fullUrl reference。
測試三:DiagnosticReport 指向不存在的 Observation
assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.FAIL);
assertThat(results.get(0).severity()).isEqualTo("error");
assertThat(results.get(0).actual()).isEqualTo("Observation/obs-not-in-bundle");
這代表合作方可以得到明確的阻擋原因。
問題不是 JSON 壞掉,而是報告引用了 Bundle 裡不存在的檢驗結果。
測試四:DiagnosticReport 沒有 result
assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.FAIL);
assertThat(results.get(0).path()).isEqualTo("DiagnosticReport/report-no-result.result");
assertThat(results.get(0).actual()).isEqualTo("N/A");
這代表本專案的檢驗報告情境要求 Report 必須能追溯到 Observation。
測試五:DiagnosticReport 指向外部 HTTP Observation reference
assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.NOT_EVALUATED);
assertThat(results.get(0).severity()).isEqualTo("warning");
assertThat(results.get(0).actual()).isEqualTo("https://example.org/fhir/Observation/external-observation");
這代表系統承認 MVP 還不支援外部查詢。
它不把外部 reference 當成通過,也不直接說資料錯。
測試六:Bundle 沒有 DiagnosticReport
assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.NOT_APPLICABLE);
assertThat(results.get(0).severity()).isEqualTo("information");
這代表規則沒有適用對象時,不會誤判成資料錯誤。
這六段 assertion 就是 Day 8 測試的核心,不需要再另外截測試檔畫面。
指令:
./mvnw test
測試結果:
Tests run: 27, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

Day 8 從 Day 7 的 21 個測試增加到 27 個測試。
新增的 6 個測試都集中在 LAB-REF-002:
PASS
PASS
FAIL
FAIL
NOT_EVALUATED
NOT_APPLICABLE
Day 8 的可展示成果仍然不是 UI。
它是用單元測試把第二條交換契約規則的行為固定下來。
FAIL
LAB-REF-002 的適用對象是 DiagnosticReport。
如果 Bundle 裡根本沒有 DiagnosticReport,這條規則沒有可以檢查的對象。
所以回:
NOT_APPLICABLE
NOT_APPLICABLE
這和前一種情況不同。
如果 DiagnosticReport 已經存在,但沒有 result,代表這份檢驗報告沒有列出可追溯的檢驗結果。
在本專案的交換契約裡,這是資料不完整,所以回:
FAIL
FAIL
外部 HTTP reference 不一定是錯。
它只是超出 MVP 目前能力,因為系統沒有去外部 FHIR Server 查詢。
所以 Day 8 回:
NOT_EVALUATED
Observation/{id},不處理 fullUrlBundle 內 reference 不一定只使用 logical id。
後續也可能透過 Bundle.entry.fullUrl 對應,例如:
urn:uuid:223e4567-e89b-12d3-a456-426614174001
所以 Day 8 在建立 Observation reference 集合時,同時保留:
Observation/{id}
entry.fullUrl
Day 8 的程式和 Day 7 確實有重複。
但目前只有兩條 Reference 規則,第三條 LAB-REF-003 還沒實作。
先等 LAB-REF-003 完成,再抽共用 reference helper,會比較知道真正要共用的是:
收集 reference
解析 reference
建立 path
分類 external / internal / missing
而不是現在先猜一個抽象。
Day 7 的問題是:
Observation 找得到 Patient 嗎?
Day 8 的問題是:
DiagnosticReport 找得到它列出的 Observation 嗎?
這兩條規則加起來,開始形成檢驗資料交換時最基本的可追溯鏈:
Patient
↑
Observation
↑
DiagnosticReport
如果 DiagnosticReport.result 指向不存在的 Observation,合作方看到的是一份「報告存在,但報告內容缺了一塊」的資料。
這不一定會被 JSON parser 擋下來。
也不應該硬說是 TW Core 官方 Profile 的錯。
它比較適合被歸類為:
Exchange Contract: FAILED
這也是資料品質閘門要補上的地方:
標準驗證告訴我們 Resource 結構是否合理。
交換契約規則告訴我們這份資料是否符合合作方接收條件。
LabRef002DiagnosticReportResultRule。LabRef002DiagnosticReportResultRuleTests。LAB-REF-002 支援 Observation/{id} reference。LAB-REF-002 支援 Bundle.entry.fullUrl reference。LAB-REF-002 對不存在的 Bundle 內 Observation 回 FAIL。LAB-REF-002 對缺少 DiagnosticReport.result 回 FAIL。LAB-REF-002 對外部 HTTP reference 回 NOT_EVALUATED。LAB-REF-002 對沒有 DiagnosticReport 的 Bundle 回 NOT_APPLICABLE。LAB-REF-002 專用 fixture。./mvnw test 通過,測試數從 21 增加到 27。Day 8 尚未處理:
BundleParseService。RuleResult。LAB-REF-003:DiagnosticReport 與 Observation 必須對應同一 Patient。目前的規則進度:
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-REF-003。
原因是它會同時用到 DiagnosticReport.subject、DiagnosticReport.result 與 Observation.subject。
完成三條 Reference 規則後,再回頭整理共用 Reference helper 更好。
Repository:twcore-data-quality-gate