iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Vibe Coding

從 GAS 到 Cloudflare D1:AI Agent 如何打造、接手並重構真實便當系統系列 第 10 篇

Day 10|「哪天可以訂」為什麼最後會變成一套規則?

  • 分享至 

  • xImage
  •  

Day 10

Day 9 談到菜單時,日期已經開始有意義:同一個品項在不同 effective date,可能對應不同版本。

到了「哪天可以訂」,時間又往前走了一步。

一開始它很像只是畫面上的日期選擇器。選一天、看到菜單、送出訂單,流程看起來沒有什麼特別。

但實際使用後,系統得回答的問題越來越多:

  • 那一天有沒有開團?
  • 今天還能不能訂?
  • 截止是在當天,還是前一天?
  • 週五之後下一個應該準備哪一天?
  • 自動排程重跑一次,會不會多開一團?

這些問題如果分散在 Frontend、排程與管理畫面各自判斷,日期很快就會出現多個答案。

這一段演進最後收斂成一條比較清楚的界線:Date Picker 只是入口,規則本身屬於 Domain。

「哪天可以訂」不是前端日期選擇器的問題,而是由工作日、開團狀態、訂購模式與截止規則共同決定的 Domain Logic。


走到第 10 天,問題已經不再是單一功能

前九天一路處理 UI、資料、身份、餘額與菜單歷史時,每一次改動看起來都像是在解一個局部問題。

把它們排在一起後,系統已經走過:

表單與操作
→ 資料與狀態
→ Identity
→ Ledger
→ Menu History
→ Calendar / Deadline

這時會看見一個變化:便當系統的複雜度,開始來自不同規則互相牽動。

誰可以操作,會碰到 Identity;餘額怎麼算,會碰到 Ledger;某一天看到哪份菜單,會碰到 effective date;而「那一天能不能訂」,又同時碰到 Calendar、mode、deadline 與排程。

Day 10 的 Calendar 已經超過單純新增日期功能的範圍。

它比較像第一個明顯訊號:當一套小系統進入真實營運,原本散在畫面與流程裡的判斷,會逐步被逼回 Domain。

一個日期,至少同時有三個問題

以便當系統來說,看到 2026-09-25 這個日期本身,資訊還不夠。

系統至少還要知道:

  1. 這一天有沒有對應的店家,也就是有沒有開團。
  2. 這一天採用哪一種訂購模式。
  3. 依照該模式計算出的 deadline 是否已經過期。

「日期存在」和「這個時間點可不可以訂」是兩件不同的事。

如果只讓 Frontend 用今天日期加幾天,再決定哪些按鈕可以按,很容易做出一個畫面看起來合理、但和後端規則不同步的版本。

這套系統後來把 Calendar 留在 Domain 層,讓某個 order date 對應的 vendor、mode 與 deadline 有明確來源。Frontend 的責任比較接近「呈現這個狀態」,不再自行推導日期規則。


Mode A / B 最先證明:截止時間已經是 Domain Rule

系統裡有兩種截止語意。

Mode 截止語意
A 訂餐當日 10:00 截止
B 訂餐前一日 18:00 截止

兩種模式最後都由同一份 deadline domain logic 計算,而且時間基準明確固定在 Asia/Taipei。

這件事乍看很小,但如果沒有集中處理,問題會很快散開。

例如 Mode B 的畫面寫著「前一天 18:00 截止」,但送單 API 若仍用「當天 10:00」判斷,就會出現最麻煩的一種錯誤:使用者看見可以訂,送出時卻被拒絕;或畫面已經關閉,後端仍接受。

截止時間不能只是一句顯示文字。

它同時會影響:

  • 使用者在這個時間點能不能下單;
  • Admin / ProxyAdmin 的操作是否還在允許範圍;
  • Vendor Hub 要顯示哪個最後點餐時間;
  • 測試要用什麼時間點驗證邊界。

Vendor Hub 顯示 deadline 時,也不應再長出第二套計算方式。它只是把 Calendar 已經決定的結果投影出來。


Timezone 一旦沒寫清楚,日期規則就會偷偷改變

日期功能很容易在開發機上看起來都正常。

原因是人腦想的是「台北今天」,程式收到的卻常常是 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」

後來加入每日自動開團時,第一個直覺很容易是:

今天 + 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 每天有成功跑」重要得多。


Manual 與 Automatic 最好走回同一條寫入語意

自動開團如果自己直接寫一套資料,短期很快,後面卻很容易分裂。

手動設定可能做 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 最後變成一個 Source of Truth

把這些規則放在一起後,Calendar 的責任就比較清楚了。

元件 責任
Business Calendar 決定工作日與目標 order date
Calendar Setting 保存某日 vendor / mode 等開團狀態
Deadline Domain 依 order date + mode 算出截止時間
Scheduled Opening 在正確時機嘗試建立未來可訂日
UI / Vendor Hub 呈現 Calendar 已決定的狀態

Day 10 Calendar Domain|Business Calendar → Calendar Setting → Deadline → Orderability → UI / Vendor Hub

這個拆法避免了一個常見問題:每個畫面都知道一點日期規則,最後沒有人知道哪一份才算數。

Frontend 不需要自己猜「週五之後是不是週日」;Vendor Hub 不需要再算一次 Mode B;Cron 也不負責重新定義 deadline。

它們都在消費同一個 Calendar Domain。


這套規則離完整的企業行事曆還有一段距離

Business-day calculation 的邊界很明確:Monday–Friday。

它還不等於完整的台灣國定假日、補班日或公司特殊休假日行事曆。

這一點很重要。

把週末跳過做好,不代表「工作日問題已經完全解決」。如果未來營運真的要求國定假日也自動跳過,就需要一個更正式的 Business Calendar source,避免持續往 weekday helper 裡塞例外。

Domain Model 的目的,在於讓下一個規則出現時,知道應該加在哪裡;不必一開始就抽象到最完整。


三個訊號,表示日期規則已經該進 Domain

如果同一個日期會因 mode 不同而有不同截止時間,前後端與排程又都需要使用同一份 deadline,加上自動排程必須能安全重跑,日期就已經不是單純的 UI 輸入。

這時應該把 timezone、工作日、開團狀態與 deadline 收回同一個 Domain 邊界。之後規則改變時,才不需要逐一追查哪些畫面或排程各自藏了一份判斷。


Calendar 開始承擔營運規則

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 也開始參與這種跨模組修改時,怎麼避免「能改很多」變成「一次改太多」。


上一篇
Day 9|菜單不是一張圖片,它是一段歷史
下一篇
Day 11|當 AI 開始跨模組幫我改,第一個學到的不是速度
系列文
從 GAS 到 Cloudflare D1:AI Agent 如何打造、接手並重構真實便當系統 共 12 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言