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,131 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Http\Controllers\Controller;
use App\Models\CourseSchedule;
use App\Models\DivingOffer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ScheduleController extends Controller
{
public function index(Request $request)
{
$provider = $request->user();
$schedules = CourseSchedule::with('divingOffer')
->where('provider_id', $provider->id)
->orderBy('scheduled_date')
->orderBy('start_time')
->get()
->map(fn($s) => $this->formatSchedule($s));
return response()->json(['status' => true, 'data' => $schedules]);
}
public function store(Request $request)
{
$data = $request->validate([
'diving_offer_id' => 'required|integer|exists:diving_offers,id',
'scheduled_date' => 'required|date|after_or_equal:today',
'start_time' => 'required|date_format:H:i',
'max_participants' => 'required|integer|min:1',
]);
$offer = DivingOffer::findOrFail($data['diving_offer_id']);
if ($offer->provider_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權操作此課程'], 403);
}
$schedule = CourseSchedule::create([
'diving_offer_id' => $data['diving_offer_id'],
'provider_id' => $request->user()->id,
'scheduled_date' => $data['scheduled_date'],
'start_time' => $data['start_time'],
'max_participants' => $data['max_participants'],
'current_participants'=> 0,
'status' => ScheduleStatus::Open,
]);
return response()->json(['status' => true, 'data' => $this->formatSchedule($schedule)], 201);
}
public function update(Request $request, int $id)
{
$schedule = CourseSchedule::findOrFail($id);
if ($schedule->provider_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權操作此時段'], 403);
}
$data = $request->validate([
'start_time' => 'sometimes|date_format:H:i',
'max_participants' => 'sometimes|integer|min:1',
]);
if (isset($data['max_participants']) && $data['max_participants'] < $schedule->current_participants) {
return response()->json([
'status' => false,
'message' => '人數上限不可低於目前已確認人數(' . $schedule->current_participants . '',
], 422);
}
$schedule->update($data);
return response()->json(['status' => true, 'data' => $this->formatSchedule($schedule->fresh())]);
}
public function destroy(Request $request, int $id)
{
$schedule = CourseSchedule::findOrFail($id);
if ($schedule->provider_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權操作此時段'], 403);
}
DB::transaction(function () use ($schedule) {
$schedule->update(['status' => ScheduleStatus::Cancelled]);
$schedule->bookings()
->whereIn('status', [BookingStatus::Pending->value, BookingStatus::Confirmed->value])
->update(['status' => BookingStatus::ProviderCancelled->value]);
});
return response()->json(['status' => true, 'message' => '時段已取消']);
}
public function publicList(int $offerId)
{
$offer = DivingOffer::findOrFail($offerId);
$schedules = CourseSchedule::where('diving_offer_id', $offer->id)
->where('status', ScheduleStatus::Open->value)
->whereDate('scheduled_date', '>=', now()->toDateString())
->orderBy('scheduled_date')
->orderBy('start_time')
->get()
->map(fn($s) => [
'id' => $s->id,
'scheduled_date' => $s->scheduled_date->toDateString(),
'start_time' => $s->start_time,
'max_participants' => $s->max_participants,
'remaining_spots' => $s->remainingSpots(),
'status' => $s->status->value,
]);
return response()->json(['status' => true, 'data' => $schedules]);
}
private function formatSchedule(CourseSchedule $s): array
{
return [
'id' => $s->id,
'diving_offer_id' => $s->diving_offer_id,
'offer_title' => $s->divingOffer?->title,
'scheduled_date' => $s->scheduled_date?->toDateString(),
'start_time' => $s->start_time,
'max_participants' => $s->max_participants,
'current_participants' => $s->current_participants,
'remaining_spots' => $s->remainingSpots(),
'status' => $s->status->value,
];
}
}