摘要
Day 8 已完成第二條 Reference 規則LAB-REF-002,確認DiagnosticReport.result必須指向 Bundle 中存在的Observation。Day 9 實作第三條 Reference 規則LAB-REF-003:DiagnosticReport.subject與它引用的Observation.subject必須對應同一位 Patient。
Day 7 處理的是:
Observation.subject → Patient
也就是檢驗結果要能找到它所屬的病人。
Day 8 處理的是:
DiagnosticReport.result → Observation
也就是檢驗報告列出的檢驗結果,必須真的存在於同一份待驗證 Bundle 裡。
但這兩條都通過,還是不代表整份報告一定合理。
例如一份 Bundle 裡可以出現這種資料:
DiagnosticReport.subject → Patient/patient-a
DiagnosticReport.result → Observation/obs-1
Observation/obs-1.subject → Patient/patient-b
這時候每一個 reference 可能都找得到 Resource。
Patient/patient-a 存在。
Observation/obs-1 也存在。
Patient/patient-b 也存在。
但從交換情境來看,這份資料仍然有問題:
報告說自己屬於 A 病人,但報告裡的檢驗結果其實屬於 B 病人。
LAB-REF-003 要回答的問題是:
這份 DiagnosticReport 引用的 Observation,是否真的屬於同一位 Patient?
這種錯誤通常不是 JSON parser 或單一 Resource Profile 能完整判斷,因為問題發生在多個 Resource 的組合語意。
今天新增的範圍只有:
LabRef003ReportObservationPatientRule
LabRef003ReportObservationPatientRuleTests
LAB-REF-003 專用 fixture今天沒有把規則接回 BundleParseService,也沒有做 UI、契約載入、LOINC、UCUM 或 Quality Gate。
原因和 Day 8 一樣。
如果今天同時做規則、服務層、畫面和契約設定,很容易每一層都只完成一半。
先讓第三條 Reference 規則可以被單元測試穩定驗證,會比提早接畫面更重要。
今天完成後,三條 Reference 規則就補齊了:
Observation.subject → Patient
DiagnosticReport.result → Observation
DiagnosticReport.subject 與 Observation.subject → 同一 Patient
LAB-REF-003 同時看三個欄位:
DiagnosticReport.subject.reference
DiagnosticReport.result[*].reference
Observation.subject.reference
MVP 目前支援兩種 Bundle 內 reference:
| reference 形式 | 範例 | Day 9 行為 |
|---|---|---|
| Resource logical reference | Patient/patient-1、Observation/obs-1 |
若可解析且指向同一 Patient,回 PASS |
| Bundle entry fullUrl | urn:uuid:b23e4567-e89b-12d3-a456-426614174030 |
若 fullUrl 對應同一 Patient,回 PASS |
MVP 目前不解析外部 HTTP reference:
| reference 形式 | 範例 | Day 9 行為 |
|---|---|---|
| 外部 FHIR Server reference | https://example.org/fhir/Patient/external-patient |
回 NOT_EVALUATED |
| 外部 FHIR Server reference | https://example.org/fhir/Observation/external-observation |
回 NOT_EVALUATED |
原因和 Day 7、Day 8 一樣。
外部 reference 需要連到外部 FHIR Server 才能確認 Resource 是否存在,也才能進一步比對病人是否一致。
Day 9 還沒有外部查詢能力,所以不能說通過,也不能直接說資料錯誤。
比較誠實的狀態仍然是:
NOT_EVALUATED
LAB-REF-003 的分類順序如下:
Bundle 沒有 DiagnosticReport → NOT_APPLICABLE
DiagnosticReport.subject.reference 空白 → FAIL
DiagnosticReport 沒有 result → FAIL
result.reference 空白 → FAIL
result.reference 是外部 HTTP reference → NOT_EVALUATED
Bundle 內找不到 result 指向的 Observation → FAIL
Observation.subject.reference 空白 → FAIL
Observation.subject.reference 是外部 HTTP reference → NOT_EVALUATED
Report subject 與 Observation subject 指向同一 Patient → PASS
Report subject 與 Observation subject 指向不同 Patient → FAIL
這裡有四個容易混淆的地方。
第一,沒有 DiagnosticReport 時不是 FAIL。
因為這條規則的適用對象不存在,所以回:
NOT_APPLICABLE
第二,有 DiagnosticReport 但沒有 subject 或 result 時是 FAIL。
因為 LAB-REF-003 要比對報告與檢驗結果是否屬於同一位病人。
如果報告沒有病人或沒有檢驗結果,這份資料在本專案的檢驗資料交換情境中是不完整的。
第三,外部 HTTP reference 是 NOT_EVALUATED。
因為系統目前沒有外部查詢能力,不能把沒查過的外部 Resource 當成通過。
第四,只確認 reference 找得到還不夠。
LAB-REF-003 的重點是「同一位病人」:
DiagnosticReport
├─ subject → Patient A
└─ result → Observation
└─ subject → Patient A
如果 Report 和 Observation 指向不同 Patient,即使兩個 Patient 都存在於 Bundle 中,也必須回 FAIL。
Day 9 新增或複用以下 fixture:
| fixture | 用途 | LAB-REF-003 預期 |
|---|---|---|
valid-internal-reference.json |
Report 與 Observation 都用 logical id 指向同一 Patient | PASS |
report-subject-full-url-observation-subject-id.json |
Report subject 用 Patient fullUrl,Observation subject 用 logical id | PASS |
mismatched-report-observation-patient.json |
Report 與 Observation 指向不同 Patient | FAIL |
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 |
external-observation-subject-reference.json |
Observation subject 指向外部 HTTP Patient | NOT_EVALUATED |
unsupported-resource-in-bundle.json |
Bundle 沒有 DiagnosticReport | NOT_APPLICABLE |
這些 fixture 的目的不是追求案例數量。
它們的目的,是先把 LAB-REF-003 的狀態分類固定下來:
PASS
FAIL
NOT_APPLICABLE
NOT_EVALUATED
後續做情境測試包時,才有穩定的規則行為可以組合。
Day 9 新增:
src/main/java/com/twlab/qualitygate/validation
└─ LabRef003ReportObservationPatientRule.java
規則仍然實作同一個介面:
public class LabRef003ReportObservationPatientRule implements ContractRule {
public static final String RULE_CODE = "LAB-REF-003";
@Override
public String ruleCode() {
return RULE_CODE;
}
@Override
public List<RuleResult> validate(Bundle bundle) {
// ...
}
}
這代表 Day 7 建立的 ContractRule 介面仍然可以繼續使用。
第三條規則也不需要新的資料模型。
核心邏輯分成三步:
建立 Observation reference → Observation 對照表
↓
建立 Patient reference alias 對照表
↓
逐一檢查 DiagnosticReport.subject 與 Observation.subject 是否指向同一 Patient
Observation 對照表包含:
Observation/{id}
Bundle.entry.fullUrl
概念如下:
private Map<String, Observation> collectObservationsByReference(Bundle bundle) {
Map<String, Observation> observationsByReference = new HashMap<>();
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()) {
observationsByReference.put("Observation/" + id, observation);
}
String fullUrl = entry.getFullUrl();
if (fullUrl != null && !fullUrl.isBlank()) {
observationsByReference.put(fullUrl, observation);
}
}
}
return observationsByReference;
}
Day 8 的 LAB-REF-002 只需要知道 Observation 是否存在,所以用 Set<String> 就夠了。
Day 9 的 LAB-REF-003 還要讀取被引用 Observation 的 subject,所以改成:
Map<String, Observation>
接著建立 Patient alias。
這段是為了讓 logical id 和 fullUrl 可以對應到同一位 Patient:
Patient/patient-full-url-match
urn:uuid:b23e4567-e89b-12d3-a456-426614174030
如果這兩種 reference 都指到同一個 Bundle entry Patient,就應該被視為同一位病人。
概念如下:
private Map<String, String> collectPatientReferenceAliases(Bundle bundle) {
Map<String, String> patientReferenceAliases = new HashMap<>();
for (Bundle.BundleEntryComponent entry : bundle.getEntry()) {
Resource resource = entry.getResource();
if (resource != null && "Patient".equals(resource.fhirType())) {
String id = resource.getIdElement().getIdPart();
String canonical = id == null || id.isBlank() ? entry.getFullUrl() : "Patient/" + id;
if (canonical == null || canonical.isBlank()) {
continue;
}
patientReferenceAliases.put(canonical, canonical);
String fullUrl = entry.getFullUrl();
if (fullUrl != null && !fullUrl.isBlank()) {
patientReferenceAliases.put(fullUrl, canonical);
}
}
}
return patientReferenceAliases;
}
最後比對 Report subject 和 Observation subject(以下簡化示意):
String canonicalReportSubject = patientReferenceAliases.getOrDefault(reportSubject, reportSubject);
String canonicalObservationSubject = patientReferenceAliases.getOrDefault(observationSubject, observationSubject);
if (canonicalReportSubject.equals(canonicalObservationSubject)) {
return pass(path, actual);
}
return fail(path, actual, "DiagnosticReport.subject and referenced Observation.subject point to different Patients.");
這裡的重點不是把 reference 字串寫得一模一樣。
而是要知道它們是否對應同一個 Bundle 內 Patient。
所以這兩種組合都可以通過:
DiagnosticReport.subject.reference = Patient/patient-1
Observation.subject.reference = Patient/patient-1
以及:
DiagnosticReport.subject.reference = urn:uuid:b23e4567-e89b-12d3-a456-426614174030
Observation.subject.reference = Patient/patient-full-url-match
Day 7、Day 8、Day 9 的規則都有 reference 解析邏輯。
但三條規則的需求不完全一樣:
LAB-REF-001:Observation → Patient 是否存在LAB-REF-002:DiagnosticReport → Observation 是否存在LAB-REF-003:Report subject 與 Observation subject 是否一致第三條規則完成後,才比較看得出真正共用的是什麼:
收集 Resource logical reference
收集 Bundle.entry.fullUrl
辨識 external HTTP reference
建立 path
輸出一致的 RuleResult
所以 Day 9 先不抽 helper。
等三條 Reference 規則都穩定後,再整理共用 reference helper,會比現在先猜一個抽象安全。
Day 9 新增:
src/test/java/com/twlab/qualitygate/validation/LabRef003ReportObservationPatientRuleTests.java
測試一:Report 與 Observation 都用 logical id 指向同一 Patient
assertThat(results.get(0).ruleCode()).isEqualTo("LAB-REF-003");
assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.PASS);
assertThat(results.get(0).path()).isEqualTo("DiagnosticReport/report-valid-ref.result[0].reference");
這代表最基本的同病人 reference 可以通過。
測試二:Report subject 使用 Patient fullUrl,Observation subject 使用 logical id
assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.PASS);
assertThat(results.get(0).actual()).contains("urn:uuid:b23e4567-e89b-12d3-a456-426614174030");
assertThat(results.get(0).actual()).contains("Patient/patient-full-url-match");
這代表規則不是只做字串相等。
它能把同一個 Patient 的 entry.fullUrl 和 Patient/{id} 視為同一個目標。
測試三:Report 與 Observation 指向不同 Patient
assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.FAIL);
assertThat(results.get(0).severity()).isEqualTo("error");
assertThat(results.get(0).actual())
.isEqualTo("DiagnosticReport.subject=Patient/patient-report; Observation.subject=Patient/patient-observation");
這代表合作方可以得到明確的阻擋原因。
問題不是 Resource 不存在,而是報告與檢驗結果對應到不同病人。
測試四:Report result 指向不存在的 Observation
assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.FAIL);
assertThat(results.get(0).actual()).isEqualTo("Observation/obs-not-in-bundle");
這和 Day 8 的缺失類似。
如果 Report 引用的 Observation 不在 Bundle 裡,Day 9 也無法繼續比對病人一致性。
測試五: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。
測試六:Report result 指向外部 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 還不支援外部 Observation 查詢。
測試七:Observation subject 指向外部 HTTP Patient 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/Patient/external-patient");
這代表即使 Report result 指向 Bundle 內 Observation,只要 Observation 的 Patient 要到外部查詢,Day 9 仍然回 NOT_EVALUATED。
測試八:Bundle 沒有 DiagnosticReport
assertThat(results.get(0).outcome()).isEqualTo(RuleOutcome.NOT_APPLICABLE);
assertThat(results.get(0).severity()).isEqualTo("information");
這代表規則沒有適用對象時,不會誤判成資料錯誤。
這八段 assertion 就是 Day 9 測試的核心,不需要再另外截測試檔畫面。
指令:
./mvnw test
測試結果:
Tests run: 35, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

Day 9 從 Day 8 的 27 個測試增加到 35 個測試。
新增的 8 個測試都集中在 LAB-REF-003:
PASS
PASS
FAIL
FAIL
FAIL
NOT_EVALUATED
NOT_EVALUATED
NOT_APPLICABLE
Day 9 的可展示成果仍然不是 UI。
它是用單元測試把第三條交換契約規則的行為固定下來。
LAB-REF-003 不是 LAB-REF-002 的重複。
LAB-REF-002 問的是:
DiagnosticReport.result 指向的 Observation 找得到嗎?
LAB-REF-003 問的是:
DiagnosticReport.subject 和 Observation.subject 是同一位 Patient 嗎?
所以 Observation 找得到,不代表 LAB-REF-003 一定通過。
Bundle 內可能同時出現:
Patient/patient-full-url-match
urn:uuid:b23e4567-e89b-12d3-a456-426614174030
如果這兩個 reference 都對應同一個 Patient entry,就應該視為同一位病人。
Day 9 透過 Patient alias map 處理這件事。
FAIL
外部 HTTP reference 不一定是錯。
它只是超出 MVP 目前能力,因為系統沒有去外部 FHIR Server 查詢。
所以 Day 9 回:
NOT_EVALUATED
FAIL
LAB-REF-003 的適用對象是 DiagnosticReport。
如果 Bundle 裡根本沒有 DiagnosticReport,這條規則沒有可以檢查的對象。
所以回:
NOT_APPLICABLE
Day 9 完成後,三條 Reference 規則都已經存在。
這時候才適合回頭整理共用 helper。
如果 Day 7 或 Day 8 就先抽,可能會只抽到「存在性檢查」,但 Day 9 其實需要的是「reference alias 與同一目標比對」。
Day 7 的問題是:
Observation 找得到 Patient 嗎?
Day 8 的問題是:
DiagnosticReport 找得到它列出的 Observation 嗎?
Day 9 的問題是:
DiagnosticReport 和它列出的 Observation 是同一位 Patient 嗎?
三條規則加起來,才比較像一條完整的檢驗資料鏈:
Patient
↑
Observation
↑
DiagnosticReport
如果 DiagnosticReport.subject 是 A 病人,但 Observation.subject 是 B 病人,合作方收到的不是「少一筆資料」。
它收到的是一份可能把報告和檢驗結果配錯病人的資料。
這類問題不一定會被 JSON parser 擋下來。
也不應該硬說是 TW Core 官方 Profile 的錯。
它比較適合被歸類為:
Exchange Contract: FAILED
這也是資料品質閘門要補上的地方:
標準驗證告訴我們 Resource 結構是否合理。
交換契約規則告訴我們這份資料是否符合合作方接收條件。
LabRef003ReportObservationPatientRule。LabRef003ReportObservationPatientRuleTests。LAB-REF-003 支援 Patient/{id} 與 Observation/{id} reference。LAB-REF-003 支援 Bundle.entry.fullUrl reference。LAB-REF-003 可比對 Report subject 與 Observation subject 是否同一 Patient。LAB-REF-003 對不同 Patient 回 FAIL。LAB-REF-003 對缺少 DiagnosticReport.result 回 FAIL。LAB-REF-003 對不存在的 Bundle 內 Observation 回 FAIL。LAB-REF-003 對外部 HTTP reference 回 NOT_EVALUATED。LAB-REF-003 對沒有 DiagnosticReport 的 Bundle 回 NOT_APPLICABLE。LAB-REF-003 專用 fixture。./mvnw test 通過,測試數從 27 增加到 35。Day 9 尚未處理:
BundleParseService。RuleResult。目前的規則進度:
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 契約允許集合 未開始
下一步預計先整理三條 Reference 規則的共用部分。
原因是現在已經看得到三條規則真正重複的是:
Resource logical reference
Bundle.entry.fullUrl
external HTTP reference
path 顯示格式
RuleResult 狀態分類
完成這個小重構後,再進入 LAB-CODE-001,開始處理 LOINC 契約允許集合。
Repository:twcore-data-quality-gate