
Day 9 談到菜單時,日期已經開始有意義:同一個品項在不同 effective date,可能對應不同版本。
到了「哪天可以訂」,時間又往前走了一步。
一開始它很像只是畫面上的日期選擇器。選一天、看到菜單、送出訂單,流程看起來沒有什麼特別。
但實際使用後,系統得回答的問題越來越多:
這些問題如果分散在 Frontend、排程與管理畫面各自判斷,日期很快就會出現多個答案。
這一段演進最後收斂成一條比較清楚的界線:Date Picker 只是入口,規則本身屬於 Domain。
「哪天可以訂」不是前端日期選擇器的問題,而是由工作日、開團狀態、訂購模式與截止規則共同決定的 Domain Logic。
前九天一路處理 UI、資料、身份、餘額與菜單歷史時,每一次改動看起來都像是在解一個局部問題。
把它們排在一起後,系統已經走過:
表單與操作
→ 資料與狀態
→ Identity
→ Ledger
→ Menu History
→ Calendar / Deadline
這時會看見一個變化:便當系統的複雜度,開始來自不同規則互相牽動。
誰可以操作,會碰到 Identity;餘額怎麼算,會碰到 Ledger;某一天看到哪份菜單,會碰到 effective date;而「那一天能不能訂」,又同時碰到 Calendar、mode、deadline 與排程。
Day 10 的 Calendar 已經超過單純新增日期功能的範圍。
它比較像第一個明顯訊號:當一套小系統進入真實營運,原本散在畫面與流程裡的判斷,會逐步被逼回 Domain。
以便當系統來說,看到 2026-09-25 這個日期本身,資訊還不夠。
系統至少還要知道:
「日期存在」和「這個時間點可不可以訂」是兩件不同的事。
如果只讓 Frontend 用今天日期加幾天,再決定哪些按鈕可以按,很容易做出一個畫面看起來合理、但和後端規則不同步的版本。
這套系統後來把 Calendar 留在 Domain 層,讓某個 order date 對應的 vendor、mode 與 deadline 有明確來源。Frontend 的責任比較接近「呈現這個狀態」,不再自行推導日期規則。
系統裡有兩種截止語意。
| Mode | 截止語意 |
|---|---|
| A | 訂餐當日 10:00 截止 |
| B | 訂餐前一日 18:00 截止 |
兩種模式最後都由同一份 deadline domain logic 計算,而且時間基準明確固定在 Asia/Taipei。
這件事乍看很小,但如果沒有集中處理,問題會很快散開。
例如 Mode B 的畫面寫著「前一天 18:00 截止」,但送單 API 若仍用「當天 10:00」判斷,就會出現最麻煩的一種錯誤:使用者看見可以訂,送出時卻被拒絕;或畫面已經關閉,後端仍接受。
截止時間不能只是一句顯示文字。
它同時會影響:
Vendor Hub 顯示 deadline 時,也不應再長出第二套計算方式。它只是把 Calendar 已經決定的結果投影出來。
日期功能很容易在開發機上看起來都正常。
原因是人腦想的是「台北今天」,程式收到的卻常常是 UTC timestamp。
Worker 直接把時間域固定成:
Asia/Taipei
例如 Mode A 的 10:00、Mode B 的前一日 18:00,都先從台北的 Business Rule 定義,再轉成程式可比較的時間。
自動排程也是同一件事。
Cloudflare 的排程使用 UTC trigger,但這套系統要處理的是「台北午夜到了之後要做什麼」。Scheduled handler 會先把 scheduled time 轉回 Taipei business date,再進入工作日判斷。
這種寫法的價值,是避免「Server 在哪裡」悄悄變成 Domain Rule。
後來加入每日自動開團時,第一個直覺很容易是:
今天 + 2 天
→ 建立新的可訂日期
但這在週末立刻失效。
這條規則是:台北工作日午夜觸發後,計算第二個後續 Monday–Friday 工作日。
對應結果如下:
週一 → 週三
週二 → 週四
週三 → 週五
週四 → 下週一
週五 → 下週二
週六、週日本身則不執行開團。
這裡進入 Domain 的,是「第二個後續工作日」這個概念;Cron expression 只負責觸發。
排程只負責在固定時機喚醒系統;要開哪一天,仍由 business-day calculation 決定。
如果未來公司假日要納入規則,應該把它收進 Business Calendar,避免在 Frontend 或 Cron 裡各補一套日期特例。
手動按一次「開團」,通常不太會有人連續按十次。
排程不一樣。
Scheduler 可能重試,部署後也可能需要人工驗證,同一個 target date 甚至可能遇到接近同時的執行。
自動開團不能只做到「可以新增」。
這條流程有一個明確限制:只有目標日期尚未有 vendor assignment 時才寫入。
如果該日已經有設定,就回到 skip;同一個排程再次執行,也不應覆蓋既有開團結果。
簡化成狀態會像:
Taipei business date
↓
第二個後續工作日
↓
target date 已有 vendor?
├─ 是 → SKIP_ALREADY_OPEN
└─ 否 → 建立 calendar setting
日期主鍵、條件式寫入與既有 persistence path 一起保證這件事可以安全重跑。
這比「Cron 每天有成功跑」重要得多。
自動開團如果自己直接寫一套資料,短期很快,後面卻很容易分裂。
手動設定可能做 vendor normalization、mode 驗證與 audit;Cron 若自己拼 SQL,久了就會變成另一條規則。
這套系統的做法,是讓排程最後委派到 Calendar 既有的 persistence path。
兩者入口不同:
Admin manual setting ─┐
├→ Calendar persistence → calendar setting
Scheduled opening ────┘
但「一個 Calendar Setting 應該怎麼被保存」不需要分成兩個版本。
自動路徑仍有自己的限制,例如沒有真實 user actor,就不能假裝產生一筆使用者 audit;它改用 structured log 留下 automatic opening 的 business date、target date、outcome 與 vendor。
共用的是 Domain write semantics;兩種 actor 仍保留各自的行為邊界。
把這些規則放在一起後,Calendar 的責任就比較清楚了。
| 元件 | 責任 |
|---|---|
| Business Calendar | 決定工作日與目標 order date |
| Calendar Setting | 保存某日 vendor / mode 等開團狀態 |
| Deadline Domain | 依 order date + mode 算出截止時間 |
| Scheduled Opening | 在正確時機嘗試建立未來可訂日 |
| UI / Vendor Hub | 呈現 Calendar 已決定的狀態 |

這個拆法避免了一個常見問題:每個畫面都知道一點日期規則,最後沒有人知道哪一份才算數。
Frontend 不需要自己猜「週五之後是不是週日」;Vendor Hub 不需要再算一次 Mode B;Cron 也不負責重新定義 deadline。
它們都在消費同一個 Calendar Domain。
Business-day calculation 的邊界很明確:Monday–Friday。
它還不等於完整的台灣國定假日、補班日或公司特殊休假日行事曆。
這一點很重要。
把週末跳過做好,不代表「工作日問題已經完全解決」。如果未來營運真的要求國定假日也自動跳過,就需要一個更正式的 Business Calendar source,避免持續往 weekday helper 裡塞例外。
Domain Model 的目的,在於讓下一個規則出現時,知道應該加在哪裡;不必一開始就抽象到最完整。
如果同一個日期會因 mode 不同而有不同截止時間,前後端與排程又都需要使用同一份 deadline,加上自動排程必須能安全重跑,日期就已經不是單純的 UI 輸入。
這時應該把 timezone、工作日、開團狀態與 deadline 收回同一個 Domain 邊界。之後規則改變時,才不需要逐一追查哪些畫面或排程各自藏了一份判斷。
Day 8 的 Ledger 在處理「這個餘額怎麼形成」。
Day 9 的 Menu History 在處理「那一天有效的是哪一版」。
到了 Calendar,問題變成:
這個時間點,這一天到底能不能被操作?
日期、截止與排程看起來只是幾個時間欄位,進入真實營運後卻開始承擔 Business Rule。
這次把 Calendar 從 UI 選項拉回 Domain:由同一個地方決定工作日、開團狀態與 deadline,再讓各個畫面去呈現結果。
這不是純概念示範,而是一套持續開發中的便當訂購系統。文章著重「為什麼」,GitHub 保留實際程式碼、文件與演進紀錄。
GitHub:https://github.com/henryfir456/bento-order-app
到這裡,Frontend、Worker、D1、Identity、Ledger、Menu、Calendar 已經開始互相牽動。
接下來碰到的問題會換一個方向:當 AI 也開始參與這種跨模組修改時,怎麼避免「能改很多」變成「一次改太多」。