07 · 後端實作指南:不用 HAPI,怎麼做出 FHIR 模式的 API

這份文件寫給後端工程師。 前面 01–06 說的是「介面長什麼樣、為什麼這樣設計」; 這一份說的是「你們手上那套自建資料表,要怎麼長出那個介面」。

本文件每一節都有對應的可執行程式:../fhir-server(Express + SQLite 參考實作)。 讀到某一段想看實際長怎樣,就去翻那邊對應的檔案,或直接 npm start 打開演練台點點看。

前提:伺服器不使用 HAPI FHIR,資料庫是依 G1_物件目錄(vietnamEMR repo:用戶需求/G_資料模型/G1_物件目錄.md) 自建的關聯式資料表。這不影響 API 的形狀——FHIR 在本專案是介面契約,不是儲存格式。


7.1 一句話架構

Vue 前端  ──FHIR R4 JSON──>  API 層  ──SQL──>  自建資料表(G1 的 71 個物件)
                              │
                              └──事件──>  整合層  ──>  HIS(D1 合約)

API 層做三件事,只做這三件事:

職責 內容
投影 把自建表的列組成 FHIR 資源;把進來的 FHIR 資源拆回自建表
驗證 擋掉違反 04 BR-* 的請求,回 05 的 OperationOutcome
記錄 每次寫入產生新版本、AuditEvent,必要時 Provenance

三件不要做的事

  1. 不要把整份 FHIR JSON 塞進一個 text 欄位就當作完成。 搜尋、統計、趨勢圖、I/O 平衡全部會卡死。 JSON 快照要存(見 7.4),但它是版本留存用的,不是主要查詢路徑。
  2. 不要為了像 FHIR 而把自建表改名成 FHIR 資源名。 表是你們的,欄位語意才是契約。 medication_request 這張表不必改叫 MedicationRequest,但它的 route 欄位必須能組出 dosageInstruction[0].route,且帶得出 coding.system + coding.code。
  3. 不要把 HAPI 才有的功能當成契約。 本規格刻意不要求 chained search、_filter、 GraphQL、Subscription、$everything。沒有 HAPI 也做得完,就是因為這些一開始就不在範圍內。

7.2 先分清楚兩種表,做法不一樣

H12_API共用規範 §1(vietnamEMR repo:用戶需求/H_產品規格/)的三層分離直接決定後端要寫多少程式:

類別 物件 API 寫入從哪來
HIS 唯讀鏡像(13 個) patient、patient_identifier、encounter、encounter_location、coverage、diagnostic_report、specimen、microbiology_isolate、antimicrobial_susceptibility、medication_dispense、practitioner、organization、location 只開 GET 整合層收 D1 事件寫入,臨床端一律唯讀
EMR 自有 medication_request、medication_administration、observation、composition、task、signature、service_request… 完整 CRUD(無 delete) 臨床端寫入

唯讀鏡像不需要版本控制、不需要 If-Match、不需要業務規則驗證——它們只要能被投影成 FHIR 讀出來。 真正要花工的是 EMR 自有那一組。先把工作量這樣切開,排程會準很多。

臨床帳號寫唯讀資源一律回 422 READ-ONLY-SOURCE,不是 403。 理由見 01 §1.2.1:這不是權限不足,是這筆資料在本系統沒有寫入路徑。


7.3 儲存策略:三種選項,本專案選 C

選項 做法 優點 代價
A · 純關聯式 只有自建表,每次讀取即時組裝 FHIR 查詢快、統計容易 vread/_history/帶版本 Reference 做不出來
B · 純文件 一張 resource(id, type, json) 表 實作最快 搜尋、趨勢、I/O 全要掃 JSON;等於放棄 G1 的建模成果
C · 混合(本專案) 自建表是現況的權威;另加版本表存每次寫入的 FHIR JSON 快照 兩邊的優點都拿到 每次寫入多一次 insert

選 C 的理由不是效能,是 01 §1.4「不覆寫」:

這三件事只要少一件,稽核就辯護不了。而它們共同需要的,就是「任一資源的任一舊版本,原樣讀得出來」—— 最省事的做法就是寫入時順手存一份 JSON 快照。


7.4 三張基礎設施表

以下用 PostgreSQL 方言。SQL Server/MySQL 請自行換型別(jsonb→nvarchar(max)/json, gen_random_uuid()→newid()/uuid()),語意不變。

-- 1) 邏輯識別碼 ↔ 自建表主鍵的對照
--    FHIR 的 URL 用 logical_id,資料還是放在各自的業務表
CREATE TABLE fhir_resource (
  resource_type   varchar(64)  NOT NULL,          -- 'MedicationRequest'
  logical_id      varchar(64)  NOT NULL,          -- 出現在 URL 的 id
  source_table    varchar(64)  NOT NULL,          -- 'medication_request'
  source_pk       varchar(64)  NOT NULL,          -- med_request_id
  current_version integer      NOT NULL DEFAULT 1,
  is_deleted      boolean      NOT NULL DEFAULT false,  -- 永遠 false,保留欄位不使用
  created_at      timestamptz  NOT NULL DEFAULT now(),
  last_updated    timestamptz  NOT NULL DEFAULT now(),
  PRIMARY KEY (resource_type, logical_id),
  UNIQUE (source_table, source_pk)
);

-- 2) 版本快照(append-only,永不 UPDATE、永不 DELETE)
CREATE TABLE fhir_resource_version (
  resource_type varchar(64) NOT NULL,
  logical_id    varchar(64) NOT NULL,
  version_id    integer     NOT NULL,
  content       jsonb       NOT NULL,            -- 該版本的完整 FHIR JSON
  recorded_at   timestamptz NOT NULL DEFAULT now(),
  recorded_by   varchar(64) NOT NULL,            -- practitioner_id,稽核用
  PRIMARY KEY (resource_type, logical_id, version_id)
);

-- 3) HIS 業務識別碼對照(冪等與反查都靠它)
CREATE TABLE his_id_map (
  his_system    varchar(64) NOT NULL,            -- 'encounterNumber' | 'prescriptionNumber' | 'orderNumber'
  his_key       varchar(128) NOT NULL,
  resource_type varchar(64) NOT NULL,
  logical_id    varchar(64) NOT NULL,
  PRIMARY KEY (his_system, his_key)
);

三點務必照做:

  1. fhir_resource_version 只給 INSERT 權限。 用 DB 角色擋掉 UPDATE/DELETE,不要只靠程式自律。 這是「不覆寫」原則唯一不會被繞過的實作方式。
  2. logical_id 發出去就不可變、不可重用。 即使資料被標成 entered-in-error 也一樣。
  3. current_version 就是 ETag 的來源,不要另外算。

7.5 id 與 identifier 是兩件事

最常見的錯誤是把 HIS 的處方號直接拿來當 FHIR 的 id。

FHIR 元素 誰發 可變 例
邏輯識別碼 id(在 URL 裡) 你們的 API 否 mr-efferalgan-0831
業務識別碼 identifier[] HIS 或院方流程 是(可補、可多組) HP-2026083100417

理由很實際:醫囑在還沒送 HIS 之前就已經存在(draft),那時候根本沒有處方號。 如果拿處方號當 id,草稿階段就沒有 URL 可以讀寫。

{
  "resourceType": "MedicationRequest",
  "id": "mr-efferalgan-0831",                       // URL:/MedicationRequest/mr-efferalgan-0831
  "identifier": [
    { "system": "http://icu.emr.local/CodeSystem/his-prescription-number",
      "value": "HP-2026083100417" }                  // HIS 回來之後才有
  ]
}

搜尋 ?identifier=HP-2026083100417 就是查 his_id_map(或業務表的識別碼欄位),不是查主鍵。


7.6 讀:一筆資源怎麼從表組出來

7.6.1 MedicationRequest

對映沿用 02 §2.3,這裡補上 SQL 與組裝。

SELECT r.logical_id, r.current_version, r.last_updated,
       m.medication_code, m.ten_thuoc, m.dose_value, m.dose_unit,
       m.route, m.frequency, m.cach_dung_thuoc, m.rate_value, m.rate_unit,
       m.period_start, m.period_end, m.prescriber_id, m.order_set_id,
       m.status, m.his_transmission_status, m.source_composition_id, m.source_composition_version,
       m.catalog_version, m.catalog_source_sha256
FROM   medication_request m
JOIN   fhir_resource r
  ON   r.source_table = 'medication_request' AND r.source_pk = m.med_request_id
WHERE  r.resource_type = 'MedicationRequest' AND r.logical_id = $1;

組裝規則:

表欄位 FHIR 元素 規則
medication_code medicationCodeableConcept.coding[system=his-medication].code 必帶 system+code,只有 text 要拒(CODE-REQUIRED)
ten_thuoc .coding[0].display + .text 顯示名用來源原文(越文),中文譯名放 coding.extension[reviewTranslation]
dose_value / dose_unit dosageInstruction[0].doseAndRate[0].doseQuantity system 填 UCUM;院內單位未對照完成時填院內 CodeSystem
route dosageInstruction[0].route.coding 同樣必帶 system+code
frequency dosageInstruction[0].timing.repeat BID → {frequency:2, period:1, periodUnit:'d'}
rate_value dosageInstruction[0].doseAndRate[0].rateQuantity 需單位與濃度,不可把 mg/kg/min 當成 mL/h
status status 臨床狀態軸,見 04 §4.1
his_transmission_status extension[hisTransmissionStatus] 獨立的第三軸,不可併進 status
source_composition_id + _version supportingInformation[0].reference 組成 Composition/{id}/_history/{version}
catalog_version、catalog_source_sha256 extension[catalogSnapshot] 開立當下的目錄快照,稽核可辯護性的基礎

NULL 一律省略該元素,不要送 0、""、false。 這是 01 §1.3 缺值不補, 不是風格偏好:0 在臨床資料裡是一個真實數值(見錯誤碼 ZERO-AS-MISSING)。 確實需要表達「已知缺漏」時用 dataAbsentReason。

7.6.2 保留「給人看的原文」,否則畫面會走樣

結構化與顯示是兩件事。把 200 mg(1 錠) 拆成 {value: 200, unit: 'mg'} 之後, 再組回去只剩 200 mg——括號裡的包裝寫法沒了,臨床看到的字就跟原本不一樣。 頻次 BID(每日 2 次) 也一樣:從 timing.repeat.frequency 重組只會得到「每日 2 次」。

所以兩種都要存:結構化的值給機器算,原文給人看。FHIR 本來就留了位置:

內容 結構化 原文
藥物劑量與用法 doseAndRate[0].doseQuantity、timing.repeat dosageInstruction[0].text
實際給藥劑量 dosage.dose dosage.text
醫令的執行方式與數量 quantityInteger orderDetail[](以 coding.code 分 route/quantity)
醫令頻次 occurrenceTiming.repeat occurrenceTiming.code.text
資料來源說明 — meta.source

對應到資料表就是多幾個 *_text 欄位。這不是冗餘: 沒有原文,畫面就只能自己拼字串,而拼出來的跟醫師當初打的不一樣。

7.6.3 Observation:一列一值,血壓例外

observation 是長表(一列 = 一個 code + 一個值 + 一個單位 + 一個時點 + 誰量的)。 多數情況一列對一個 Observation,血壓、PEWS、Apgar 這類多子項的量測要靠 observation_component:

SELECT o.observation_id, o.code, o.value_num, o.unit, o.effective_time, o.performer_id,
       c.component_id, c.code AS component_code, c.value_num AS component_value, c.unit AS component_unit
FROM   observation o
LEFT   JOIN observation_component c ON c.observation_id = o.observation_id
WHERE  o.patient_id = $1
  AND  o.category = 'vital-signs'
  AND  o.effective_time >= $2 AND o.effective_time <= $3
ORDER  BY o.effective_time DESC, c.display_order;

有 component 的列:主列不放 valueQuantity,值全放在 component[]。 收縮壓與舒張壓不可拆成兩筆獨立 Observation(BP-COMPONENTS-REQUIRED), 否則時間軸上會失去配對,趨勢圖就畫錯了。

7.6.4 DiagnosticReport:表頭 + 逐項

一份報告 1 : N 個 observation,這個層級是 D1 的 ClinicalResultAvailable 合約就決定好的:

diagnostic_report(表頭)──> Observation[](每個檢驗指標一筆)
                        └──> specimen ──> microbiology_isolate ──> antimicrobial_susceptibility

每個指標必須是獨立的 Observation,不可以塞進 conclusion 的文字裡。 趨勢圖只有這種結構撐得住。


7.7 搜尋:query 參數怎麼翻成 SQL

支援的參數清單在 03 §3.6.2。翻譯規則只有四種型別:

型別 參數例 寫法 SQL
reference patient=Patient/demo-a 也接受純 id WHERE patient_id = :id
token status=active
code=http://loinc.org|8867-4
system|code、|code、code 三種形式 WHERE status = :v
WHERE code_system = :s AND code = :c
date date=ge2026-08-31T07:00:00+07:00 前綴 eq(預設)ne gt lt ge le WHERE effective_time >= :v
string name=Nguyen 預設前綴比對,:exact 為完全相等 WHERE name ILIKE :v || '%'

同一參數重複出現= AND(時間窗就是這樣表達); 逗號分隔的多值= OR(status=active,on-hold)。

7.7.1 時間窗直接對映

GET /Observation?patient=Patient/demo-a&category=vital-signs
    &date=ge2026-08-30T19:00:00+07:00&date=le2026-08-31T19:00:00+07:00
WHERE patient_id = :patient
  AND category   = 'vital-signs'
  AND effective_time >= :from
  AND effective_time <= :to

伺服器不提供 window=shift 這種語意參數。 班別起訖是排班資料,換算責任在前端; 伺服器對「本班」有隱含定義,之後一定會跟排班表對不上。

7.7.2 分頁用 keyset,不要用 OFFSET

臨床資料一直在寫入,OFFSET 會讓第 2 頁漏資料或重複。

-- 第一頁
SELECT ... WHERE patient_id = :p ORDER BY effective_time DESC, observation_id DESC LIMIT 21;
-- 下一頁(cursor 來自前一頁最後一列)
SELECT ... WHERE patient_id = :p
  AND (effective_time, observation_id) < (:last_time, :last_id)
ORDER BY effective_time DESC, observation_id DESC LIMIT 21;

多取一列用來判斷還有沒有下一頁;有的話就放 Bundle.link[relation=next],URL 裡帶不透明的 cursor。 前端不得自己拼 offset,一律跟隨 next(03 §3.6.1)。

_count 預設 20、上限 200。_total=accurate 要另跑一次 COUNT(*),成本自負; 沒帶就不回 Bundle.total(回 0 是錯的,那代表「查無資料」)。

7.7.3 _include / _revinclude:第二段查詢

不必做成 JOIN。先查主結果,收集要帶的 reference,再查一次,放進同一個 Bundle, 差別只在 entry.search.mode:

GET /DiagnosticReport?patient=Patient/demo-a&date=ge2026-08-31
    &_include=DiagnosticReport:result
    &_revinclude=Provenance:target
① 主查詢 → diagnostic_report 列 → entry.search.mode = "match"
② _include     → 這些報告的 observation 列        → mode = "include"
③ _revinclude  → target 指向這些報告的 provenance → mode = "include"

mode=include 的資源不算在 Bundle.total 裡,也不該被前端當成搜尋命中。 支援的組合就是 03 §3.6.4 列的那幾個,不必做成通用機制。

7.7.4 明確不做

chained search(patient.name=…)、_filter、_has 以外的反向查詢、模糊比對、 「找不到就回相近項目」——全部不做。code 查不到就是空集合。

interpretation 沒填不代表正常,不可以在查詢時把它歸進 normal(BR-RPT 系列)。

B 目錄的品項查詢(藥品/服務/ICD 共 31,146 筆)走 ValueSet/$expand,不走資源搜尋。


7.8 寫:版本、樂觀鎖、ETag

7.8.1 一次寫入的完整流程

① 驗證 If-Match 存在              缺 → 412 PRECONDITION-REQUIRED
② 驗證權限與病人範圍              不符 → 403
③ 驗證業務規則(BR-*)            不符 → 422
④ BEGIN
⑤   UPDATE 業務表 ... WHERE pk = :pk AND version = :ifMatch   影響 0 列 → ROLLBACK, 409
⑥   INSERT fhir_resource_version(新版本的 JSON 快照)
⑦   UPDATE fhir_resource SET current_version = current_version + 1, last_updated = now()
⑧   INSERT audit_event
⑨ COMMIT                          失敗 → 500,且資源必須不存在
⑩ 回 200/201 + ETag: W/"{新版本}"

第 ⑤ 步是樂觀鎖的全部:用 WHERE version = :ifMatch 讓資料庫判斷,不要先 SELECT 再比較—— 先查再寫之間有競態,兩個人可以同時通過檢查。

UPDATE medication_request
   SET dose_value = :dose, version = version + 1, last_updated = now()
 WHERE med_request_id = :pk
   AND version = :if_match;     -- 影響 0 列 ⇒ 版本已被別人推進 ⇒ 409

409 的 OperationOutcome 必須帶目前版本(details.text = "currentVersionId=6"), 前端才能就地合併而不丟掉使用者輸入。

7.8.2 保存失敗就不能回 2xx

原型(icu-vue)在持久化失敗時仍把交易留在記憶體並顯示警示,那是 localStorage 的權宜做法。 正式 API 不得如此(05 §5.4): 沒寫進資料庫就回 500,資源必須不存在。保留使用者輸入是前端的責任,不是靠伺服器回假成功。

7.8.3 沒有 DELETE

任何資源都不提供 DELETE。誤建資料用 PATCH 標記:

PATCH /MedicationRequest/mr-efferalgan-0831
Content-Type: application/json-patch+json
If-Match: W/"3"

[{ "op": "replace", "path": "/status", "value": "entered-in-error" }]

後端要做的是:正常進版 + 產生 AuditEvent(action=D)。 標為 entered-in-error 的資源仍可 read,但預設不出現在搜尋結果—— 所有搜尋預設加上 AND status <> 'entered-in-error',除非查詢明寫 status=entered-in-error。

7.8.4 建立後不可改的四種資源

MedicationAdministration、Procedure、Observation、Communication 是已發生事實的紀錄, 只能標 entered-in-error 後重建,不提供 update。路由層直接不註冊 PUT 就好。


7.9 Bundle transaction

7.9.1 內部引用(urn:uuid)的解析

一次開立多筆醫囑、簽署(文件+Provenance)、接收交班(Provenance+多筆 Task) 都必須是 type=transaction,全成功或全失敗。難點只有一個:entry 之間會互相引用。

{
  "resourceType": "Bundle", "type": "transaction",
  "entry": [
    { "fullUrl": "urn:uuid:...0001",
      "resource": { "resourceType": "Composition", ... },
      "request": { "method": "POST", "url": "Composition" } },
    { "fullUrl": "urn:uuid:...0002",
      "resource": { "resourceType": "Provenance",
                    "target": [{ "reference": "urn:uuid:...0001" }] },   // 指向上一筆
      "request": { "method": "POST", "url": "Provenance" } }
  ]
}

做法(兩階段,最不容易出錯):

① 開一個 DB transaction
② 先為每個 POST entry 配發 logical_id,建立 urn:uuid → "Composition/xxx" 的對照表
③ 依對照表把所有 entry 的 reference 就地改寫
④ 逐筆寫入(此時已沒有 urn:uuid)
⑤ 全成功 → COMMIT,回 transaction-response(entry 順序與請求相同)
   任一筆失敗 → ROLLBACK,回單一 OperationOutcome,且指出是第幾筆

transaction-response 的每個 entry 只回 response(status/location/etag/lastModified), 不必回整份資源。

batch 只用於純讀取的批次載入,任何寫入不得用 batch。

7.9.2 條件式建立:用資料庫的唯一索引,不要先查再寫

劑次唯一性(同一劑不可重複給藥)是安全問題,不能靠應用層檢查:

POST /MedicationAdministration
If-None-Exist: identifier=http://icu.emr.local/occurrence-id|M3-V1-D1&status=completed
CREATE UNIQUE INDEX ux_med_admin_occurrence
    ON medication_administration (occurrence_id)
 WHERE status = 'completed';   -- 部分索引:只有完成的劑次互斥

寫入時直接 INSERT,捕捉唯一鍵衝突:衝突就回 200 OK + 既有資源(不是 201、不是 409), 前端據此顯示「本劑次已有完成紀錄」。這比先查再寫安全:兩個護理師同時操作時, 資料庫只會讓一個成功。


7.10 自訂操作($sign / $submit / $receive / $roster)

這四支不是「另一種風格的 REST」,它們存在的唯一理由是多個副作用必須原子完成。

操作 一個交易內要完成的事
POST /MedicationRequest/{id}/$sign 驗證病程來源存在且同病人同作者 → 把病程當下版本寫進 supportingInformation → 建立 Provenance.signature → 醫囑進版
POST /MedicationRequest/{id}/$submit 產生 outbound_request(含冪等鍵)→ 呼叫 HIS → 落回 hisTransmissionStatus 與處方號
POST /Composition/{id}/$sign 文件轉 final → 建立 Provenance.signature
POST /Composition/{id}/$receive 建立接收 Provenance → 同一交易內轉移 Task.owner
GET /Patient/$roster 回登入者的照護名單(Patient + CareTeam),省掉前端自己拼 _has

$submit 的冪等

CREATE TABLE outbound_request (
  idempotency_key varchar(128) PRIMARY KEY,   -- resource_type + logical_id + version_id
  event_type      varchar(64)  NOT NULL,      -- 'PrescriptionCreated'
  payload         jsonb        NOT NULL,
  status          varchar(16)  NOT NULL,      -- pending | sent | accepted | rejected | unknown
  his_response    jsonb,
  sent_at         timestamptz,
  responded_at    timestamptz
);

同一資源同一版本重送,回第一次的結果,不重打 HIS。 status = unknown(逾時)時禁止自動重送,回 409 RESEND-BLOCKED-UNKNOWN, 改開一件人工查明的 Task——自動重送會造成重複處方(06 §6.6)。


7.11 錯誤:一個中介層做完

不要在每支端點各寫一段錯誤處理。定義一個業務規則例外,在最外層轉成 OperationOutcome:

// 丟出
throw new BusinessRuleError('FIVE-RIGHTS-INCOMPLETE', {
  http: 422,
  issueCode: 'business-rule',
  diagnostics: '需逐項人工確認五對,不能由單次掃碼推定。',
  expression: ["MedicationAdministration.extension('…/verificationChecklist')"],
})

// 中介層統一轉換
{
  resourceType: 'OperationOutcome',
  issue: [{
    severity: 'error',
    code: err.issueCode,
    details: { coding: [{ system: 'http://icu.emr.local/CodeSystem/api-error', code: err.code }] },
    diagnostics: err.diagnostics,
    expression: err.expression,
  }],
}

四個欄位缺一不可(05 §5.1)。 錯誤碼表(61 個)在 05 §5.2, Swagger 的 ApiErrorCode schema 是同一份列舉。

diagnostics 是給臨床看的,不是給工程師看的

它會被前端直接顯示在畫面上,所以寫「需逐項人工確認五對」,不寫 validation failed at field 3。 越南語版本另建訊息表以 code 為鍵,不要在程式裡寫死字串。

最常搞錯的三個界線

情況 碼 判準
沒有權限(角色、病人範圍) 403 你不能做這件事
狀態不允許、版本衝突 409 現在不能做這件事,之後可能可以
結構合法但違反業務規則 422 這份資料本身不合規

不因無權而回 404。 資源不存在才 404。 但搜尋是例外:回較少的結果,不要回 403,否則會變成探測病人是否存在的管道。


7.12 稽核與溯源

AuditEvent Provenance
誰產生 伺服器自動產生,客戶端不可寫 客戶端建立(簽署、判讀、核對、交班接收)
記什麼 誰在什麼時候存取/改了什麼(系統事實) 誰用什麼身分做了什麼臨床動作(臨床事實)
可否修改 否 否,只新增

每個請求(含讀取)都要寫 AuditEvent,必填欄位見 05 §5.6。 實作上放在同一個中介層,成功與失敗都記(outcome)。

「已閱」用 HL7 標準碼 v3-DataOperation#READ; 「判讀」「核對」「交班接收」「名單確認」是臨床業務動作,標準碼沒有對應語義, 一律用本地 CodeSystem provenance-activity(02 §2.12)。不要硬套。


7.13 安全:病人範圍要變成 SQL 條件

SMART on FHIR 的 scope 檢查(user/MedicationRequest.c 之類)只是第一道。 真正要做對的是病人範圍:使用者只能看到自己照護範圍內的病人。

-- 每個病人相關查詢都自動追加這個條件,不要靠各端點自己記得加
AND patient_id IN (
  SELECT ct.patient_id FROM care_team ct
  JOIN care_team_participant p ON p.care_team_id = ct.care_team_id
  WHERE p.practitioner_id = :current_user
    AND ct.status = 'active'
    AND (ct.period_end IS NULL OR ct.period_end >= now())
)

把它做成資料存取層的強制條件(例如每個 repository 都吃一個 AccessScope 參數), 而不是每支端點自己拼——漏掉一支就是一次病歷外洩。

照會醫師是特例:能讀該病人資料、能回覆照會,但不能開立醫囑(CONSULT-READ-ONLY), 而且他不在 CareTeam 裡,範圍要由 ServiceRequest(category=consultation).performer 推導。


7.14 前端(Vue 3)怎麼呼叫

前端是 Vue 3 + Pinia(icu-vue)。對後端的意義是三件事:ETag 要保存、409 要能合併、 一次載入要用 _include。以下是前端已經在用的形狀,後端照這個回就對得上。

7.14.1 最小 client

// src/api/fhirClient.js
const BASE = import.meta.env.VITE_FHIR_BASE  // https://…/fhir/r4
const JSON_FHIR = 'application/fhir+json'

/** ETag 由 client 統一保管,畫面不必自己記版本 */
const etags = new Map()   // "MedicationRequest/mr-1" → 'W/"3"'

async function request(method, path, { body, headers = {} } = {}) {
  const res = await fetch(BASE + path, {
    method,
    headers: { Accept: JSON_FHIR, ...(body ? { 'Content-Type': JSON_FHIR } : {}), ...headers },
    body: body ? JSON.stringify(body) : undefined,
  })

  const etag = res.headers.get('ETag')
  const payload = res.status === 204 ? null : await res.json()

  if (!res.ok) throw new FhirError(res.status, payload)      // payload 一定是 OperationOutcome
  if (etag && payload?.resourceType && payload?.id) etags.set(`${payload.resourceType}/${payload.id}`, etag)
  return payload
}

export const read = path => request('GET', path)

export function update(resource) {
  const key = `${resource.resourceType}/${resource.id}`
  const etag = etags.get(key)
  if (!etag) throw new Error(`${key} 沒有 ETag:請先讀取再更新,不可盲目覆寫`)
  return request('PUT', `/${key}`, { body: resource, headers: { 'If-Match': etag } })
}

export class FhirError extends Error {
  constructor(status, outcome) {
    const issue = outcome?.issue?.[0]
    super(issue?.diagnostics ?? `HTTP ${status}`)
    this.status = status
    this.code = issue?.details?.coding?.[0]?.code   // 本院錯誤碼,用來決定畫面行為
    this.expression = issue?.expression             // 要標紅的欄位
    this.outcome = outcome
  }
}

重點:diagnostics 直接顯示給使用者,code 用來決定畫面行為(例如 ROSTER-NOT-CONFIRMED 就跳去確認名單),expression 用來標出出錯的欄位。這三個用途分開,畫面才不必解析字串。

7.14.2 409 要保留使用者輸入

try {
  await update(draft)
} catch (e) {
  if (e.status === 409) {
    // 伺服器帶回 currentVersionId=6;重新讀取後把使用者改過的欄位套上去,
    // 不要直接丟掉畫面內容(後端保證內容未被覆寫)
    const latest = await read(`/Composition/${draft.id}`)
    showMergeDialog(latest, draft)
  } else if (e.status === 412) {
    // 沒帶 If-Match:這是前端的 bug,不是使用者的問題
  }
}

7.14.3 一次載入病人工作區

// S13 檢驗結果頁:報告 + 逐項數值 + 已閱判讀,一次 request
const bundle = await read(
  `/DiagnosticReport?patient=Patient/${pid}&date=ge${from}` +
  `&_include=DiagnosticReport:result&_revinclude=Provenance:target`)

const matched  = bundle.entry.filter(e => e.search?.mode === 'match').map(e => e.resource)
const included = bundle.entry.filter(e => e.search?.mode === 'include').map(e => e.resource)

Pinia store 只存這個 Bundle 拆出來的結果,不要在 store 裡再造一套自己的資料模型—— 原型那層 store.js 的 mutation 介面保留,內部改成呼叫上面的 client 即可。


7.15 建議的交付順序

每個階段都能獨立驗收,前端也能同步接上,不必等整套做完。

階段 後端做什麼 前端這時能做什麼 驗收
M1 讀路徑 唯讀鏡像 13 個物件的 GET + 搜尋;/metadata 病人總覽、生命徵象、檢驗結果三頁接真資料 用本文件的範例逐支比對回應形狀
M2 寫路徑 EMR 自有資源的 create/update、版本表、ETag、OperationOutcome 病程撰寫、醫囑開立(草稿) 併行更新測試:兩個工作階段同時改同一份病程,一個成功一個 409
M3 原子操作 Bundle transaction、$sign/$receive、條件式建立 簽署、交班、eMAR 給藥 劑次重複測試:同一劑次併發 POST 兩次,只有一筆完成紀錄
M4 HIS 整合 $submit、outbound_request、D1 事件落回 醫囑送出與狀態顯示 逾時情境:HIS 不回應時本地狀態為 sent+unknown,且不自動重送

7.16 後端自測清單

做完可以自己勾。每一條都對應本文件或 04/05 的規則,不是風格建議。

基本

讀

寫

原子性

安全與稽核

HIS 整合


附錄 A · 自建資料表 → FHIR 資源 → 端點

Phase 1 ★必要的物件。完整欄位定義見 G7_物件欄位規格.xlsx,權威歸屬見 G1。

自建表 主鍵 FHIR 資源 端點 寫入者
patient + patient_identifier patient_id Patient GET /Patient、GET /Patient/{id} 整合層(HIS)
encounter + encounter_location encounter_id Encounter + Location GET /Encounter 整合層(HIS)
practitioner practitioner_id Practitioner / PractitionerRole 由 Reference 帶出 整合層(HIS)
care_assignment — CareTeam + Provenance GET/POST /CareTeam、GET /Patient/$roster 臨床端
condition condition_id Condition 由 Composition 引用 臨床端
medication_request med_request_id MedicationRequest /MedicationRequest、$sign、$submit 臨床端 → HIS
medication_administration med_admin_id MedicationAdministration /MedicationAdministration 臨床端
medication_dispense dispense_id MedicationDispense 唯讀 整合層(HIS)
service_request service_request_id ServiceRequest /ServiceRequest 臨床端 → HIS
procedure + procedure_performer procedure_id Procedure /Procedure 臨床端
observation + observation_component observation_id Observation /Observation 臨床端/設備/HIS
diagnostic_report report_id DiagnosticReport GET /DiagnosticReport 整合層(HIS)
specimen specimen_id Specimen 由 Reference 帶出 整合層(HIS)
composition + composition_section composition_id Composition /Composition、$sign、$receive 臨床端
clinical_task — Task /Task 臨床端
signature signature_id Provenance.signature 由 $sign 產生 伺服器
audit_event — AuditEvent GET /AuditEvent 伺服器
form_definition + form_field_binding form_id Questionnaire + Questionnaire.item later-work —
checklist_response checklist_resp_id QuestionnaireResponse later-work 臨床端

order_execution_link 不需要對應資源:MedicationAdministration.request 與 Procedure.basedOn 就是那條連結,版本鎖定靠帶版本的 Reference。

附錄 B · 這份規格明確不做的事

不必實作,也不要「順手做一下」——每一項都是刻意排除的,做了反而要回頭拆。

不做 理由
DELETE 病歷不刪除,改標 entered-in-error
條件式更新(PUT /X?params) 語義模糊
chained search、_filter、GraphQL 不在 Phase 1 範圍,且沒有 HAPI 就不划算
Subscription/WebSocket 推播 本輪不做自動警報,H8 已裁示
window=shift 之類的語意查詢參數 班別換算屬排班資料,責任在前端
自動重送 HIS 可能造成重複處方,一律人工確認
伺服器端計算年齡、I/O 平衡的預設值 缺值不補;沒有來源就不要生出數字
XML 格式 只支援 application/fhir+json

未決事項對後端的影響

README 的 Q-01~Q-06 會直接卡到實作,這裡標出卡在哪:

編號 事項 卡住什麼
Q-01 院區時區是否固定 +07:00 所有時間欄位的儲存與比較。建議一律存 UTC,回應時轉 +07:00,等院方確認後不必改資料
Q-02 檢驗指標的 LOINC 或院內字典 Observation.code 的 system。先用院內碼 his-lab-indicator,之後加一個 coding,不要換掉原本的
Q-03 出入量項目對照 I/O 平衡的計算與呈現,Phase 1 可先只存不算
Q-04 SBAR 交班的 Composition.type 代碼 交班文件的檢索;先用本地碼 handover-sbar
Q-05 repeatPeriod/priority 值域 送 HIS 前的阻擋條件;EMR 內部先用 FHIR priority,對映在整合層
Q-06 部分成功契約 ServiceRequestCreated 的逐行對帳與補償邏輯

共同原則:未定的先用本地碼,之後用「新增 coding」的方式補標準碼,不要換掉既有的。 這樣已經寫進去的資料不用回頭改。