Files
CFDivePlatform/app/Http/Controllers/API/ScheduleController.php
T
a620906209 975b56ca54 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>
2026-05-12 00:24:51 +08:00

132 lines
5.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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,
];
}
}