Skip to Content
實戰案例預約:牙科診所

預約:牙科診所的時段與狀態生命週期

情境

ACME 牙科診所的櫃檯要同時管理療程、醫師可做的方案、病患、可預約時間與預約。預約寫入時不能讓同一位醫師在同一天重疊;狀態也只能依 Pending → Confirmed → Completed/Cancelled 的路徑前進。診所主管還想從已完成預約自動計算醫師使用率、病患累計消費與最後看診日。

可預約時間本身是宣告出來的,不是打字打出來的:診所只維護一份每週班表,每日掃描會把它變成未來一個月具體、可預約的時段列。

資料模型

  • Treatments:Name、Category、Age Range、Bookable;incoming rollups 統計方案與預約
  • Practitioners:Nickname、Gender、Title;計算 Appointment Count、Booked Minutes、Scheduled Hours、Utilization、No Show Rate
  • Specs:Treatment × Practitioner junction,含 Duration Minutes、Priority、Price 與 Treatment Category lookup
  • Patients:基本資料;計算 Appointment / Completed / Cancelled Count、Total Spent、Last Visit Date
  • Appointments:Date、Start/End Time、Status、Amount、Duration;連到 Patient、Spec、Practitioner、Treatment
    • List Price lookup、Discount = [List Price] - [Amount]
  • Shifts:可預約時間的樣板 — Weekday、Shift Start、Shift End、Practitioner,另有 Override Date 與 Closed 供一次性例外使用
  • Slots被產生出來的可預約列 — Slot Date、Start、End、Status、Practitioner。沒有人手寫這些列,由 materializer 產生;Practitioner 的 Scheduled Hours 由此彙總
Treatment ◀─ Spec ─▶ Practitioner ◀─ Shift ▲ ▲ │ 每日 materialize_slots │ │ ▼ └─ Appointment ─▶ Patient Slot rules + rollups unique (日期, 開始, 醫師)

Start Time / End Time 使用零補齊的 HH:MM 字串。no_overlap 採半開區間 [start, end),所以 09:00–09:3009:30–10:00 可以相接而不衝突。時段 materializer 切分班別時採用同一套半開慣例。

整間診所就是一份 IaC 文件

上面講的東西就是一份 JSONL:七張依相依順序排好的 table、診所實際在讀的 lookup 與 rollup、兩條讓行事曆維持誠實的 rule、每晚的時段掃描、各種角色會打開的 view、對外的匿名預約頁,以及櫃檯與診間的權限分界。把它貼進 IaC 工作台,先跑 iac.plan 看清楚每一個 action,再把逐位元組相同的文件連同回傳的 plan_hash 送給 iac.apply

套用前有三件事要改成你自己的。兩個 $dept: token 指的是貴公司真實存在的部門,請把 Front DeskClinical 換掉。文件末尾的 record 行會種下一位醫師、一種療程、一組方案與一列班表;請把 Dr. ChenScalingchen-tue-am 換成你自己的資料,或直接刪掉那四行,套用一間空診所。其餘都是 ref,而 ref 是可攜的。

{"kind":"header","version":1,"system":"acme-dental","description":"ACME 牙科診所的排班、可預約時間與預約"} {"kind":"table","ref":"treatments","spec":{"name":"Treatments","description":"療程目錄","key":"name"}} {"kind":"column","table":"treatments","ref":"name","spec":{"name":"Name","type":"string","required":true}} {"kind":"column","table":"treatments","ref":"category","spec":{"name":"Category","type":"select","options":["Preventive","Restorative","Surgical","Cosmetic"]}} {"kind":"column","table":"treatments","ref":"age_range","spec":{"name":"Age Range","type":"string"}} {"kind":"column","table":"treatments","ref":"bookable","spec":{"name":"Bookable","type":"boolean","default_value":true}} {"kind":"table","ref":"practitioners","spec":{"name":"Practitioners","description":"可被預約的醫師","key":"nickname"}} {"kind":"column","table":"practitioners","ref":"nickname","spec":{"name":"Nickname","type":"string","required":true}} {"kind":"column","table":"practitioners","ref":"gender","spec":{"name":"Gender","type":"select","options":["F","M","Other"]}} {"kind":"column","table":"practitioners","ref":"title","spec":{"name":"Title","type":"string"}} {"kind":"table","ref":"patients","spec":{"name":"Patients","description":"病患基本資料","key":"chart_no"}} {"kind":"column","table":"patients","ref":"chart_no","spec":{"name":"Chart No","type":"string","required":true}} {"kind":"column","table":"patients","ref":"full_name","spec":{"name":"Full Name","type":"string","required":true}} {"kind":"column","table":"patients","ref":"phone","spec":{"name":"Phone","type":"string","max_length":32}} {"kind":"table","ref":"specs","spec":{"name":"Specs","description":"Treatment × Practitioner 的搭配","key":"code"}} {"kind":"column","table":"specs","ref":"code","spec":{"name":"Code","type":"string","required":true}} {"kind":"column","table":"specs","ref":"treatment","spec":{"name":"Treatment","type":"link","target":"treatments","cardinality":"one"}} {"kind":"column","table":"specs","ref":"practitioner","spec":{"name":"Practitioner","type":"link","target":"practitioners","cardinality":"one"}} {"kind":"column","table":"specs","ref":"duration_minutes","spec":{"name":"Duration Minutes","type":"integer"}} {"kind":"column","table":"specs","ref":"priority","spec":{"name":"Priority","type":"integer"}} {"kind":"column","table":"specs","ref":"price","spec":{"name":"Price","type":"float"}} {"kind":"column","table":"specs","ref":"treatment_category","spec":{"name":"Treatment Category","type":"lookup","link_field":"treatment","target_column":"category"}} {"kind":"table","ref":"shifts","spec":{"name":"Shifts","description":"每週班表樣板與單日例外","key":"code"}} {"kind":"column","table":"shifts","ref":"code","spec":{"name":"Code","type":"string","required":true}} {"kind":"column","table":"shifts","ref":"practitioner","spec":{"name":"Practitioner","type":"link","target":"practitioners","cardinality":"one"}} {"kind":"column","table":"shifts","ref":"weekday","spec":{"name":"Weekday","type":"select","options":["mon","tue","wed","thu","fri","sat","sun"]}} {"kind":"column","table":"shifts","ref":"shift_start","spec":{"name":"Shift Start","type":"string","max_length":5}} {"kind":"column","table":"shifts","ref":"shift_end","spec":{"name":"Shift End","type":"string","max_length":5}} {"kind":"column","table":"shifts","ref":"override_date","spec":{"name":"Override Date","type":"date"}} {"kind":"column","table":"shifts","ref":"closed","spec":{"name":"Closed","type":"boolean","default_value":false}} {"kind":"table","ref":"slots","spec":{"name":"Slots","description":"由 materializer 產生的可預約時段,不手寫","key":"slot_date"}} {"kind":"column","table":"slots","ref":"slot_date","spec":{"name":"Slot Date","type":"date"}} {"kind":"column","table":"slots","ref":"start_time","spec":{"name":"Start","type":"string","max_length":5}} {"kind":"column","table":"slots","ref":"end_time","spec":{"name":"End","type":"string","max_length":5}} {"kind":"column","table":"slots","ref":"status","spec":{"name":"Status","type":"select","options":["Open","Held","Booked"]}} {"kind":"column","table":"slots","ref":"practitioner","spec":{"name":"Practitioner","type":"link","target":"practitioners","cardinality":"one"}} {"kind":"column","table":"slots","ref":"patient","spec":{"name":"Patient","type":"link","target":"patients","cardinality":"one"}} {"kind":"table","ref":"appointments","spec":{"name":"Appointments","description":"已成立的預約","key":"booking_no"}} {"kind":"column","table":"appointments","ref":"booking_no","spec":{"name":"Booking No","type":"string","required":true}} {"kind":"column","table":"appointments","ref":"date","spec":{"name":"Date","type":"date"}} {"kind":"column","table":"appointments","ref":"start_time","spec":{"name":"Start Time","type":"string","max_length":5}} {"kind":"column","table":"appointments","ref":"end_time","spec":{"name":"End Time","type":"string","max_length":5}} {"kind":"column","table":"appointments","ref":"status","spec":{"name":"Status","type":"select","options":["Pending","Confirmed","Completed","Cancelled"]}} {"kind":"column","table":"appointments","ref":"amount","spec":{"name":"Amount","type":"float"}} {"kind":"column","table":"appointments","ref":"duration_minutes","spec":{"name":"Duration Minutes","type":"integer"}} {"kind":"column","table":"appointments","ref":"patient","spec":{"name":"Patient","type":"link","target":"patients","cardinality":"one"}} {"kind":"column","table":"appointments","ref":"spec","spec":{"name":"Spec","type":"link","target":"specs","cardinality":"one"}} {"kind":"column","table":"appointments","ref":"practitioner","spec":{"name":"Practitioner","type":"link","target":"practitioners","cardinality":"one"}} {"kind":"column","table":"appointments","ref":"treatment","spec":{"name":"Treatment","type":"link","target":"treatments","cardinality":"one"}} {"kind":"column","table":"appointments","ref":"slot","spec":{"name":"Slot","type":"link","target":"slots","cardinality":"one"}} {"kind":"column","table":"appointments","ref":"list_price","spec":{"name":"List Price","type":"lookup","link_field":"spec","target_column":"price"}} {"kind":"column","table":"appointments","ref":"discount","spec":{"name":"Discount","type":"formula","expression":"[list_price] - [amount]"}} {"kind":"column","table":"treatments","ref":"spec_count","spec":{"name":"Spec Count","type":"rollup","direction":"incoming","source":"specs","match":{"treatment":"$self"},"aggregation":"count"}} {"kind":"column","table":"treatments","ref":"appointment_count","spec":{"name":"Appointment Count","type":"rollup","direction":"incoming","source":"appointments","match":{"treatment":"$self"},"aggregation":"count"}} {"kind":"column","table":"practitioners","ref":"appointment_count","spec":{"name":"Appointment Count","type":"rollup","direction":"incoming","source":"appointments","match":{"practitioner":"$self"},"aggregation":"count"}} {"kind":"column","table":"practitioners","ref":"booked_minutes","spec":{"name":"Booked Minutes","type":"rollup","direction":"incoming","source":"appointments","match":{"practitioner":"$self"},"aggregation":"sum","target_column":"duration_minutes","filter":[{"column":"status","op":"eq","value":"Completed"}]}} {"kind":"column","table":"practitioners","ref":"cancelled_count","spec":{"name":"Cancelled Count","type":"rollup","direction":"incoming","source":"appointments","match":{"practitioner":"$self"},"aggregation":"count","filter":[{"column":"status","op":"eq","value":"Cancelled"}]}} {"kind":"column","table":"practitioners","ref":"slot_count","spec":{"name":"Slot Count","type":"rollup","direction":"incoming","source":"slots","match":{"practitioner":"$self"},"aggregation":"count"}} {"kind":"column","table":"practitioners","ref":"scheduled_hours","spec":{"name":"Scheduled Hours","type":"formula","expression":"[slot_count] * 0.5"}} {"kind":"column","table":"practitioners","ref":"utilization","spec":{"name":"Utilization","type":"formula","expression":"[booked_minutes] / ([slot_count] * 30)"}} {"kind":"column","table":"practitioners","ref":"no_show_rate","spec":{"name":"No Show Rate","type":"formula","expression":"[cancelled_count] / [appointment_count]"}} {"kind":"column","table":"patients","ref":"appointment_count","spec":{"name":"Appointment Count","type":"rollup","direction":"incoming","source":"appointments","match":{"patient":"$self"},"aggregation":"count"}} {"kind":"column","table":"patients","ref":"completed_count","spec":{"name":"Completed Count","type":"rollup","direction":"incoming","source":"appointments","match":{"patient":"$self"},"aggregation":"count","filter":[{"column":"status","op":"eq","value":"Completed"}]}} {"kind":"column","table":"patients","ref":"cancelled_count","spec":{"name":"Cancelled Count","type":"rollup","direction":"incoming","source":"appointments","match":{"patient":"$self"},"aggregation":"count","filter":[{"column":"status","op":"eq","value":"Cancelled"}]}} {"kind":"column","table":"patients","ref":"total_spent","spec":{"name":"Total Spent","type":"rollup","direction":"incoming","source":"appointments","match":{"patient":"$self"},"aggregation":"sum","target_column":"amount","filter":[{"column":"status","op":"eq","value":"Completed"}]}} {"kind":"column","table":"patients","ref":"last_visit_date","spec":{"name":"Last Visit Date","type":"rollup","direction":"incoming","source":"appointments","match":{"patient":"$self"},"aggregation":"max","target_column":"date","filter":[{"column":"status","op":"eq","value":"Completed"}]}} {"kind":"rule","table":"appointments","ref":"no_double_booking","spec":{"type":"no_overlap","name":"No double booking","start_column":"start_time","end_column":"end_time","scope_columns":["practitioner"],"date_column":"date","when":[{"column":"status","op":"neq","value":"Cancelled"}]}} {"kind":"rule","table":"appointments","ref":"appointment_lifecycle","spec":{"type":"transition","name":"Appointment lifecycle","column":"status","pairs":[[null,"Pending"],["Pending","Confirmed"],["Confirmed","Completed"],["Pending","Cancelled"],["Confirmed","Cancelled"]]}} {"kind":"rule","table":"slots","ref":"one_slot_per_start","spec":{"type":"unique","name":"One slot per practitioner start","columns":["slot_date","start_time","practitioner"],"case_insensitive":false}} {"kind":"trigger","table":"shifts","ref":"materialize_bookable_slots","spec":{"name":"Materialize bookable slots","on":"schedule","schedule":{"type":"daily","at":"01:30","timezone":"Asia/Taipei"},"actions":[{"type":"materialize_slots","target":{"table_id":"$table:slots","slot_date":"slot_date","slot_start":"start_time","slot_end":"end_time","link_map":{"practitioner":"practitioner"},"defaults":{"status":"Open"}},"source":{"weekday_column":"weekday","start_column":"shift_start","end_column":"shift_end","date_column":"override_date","closed_column":"closed"},"horizon_days":30,"slot_minutes":30}]}} {"kind":"view","table":"slots","ref":"open_slots","spec":{"name":"Open slots","is_shared":true,"config":{"filters":{"status":"Open"},"sort_by":"slot_date","sort_order":"asc","columns":["slot_date","start_time","end_time","status","practitioner"]}}} {"kind":"view","table":"appointments","ref":"front_desk_day","spec":{"name":"Front desk day","is_shared":true,"config":{"sort_by":"date","sort_order":"asc","columns":["booking_no","date","start_time","end_time","status","patient","practitioner","treatment","amount","discount"]}}} {"kind":"view","table":"practitioners","ref":"utilization_board","spec":{"name":"Utilization board","is_shared":true,"config":{"sort_by":"nickname","sort_order":"asc","columns":["nickname","title","appointment_count","booked_minutes","slot_count","scheduled_hours","utilization","no_show_rate"]}}} {"kind":"public_read","table":"slots","ref":"booking_page","spec":{"view":"open_slots","name":"Public booking page","visible_columns":["slot_date","start_time","end_time","status","practitioner"],"read_filter":{"and":[{"column":"status","op":"eq","value":"Open"},{"column":"slot_date","op":"gte","value":"$today"}]},"allow_query":true,"rpm":120,"secretless":true}} {"kind":"grant","table":"appointments","principal":{"type":"department","id":"$dept:Front Desk"},"spec":{"can_read":"all","can_insert":true,"can_edit":"all"}} {"kind":"grant","table":"appointments","principal":{"type":"department","id":"$dept:Clinical"},"spec":{"can_read":"all","can_insert":false,"can_edit":"none","visible_columns":["booking_no","date","start_time","end_time","status","duration_minutes","patient","spec","practitioner","treatment"]}} {"kind":"grant","table":"slots","principal":{"type":"department","id":"$dept:Front Desk"},"spec":{"can_read":"all","can_insert":false,"can_edit":"all"}} {"kind":"grant","table":"shifts","principal":{"type":"department","id":"$dept:Clinical"},"spec":{"can_read":"all","can_insert":true,"can_edit":"all"}} {"kind":"record","table":"treatments","data":{"name":"Scaling","category":"Preventive","age_range":"6+","bookable":true},"on_drift":"skip"} {"kind":"record","table":"practitioners","data":{"nickname":"Dr. Chen","gender":"F","title":"Periodontist"},"on_drift":"skip"} {"kind":"record","table":"specs","data":{"code":"scaling-chen","treatment":["Scaling"],"practitioner":["Dr. Chen"],"duration_minutes":30,"priority":1,"price":1500.0},"on_drift":"update"} {"kind":"record","table":"shifts","data":{"code":"chen-tue-am","practitioner":["Dr. Chen"],"weekday":"tue","shift_start":"09:00","shift_end":"12:00","closed":false},"on_drift":"update"}

其中大部分是管線。真正撐起這間診所的是這幾行:

  • column appointments.list_price 直接讀取所連 Spec 的 Price,因此預約不會自己留一份會過期的價格。
  • column practitioners.booked_minutes 是帶 filter 的 incoming rollup:只有 Completed 的預約會計入醫師工時,取消的預約會自動從數字裡消失。
  • column practitioners.utilization 用已預約分鐘數除以被產生出來的時段分鐘數,所以要等下面的掃描跑過一次,使用率才有意義。
  • rule appointments.no_double_bookingno_overlap 的範圍收斂到醫師 link 加上日期,而它的 when 放行已取消的資料列,讓被取消的時段可以重新被預約。
  • rule appointments.appointment_lifecycle 就是那台狀態機 — [null, "Pending"] 這一對允許全新資料列從 Pending 起算,而且沒有任何一對能離開 Completed
  • rule slots.one_slot_per_start 正是 materializer 依賴的那道約束:重新掃描或併發的人工認領會撞出 unique 衝突,而掃描把它算成 skipped_existing 而不是整個失敗。
  • trigger shifts.materialize_bookable_slots 就是整個可預約時間的故事 — 每日掃描把每週的 Shift 列切成未來 30 天、每段 30 分鐘的 Slot 列,並以可攜的 $table:slots 指向目標表。
  • view slots.open_slots 存在的理由是 public_read token 只能發佈受管理的 view;刪掉這一行,下面那行 token 就無法規劃。
  • public_read slots.booking_page 是對外的匿名預約頁 — secretless(IaC 唯一能鑄造的形式)、有速率上限,並且只篩出未來的 Open 時段,讓匿名讀者永遠列舉不到病患的預約。
  • 四行 grant 就是櫃檯與診間的分界:櫃檯擁有 Appointments 與 Slots,診間擁有自己的 Shifts,而讀 Appointments 時 visible_columns 已經把金額欄位排除在外。

Apply 之後

public_read 那一行把開放時段的檢視公開成無密鑰權杖,也就是 URL 本身就是全部的憑證,這正是公開預約頁需要的形式。如果你要的是帶 bearer 密鑰的版本(例如給夥伴的資料流,而不是公開頁面),IaC v1 宣告不了:請在檢視存在之後用 REST 鑄造,見用 REST 補完

產品流程

1. 建立診所模型

把上面整份文件送給 iac.plan,逐一檢視規劃出來的 action,再把逐位元組相同的文件連同回傳的 SHA-256 plan_hash 送給 iac.apply。plan 不會寫入任何東西,因此在空的 scope 先跑一次 plan,是在什麼都還不存在之前,把整個模型當成一串 create action 讀回來的安全做法。

2. 建立療程方案與既有預約

先寫 Treatment、Practitioner、Spec 與 Patient,取得 ID 後才建立 Appointment。這筆預約會自動讀到方案定價並算出折扣:

POST /private/module/custom_tables/chatroom/11111111-1111-4111-8111-111111111111/tables/22222222-2222-4222-8222-222222222222/records { "data": { "Date": "2026-05-04", "Start Time": "09:00", "End Time": "09:30", "Status": "Pending", "Amount": 1350.0, "Duration Minutes": 30, "Patient": ["33333333-3333-4333-8333-333333333333"], "Spec": ["44444444-4444-4444-8444-444444444444"], "Practitioner": ["55555555-5555-4555-8555-555555555555"], "Treatment": ["66666666-6666-4666-8666-666666666666"] }, "created_by_ai": false }

3. 同時啟用時段與狀態規則

rules.set 會取代整份規則設定,因此要保留兩種保護時,請在同一次 PUT 中送出完整陣列:

PUT /private/module/custom_tables/chatroom/11111111-1111-4111-8111-111111111111/tables/22222222-2222-4222-8222-222222222222/rules { "rules": [ { "type": "no_overlap", "name": "no double booking", "start_column": "Start Time", "end_column": "End Time", "scope_columns": ["Practitioner"], "date_column": "Date" }, { "type": "transition", "name": "appointment lifecycle", "column": "Status", "pairs": [ [null, "Pending"], ["Pending", "Confirmed"], ["Confirmed", "Completed"], ["Pending", "Cancelled"], ["Confirmed", "Cancelled"] ] } ] }

4. 建立相鄰時段,並推進狀態

09:30–10:00 與既有 09:00–09:30 相接,因此同一位醫師可成功建立;09:05–09:25 則會收到 400,回應會指出 no double booking 與衝突資料列。成功建立後,以 records.update 的內部 Status key 推進狀態:

PUT /private/module/custom_tables/chatroom/11111111-1111-4111-8111-111111111111/tables/22222222-2222-4222-8222-222222222222/records/77777777-7777-4777-8777-777777777777 { "col_88888888_8888_4888_8888_888888888888": "Confirmed" }

Completed → Pending 不在 allowed pairs 中,會回傳 409 且保留原值;只更新 Amount 而沒有帶 Status,則不會被誤判成狀態轉換。

5. 只宣告一次可預約時間,其餘交給 materializer

不要把明天的空檔一格一格打進 Slots 表。改成在 Shifts 描述這一週,再讓每日 trigger 把它掃成具體資料列。

先種下每週樣板 — 每位醫師、每個星期幾一列;同一個星期幾再加一列就是分段班表:

POST /private/module/custom_tables/chatroom/11111111-1111-4111-8111-111111111111/tables/99999999-9999-4999-8999-999999999999/records { "data": { "Weekday": "tue", "Shift Start": "09:00", "Shift End": "12:00", "Practitioner": ["55555555-5555-4555-8555-555555555555"] }, "created_by_ai": false }

Weekday cell 必須是 montuewedthufrisatsun;寫 Tuesday2 會記一筆警告並靜默跳過該列。若同一列還填了 Override Date,它就是那一天的一次性例外,並完全取代該醫師當日的每週樣板;若再把 Closed 設為真,則是把當天關閉。

接著在任何東西寫入之前,先保護 Slots 表,讓重新掃描與併發人工認領都不可能產生重複列:

PUT /private/module/custom_tables/chatroom/11111111-1111-4111-8111-111111111111/tables/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/rules { "rules": [ { "type": "unique", "name": "one slot per practitioner start", "columns": ["Slot Date", "Start", "Practitioner"] } ] }

最後,把 materializer 掛到 Shifts 表的 daily schedule 上:

PUT /private/module/custom_tables/chatroom/11111111-1111-4111-8111-111111111111/tables/99999999-9999-4999-8999-999999999999/triggers { "triggers": [ { "name": "materialize bookable slots", "on": "schedule", "schedule": { "type": "daily", "at": "01:30", "timezone": "Asia/Taipei" }, "actions": [ { "type": "materialize_slots", "target": { "table_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "slot_date": "Slot Date", "slot_start": "Start", "slot_end": "End", "link_map": { "Practitioner": "Practitioner" }, "defaults": { "Status": "Open" } }, "source": { "weekday_column": "Weekday", "start_column": "Shift Start", "end_column": "Shift End", "date_column": "Override Date", "closed_column": "Closed" }, "horizon_days": 30, "slot_minutes": 30 } ] } ] }

每天台北時間 01:30,掃描會把未來 30 天填滿。星期二的 09:00–12:00 班別在視窗內的每個星期二各產生六個 30 分鐘時段;而 09:00–10:20 的班別只會產生 09:0009:30,因為最後一段不完整的視窗會被丟棄。該次 run 的 action_results 會帶著收據:

{ "type": "materialize_slots", "ok": true, "created": 412, "skipped_existing": 1088, "truncated": false, "window": ["2026-07-25", "2026-08-24"] }

收據裡 window 的第二個日期是不含的 — 上面實際產生的最後一天是 2026-08-23。skipped_existing 統計的是自然鍵已存在者,其中刻意包含已取消的時段:soft-deleted 的時段永遠不會被下一次掃描復活。此外 materializer 只會插入,因此縮短班表列不會動到已產生的未來時段,那些要人工取消。

6. 讀取並認領時段

櫃檯前端或助理會直接查 Slots 表找出空檔 — 日期篩選、狀態篩選,再加上以 in 比對醫師 id 的 link 篩選 — 依日期、再依開始時間排序,然後以更新該列(寫入病患 link 或翻轉 Status)來認領。如果規則或 unique 約束拒絕了這次認領,請重新查詢並改挑另一個時段,不要重試同一個;那個時段已經被別人拿走了。

沒有人手動插入時段列。當 horizon 太短或班表寫錯時,由 moderator 調大 horizon_days 或修改 Shifts 表 — 下一次 tick 就會收斂。

使用者會看到什麼

櫃檯選擇醫師、病患與方案後,List Price 與 Discount 自動出現。未來一個月的可預約時段早已在畫面上,因為前一晚的掃描已經把它們建好了。重疊時段會在儲存當下被拒絕,而不是等到行事曆同步後才發現。患者在聊天室要求取消 Pending 預約時,助理會先確認再只更新 Status;已完成的預約則保持不變。主管頁面會直接顯示各醫師的 Booked Minutes、Scheduled Minutes 與 Utilization。

變化與下一步

試試看

先在 IaC 工作台建立最小的 Practitioners/Shifts/Slots 模型,再用 API Playground 設定 unique 規則、掛上每日 materializer,並在 trigger run 裡檢查第一次掃描的收據。

Last updated on