跳至主要内容

營業行事曆與日期可選性(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:dateselectablereasonCodedescription,序列化即 wire format
ReasonCodes原因代碼常數:PASTHOLIDAYCLOSED_WEEKLYLEAD_TIMEOUT_OF_RANGECLOSED(非封閉,可自訂字串)
DateNotSelectableExceptionvalidate() 丟出;繼承 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),假日不可選、節日名稱透傳 descriptionHOLIDAY
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 表單欄位契約(namecontrol、label 自動翻譯、schema 驗證),吃已解算的資料:

  • days: DayStatus[] —— 應用 service 呼叫解算 endpoint 取得後注入(TS 型別鏡射本契約,文檔對齊、非程式碼依賴)。date 的 wire format 為 YYYY-MM-DD;前端以字串保存 LocalDate,service 層零轉換
  • 表單值為 string | null(ISO YYYY-MM-DDLocalDate 語意);request 原樣送出,由後端 LocalDate 直接反序列化
  • onRangeChange(from, to) —— 月份導航跨出已載入範圍時,應用層補載
  • disabledDate predicate 逃生口 —— 僅供純前端表單內約束(如迄日不可早於起日);業務行事曆一律走資料路徑
  • 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);PASTLEAD_TIME 邊界由 server 決定,前端不自算
一個情境一份行事曆配送、預約、取貨各自宣告具名 bean;不要一份行事曆塞多情境的規則
規則變更即生效行事曆是無狀態組合(政府年曆快取在 almanac 層),改 bean 宣告重啟即生效;動態規則(後台可調店休)可自行以 rule() 掛 repository 查詢,注意查詢成本
事件行事曆會議室/個人排程是另一個 domain,不要硬塞進 BusinessCalendar;以 DayStatus 投影橋接(ADR-012 D-2)

9. 相關文檔