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. 格式規則(六條)
- 一列一筆,全部扁平。 每筆記錄是一個 JSON 物件,欄位只能是純量(字串/數字/布林/null)或純量陣列(例
flags: ["ALG","LINE"]、referenceIds: [...])。不得有巢狀物件;一對多拆成另一種記錄,用xxxId回指母記錄(例labResult.reportId、noteSection.noteId)。 resourceType必有、小寫駝峰。 例patient、order、labResult、handoverReceipt。這是我們自己的分類,不是 FHIR 型別(和 fhirfox 來源資料一樣)。- 引用只用 id。 欄位名以
Id結尾(patientId、orderId、authorId),值是對方記錄的id。id 全部是字串,由伺服器發(原型的A、ENC-A、M1、DOC102直接沿用)。id 對不上整包就散了,所以tools/check-api.mjs會檢查同一份範例裡的參照。 - 代碼走值域表。 列舉欄位的值只能是
api/value-sets/<name>.csv的code欄(表上標「開放」者例外,允許自訂文字,例frequency)。顯示文字由前端查表翻譯,API 不送中文標籤。 - 時間一律 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)。 - 缺值回
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 分頁、版本、冪等
- 清單查詢參數
limit(預設 20)、offset、updatedFrom;回{ total, list }。 - 讀取回應帶
ETag: "<revision>",寫入帶If-Match,不符回409並附目前版本。 - 寫入帶
Idempotency-Key;同鍵重送回第一次的結果,不建第二筆(劑次唯一另有 G11)。
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 覆蓋
- 要改欄位、值域、端點:只改
api/model.mjs,再跑上面兩支。10a、schema、CSV、網頁版都不要手改。 - 原型
store.js新增 action:check-api會報「沒有端點承接」,到model.mjs的endpoints補一支並標action。 - 前端串接時,在
src/stores/session.js的dispatch後面接一層轉接(§5 的表),payload 鍵名不動。