Files
CFDivePlatform/app/Http/Controllers/API/MemberBookingController.php
T
a620906209 43720482c4
Run Tests / test (pull_request) Successful in 2m3s
feat(verification): 教練審核流程完整版——狀態機/證照送審/Admin 裁決/通知
實作 openspec change provider-verification-workflow(25/25 tasks):
- is_verified 升級為 verification_status 四狀態機(unsubmitted/pending/
  approved/rejected),migration 自動轉換既有資料,API 以 accessor 保留
  is_verified 相容輸出
- 教練端:證照上傳(≤3 張、複用 CompressesImages)、送審、駁回原因
  顯示與重送(/coach/verification 新頁面)
- Admin 端:審核佇列、通過/駁回(原因必填)、撤銷 approved;移除
  toggle-verified 端點(防繞過狀態機);裁決後 flush 課程快取
- 通知:審核通過/駁回(站內+Email,駁回含原因)
- 可見性與預約入口改判 approved(行為等價)
- 測試 +18(ProviderVerificationTest 10、AdminVerificationTest 8),
  既有 4 個測試檔 helper 遷移;Swagger 同步
- 規格同步:provider-verification 全面改寫、admin-user-management
  toggle 段落改為移除聲明

容器內全套件 173 passed / 439 assertions。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:26:12 +08:00

177 lines
7.4 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\Booking;
use App\Models\CourseSchedule;
use App\Notifications\BookingCreatedNotification;
use App\Notifications\BookingCancelledNotification;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
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.provider.providerProfile')->findOrFail($data['schedule_id']);
// Layer 1:快速失敗
// 可見性繞過防護:課程教練未通過審核時不可建立新預約(既有預約不受影響,見 provider-verification 規格)
$offer = $schedule->divingOffer;
if ($offer->provider_id !== null && !($offer->provider?->providerProfile?->is_verified)) {
// is_verified 為 accessor= verification_status === approved
return response()->json(['status' => false, 'message' => '此課程目前不開放預約'], 422);
}
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 2lockForUpdate 後二次驗證
$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);
}
try {
$booking->load('schedule.divingOffer.provider');
$provider = $booking->schedule->divingOffer->provider;
$provider->notify(new BookingCreatedNotification($booking));
} catch (\Throwable $e) {
Log::error('BookingCreatedNotification failed: ' . $e->getMessage());
}
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]);
}
}
});
try {
$booking->load('schedule.divingOffer.provider');
$provider = $booking->schedule->divingOffer->provider;
$provider->notify(new BookingCancelledNotification($booking, cancelledBy: 'member'));
} catch (\Throwable $e) {
Log::error('BookingCancelledNotification(member) failed: ' . $e->getMessage());
}
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(),
];
}
}