本文同步發表於個人部落格:當標準還沒寫到你要的
火線超人要做的功能講起來很單純:讓病人在 LINE 上看到自己那間診間叫到幾號了。
我去翻標準,想知道候診叫號該用哪個 profile。翻完發現沒有。台灣的核心實作指引(TW Core IG)管的是健康照護資料交換那類東西。候診進度不在裡面。
這時候有兩條路。一條是等,等哪天有人把它寫進標準。另一條是自己定一份規格,再說服醫院把資料轉進自己的 FHIR 資料平台。
我們走了第二條。跟一家醫學中心來回對過好幾輪,這篇就是那份規格長出來的過程。
定規格的第一步不是挑資源,是先看資料多久變一次。
醫院名、診間、醫師這些是主檔,建一次就好,一年也未必動一次。真正會一直變的只有叫號本身。
分開之後對應就很自然。讀取端就是靠搜尋 Schedule 找出某家醫院當天開了哪些診。

還有一個決定值得講:候診人數這種數字,由讀取端自己算,醫院端不提供。
醫院端只要把每個號碼的狀態維持正確就好,不用另外開一支彙總 API。讀取端拿到當天那批 Appointment,數一數狀態是候診的有幾筆。再減掉正在看的那一筆,就是候診人數。醫院端也少一個會算錯的地方。
這是整份規格最關鍵的決定。
院內的狀態有四種:未到、候診、看診中、看畢。FHIR R4 的 Appointment.status 有 booked、arrived、fulfilled,剛好對得上三種。
問題出在「看診中」。Appointment.status 那張列舉表裡沒有 in-progress 這個值。
熟 R4 的人會想到 checked-in。R4 給它的定義是行政報到辦完了,看診可以開始。可以開始不等於已經開始,還是差一階。
這時候很容易做出錯誤決定。找一個看起來沒在用的 status 值借來用,反正自己這家讀得懂。
這個做法會壞掉。列舉值是有語意的,你借來用,別人拿標準工具讀你的資料就會讀出錯的意思。而且那個「沒在用」通常只是你這家沒在用。
我們的做法是:狀態照標準填 arrived,另外掛一個 extension。
extension 是 FHIR 留的擴充欄位。它讓你不動既有欄位就能多掛自己的資料,我們用它標記這一筆是目前叫號。
{
"resourceType": "Appointment",
"status": "arrived",
"extension": [
{
"url": "https://fhirlinebot.turbos.tw/fhir/current-serving",
"valueBoolean": true
}
]
}
差別在哪?arrived 這個值的意思沒有被動過,我們只是在旁邊多掛一件事。
代價還是有,這裡要講清楚。R4 給 arrived 的定義是「病人已經到了,正在等著被看診」,本來就帶著「還在等」。看診中的那一筆填 arrived,只認標準欄位的讀取端會以為這個人還在候診。
所以不是沒有落差,是選擇讓落差落在哪裡。填 arrived 是少讀到一件事,那個人的報到狀態仍然是對的。借用別的 status 值是讀到相反的事,連報到狀態都被汙染。

加東西可以,改既有欄位的意思不行。 這是自訂規格的第一條紅線。
第二個決定:叫號的號碼要放哪個欄位。
直覺會想放 id。這是錯的。resource 的 id 是那台伺服器給的識別碼。很多 FHIR server 根本不讓你指定,它會自己產一個你控制不了的 id 塞給你。你把業務號碼放進去,換一台伺服器就沒了。
day18 講過,同一台上可能同時有數字字串跟 UUID 兩種形態。不要對 id 格式做假設。
號碼要放 identifier。
"identifier": [
{
"system": "https://fhirlinebot.turbos.tw/fhir/appointment-number",
"value": "37"
}
]
system 講的是「這個號碼是誰家的號碼」,value 才是號碼本身。看板上要顯示的就是 value 那個字串。
這個選擇還帶來一個好處,等一下的跟著做會驗證。identifier 是 FHIR 為 Appointment 定義的標準搜尋參數。自訂的 extension 沒有現成的。要讓 extension 也查得到,得自己定一份 SearchParameter,再讓伺服器索引並宣告。

https://fhirlinebot.turbos.tw/fhir/appointment-number 這種 URI,第一次看到的人十個有九個會問同一句:這個網址打開是什麼?
答案是:不用打得開。
命名空間 URI 的作用是讓不同系統一致地認出這個欄位的意思。它長得像網址是因為網域有天然的唯一性,你有那個網域就不會跟別人撞。它不是拿來連的。
不過 Extension.url 多一層義務:規範要求它指向一份 StructureDefinition。網址可以打不開,那份定義還是得寫。
我們現行這一組有四個,都掛在同一個路徑底下:appointment-number、current-serving、clinic-code、department。
真正重要的是所有採用這份規格的醫院要共用同一組。十家醫院各自發明自己的 appointment-number,那這份規格就白定了。
實務上還有一件事要先想好:命名空間會改版。
我們自己就換過一次,舊的那組是 https://fhir.clinic-queue.tw/。舊的還在外面跑,總不能叫所有醫院同一天全部改完。
做法是把讀取端的常數從單一個 URI 改成一個「已知 URI 集合」,新舊都收。寫入端只輸出新的。跑一段時間,等舊的沒人在用了再拿掉。
規格演進的相容性,設計時就要留位置,不然改版那天會很痛苦。
前面說號碼要放 identifier。那 resource 自己的 id 呢?
我們希望它是推算得出來的。診間是 loc-{org}-{clinic},診次是 sch-{org}-{clinic}-{YYYYMMDD}-{session}。{org} 是健保院所代碼那串數字,{clinic} 用院內的英數診間代碼就行。
這種看得出組成規則的 id,底下都叫它語意 id。
好處很直接:讀取端不用先搜尋就組得出查詢。靠 loc- 後面那串數字也反推得出是哪一家醫院。
先說清楚,這樣做是踩線。FHIR 明講 logical id 是 opaque 的,外部系統不該去解析它的結構。
問題是很多 FHIR server 不讓你指定 id,它會強制產一個 UUID。折衷案是把語意 id 降級成 identifier.value,server 的 id 讓它自己產。
於是同一份規格長出三條查詢路徑,我們做成一個叫 id_mode 的設定:
id_mode |
什麼時候用 | 查詢怎麼寫 |
|---|---|---|
direct |
id 本身就是語意 id |
Appointment?actor=Location/{語意 id} |
chained |
id 是 UUID,而且 server 支援 chained search |
Appointment?actor:Location.identifier={語意 id} |
identifier |
id 是 UUID,server 不支援 chained |
先 Location?identifier={語意 id} 換到 UUID,再 Appointment?actor=Location/{uuid} |
chained 那一條是 FHIR 的鏈式查詢,意思是順著 actor 走到 Location 再比對它的 identifier。一次請求就好,不用先換 id。
怎麼知道支援不支援?送一組必定不命中的值。chain 有生效會回 total 0,被忽略就會回一整包沒篩過的資料。光看回 200 沒有用,規範允許伺服器忽略它不支援的參數。
介接手冊裡有一句話是這樣寫的:幾乎所有醫院之間的差異,最後都歸到這一個問題上。 你以為要處理一百種狀況,實際上只要問一句「你的 id 是什麼形態」。
順帶一個細節。搜尋參數可以用逗號串多個值表示 OR,整院查詢不必一個診間發一次請求。但塞太多 URL 會長到被伺服器擋下來,所以是分批送。
介接手冊的第一節不是技術規格,是匿名化鐵則,而且明講違反就不介接。
會這樣排是有原因的。叫號進度本來就掛在候診區的牆上給所有人看,是公開的群體層級資料。正因為要攤開給大家看,界線才要畫得比病歷更清楚。
兩條具體要求。第一,候診名單裡的 Appointment 只放號碼和狀態,不帶 Patient 參照與姓名病歷號。第二,叫號的 identifier.system 要跟臨床的分開,用自己獨立的命名空間。
第二條是為了讓「讀叫號」這件事在技術上就碰不到臨床資料,而不是靠大家自律。
規格裡的欄位對照表也照這個邏輯做。除了必填和選填,還多一欄「禁止」,Patient 參照就列在那裡。紅線變成驗收得了的項目,才不會只是一句口號。
這一節不用授權,用公開的開放測試端點就行,也不用改你的 app。
端點是 https://launch.smarthealthit.org/v/r4/fhir。
下面五個 curl 呼叫都加了 -i,因為這一節的驗收全部看狀態碼。不加的話 curl 預設只印回應內容,你會看不到 201 跟 400。
第一步,寫進去。 把下面這段存成 appt.json:
{
"resourceType": "Appointment",
"status": "arrived",
"identifier": [
{ "system": "https://example.org/fhir/appointment-number", "value": "37" }
],
"extension": [
{ "url": "https://example.org/fhir/current-serving", "valueBoolean": true }
],
"start": "2026-08-09T09:00:00+08:00",
"end": "2026-08-09T09:15:00+08:00",
"participant": [
{ "actor": { "reference": "Location/loc-demo-A12" }, "status": "accepted" }
]
}
start 跟 end 要嘛都給要嘛都不給,這是 R4 對 Appointment 的 apt-1 限制。另一條 apt-2 說,只有 proposed 跟 cancelled 可以兩個都缺。
這裡用的是 example.org,不是前面那個我自己的網域。前面兩張圖也一樣。
example.org 是保留給文件使用的網域。你要練的是挑一個自己控制得了的命名空間,不是抄我的。
curl -i -X POST "https://launch.smarthealthit.org/v/r4/fhir/Appointment" \
-H "Content-Type: application/fhir+json" \
--data-binary @appt.json
回 201,伺服器會指派一個 id。我這次拿到的是 4837972,你的會不一樣,記下來。
第二步,讀回來。 把 id 換成你的:
curl -i "https://launch.smarthealthit.org/v/r4/fhir/Appointment/4837972"
回 200,identifier 和 extension 兩個物件逐字回來,一個字都沒改。
這裡有一件事一定要注意:meta 裡面沒有 profile。
所以「寫得進去」不能拿來當「規格對了」的證據。換一台有掛 profile 驗證的伺服器,結果可能完全不同。那台可能直接拒絕,也可能收下來但不處理它不認得的欄位。
第三步,查查看。 這一步是本節的重點。先用 identifier 查:
curl -i -G "https://launch.smarthealthit.org/v/r4/fhir/Appointment" \
--data-urlencode "identifier=https://example.org/fhir/appointment-number|37"
回 200,Bundle 的 total 是 1。自訂命名空間查得到。
如果你的 total 大於 1,那是別人也在練同一節,寫進了同一個命名空間加同一個號碼。這是公開共用端點的正常現象。
再用 extension 查:
curl -i -G "https://launch.smarthealthit.org/v/r4/fhir/Appointment" \
--data-urlencode "current-serving=true"
回 400,一個 OperationOutcome。訊息開頭是 Unknown search parameter "current-serving",後面接該資源的合法參數清單。
這個對照就是本篇的技術結論。identifier 有標準搜尋參數,自訂的 extension 沒有,這台就回 400。
推導出來的原則很直接:要拿來查的放 identifier,只是拿來標記的放 extension。 不是說 extension 永遠查不到,是要查就得自己補一份 SearchParameter 再讓伺服器配合。
順帶一個坑。我第一次查回來 total 是 0,還以為伺服器沒索引自訂 system。實際上是 | 沒有做 URL encoding。直接寫在網址裡的話要寫成 %7C。
第四步,收乾淨。 id 一樣換成你自己那一個,不要照抄我的。
curl -i -X DELETE "https://launch.smarthealthit.org/v/r4/fhir/Appointment/4837972"
回 200。這台再讀一次會回 410 Gone。

規格定完、手冊寫完、醫院也照著轉了,然後呢?然後才是真的開始。
有一台的 FHIR server 前面還擋了一層 gateway,請求先過它才轉進去。那一層漏了 application/x-www-form-urlencoded 這個 Content-Type。token 請求就一直回 401,說 client 無效。這個查了很久,因為錯誤訊息長得像憑證填錯。
另一台的 gateway 對分頁請求回 404,說那不是 FHIR 路徑。結果是搜尋超過一頁就撈不齊。而且撈不齊的時候沒有任何錯誤,你拿到的是一份少一半的資料。這種最可怕。
還有一次整院看板全空,但單一診間查詢正常。成因就是前面那個 id 形態問題。整院流程拿到的是 UUID,後面的查詢路徑卻預期語意 id。開發環境走 direct,剛好踩不到。
這些不是規格寫錯,是規格真的拿去用才長出來的問題。所以手冊最後那一部分是一張驗收對照表,把規格的每一條對映到一個看得到的驗證方式。另外還有一支唯讀的自檢腳本,只發 GET,產出六段報告對應醫院端可能填錯的地方。
規格是給人看的,驗收才是給機器跑的。兩個都要有,規格才不會默默走鐘。
自己定規格有四個決定。先分清楚哪些資料會變。標準列舉值不夠用時加 extension,不改既有欄位的語意。要拿來查的放 identifier。命名空間 URI 是標籤,不是位址。
最前面還有一條紅線:匿名化界線寫在技術規格之前,而且要能驗收。
今天處理的是一家醫院內部的規格。明天換一個問題。同一個病人的資料散在兩家醫院,怎麼合成一條看得懂的時間軸。