營業行事曆與日期可選性(BusinessCalendar)
狀態: 已實作(後端);前端 DatePicker 隨 appfuse-web 提供(ADR-012 Phase 3) 建立日期: 2026-07-04 相關文件: ADR-012: 營業行事曆與日期可選性契約、almanac 服務、錯誤處理
本文檔定義框架對「日期輸入受行事曆限制」的立場與做法:配送日期不可選店休日、預約日期只能選工作日、申請日期不可回填過去——這類需求的組合、曝露與驗證。
1. 設計立場:組合只在後端,前後端消費同一份
前端的日期限制只是 UX,後端才是驗證權威。若前端自己組合規則(寫死星期幾、自算假日),與後端驗證必然漂移。框架的做法:
應用層宣告「具名行事曆」(如 deliveryCalendar)
│
├─ resolve(from, to) → DayStatus[] → 應用 endpoint → 前端 DatePicker 渲染
└─ validate(date) → 提交時後端驗證,不可選 → 400
同一個 BusinessCalendar 實例同時餵兩個消費點,組合邏輯只存在後端一份——前端拿到的是已解算的結果,規則漂移在結構上不可能發生。
「可選性行事曆」(日期 → 狀態)與「事件行事曆」(會議室/個人排程:時段粒度、重複規則、衝突偵測)是不同 domain。後者不在本機制範圍;需要時可把「當天已無空檔」投影成
DayStatus(selectable=false, reasonCode="FULLY_BOOKED")橋接給日期選擇消費。詳見 ADR-012 附帶決策 D-1/D-2。
2. 核心 API(io.leandev.appfuse.calendar)
| 類 | 職責 |
|---|---|
BusinessCalendar | 核心介面:statusOf(date)、isSelectable(date)、resolve(from, to)、validate(date)、plusSelectableDays(from, days) |
Calendars | 組合工廠(builder):底座 + 規則組合子 |
DayStatus | 跨端契約 record:date/selectable/reasonCode/description,序列化即 wire format |
ReasonCodes | 原因代碼常數:PAST/HOLIDAY/CLOSED_WEEKLY/LEAD_TIME/OUT_OF_RANGE/CLOSED(非封閉,可自訂字串) |
DateNotSelectableException | validate() 丟出;繼承 InvalidDataException → 框架錯誤處理映射 400(i18n params:日期、reasonCode) |
3. 宣告具名行事曆
以 Spring bean 宣告,一個輸入情境一份具名行事曆(配送 ≠ 預約 ≠ 取貨):
@Configuration
public class CalendarConfig {
/// 配送行事曆:政府工作日 + 週一店休 + 非過去
@Bean
public BusinessCalendar deliveryCalendar(Almanac almanac) {
return Calendars.government(almanac.calendar()) // 政府行事曆底座(假日 → HOLIDAY)
.closedOn(DayOfWeek.MONDAY) // 每週店休 → CLOSED_WEEKLY
.noPast() // 非過去 → PAST
.build();
}
}
組合子一覽
| 組合子 | 語意 | reasonCode |
|---|---|---|
Calendars.government(calendarService) | 底座:政府行事曆(almanac),假日不可選、節日名稱透傳 description | HOLIDAY |
Calendars.always() | 底座:全可選(純業務規則行事曆) | — |
.closedOn(DayOfWeek...) | 每週固定店休 | CLOSED_WEEKLY |
.noPast() | 早於今天不可選 | PAST |
.leadDays(n) | 今天起 n 天內不可選(日曆天計) | LEAD_TIME |
.maxAdvance(n) | 晚於今天 + n 天不可選 | OUT_OF_RANGE |
.closed(date[, reasonCode[, description]]) | 特例休(最優先,後設定覆蓋先設定) | CLOSED 或自訂 |
.open(date) | 特例補開(覆寫所有規則與底座) | — |
.rule(predicate, reasonCode) | 自訂規則 | 自訂 |
.zone(zoneId) / .clock(clock) | 「今天」的錨(預設 Asia/Taipei;測試注入固定 clock) | — |
判定順序:特例覆寫 → 時間窗(noPast/leadDays/maxAdvance)→ 每週店休 → 自訂規則 → 底座。
政府底座查無該年資料時拋
AlmanacException(fail-loud),不靜默視為可選。
4. 曝露解算 endpoint(前端渲染用)
框架不出 controller(URL 與 wire contract 屬應用),但提供 BusinessCalendarCatalog
集中名稱正規化、404 與日期範圍保護。Calendar Feature 預設把所有
*Calendar bean 接入 catalog;應用 controller 只保留 HTTP 殼:
@RestController
@RequestMapping("/api/v1/calendars")
public class CalendarController {
private final BusinessCalendarCatalog calendars;
@GetMapping("/{name}")
public List<DayStatus> resolve(@PathVariable String name,
@RequestParam LocalDate from,
@RequestParam LocalDate to) {
return calendars.resolve(name, from, to);
}
}
預設最大範圍為 400 天;只有公開政策不同時才以自訂 BusinessCalendarCatalog bean 覆寫。
具體的 deliveryCalendar 規則則屬 reference implementation,tenant 與 ownership
兩種資料隔離模式可原樣共用。
回應即 DayStatus[]:
[
{ "date": "2026-07-05", "selectable": true, "reasonCode": null, "description": null },
{ "date": "2026-07-06", "selectable": false, "reasonCode": "CLOSED_WEEKLY", "description": null },
{ "date": "2026-09-28", "selectable": false, "reasonCode": "HOLIDAY", "description": "教師節" }
]
- 範圍建議一次給前端前後各 2–3 個月(每月 ~31 筆,資料量無壓力),減少月份導航的補載請求。
- 解算為純運算(政府年曆已由 almanac 檔案快取承擔),endpoint 可再加 HTTP 快取標頭。
5. 提交驗證(後端權威)
service 層在提交路徑呼叫 validate()——與渲染消費同一個 bean:
@Service
public class OrderService {
private final BusinessCalendar deliveryCalendar;
public Order create(CreateOrderRequest request) {
deliveryCalendar.validate(request.deliveryDate()); // 不可選 → DateNotSelectableException → 400
// ... 建單
}
}
不可選時框架錯誤處理回 RFC 7807(urn:appfuse:error:invalid-data),i18n params 帶日期與 reasonCode,前端可統一呈現。
6. 前端接線(appfuse-web DatePicker)
前端零行事曆邏輯:DatePicker 遵循 data-input 表單欄位契約(name/control、label 自動翻譯、schema 驗證),吃已解算的資料:
days: DayStatus[]—— 應用 service 呼叫解算 endpoint 取得後注入(TS 型別鏡射本契約,文檔對齊、非程式碼依賴)。date的 wire format 為YYYY-MM-DD;前端以字串保存LocalDate,service 層零轉換- 表單值為
string | null(ISOYYYY-MM-DD,LocalDate語意);request 原樣送出,由後端LocalDate直接反序列化 onRangeChange(from, to)—— 月份導航跨出已載入範圍時,應用層補載disabledDatepredicate 逃生口 —— 僅供純前端表單內約束(如迄日不可早於起日);業務行事曆一律走資料路徑reasonCode經前端 nls 翻譯呈現(圖例、tooltip);自訂代碼須自行補翻譯
Mockup(MSW)階段:handler 直接回傳 seed 寫死的 DayStatus[](純值、零運算),符合 mock 邊界的「用 seed 寫死預期值、不在 handler 重算」紀律;E2E 斷言錨定 seed。
7. 工作日運算
plusSelectableDays(from, days) 泛化上一代 plusWorkingDays 到任何組合後行事曆——「第 N 個可選日」:
// 純政府行事曆:等價 plusWorkingDays
LocalDate due = governmentCalendar.plusSelectableDays(LocalDate.now(), 5);
// 配送行事曆:加 3 個「可配送日」(自動跳過假日與店休)
LocalDate earliest = deliveryCalendar.plusSelectableDays(today, 3);
days為負數往前數;0回傳基準日本身。- 前方再無可選日時(如全週店休的組合)拋
IllegalStateException(掃描上限 ~20 年)。 - almanac 原有的
CalendarService.plusWorkingDays等便利方法仍在,適合「純政府工作日」的簡單場景。
8. 注意事項
| 事項 | 說明 |
|---|---|
| 「今天」的判定 | 以行事曆的 zone/clock 為準(預設 Asia/Taipei);PAST/LEAD_TIME 邊界由 server 決定,前端不自算 |
| 一個情境一份行事曆 | 配送、預約、取貨各自宣告具名 bean;不要一份行事曆塞多情境的規則 |
| 規則變更即生效 | 行事曆是無狀態組合(政府年曆快取在 almanac 層),改 bean 宣告重啟即生效;動態規則(後台可調店休)可自行以 rule() 掛 repository 查詢,注意查詢成本 |
| 事件行事曆 | 會議室/個人排程是另一個 domain,不要硬塞進 BusinessCalendar;以 DayStatus 投影橋接(ADR-012 D-2) |
9. 相關文檔
- ADR-012: 營業行事曆與日期可選性契約 —— 決策背景與方案比較
- almanac 服務 —— 政府行事曆資料來源(
CalendarService) - 錯誤處理 ——
DateNotSelectableException的 400 映射 - appfuse-web DatePicker 元件文檔(Phase 3 隨元件提供)