feat:實作預約系統 — 時段管理、預約生命週期與前端整合

後端:
- 新增 course_schedules / bookings migration(含索引)
- BookingStatus / ScheduleStatus PHP BackedEnum
- CourseSchedule / Booking Model(七狀態機 VALID_TRANSITIONS)
- ScheduleController、ProviderBookingController、MemberBookingController
- 雙層名額驗證(API 層快速失敗 + DB lockForUpdate 防超賣)
- 24h 取消截止、pending 不佔位設計
- ExpirePendingBookings(每小時)/ CompleteFinishedBookings(每日)Scheduler
- Docker cron 配置、CACHE_STORE 改為 file 修正 502

前端:
- 課程詳情頁加入時段選擇與預約流程
- 我的預約頁(展開式卡片、狀態說明、連結課程詳情)
- Coach 時段管理(上午/下午時間選擇器、新課程引導)
- Coach 預約管理(依課程分組、待確認徽章)
- Navbar 新增「我的預約」與「時段/預約管理」入口
- 密碼格式提示與即時比對

OpenSpec:
- booking-system change 歸檔至 archive/2026-05-12-booking-system
- 新增 specs/course-scheduling 與 specs/booking-lifecycle 主規格

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 00:24:51 +08:00
parent ad2c05779d
commit 975b56ca54
40 changed files with 2202 additions and 18 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-11
@@ -0,0 +1,3 @@
# booking-system
預約系統:時段制課程預約、狀態機管理、教練排程
@@ -0,0 +1,385 @@
## Context
CFDivePlatform 是 Laravel 11 + Vue 3 的潛水課程媒合平台。目前 Member 只能瀏覽課程,無法完成預約。需要在現有 Sanctum 認證體系上新增預約流程,並引入 Laravel Scheduler 處理自動狀態轉換。
現有關鍵資料模型:`users`(三角色)、`diving_offers`(含 `price``provider_id`)。本次不修改任何現有資料表。
## Goals / Non-Goals
**Goals:**
- Provider 可建立/管理開課時段(`course_schedules`
- Member 可對時段送出預約(`bookings`
- 七狀態狀態機,含自動過期(48h)與自動完成(課程後)
- 前後端完整串接
**Non-Goals:**
- 金流整合(payment 欄位預留但不串接)
- 推播通知(Email/SMS
- 管理員預約管理介面(Admin Panel 待後續)
- 退款流程(取消後僅改狀態,不觸發退款)
## Decisions
### 決策一:狀態機實作方式 — PHP BackedEnum + string 欄位
**選擇**DB 欄位用 `string`(非 MySQL ENUM),應用層用 PHP `BackedEnum` 管理合法值。
```php
// app/Enums/BookingStatus.php
enum BookingStatus: string {
case Pending = 'pending';
case Confirmed = 'confirmed';
case Completed = 'completed';
case Rejected = 'rejected';
case Expired = 'expired';
case MemberCancelled = 'member_cancelled';
case ProviderCancelled = 'provider_cancelled';
}
// app/Enums/ScheduleStatus.php
enum ScheduleStatus: string {
case Open = 'open';
case Full = 'full';
case Cancelled = 'cancelled';
}
```
Migration 使用 `$table->string('status')->default('pending')`Model 用 `$casts = ['status' => BookingStatus::class]`
`Booking` Model 定義 `VALID_TRANSITIONS` 常數,transition 前統一驗證合法性:
```php
const VALID_TRANSITIONS = [
'pending' => ['confirmed', 'rejected', 'expired', 'member_cancelled'],
'confirmed' => ['completed', 'member_cancelled', 'provider_cancelled'],
'completed' => [],
'rejected' => [],
'expired' => [],
'member_cancelled' => [],
'provider_cancelled' => [],
];
```
**理由**MySQL ENUM 加欄位值需要 `ALTER TABLE`(鎖表),在大資料量下有停機風險。用 string 欄位,未來加狀態只需改 PHP Enum,零 Migration。PHP 8.1 BackedEnum 提供 IDE 自動補全與型別安全,兼顧可維護性。
**放棄**:DB ENUM → 維護成本高,每次加狀態都要 Migration;引入狀態機套件 → 過度設計,transition 數量不值得。
---
### 決策二:人數計數 — DB 欄位 + 悲觀鎖
**選擇**`course_schedules.current_participants` 實體欄位,更新時用 `lockForUpdate()`
```
DB::transaction(function () use ($booking, $schedule) {
$schedule = CourseSchedule::lockForUpdate()->find($schedule->id);
// 驗證剩餘名額...
$schedule->increment('current_participants', $booking->participants);
});
```
**理由**:比每次 COUNT(bookings) 查詢效率高;悲觀鎖防止超賣 race condition。
**放棄**:樂觀鎖(version column)→ 需要 retry 邏輯,複雜度不值得。
---
### 決策三:Scheduler 頻率
| Job | 頻率 | 原因 |
|-----|------|------|
| `ExpirePendingBookings` | 每小時 | 過期精確度到小時即可 |
| `CompleteFinishedBookings` | 每日凌晨 | 課程完成以「日」為單位 |
---
### 決策四:價格快照
**選擇**:建立 Booking 時將 `diving_offer.price × participants` 存入 `bookings.total_price`
**理由**:Provider 日後調整課程價格不應影響已建立的預約;金流整合時直接使用此欄位。
---
### 決策五:取消時段的 Cascade 實作
**選擇**:在 `ScheduleController::destroy()` 內用單一 DB transaction 同時更新時段與相關 Booking。
```
DB::transaction(function () use ($schedule) {
$schedule->update(['status' => ScheduleStatus::Cancelled]);
$schedule->bookings()
->whereIn('status', [BookingStatus::Pending, BookingStatus::Confirmed])
->update(['status' => BookingStatus::ProviderCancelled]);
});
```
**理由**Booking cascade 必須與時段取消原子性完成,避免時段已取消但 Booking 仍掛 `confirmed` 的髒狀態。批次 `update` 不逐筆觸發 Model Event,效率優先(MVP 規模不需要逐筆通知)。
**放棄**:逐筆呼叫 `$booking->transitionTo(ProviderCancelled)` → 在大量預約時效率差,且 Model Event 觸發通知屬於未來功能。
---
### 決策六:Member 取消截止時間
**選擇**:課程開始前 24 小時為截止點,計算方式為 `$schedule->scheduled_date + $schedule->start_time`Carbon datetime)。
```php
$courseStart = Carbon::parse($schedule->scheduled_date . ' ' . $schedule->start_time);
if (now()->diffInHours($courseStart, false) < 24) {
return response()->json(['status' => false, 'message' => '距課程開始不足 24 小時,無法取消'], 422);
}
```
**理由**:潛水課程有實際的人力與設備準備成本,24h 截止是業界常見標準。`pending` 狀態同樣受此限制,避免 Member 在課程即將開始時仍送出再取消的操作。
**放棄**:只限制 `confirmed` 取消、`pending` 不限 → 業務上應一致處理,24h 截止對兩種狀態同樣適用。
---
### 決策七:Participants 驗證時機
**選擇**:分兩個階段各做一次名額驗證。
**階段 A — 建立 pending 時(早期拒絕,`current_participants` = 已確認人數)**
```
Layer 1 (Controller,進 transaction 前):
$remaining = $schedule->max_participants - $schedule->current_participants;
if ($participants > $remaining) return 422; // 連確認的名額都滿了,早期拒絕
Layer 2 (transaction 內,lockForUpdate 後):
$schedule = CourseSchedule::lockForUpdate()->find($id);
$remaining = $schedule->max_participants - $schedule->current_participants;
if ($participants > $remaining) throw new InsufficientSlotsException();
// 通過後只建立 Booking,不 incrementpending 不佔位)
```
**階段 B — Provider 確認時(真正佔位,lockForUpdate + increment**
```
DB::transaction(function () use ($booking) {
$schedule = CourseSchedule::lockForUpdate()->find($booking->schedule_id);
$remaining = $schedule->max_participants - $schedule->current_participants;
if ($booking->participants > $remaining) throw new InsufficientSlotsException();
$booking->update(['status' => 'confirmed']);
$schedule->increment('current_participants', $booking->participants);
$schedule->refresh();
if ($schedule->current_participants >= $schedule->max_participants) {
$schedule->update(['status' => 'full']);
}
});
```
**理由**`current_participants` 只計算 confirmed 人數。pending 是「申請」不是「保留」,Provider 確認時才真正佔位。階段 A 的早期拒絕防止在所有 confirmed 額度滿後仍接受新 pending;階段 B 的 lockForUpdate 是真正防超賣機制。
---
### 決策八:重複預約防護 — 應用層 transaction 內檢查
**選擇**:不使用 DB UNIQUE constraint,改在建立 Booking 的 DB transaction 內執行重複性檢查。
```php
DB::transaction(function () use ($memberId, $scheduleId, ...) {
// 在 lockForUpdate 取得 schedule 的同一 transaction 內檢查
$duplicate = Booking::where('member_id', $memberId)
->where('schedule_id', $scheduleId)
->whereIn('status', [BookingStatus::Pending, BookingStatus::Confirmed])
->exists();
if ($duplicate) {
throw new DuplicateBookingException();
}
// ... 建立 Booking
});
```
**理由**DB UNIQUE(member_id, schedule_id) 會阻止 Member 在取消後重新預約同一時段(如原 pending 取消後想改約),業務上不合理。應用層只檢查活躍狀態(pending/confirmed),允許對同一時段在取消後再次預約。將檢查放在 transaction 內確保與建立操作原子性,避免 TOCTOU race condition。
**放棄**DB UNIQUE constraint → 語意過強,阻擋合法的取消後重訂;Controller 層(transaction 外)檢查 → 有 TOCTOU 風險。
---
### 決策九:公開時段 API 的明確過濾條件
**選擇**`GET /api/diving-offers/{id}/schedules` 回傳條件為:
```sql
WHERE diving_offer_id = :id
AND status = 'open'
AND scheduled_date >= CURDATE()
ORDER BY scheduled_date ASC, start_time ASC
```
`full``cancelled` 時段不回傳;過去日期時段不回傳。
**理由**:只回傳 `open` 確保 Member 看到的全是可預約的時段,前端不需要再做客戶端過濾。`full` 不顯示(MVP 不做候補名單功能),`scheduled_date < today` 的時段對 Member 無意義。
**放棄**:回傳 `open` + `full`(前端再過濾)→ 增加前端複雜度,且 full 時段對無候補功能的 MVP 無用。
---
### 決策十:Coach / Provider 命名慣例
**現況**:codebase 存在兩套命名:前端用 `coach`,後端 API 和 DB 用 `provider`。這是前期開發的歷史遺留,本次不重構。
**決策**
- **前端路由**:維持 `/coach/*`(已存在,不破壞現有 URL
- **後端 API 路由**:統一用 `/provider/*`(與 DB role 欄位值一致)
- **DB `users.role` 欄位值**`'provider'`PHP 端 `isProvider()` 方法判斷)
- **本次新增程式碼**:後端一律用 `provider` 命名(Controller、Policy、Middleware);前端新增頁面放在 `/coach/*` 路由下
新加入開發者應知道:前端 `/coach/*` = 後端 `/api/provider/*` = DB `role = 'provider'`,三者指同一群用戶。
---
### 決策十一:審計追蹤(已知限制)
**現況**`bookings` 表只有 `created_at`/`updated_at`,無法從 DB 層直接查「何時確認」「何時取消」。
**MVP 決策**:接受此限制。`updated_at` 可作為最後一次狀態變更的時間戳,精確度足夠 MVP 使用。
**未來(金流整合前必須處理)**:加入 `booking_status_logs` 歷史表,記錄每次 status transition 的 `from_status``to_status``changed_by`user_id)、`changed_at`。屆時新增一個 Migration 即可,不影響現有 `bookings` 結構。
---
### 決策十三:名額回收與時段狀態自動轉換
**`current_participants` 增減規則**(只計算 confirmed 人數):
| 觸發動作 | current_participants | 說明 |
| ------- | ------------------- | ---- |
| pending → confirmedProvider 確認) | +participants | 確認時才佔位 |
| confirmed → member_cancelled | -participants | Member 取消,釋放名額 |
| confirmed → provider_cancelled | -participants | Provider 取消時段或單筆取消,釋放名額 |
| confirmed → completed | 不變 | 課程已完成,名額已消耗 |
| pending → rejected | 不變 | pending 從未佔位,無需釋放 |
| pending → expired | 不變 | pending 從未佔位,無需釋放 |
| pending → member_cancelled | 不變 | pending 從未佔位,無需釋放 |
**`course_schedules.status` 自動轉換規則**(在 confirm/cancel 的同一 DB transaction 內執行):
```php
// confirm 後 increment,檢查是否 full
if ($schedule->current_participants >= $schedule->max_participants) {
$schedule->update(['status' => ScheduleStatus::Full]);
}
// cancel 後 decrement,檢查是否回 open
if ($schedule->current_participants < $schedule->max_participants
&& $schedule->status === ScheduleStatus::Full) {
$schedule->update(['status' => ScheduleStatus::Open]);
}
```
`cancelled` 是終態,不受上述規則影響。
**理由**:與 `specs/course-scheduling/spec.md` 一致,`current_participants` 反映已確認(實際佔用)的人數。pending 是申請,不保留名額;Provider 確認時才真正佔位。
---
### 決策十四:`ExpirePendingBookings` 過期條件
**選擇**:過期條件為 `status = 'pending'``created_at <= now() - 48 hours`。批次 update 即可,無需碰 `current_participants`pending 從未佔位)。
```php
$count = Booking::where('status', BookingStatus::Pending)
->where('created_at', '<=', now()->subHours(48))
->update(['status' => BookingStatus::Expired]);
Log::info("ExpirePendingBookings: {$count} expired");
```
**理由**:pending 確認時才佔位(決策十三),因此過期只需改狀態,`current_participants` 與時段 `status` 均不受影響。批次 `update` 效率優於逐筆 transaction。
---
### 決策十二:前端路由新增
```
Member 新路由:
/courses/:id → 課程詳情(新增時段選擇區塊)
/my-bookings → 我的預約列表
Coach 新路由(在現有 /coach/* 下):
/coach/schedules → 時段管理
/coach/bookings → 預約管理
```
## 資料表設計
### course_schedules
```
id bigint PK
diving_offer_id bigint FK → diving_offers.id
provider_id bigint FK → users.id
scheduled_date date NOT NULL
start_time time NOT NULL
max_participants int NOT NULL (≥1)
current_participants int DEFAULT 0
status string DEFAULT 'open' ← PHP ScheduleStatus BackedEnum
created_at timestamp
updated_at timestamp
索引:
idx_offer_status_date (diving_offer_id, status, scheduled_date) ← 公開 API 查詢
idx_provider_id (provider_id) ← Provider 管理頁
```
### bookings
```
id bigint PK
schedule_id bigint FK → course_schedules.id
member_id bigint FK → users.id
participants int NOT NULL DEFAULT 1
total_price int NOT NULL (快照,單位:元)
status string DEFAULT 'pending' ← PHP BookingStatus BackedEnum
notes text nullable
created_at timestamp
updated_at timestamp
索引:
idx_member_status (member_id, status) ← Member 預約列表
idx_schedule_status (schedule_id, status) ← 重複預約檢查、人數統計
idx_status_created_at (status, created_at) ← ExpirePendingBookings Scheduler
idx_status_sched (status, schedule_id) ← CompleteFinishedBookings Scheduler
```
## API 路由總覽
```
公開
GET /api/diving-offers/{id}/schedules → 取得課程可用時段
Member (auth:sanctum)
GET /api/member/bookings → 我的預約列表
POST /api/member/bookings → 建立預約
GET /api/member/bookings/{id} → 預約詳情
DELETE /api/member/bookings/{id} → 取消預約
Provider (auth:sanctum)
GET /api/provider/schedules → 我的時段列表
POST /api/provider/schedules → 建立時段
PUT /api/provider/schedules/{id} → 更新時段
DELETE /api/provider/schedules/{id} → 取消時段
GET /api/provider/bookings → 課程預約列表
PUT /api/provider/bookings/{id}/confirm → 確認預約
PUT /api/provider/bookings/{id}/reject → 拒絕預約
PUT /api/provider/bookings/{id}/cancel → 取消預約
```
## Risks / Trade-offs
- **Race condition on 最後一個名額** → 已用 `lockForUpdate()` 在 DB transaction 內處理
- **Scheduler 停擺(高風險)**
Scheduler 若未啟動,`pending` 預約永遠不過期、`confirmed` 課程永遠不完成,資料持續累積髒狀態。
Mitigation 三層:
1. **日誌**:每次 Job 執行結尾記錄 `Log::info("ExpirePendingBookings: {$count} expired")`,可在 Laravel log 中查驗
2. **可觀測性**:開發環境啟用 Laravel Telescope 監控 Schedule 執行;生產環境至少保留 `storage/logs/laravel.log` 並定期 rotate
3. **手動補跑**:兩支 Artisan Command 須可獨立執行(`php artisan app:expire-pending-bookings`),維運人員可在 Scheduler 異常時手動補跑,不依賴 cron
- **取消後無退款** → 目前僅狀態標記,金流整合時需補充退款邏輯
- **`completed` 自動轉換** → 課程當天仍進行中的預約到凌晨才會轉 completed,業務上可接受
- **string status 未加 DB CHECK constraint** → 合法值由應用層 BackedEnum 控制,若繞過 ORM 直接寫 DB 可能插入非法值;可接受此 trade-off,未來有需要可加 DB constraint
## Closed Questions
- **Q1`notes` 欄位是否強制填寫?****已決定:nullable**。Member 預約時不強制填寫,`notes` 保留為選填欄位供日後使用。
- **Q2Provider 取消時段時,confirmed Booking 的通知方式?****已決定:只改狀態**。取消後 Booking 狀態變為 `provider_cancelled`,本次不觸發任何通知。Email/推播通知留給未來 Email 模組實作。
@@ -0,0 +1,42 @@
## Why
CFDivePlatform 目前只有課程瀏覽,Member 無法預約課程,Provider 無法管理開課時段,平台缺少核心商業閉環。預約系統是金流整合與平台商業化的前置條件,必須優先實作。
## What Changes
- 新增 `course_schedules` 資料表:Provider 建立開課時段(日期、時間、人數上限)
- 新增 `bookings` 資料表:記錄 Member 預約紀錄,含價格快照
- 新增 Member API:查詢可用時段、送出預約、取消預約
- 新增 Provider API:管理開課時段 CRUD、接受/拒絕/取消預約
- 新增 Laravel Schedulerpending 超 48 小時自動 expired;課程日期過後自動 completed
- 新增前端頁面:Member 課程詳情頁加入時段選擇與預約流程;Provider Dashboard 加入時段管理與預約管理
## Capabilities
### New Capabilities
- `course-scheduling`:Provider 建立與管理開課時段,含日期、時間、人數上限、狀態(open/full/cancelled
- `booking-lifecycle`:Member 送出預約、取消預約;Provider 確認/拒絕/取消預約;系統自動過期與完成;七狀態狀態機(pending / confirmed / completed / rejected / expired / member_cancelled / provider_cancelled
### Modified Capabilities
(無既有 spec 受影響)
## Impact
**後端**
- 新增 Migration`course_schedules``bookings`
- 新增 Model`CourseSchedule``Booking`
- 新增 Controller`ScheduleController`Provider)、`BookingController`Member/Provider
- 新增 Laravel Scheduler`ExpirePendingBookings``CompleteFinishedBookings`
- 更新 `api.php`:新增 `/member/bookings``/member/schedules``/provider/schedules``/provider/bookings` 路由群組
**前端**
- 更新 `CourseDetail.vue`(或新建):加入時段列表與預約按鈕
- 新增 `src/pages/member/MyBookings.vue`:我的預約列表
- 新增 Coach Dashboard 子頁面:`ScheduleManager.vue``BookingManager.vue`
- 新增 `src/api/bookingApi.js`:封裝預約相關 API 呼叫
**資料庫**
- 兩張新資料表,無現有資料表結構變更
- `diving_offers.price` 作為預約時的價格快照來源
@@ -0,0 +1,104 @@
## ADDED Requirements
### Requirement: Member 送出預約
Member SHALL 能選擇一個開放時段送出預約,系統記錄價格快照。
#### Scenario: 成功建立預約
- **WHEN** 已登入 Member 送出 `POST /api/member/bookings`,指定 `schedule_id``participants`(≥1
- **THEN** 系統建立 Bookingstatus 為 `pending``total_price` 快照為 `diving_offer.price × participants`,回傳 201
#### Scenario: 時段已滿無法預約
- **WHEN** 指定時段 status 為 `full``cancelled`
- **THEN** 系統回傳 422,告知時段不可用
#### Scenario: 超過剩餘名額(API 層快速驗證)
- **WHEN** `participants` 大於時段當前剩餘名額(`max_participants - current_participants`),在進入 DB transaction 前
- **THEN** 系統回傳 422,告知人數超過上限,不進入 lockForUpdate 流程
#### Scenario: 超過剩餘名額(DB 層二次驗證)
- **WHEN** API 層通過但 lockForUpdate 後重新計算剩餘名額仍不足(race condition 情境)
- **THEN** 系統 rollback transaction,回傳 422,告知名額不足
#### Scenario: 不可重複預約同一時段
- **WHEN** Member 對同一 `schedule_id` 已有 `pending``confirmed` 狀態的 Booking
- **THEN** 系統回傳 422,告知已有預約
### Requirement: 預約狀態機
系統 SHALL 維護七個合法狀態,且只允許以下轉換:
- `pending``confirmed`Provider 確認)
- `pending``rejected`Provider 拒絕)
- `pending``member_cancelled`Member 取消)
- `pending``expired`Scheduler 超時)
- `confirmed``completed`Scheduler 課程後自動)
- `confirmed``member_cancelled`Member 取消)
- `confirmed``provider_cancelled`Provider 取消)
#### Scenario: 非法狀態轉換被拒絕
- **WHEN** 任何角色嘗試執行上述以外的狀態轉換
- **THEN** 系統回傳 422,說明當前狀態不允許此操作
### Requirement: Provider 確認或拒絕預約
Provider SHALL 能對自己課程的 `pending` 預約執行確認或拒絕。
#### Scenario: 確認預約
- **WHEN** Provider 送出 `PUT /api/provider/bookings/{id}/confirm`
- **THEN** Booking status 改為 `confirmed`,時段 `current_participants` 更新
#### Scenario: 拒絕預約
- **WHEN** Provider 送出 `PUT /api/provider/bookings/{id}/reject`
- **THEN** Booking status 改為 `rejected`
#### Scenario: 只能操作自己課程的預約
- **WHEN** Provider 嘗試操作不屬於自己課程的 Booking
- **THEN** 系統回傳 403 Forbidden
### Requirement: Provider 取消已確認預約
Provider SHALL 能取消 `confirmed` 狀態的預約(例如天氣因素)。
#### Scenario: Provider 取消確認中預約
- **WHEN** Provider 送出 `PUT /api/provider/bookings/{id}/cancel`
- **THEN** Booking status 改為 `provider_cancelled`,時段名額釋放
### Requirement: Member 取消預約
Member SHALL 能取消自己的 `pending``confirmed` 預約,但須在課程開始前 24 小時之前提出。
#### Scenario: 取消 pending 預約(期限內)
- **WHEN** Member 送出 `DELETE /api/member/bookings/{id}`Booking status 為 `pending`,且當前時間早於 `scheduled_date + start_time - 24h`
- **THEN** Booking status 改為 `member_cancelled`
#### Scenario: 取消 confirmed 預約(期限內)
- **WHEN** Member 送出 `DELETE /api/member/bookings/{id}`Booking status 為 `confirmed`,且當前時間早於 `scheduled_date + start_time - 24h`
- **THEN** Booking status 改為 `member_cancelled`,時段名額釋放
#### Scenario: 課程開始前 24h 內不可取消
- **WHEN** Member 送出 `DELETE /api/member/bookings/{id}`,但當前時間距 `scheduled_date + start_time` 不足 24 小時
- **THEN** 系統回傳 422,告知「距課程開始不足 24 小時,無法取消,請聯繫教練」;Booking 狀態不變
#### Scenario: 不可取消已終態預約
- **WHEN** Booking status 為 `completed``rejected``expired``provider_cancelled`
- **THEN** 系統回傳 422,告知無法取消
### Requirement: 系統自動過期 pending 預約
Scheduler SHALL 每小時掃描 `pending` 超過 48 小時的 Booking 並標記為 `expired`
#### Scenario: 過期觸發
- **WHEN** Booking status 為 `pending``created_at` 早於 48 小時前
- **THEN** Scheduler 將 status 改為 `expired`
### Requirement: 系統自動完成 confirmed 預約
Scheduler SHALL 每日掃描課程日期已過的 `confirmed` Booking 並標記為 `completed`
#### Scenario: 自動完成
- **WHEN** Booking status 為 `confirmed`,對應 `course_schedule.scheduled_date` 早於今天
- **THEN** Scheduler 將 status 改為 `completed`
### Requirement: Member 查看自己的預約列表
Member SHALL 能查詢自己所有預約的列表及詳情。
#### Scenario: 取得預約列表
- **WHEN** 已登入 Member 送出 `GET /api/member/bookings`
- **THEN** 系統回傳該 Member 所有 Booking,含課程名稱、時段日期、狀態、金額
#### Scenario: 取得單一預約詳情
- **WHEN** 已登入 Member 送出 `GET /api/member/bookings/{id}`
- **THEN** 系統回傳該 Booking 詳情;若非本人預約則回傳 403
@@ -0,0 +1,72 @@
## ADDED Requirements
### Requirement: Provider 建立開課時段
Provider SHALL 能為自己擁有的 DivingOffer 建立開課時段,指定日期、開始時間、人數上限。
#### Scenario: 成功建立時段
- **WHEN** Provider 送出 `POST /api/provider/schedules`,包含合法的 `diving_offer_id``scheduled_date`(未來日期)、`start_time``max_participants`(≥1
- **THEN** 系統建立 CourseSchedulestatus 為 `open`,回傳 201 與新時段資料
#### Scenario: 不可為他人課程建立時段
- **WHEN** Provider 送出的 `diving_offer_id` 屬於其他 Provider
- **THEN** 系統回傳 403 Forbidden
#### Scenario: 日期不可為過去
- **WHEN** `scheduled_date` 早於今天
- **THEN** 系統回傳 422,錯誤訊息指出日期無效
### Requirement: Provider 管理既有時段
Provider SHALL 能更新或取消自己的開課時段。
#### Scenario: 更新時段資訊
- **WHEN** Provider 送出 `PUT /api/provider/schedules/{id}`,修改 `start_time``max_participants`
- **THEN** 系統更新時段資料,回傳更新後內容
#### Scenario: 取消時段
- **WHEN** Provider 送出 `DELETE /api/provider/schedules/{id}`
- **THEN** 系統將時段 status 改為 `cancelled`,不實體刪除;cascade 處理所有相關 Booking(詳見下方 Requirement
#### Scenario: 不可修改他人時段
- **WHEN** Provider 嘗試修改不屬於自己的時段
- **THEN** 系統回傳 403 Forbidden
### Requirement: 取消時段的 Booking Cascade 處理
Provider 取消時段時,系統 SHALL 在同一 DB transaction 內處理該時段下所有活躍 Booking,並明確定義各狀態的 cascade 規則。
#### Scenario: pending Booking cascade 為 provider_cancelled
- **WHEN** Provider 取消時段,時段下存在 status 為 `pending` 的 Booking
- **THEN** 這些 Booking status 全部改為 `provider_cancelled`
#### Scenario: confirmed Booking cascade 為 provider_cancelled
- **WHEN** Provider 取消時段,時段下存在 status 為 `confirmed` 的 Booking
- **THEN** 這些 Booking status 全部改為 `provider_cancelled``current_participants` 不需調整(時段已取消)
#### Scenario: 終態 Booking 不受 cascade 影響
- **WHEN** Provider 取消時段,時段下存在 status 為 `completed``rejected``expired``member_cancelled``provider_cancelled` 的 Booking
- **THEN** 這些 Booking status 維持不變
#### Scenario: cascade 在同一 transaction 內完成
- **WHEN** Provider 取消時段
- **THEN** 時段狀態更新與所有 Booking cascade 更新在同一 DB transaction 內完成;任一失敗則全部 rollback,API 回傳 500
### Requirement: 時段人數自動管理
系統 SHALL 在預約確認時自動累計 `current_participants`,並於額滿時將時段 status 改為 `full`
#### Scenario: 預約確認後人數更新
- **WHEN** Provider 確認一筆 Bookingconfirmed),booking 的 `participants` 為 N
- **THEN** `course_schedules.current_participants` 增加 N;若達到 `max_participants` 則 status 改為 `full`
#### Scenario: 預約取消後人數釋放
- **WHEN** 一筆 `confirmed` 狀態的 Booking 被取消(member_cancelled 或 provider_cancelled
- **THEN** `current_participants` 減少對應人數;若原本為 `full` 則 status 改回 `open`
### Requirement: Member 查詢可用時段
Member SHALL 能查詢指定課程的可用開課時段列表。
#### Scenario: 取得開放時段
- **WHEN** 任何人(含未登入)送出 `GET /api/diving-offers/{id}/schedules`
- **THEN** 系統回傳該課程 status 為 `open`、日期未過的時段列表(含剩餘名額)
#### Scenario: 已滿時段不顯示
- **WHEN** 時段 status 為 `full`
- **THEN** 不包含在上述列表中
@@ -0,0 +1,77 @@
## 1. 資料庫層
- [x] 1.1 [後端] 建立 Migration `create_course_schedules_table`:欄位含 `diving_offer_id``provider_id``scheduled_date``start_time``max_participants``current_participants``status` string(非 enum);加索引 `(diving_offer_id, status, scheduled_date)``(provider_id)`
- [x] 1.2 [後端] 建立 Migration `create_bookings_table`:欄位含 `schedule_id``member_id``participants``total_price``status` string(非 enum)、`notes`;加索引 `(member_id, status)``(schedule_id, status)``(status, created_at)`
- [x] 1.3 [後端] 執行 Migration,確認 DB schema 與索引正確(`SHOW INDEX FROM course_schedules`
## 2. Enum 與 Model 層
- [x] 2.1 [後端] 建立 `app/Enums/BookingStatus.php`PHP BackedEnum,七個 casepending / confirmed / completed / rejected / expired / member_cancelled / provider_cancelled
- [x] 2.2 [後端] 建立 `app/Enums/ScheduleStatus.php`PHP BackedEnum,三個 caseopen / full / cancelled
- [x] 2.3 [後端] 建立 `app/Models/CourseSchedule.php`fillable、`casts = ['status' => ScheduleStatus::class]`、關聯(belongsTo DivingOffer / belongsTo User as provider、hasMany Booking
- [x] 2.4 [後端] 建立 `app/Models/Booking.php`fillable、`casts = ['status' => BookingStatus::class]``VALID_TRANSITIONS` 常數(pending→{confirmed,rejected,expired,member_cancelled}confirmed→{completed,member_cancelled,provider_cancelled};其餘終態→[])、`canTransitionTo()` 驗證方法、關聯(belongsTo CourseSchedule / belongsTo User as member
- [x] 2.5 [後端] 在 `DivingOffer` Model 新增 `hasMany CourseSchedule` 關聯
## 3. Provider 時段管理 API
- [x] 3.1 [後端] 建立 `app/Http/Controllers/API/ScheduleController.php``index``store`(含所有權驗證、日期驗證)、`update``destroy`DB transaction:時段 → cancelled + 批次 cascade pending/confirmed Booking → provider_cancelled
- [x] 3.2 [後端] 在 `routes/api.php` 新增 `/provider/schedules` 路由群組(CRUD
- [x] 3.3 [後端] 在 `routes/api.php` 新增公開路由 `GET /diving-offers/{id}/schedules`Controller 查詢條件:`status = 'open' AND scheduled_date >= CURDATE()` ORDER BY `scheduled_date ASC, start_time ASC`;回傳每筆含 `remaining_spots = max_participants - current_participants`
## 4. Provider 預約管理 API
- [x] 4.1 [後端] 建立 `app/Http/Controllers/API/ProviderBookingController.php`
- `confirm`(階段 B 佔位):DB transaction + lockForUpdate 取得 schedule → 重新計算剩餘名額(`max - current_participants`)→ 不足則 422 → 更新 Booking status=confirmed + `increment('current_participants')` → 若達滿則 schedule status=full
- `reject`Booking status → rejected,不動 current_participantspending 本來就未佔位)
- `cancel`Booking status → provider_cancelled + `decrement('current_participants')` + 若原為 full 則 schedule status 改回 open(僅 confirmed 才需釋放)
- `index`:列出自己課程的預約
- [x] 4.2 [後端] 在 `routes/api.php` 新增 `/provider/bookings` 路由群組(index / confirm / reject / cancel
## 5. Member 預約 API
- [x] 5.1 [後端] 建立 `app/Http/Controllers/API/MemberBookingController.php`
- `store`(階段 Apending 不佔位):Layer 1 快速名額檢查(`max - current_participants`422 early return)→ DB transaction 內:lockForUpdate 取得 schedule + Layer 2 名額再次驗證 + 重複預約檢查(同一 member_id + schedule_id 是否已有 pending/confirmed)→ 建立 Booking + 價格快照;**不 increment `current_participants`**
- `destroy`24h 截止驗證(Carbon datetime 比較)→ 合法則改 member_cancelled**僅 confirmed 狀態需 decrement `current_participants` + 若原為 full 改回 open**pending 取消不動人數
- `index``show`:一般查詢
- [x] 5.2 [後端] 在 `routes/api.php` 新增 `/member/bookings` 路由群組(CRUD
## 6. Scheduler 自動任務
- [x] 6.1 [後端] 建立 `app/Console/Commands/ExpirePendingBookings.php`:查詢 `status=pending``created_at < now()-48h`(利用索引 `status, created_at`),批次更新為 `expired`;執行結尾 `Log::info("ExpirePendingBookings: {$count} expired")`
- [x] 6.2 [後端] 建立 `app/Console/Commands/CompleteFinishedBookings.php`:查詢 `status=confirmed` 且 join schedule 的 `scheduled_date < today()`,批次更新為 `completed`;執行結尾 `Log::info("CompleteFinishedBookings: {$count} completed")`
- [x] 6.3 [後端] 在 `routes/console.php` 註冊:`ExpirePendingBookings` 每小時(`->hourly()`)、`CompleteFinishedBookings` 每日凌晨(`->dailyAt('00:05')`
- [x] 6.4 [後端] 在 Docker `cfdive-app` 容器的 Dockerfile / entrypoint 加入 cron job`* * * * * php /var/www/html/artisan schedule:run >> /dev/null 2>&1`;確認 cron daemon 已啟動
- [x] 6.5 [後端] 手動驗證兩支 Command 可獨立執行:`php artisan app:expire-pending-bookings``php artisan app:complete-finished-bookings` 不報錯,且 `storage/logs/laravel.log` 有對應紀錄
## 7. 前端 API 封裝
- [x] 7.1 [前端] 建立 `frontend/src/api/scheduleApi.js`:封裝 `getSchedulesByOffer(offerId)`
- [x] 7.2 [前端] 建立 `frontend/src/api/bookingApi.js`member):`getMyBookings()``getBooking(id)``createBooking(payload)``cancelBooking(id)`
- [x] 7.3 [前端] 建立 `frontend/src/api/coachBookingApi.js`provider):`getProviderBookings()``confirmBooking(id)``rejectBooking(id)``cancelBooking(id)`
- [x] 7.4 [前端] 建立 `frontend/src/api/coachScheduleApi.js``getSchedules()``createSchedule(payload)``updateSchedule(id, payload)``deleteSchedule(id)`
## 8. Member 前端頁面
- [x] 8.1 [前端] 更新課程詳情頁(`frontend/src/views/CourseDetailView.vue`):新增「可用時段」區塊,顯示日期、時間、剩餘名額,含「立即預約」按鈕(呼叫 createBooking
- [x] 8.2 [前端] 新增 `frontend/src/views/MyBookingsView.vue`:列出 Member 所有預約,顯示狀態 Badge(七種狀態對應不同顏色),含取消按鈕(pending/confirmed
- [x] 8.3 [前端] 在 Member Navbar 加入「我的預約」連結,路由 `/my-bookings`
- [x] 8.4 [前端] 在 `frontend/src/router/index.js` 新增 `/my-bookings` 路由(requiresAuth
## 9. Coach 前端頁面
- [x] 9.1 [前端] 新增 `frontend/src/views/coach/ScheduleManagerView.vue`:時段列表(含狀態、剩餘名額)、建立時段表單(日期選擇、時間、人數上限)、取消時段按鈕
- [x] 9.2 [前端] 新增 `frontend/src/views/coach/BookingManagerView.vue`:預約列表(依課程分組或全部列出)、顯示 Member 姓名、人數、金額、狀態、確認/拒絕/取消按鈕
- [x] 9.3 [前端] 在 Coach Navbar`CoachNavBar.vue`)加入「時段管理」與「預約管理」連結
- [x] 9.4 [前端] 在 `frontend/src/router/index.js` 新增 `/coach/schedules``/coach/bookings` 路由(requiresCoach
## 10. 整合驗證
- [x] 10.1 [整合測試] 完整流程測試:Provider 建立時段 → Member 預約 → Provider 確認 → 驗證人數扣減
- [x] 10.2 [整合測試] 超賣防護測試:最後一個名額同時送出兩筆預約,驗證只有一筆成功(Layer 2 lockForUpdate 生效)
- [x] 10.3 [整合測試] 取消流程測試:①Member 取消 confirmed 預約 → current_participants 減少、schedule 若原為 full 改回 open;②Member 取消 pending 預約 → current_participants **不變**pending 本來就未佔位)
- [x] 10.4 [整合測試] Scheduler 測試:手動執行 `php artisan app:expire-pending-bookings``php artisan app:complete-finished-bookings`,驗證狀態正確更新
- [x] 10.5 [整合測試] Cascade 測試:Provider 取消時段後驗證 pending/confirmed Booking 全部變 provider_cancelledcompleted/rejected Booking 狀態不變
- [x] 10.6 [整合測試] 取消截止測試:建立一筆課程開始前 12h 的 confirmed 預約,Member 嘗試取消應回傳 422;課程前 36h 的預約應可取消
- [x] 10.7 [整合測試] Participants 雙層驗證測試:超過剩餘名額的預約在 Layer 1 被攔截,回傳 422 且不進入 DB transaction
- [x] 10.8 [整合測試] 重複預約防護測試:Member 對同一時段送出第二筆 pending 預約應回傳 422;第一筆取消後再送出第三筆應成功
- [x] 10.9 [整合測試] 公開 API 過濾測試:`GET /api/diving-offers/{id}/schedules` 不回傳 status=full、cancelled 及過去日期時段;回傳時段含正確的 remaining_spots