10|傳輸格式:扁平記錄 JSON(不走 FHIR)

版本 0.1.0(2026-10-01)。這一份取代 09_新舊API對照_以新為全集.md 裡「以 FHIR R4 為全集」的方向:前後端之間傳的不是 FHIR 資源,而是扁平的記錄 JSON,樣子就像 fhirfox 教材裡「醫院匯出的來源資料」(examples/data/*.json)——一列一筆、沒有巢狀、小寫 resourceType、引用用 xxxId、代碼走對照表。 欄位表、端點表、值域表由 api/model.mjs 產生在 10a · 記錄欄位與端點(產生);本檔只講規則與範例。 H12 的分層(L1/L2/L3)、認證、錯誤碼、稽核、冪等、分頁全部沿用,本檔不重寫,只在格式層面補充。

1. 為什麼不用 FHIR 當傳輸格式

FHIR R4 當傳輸格式 扁平記錄 JSON(本檔)
一筆藥囑 MedicationRequest 巢狀五層:dosageInstruction[0].doseAndRate[0].doseQuantity.value order.quantity、order.unit、order.frequency 各一欄
一筆血壓 一個 Observation 帶兩個 component[],各自再包 code.coding[0] 與 valueQuantity 兩列 observation(SBP、DBP),同一個 setId
誰開的 requester.reference = "Practitioner/D001" authorId = "D001"
值域 要查 IG 的 ValueSet、binding strength api/value-sets/*.csv,一張表一個欄位
原型對應 每個畫面都要寫一層 FHIR ↔ state 的轉換 欄位名稱直接沿用原型 store.js 的鍵,前端 session.dispatch 的 payload 幾乎可以原樣送
日後要 FHIR — 用 fhirfox 的四招(copy/constant/code_map/build_reference)加一張規則表就能轉;每個型別與值域都已附 FHIR 對應

結論:傳輸用扁平記錄,FHIR 留給對外交換。這也是 fhirfox 教材自己的架構——HIS 給扁平資料,轉換器才長出 FHIR。

2. 格式規則(六條)

  1. 一列一筆,全部扁平。 每筆記錄是一個 JSON 物件,欄位只能是純量(字串/數字/布林/null)或純量陣列(例 flags: ["ALG","LINE"]、referenceIds: [...])。不得有巢狀物件;一對多拆成另一種記錄,用 xxxId 回指母記錄(例 labResult.reportId、noteSection.noteId)。
  2. resourceType 必有、小寫駝峰。 例 patient、order、labResult、handoverReceipt。這是我們自己的分類,不是 FHIR 型別(和 fhirfox 來源資料一樣)。
  3. 引用只用 id。 欄位名以 Id 結尾(patientId、orderId、authorId),值是對方記錄的 id。id 全部是字串,由伺服器發(原型的 A、ENC-A、M1、DOC102 直接沿用)。id 對不上整包就散了,所以 tools/check-api.mjs 會檢查同一份範例裡的參照。
  4. 代碼走值域表。 列舉欄位的值只能是 api/value-sets/<name>.csv 的 code 欄(表上標「開放」者例外,允許自訂文字,例 frequency)。顯示文字由前端查表翻譯,API 不送中文標籤。
  5. 時間一律 ISO 8601 含時區,例 2026-08-31T08:00:00+07:00;日期 YYYY-MM-DD;班別時刻 HH:mm(跨日 HH:mm+1)。原型內部的 2026-08-31T08:00(無時區)由轉接層補 +07:00。HIS 的 yyyyMMddHHmm 留在 D1 邊界,不得滲進來(H12 §5.4)。
  6. 缺值回 null,不省略欄位;不補 0。 例 surgeryDate: null 前端顯示「尚未取得」;檢驗待回 value: null、classification: "pending"。回應裡「必填」欄一定有值;非必填欄可為 null 但仍要出現。

3. 外殼、清單、交易

3.1 外殼

沿用 D1 §1.2,但 HTTP 狀態碼與 status 欄一致、業務拒絕用 4xx(H12 §5.2):

{ "error": false, "status": 200, "message": "OK", "data": { "resourceType": "order", "id": "M1", "...": "..." } }
data 的形狀 用在 範例
單一記錄 GET /orders/{id}、所有寫入的回應 { "resourceType": "order", ... }
清單 { total, list[] } 所有 GET 清單;list 內每筆同型別 { "total": 4, "list": [ {...}, {...} ] }
混合 { records[] } GET /patients/{id}/bundle、GET /provenance fhirfox 式:一個陣列裝多種 resourceType
附件 單一記錄回應可帶子記錄陣列,鍵名=子型別複數 GET /reports/{id} → { ...report, "labResults": [...], "reportReviews": [...] }

附件規則:母記錄本身仍是扁平的;子記錄只是同一個回應裡多帶幾列,每列仍帶 resourceType 與 xxxId。前端照樣能拆開放進各自的清單。

3.2 錯誤

{ "error": true, "status": 422, "message": "品項已停用或查不到,請重新選取",
  "validationErrors": [ { "path": "orders[1].catalogKey", "message": "medication:99999 不存在或已停用" } ] }

message 就是原型 must(...) 的訊息文字(H12 §6.2 的 G1–G18 對照表),前端直接顯示在 MessageBar。HIS 逾時回 202 且記錄的 hisStatus = "unknown",不得自動重送。

3.3 分頁、版本、冪等

3.4 交易 POST /batch

一個交易寫多筆,全成功或全退回(對應 FHIR 的 Bundle transaction,但內容仍是扁平記錄):

{ "items": [
  { "op": "create", "endpoint": "POST /tasks", "record": { "resourceType": "task", "patientId": "A", "title": "追蹤 L2 待回結果", "sourceId": "L2", "status": "open" } },
  { "op": "action", "endpoint": "POST /orders/M1/sign", "record": { "progressNoteId": "DOC101" } }
] }

回應 data.results[] 與 items 等長,每項 { status, record } 或 { status, message }。

4. 請求與回應送哪些欄位

每個欄位在 10a 標了「取得」字母(沿用 X02/H12 §2.2):

取得 請求 body 回應 例
M 使用者輸入 ✓ 送 ✓ order.quantity、execution.status
P 前次帶入 ✓ 送(並附來源引用) ✓ handover.I
H HIS 鏡像 ✗ 送了忽略 ✓ patient.identifier、report.text
C 系統計算 ✗ 送了忽略 ✓ id、actorId、orderedAt、labResult.classification

所以同一個 schema 同時描述請求與回應:請求只需要 M/P 的必填欄;回應必須有全部必填欄。tools/check-api.mjs 就是用這條規則分別驗範例的 request 與 response。

actorId/authorId/recorderId 永遠是 C:由 token 決定,不收前端值。

5. 原型 state → 記錄:轉接層要做的事

欄位名稱盡量沿用原型 store.js,只有這幾處要展開或改名(都是為了「扁平」):

原型 state 記錄 轉接
patients[].allergies[]/conditions[] allergy、condition 拆成獨立記錄,patientId 回指;code.text → substanceText,reaction.{manifestation,severity} → 兩欄
patients[].surgery{} patient.surgery* 攤平成 surgeryProcedure、surgeryStart…
patients[].icd{}、diagnoses[] condition icd.key → icdKey;icdCode/icdText 由伺服器從目錄填(C)
people{} practitioner 鍵 → id;start/end → shiftStart/shiftEnd
relationships[] careRelationship actor → practitionerId,by → byId
confirmed{} rosterConfirmation 鍵 → practitionerId,patients → patientIds
orders[].dose "200 mg" order.quantity + order.unit 請求分兩欄;回應另附顯示用 dose(C)
orders[].route/routeCatalog order.routeKey + routeText 請求送目錄鍵;回應附名稱
orders[].dosing{} order.dosingMode、frequency、days、rate、concentration、prn* 攤平
orders[].clinical/local/his clinicalStatus/localStatus/hisStatus 改名避免 clinical 與 clinicalInfo 混淆
orders[].safety[] orderSafety 拆記錄;decision/handling/reason 由請求帶
orders[].stop{}/cancel{} order.stop*/cancel* 攤平
executions[].checks[5] execution.checkPatient…checkTime 五個獨立布林(G13:不能由單次掃碼推定)
executions[].review{} execution.review* 攤平;歷史走 auditEvent
observations[] 的 kind:'血壓'+components[] 兩列 observation(SBP、DBP)同 setId 中文 kind → 值域 observation-code;unit 由 code 決定
reports[].components[] labResult 拆記錄,reportId 回指;classification 伺服器只依 interpretation 推
reports[].seen[]/processed[] reportReview 一列一次,action 區分
tasks[].history[] taskEvent 拆記錄
notes[].soap{} note.S/O/A/P 四欄
notes[].sections{} noteSection 一段一列,code 用值域 note-section
notes[].diagnoses[]/references[] note.diagnosisKeys[]/referenceIds[] 純量陣列(引用不複製)
notes[].signature{} signature 拆記錄,note.signatureId 回指
handovers[].sbar{} handover.I/S/B/A/R 五欄;summary 由伺服器組(C)
handovers[].tasks[](快照) handover.taskIds[] 只留 id
receipts[] handoverReceipt —
consultations[].adoption{} consultation.adoption* 攤平
settings.categories[[code,label]] orderCategory + settings.categoryOrder[] 顯示名拆記錄
drafts{} draft 內容整包放 dataJson(字串),伺服器不驗證
audit[] auditEvent 補齊 X01 八欄

病歷段落的必填規則(noteSection.code)

noteType 必填段落 選填段落
admission CC、PI、PH、vitalSummary、examGeneral、summary、initialAssessment、treatmentPlan;另 note.diagnosisKeys 至少一個 illnessDay、PHFamily、examCirculatory…examOther、plannedTests、differential、prognosis
progress note.S、O、A、P(format = soap,不用 noteSection) —
discharge admissionDate、admissionDiagnosis、dischargeDate、dischargeDiagnosis、course、labsSummary、treatmentMethod、proceduresSummary、ordersSummary、dischargeCondition、followup、dischargeReadiness、educationAudience、educationContent referral、dischargeBarriers、dischargeFollowup、educationTeachBack
nursing/free note.text(format = text) —

6. 一位病人的一天(範例檔)

每個範例在 api/examples/,格式 { title, endpoint, request, response },全部通過 node tools/check-api.mjs:

# 檔案 端點 看什麼
01 01_confirm_roster.json POST /assignments/confirm 寫入前置閘;回 rosterConfirmation
02 02_worklist.json GET /worklist 清單形狀;surgeryDate: null、stayDays 為 C
03 03_observations_set.json POST /patients/{id}/observations 一次送四列;血壓是 SBP+DBP 共用 setId
04 04_orders_batch.json POST /patients/{id}/orders:batch 藥囑 M 欄;用藥安全提示的 orderSafety 決定;回應五個狀態欄
05 05_order_send.json POST /orders/{id}/send hisStatus: sent
06 06_order_his_response.json POST /orders/{id}/his-response 介接入向;accepted → clinicalStatus: active;逾時 202 範例
07 07_execution.json POST /orders/{id}/executions 五對五個布林;occurrenceId 唯一
08 08_report_return.json POST /reports/{id}/return HIS 回報;labResult 一列一指標;classification 只看旗標
09 09_report_review.json POST /reports/{id}/reviews 判讀處理記「當時版本」
10 10_note_progress.json POST /patients/{id}/notes SOAP 四欄+引用 id
11 11_note_admission.json POST /patients/{id}/notes 結構化段落 noteSection[] + diagnosisKeys
12 12_handover_batch.json POST /handovers:batch 每位病人一筆;I 由伺服器帶入
13 13_handover_receipt.json POST /handovers/{id}/receipts 接收後工作轉手(回應附 taskEvents)
14 14_admit_patient.json POST /patients 示範接診:patient+allergy[]+condition[]
15 15_consultation.json POST /patients/{id}/consultations 照會單
16 16_patient_bundle.json GET /patients/{id}/bundle fhirfox 式混合陣列,一次拿整位病人
17 17_batch_transaction.json POST /batch 一個交易兩筆寫入

7. 與 FHIR 的關係(日後要出 FHIR 怎麼辦)

每個記錄型別與值域都標了 FHIR 對應(10a §1、§4;api/value-sets/*.csv 的 fhir_code 欄)。要產 FHIR 時照 fhirfox 的做法寫一張 generator-rules.csv:

轉換招式 這裡的欄位 FHIR 路徑
copy observation.value Observation.valueQuantity.value
constant — Observation.category[0].coding[0].code = vital-signs
code_map(查 api/value-sets/observation-code.csv) observation.code = HR Observation.code.coding[0].code = 8867-4
build_reference observation.patientId = A Observation.subject.reference = Patient/A

因為記錄已經是扁平、id 已經一致、代碼已經有對照表,轉換器不用改程式,只要加規則列——這正是 deck2_his_to_fhir 那一軌教的「八成的變更只改 CSV」。

8. 與 09(FHIR 全集)的對應

09 列的 64 個 FHIR operation 在本檔都有承接,差別只是路徑與形狀:

09 的 FHIR 本檔
GET /metadata、POST /(Bundle) GET /capabilities、POST /batch
Patient/Encounter/CareTeam/Flag patient/encounter/careRelationship/patient.flags[]
MedicationRequest/ServiceRequest + $sign/$submit/PUT/PATCH order + /sign//send//stop//cancel
MedicationAdministration/Procedure(執行) execution
Observation(vital)/DiagnosticReport/Observation(lab) observation/report/labResult
Composition + $sign/$receive note+noteSection/handover+handoverReceipt
Task、Provenance、AuditEvent task+taskEvent、reportReview+signature+rosterConfirmation(GET /provenance)、auditEvent
ValueSet/$expand、List(favorites)、PlanDefinition(套組) catalogItem、GET /value-sets/{name}、favorite、GET /order-sets
Practitioner/PractitionerRole、Condition、Goal、Communication practitioner、condition、goal/careFocus、communication/consultation

9. 怎麼維護

node tools/build-api.mjs   # 從 api/model.mjs 重新產出 schema/CSV/endpoints.json/10a/網頁版
node tools/check-api.mjs   # 範例 ↔ schema、參照、原型 action 覆蓋