975b56ca54
後端: - 新增 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>
152 lines
6.0 KiB
PHP
152 lines
6.0 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\API;
|
||
|
||
use App\Enums\BookingStatus;
|
||
use App\Enums\ScheduleStatus;
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\Booking;
|
||
use App\Models\CourseSchedule;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
class MemberBookingController extends Controller
|
||
{
|
||
public function index(Request $request)
|
||
{
|
||
$bookings = Booking::with(['schedule.divingOffer'])
|
||
->where('member_id', $request->user()->id)
|
||
->orderByDesc('created_at')
|
||
->get()
|
||
->map(fn($b) => $this->formatBooking($b));
|
||
|
||
return response()->json(['status' => true, 'data' => $bookings]);
|
||
}
|
||
|
||
public function show(Request $request, int $id)
|
||
{
|
||
$booking = Booking::with(['schedule.divingOffer'])->findOrFail($id);
|
||
if ($booking->member_id !== $request->user()->id) {
|
||
return response()->json(['status' => false, 'message' => '無權查看此預約'], 403);
|
||
}
|
||
|
||
return response()->json(['status' => true, 'data' => $this->formatBooking($booking)]);
|
||
}
|
||
|
||
public function store(Request $request)
|
||
{
|
||
$data = $request->validate([
|
||
'schedule_id' => 'required|integer|exists:course_schedules,id',
|
||
'participants' => 'required|integer|min:1',
|
||
'notes' => 'nullable|string|max:500',
|
||
]);
|
||
|
||
$schedule = CourseSchedule::with('divingOffer')->findOrFail($data['schedule_id']);
|
||
|
||
// Layer 1:快速失敗
|
||
if ($schedule->status !== ScheduleStatus::Open) {
|
||
return response()->json(['status' => false, 'message' => '此時段不開放預約'], 422);
|
||
}
|
||
if ($data['participants'] > $schedule->remainingSpots()) {
|
||
return response()->json(['status' => false, 'message' => '人數超過剩餘名額'], 422);
|
||
}
|
||
|
||
$memberId = $request->user()->id;
|
||
|
||
try {
|
||
$booking = DB::transaction(function () use ($data, $schedule, $memberId) {
|
||
// Layer 2:lockForUpdate 後二次驗證
|
||
$schedule = CourseSchedule::lockForUpdate()->find($schedule->id);
|
||
if ($data['participants'] > $schedule->remainingSpots()) {
|
||
throw new \RuntimeException('名額不足,請重新選擇');
|
||
}
|
||
|
||
// 重複預約檢查
|
||
$duplicate = Booking::where('member_id', $memberId)
|
||
->where('schedule_id', $schedule->id)
|
||
->whereIn('status', [BookingStatus::Pending->value, BookingStatus::Confirmed->value])
|
||
->exists();
|
||
if ($duplicate) {
|
||
throw new \RuntimeException('您已預約此時段');
|
||
}
|
||
|
||
return Booking::create([
|
||
'schedule_id' => $schedule->id,
|
||
'member_id' => $memberId,
|
||
'participants' => $data['participants'],
|
||
'total_price' => $schedule->divingOffer->price * $data['participants'],
|
||
'status' => BookingStatus::Pending,
|
||
'notes' => $data['notes'] ?? null,
|
||
]);
|
||
});
|
||
} catch (\RuntimeException $e) {
|
||
return response()->json(['status' => false, 'message' => $e->getMessage()], 422);
|
||
}
|
||
|
||
return response()->json([
|
||
'status' => true,
|
||
'message' => '預約已送出,等待教練確認',
|
||
'data' => $this->formatBooking($booking->fresh(['schedule.divingOffer'])),
|
||
], 201);
|
||
}
|
||
|
||
public function destroy(Request $request, int $id)
|
||
{
|
||
$booking = Booking::with('schedule')->findOrFail($id);
|
||
if ($booking->member_id !== $request->user()->id) {
|
||
return response()->json(['status' => false, 'message' => '無權操作此預約'], 403);
|
||
}
|
||
|
||
$canCancelFrom = [BookingStatus::Pending, BookingStatus::Confirmed];
|
||
if (!in_array($booking->status, $canCancelFrom)) {
|
||
return response()->json(['status' => false, 'message' => '此預約狀態無法取消'], 422);
|
||
}
|
||
|
||
// 24h 截止驗證
|
||
$schedule = $booking->schedule;
|
||
$courseStart = Carbon::parse($schedule->scheduled_date->toDateString() . ' ' . $schedule->start_time);
|
||
if (now()->diffInHours($courseStart, false) < 24) {
|
||
return response()->json(['status' => false, 'message' => '距課程開始不足 24 小時,無法取消,請聯繫教練'], 422);
|
||
}
|
||
|
||
DB::transaction(function () use ($booking, $schedule) {
|
||
$wasConfirmed = $booking->status === BookingStatus::Confirmed;
|
||
$booking->update(['status' => BookingStatus::MemberCancelled]);
|
||
|
||
if ($wasConfirmed) {
|
||
$schedule = $booking->schedule()->lockForUpdate()->first();
|
||
$schedule->decrement('current_participants', $booking->participants);
|
||
$schedule->refresh();
|
||
|
||
if ($schedule->current_participants < $schedule->max_participants
|
||
&& $schedule->status === ScheduleStatus::Full) {
|
||
$schedule->update(['status' => ScheduleStatus::Open]);
|
||
}
|
||
}
|
||
});
|
||
|
||
return response()->json(['status' => true, 'message' => '預約已取消']);
|
||
}
|
||
|
||
private function formatBooking(Booking $b): array
|
||
{
|
||
$offer = $b->schedule?->divingOffer;
|
||
return [
|
||
'id' => $b->id,
|
||
'offer_id' => $offer?->id,
|
||
'offer_title' => $offer?->title,
|
||
'offer_location' => $offer?->location,
|
||
'offer_region' => $offer?->region,
|
||
'offer_price' => $offer?->price,
|
||
'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
|
||
'start_time' => $b->schedule?->start_time,
|
||
'participants' => $b->participants,
|
||
'total_price' => $b->total_price,
|
||
'status' => $b->status->value,
|
||
'notes' => $b->notes,
|
||
'created_at' => $b->created_at?->toISOString(),
|
||
];
|
||
}
|
||
}
|