下拉重新整理

當標準還沒寫到你要的

5,283 字 14 分鐘閱讀 16 次閱讀
字級
行距

火線超人要做的功能講起來很單純:讓病人在 LINE 上看到自己那間診間叫到幾號了。

我去翻標準,想知道候診叫號該用哪個 profile。翻完發現沒有。台灣的核心實作指引(TW Core IG)管的是健康照護資料交換那類東西。候診進度不在裡面。

這時候有兩條路。一條是等,等哪天有人把它寫進標準。另一條是自己定一份規格,再說服醫院把資料轉進自己的 FHIR 資料平台。

我們走了第二條。跟一家醫學中心來回對過好幾輪,這篇就是那份規格長出來的過程。

先分清楚哪些資料會變

定規格的第一步不是挑資源,是先看資料多久變一次。

醫院名、診間、醫師這些是主檔,建一次就好,一年也未必動一次。真正會一直變的只有叫號本身。

分開之後對應就很自然。讀取端就是靠搜尋 Schedule 找出某家醫院當天開了哪些診。

主檔與叫號兩類資料的資源對應圖,米色底,標題「哪些資料會變,決定用哪個資源」,副標「五組一對一,加上 Schedule 指過去的兩條 actor」。上半部左右各一個圓角區塊。左邊是淺米褐色區塊,左上角灰字標「一年動不到一次的主檔」,區塊內三列由上而下排列,每一列都是左邊一張白色圓角卡片、中間一支灰色短箭頭指向右、右邊一個深藍色圓角色塊裡放白色等寬粗體字:第一列醫院指向 Organization,第二列診間指向 Location,第三列醫師指向 Practitioner。右邊是淺珊瑚色區塊,左上角灰字標「每叫一號就變一次的叫號」,區塊內只有一列,白色卡片寫叫號,灰色短箭頭指向一個珊瑚色圓角色塊,裡面是白色等寬粗體字 Appointment。這一列底下左邊一行灰字寫「一個號碼就是一筆」,右邊四條由深到淺的珊瑚色橫條,表示同一天會累積很多筆。兩個區塊下方置中一列,白色卡片寫診次,灰色短箭頭指向深藍色色塊裡的等寬粗體字 Schedule。從 Schedule 那個色塊的上緣往上拉出兩條灰藍色連線,各走一條通道。一條實線往上走到 Location 那一列的高度,轉向左,箭頭指進 Location 色塊的右緣,線上標一個等寬小字 actor。另一條虛線往上走一小段後轉向左,橫越到 Practitioner 色塊下方再轉向上,箭頭指進 Practitioner 色塊的下緣,線上標一個等寬小字「actor 最好也放」。圖最下方一整條深藍色橫幅,第一行淺色小字寫「兩類分開之後」,第二行白色粗體寫「主檔建一次就好,讀取端每次只重撈叫號那一批」,第三行珊瑚色寫「混在一起設計,每次都得把全部資料重撈一次」

還有一個決定值得講:候診人數這種數字,由讀取端自己算,醫院端不提供。

醫院端只要把每個號碼的狀態維持正確就好,不用另外開一支彙總 API。讀取端拿到當天那批 Appointment,數一數狀態是候診的有幾筆。再減掉正在看的那一筆,就是候診人數。醫院端也少一個會算錯的地方。

標準的列舉值不夠用怎麼辦

這是整份規格最關鍵的決定。

院內的狀態有四種:未到、候診、看診中、看畢。FHIR R4 的 Appointment.statusbookedarrivedfulfilled,剛好對得上三種。

問題出在「看診中」。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 值是讀到相反的事,連報到狀態都被汙染。

兩種讀取端讀到什麼的對照圖,米色底,標題「兩種讀取端,同一筆資料讀到什麼」,副標「看診中不是新的 status,是 arrived 加一個布林旗標」。上方一整條深藍色圓角區塊,左上角淺色小字寫「寫進去的那一筆 Appointment」,區塊內分左右兩欄,中間一條淺色垂直分隔線。左欄上方灰字標「標準欄位,照規範填」,底下一行等寬字,淺色的 status 接白色粗體的 arrived。右欄上方灰字標「額外掛上去的 extension」,底下一行等寬字,淺色的 valueBoolean 接白色粗體的 true,再下面一行較小的等寬字寫 https://example.org/fhir/current-serving。深藍區塊底下有兩條灰色垂直箭頭,各自從左右兩欄往下指到兩張白色圓角卡片。左卡上方灰字標「只認標準欄位的讀取端」,卡內兩個等寬字膠囊由上而下排列,第一個是淺藍底深藍字的 status arrived,第二個是淺米底灰字的 current-serving 讀不到;再往下一行黑色粗體寫「讀到『已報到,還在等』」,最下面是一個珊瑚色圓形驚嘆號接珊瑚色字「這個人其實已經在診間裡」。右卡上方灰字標「認得這個 extension 的讀取端」,卡內同樣兩個膠囊,都是淺藍底深藍字,依序是 status arrived 與 current-serving true;再往下一行黑色粗體寫「讀到『已報到,正在看診』」,最下面是一個綠色圓形勾號接綠字「兩件事都讀到」。圖最下方一整條深藍色橫幅,第一行白色粗體寫「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,再讓伺服器索引並宣告。

兩種查法的結果對照圖,米色底,標題「identifier 查得到,extension 查不到」,副標「同一筆 Appointment,三種查法各打一次」。上半部三張圓角卡片由上而下排列。第一張是白底、左緣一條深藍色直條,卡內等寬字寫 Appointment?identifier=https://example.org/fhir/appointment-number|37,右側是灰字 total 接黑色粗體 1,再往右一個淺藍底深藍字的狀態碼徽章 200。第二張同樣是白底加深藍色直條,等寬字寫 Appointment?identifier=37,底下一行灰色小字註「只給 value,不給 system」,右側同樣是 total 1 與狀態碼徽章 200。第三張是淺珊瑚底、左緣一條珊瑚色直條,等寬字寫 Appointment?current-serving=true,底下一行珊瑚色粗體等寬字寫 Unknown search parameter "current-serving",右側是灰字 OperationOutcome 接一個珊瑚底白字的狀態碼徽章 400。三張卡片下方是一整塊深藍色圓角區塊,上方淺色小字寫「同一台回的那則 400,訊息裡就把合法參數列完了」,底下十九個等寬字小膠囊分三行排列,依序是 _id、_language、actor、appointment-type、based-on、date、identifier、location、part-status、patient、practitioner、reason-code、reason-reference、service-category、service-type、slot、specialty、status、supporting-info,其中 identifier 那一個是白底深藍字並加粗,其餘都是半透明底淺色字;區塊最下面一行珊瑚色粗體寫「十九個裡面沒有 current-serving」。圖最下方兩行灰字,第一行寫 identifier 查的是那個欄位本身,不在意 system 是誰的命名空間,所以自訂的也查得到;第二行寫 extension 沒有現成的搜尋參數,沒人定義伺服器就生不出來,這台的選擇是回 400

命名空間 URI 是標籤,不是位址

https://fhirlinebot.turbos.tw/fhir/appointment-number 這種 URI,第一次看到的人十個有九個會問同一句:這個網址打開是什麼?

答案是:不用打得開。

命名空間 URI 的作用是讓不同系統一致地認出這個欄位的意思。它長得像網址是因為網域有天然的唯一性,你有那個網域就不會跟別人撞。它不是拿來連的。

不過 Extension.url 多一層義務:規範要求它指向一份 StructureDefinition。網址可以打不開,那份定義還是得寫。

我們現行這一組有四個,都掛在同一個路徑底下:appointment-numbercurrent-servingclinic-codedepartment

真正重要的是所有採用這份規格的醫院要共用同一組。十家醫院各自發明自己的 appointment-number,那這份規格就白定了。

實務上還有一件事要先想好:命名空間會改版。

我們自己就換過一次,舊的那組是 https://fhir.clinic-queue.tw/。舊的還在外面跑,總不能叫所有醫院同一天全部改完。

做法是把讀取端的常數從單一個 URI 改成一個「已知 URI 集合」,新舊都收。寫入端只輸出新的。跑一段時間,等舊的沒人在用了再拿掉。

規格演進的相容性,設計時就要留位置,不然改版那天會很痛苦。

id 怎麼命名,決定了查詢怎麼寫

前面說號碼要放 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 參照就列在那裡。紅線變成驗收得了的項目,才不會只是一句口號。

跟著做:把自訂規格寫進 sandbox 再讀回來

這一節不用授權,用公開的開放測試端點就行,也不用改你的 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" }
  ]
}

startend 要嘛都給要嘛都不給,這是 R4 對 Appointment 的 apt-1 限制。另一條 apt-2 說,只有 proposedcancelled 可以兩個都缺。

這裡用的是 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,identifierextension 兩個物件逐字回來,一個字都沒改。

這裡有一件事一定要注意: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。

執行結果圖,淺灰綠底色,標題「自訂欄位寫進去再讀回來的樣子」,副標「四步跑完再收乾淨,用的是公開的開放測試端點,不需要授權」。中央一個白色圓角面板,左上角三個灰色小圓點代表視窗,圓點右邊一行灰字寫「curl 直接打端點,沒有經過瀏覽器,所以這個版型沒有網址列」。圓點下方一條淺灰橫帶寫「終端機輸出」。橫帶底下是四段輸出,每段開頭一個綠色圓形編號。編號 1 那一行等寬字寫 POST /Appointment,同一行最右邊是淺藍底深藍字的狀態碼徽章 201;底下縮排一行灰字寫「伺服器指派的 id 是」,接珊瑚色粗體等寬字 4837972。編號 2 那一行寫 GET /Appointment/4837972,右邊徽章 200;底下縮排兩行灰字,第一行寫「identifier 與 extension 兩個物件逐字回來」,第二行寫「meta 只有 versionId 與 lastUpdated,」後面接珊瑚色粗體的「沒有 profile」。編號 3 那一行用較小的等寬字寫 GET /Appointment?identifier=https://example.org/fhir/appointment-number|37,結果放在下一行,左邊灰字寫「回一份 Bundle」,右邊是灰字 total 接黑色粗體 1 再接徽章 200;同一段再下一行等寬字寫 GET /Appointment?current-serving=true,右邊是灰字 OperationOutcome 接一個珊瑚底白字的徽章 400。編號 4 那一行寫 DELETE /Appointment/4837972,右邊徽章 200;底下縮排一行寫 GET /Appointment/4837972 再讀一次,右邊是珊瑚底白字的徽章 410。面板下方四條綠色圓形編號註記,編號 1 寫 201 代表伺服器收下了,4837972 是它指派的 id,你跑一次會拿到不一樣的一組;編號 2 寫這台對自訂 extension 不做 profile 檢查,寫得進去不等於規格對了;編號 3 寫 total 第一次是 0,因為那個直立線沒有做 URL encoding,改用 --data-urlencode 才是 1;編號 4 寫這台把已刪除的 id 回 410、從來沒存在過的回 404,不保留刪除歷史的伺服器對已刪除的也可能回 404

規格寫完才是開始

規格定完、手冊寫完、醫院也照著轉了,然後呢?然後才是真的開始。

有一台的 FHIR server 前面還擋了一層 gateway,請求先過它才轉進去。那一層漏了 application/x-www-form-urlencoded 這個 Content-Type。token 請求就一直回 401,說 client 無效。這個查了很久,因為錯誤訊息長得像憑證填錯。

另一台的 gateway 對分頁請求回 404,說那不是 FHIR 路徑。結果是搜尋超過一頁就撈不齊。而且撈不齊的時候沒有任何錯誤,你拿到的是一份少一半的資料。這種最可怕。

還有一次整院看板全空,但單一診間查詢正常。成因就是前面那個 id 形態問題。整院流程拿到的是 UUID,後面的查詢路徑卻預期語意 id。開發環境走 direct,剛好踩不到。

這些不是規格寫錯,是規格真的拿去用才長出來的問題。所以手冊最後那一部分是一張驗收對照表,把規格的每一條對映到一個看得到的驗證方式。另外還有一支唯讀的自檢腳本,只發 GET,產出六段報告對應醫院端可能填錯的地方。

規格是給人看的,驗收才是給機器跑的。兩個都要有,規格才不會默默走鐘。

小結

自己定規格有四個決定。先分清楚哪些資料會變。標準列舉值不夠用時加 extension,不改既有欄位的語意。要拿來查的放 identifier。命名空間 URI 是標籤,不是位址。

最前面還有一條紅線:匿名化界線寫在技術規格之前,而且要能驗收。

今天處理的是一家醫院內部的規格。明天換一個問題。同一個病人的資料散在兩家醫院,怎麼合成一條看得懂的時間軸。