From ddbf9561440f68aa883f4cf7bf26ebe488a8f06f Mon Sep 17 00:00:00 2001 From: Hank Date: Sun, 18 Oct 2026 01:35:38 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(booking):=20=E4=BA=A4=E6=98=93=E5=85=A7?= =?UTF-8?q?=E5=8F=AA=E6=94=94=E6=88=AA=E6=A5=AD=E5=8B=99=E4=BE=8B=E5=A4=96?= =?UTF-8?q?=EF=BC=8C=E8=B3=87=E6=96=99=E5=BA=AB=E9=8C=AF=E8=AA=A4=E6=94=B9?= =?UTF-8?q?=E4=BA=A4=E7=94=B1=E4=BE=8B=E5=A4=96=E8=99=95=E7=90=86=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 BookingRuleViolationException,MemberBookingController::store 與 ProviderBookingController::confirm 只 catch 此型別並回 422(訊息不變)。 QueryException 等其他例外不再被當成 422 回傳 SQL,改由 Laravel 回 500 並回報 Sentry。對應 OpenSpec change fix-booking-exception-handling(M7)。 Co-Authored-By: Claude Opus 5.5 --- .../BookingRuleViolationException.php | 5 + .../API/MemberBookingController.php | 7 +- .../API/ProviderBookingController.php | 5 +- .../fix-booking-exception-handling/tasks.md | 22 +- .../Feature/BookingExceptionHandlingTest.php | 201 ++++++++++++++++++ 5 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 app/Exceptions/BookingRuleViolationException.php create mode 100644 tests/Feature/BookingExceptionHandlingTest.php diff --git a/app/Exceptions/BookingRuleViolationException.php b/app/Exceptions/BookingRuleViolationException.php new file mode 100644 index 0000000..153f8c6 --- /dev/null +++ b/app/Exceptions/BookingRuleViolationException.php @@ -0,0 +1,5 @@ +find($schedule->id); if ($data['participants'] > $schedule->remainingSpots()) { - throw new \RuntimeException('名額不足,請重新選擇'); + throw new BookingRuleViolationException('名額不足,請重新選擇'); } // 重複預約檢查 @@ -77,7 +78,7 @@ class MemberBookingController extends Controller ->whereIn('status', [BookingStatus::Pending->value, BookingStatus::Confirmed->value]) ->exists(); if ($duplicate) { - throw new \RuntimeException('您已預約此時段'); + throw new BookingRuleViolationException('您已預約此時段'); } return Booking::create([ @@ -89,7 +90,7 @@ class MemberBookingController extends Controller 'notes' => $data['notes'] ?? null, ]); }); - } catch (\RuntimeException $e) { + } catch (BookingRuleViolationException $e) { return response()->json(['status' => false, 'message' => $e->getMessage()], 422); } diff --git a/app/Http/Controllers/API/ProviderBookingController.php b/app/Http/Controllers/API/ProviderBookingController.php index ecd52ab..4384204 100644 --- a/app/Http/Controllers/API/ProviderBookingController.php +++ b/app/Http/Controllers/API/ProviderBookingController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\API; use App\Enums\BookingStatus; use App\Enums\ScheduleStatus; +use App\Exceptions\BookingRuleViolationException; use App\Http\Controllers\Controller; use App\Models\Booking; use App\Notifications\BookingCancelledNotification; @@ -43,7 +44,7 @@ class ProviderBookingController extends Controller $remaining = $schedule->max_participants - $schedule->current_participants; if ($booking->participants > $remaining) { - throw new \RuntimeException('名額不足,無法確認此預約'); + throw new BookingRuleViolationException('名額不足,無法確認此預約'); } $booking->update(['status' => BookingStatus::Confirmed]); @@ -54,7 +55,7 @@ class ProviderBookingController extends Controller $schedule->update(['status' => ScheduleStatus::Full]); } }); - } catch (\RuntimeException $e) { + } catch (BookingRuleViolationException $e) { return response()->json(['status' => false, 'message' => $e->getMessage()], 422); } diff --git a/openspec/changes/fix-booking-exception-handling/tasks.md b/openspec/changes/fix-booking-exception-handling/tasks.md index dbf841c..0f8555d 100644 --- a/openspec/changes/fix-booking-exception-handling/tasks.md +++ b/openspec/changes/fix-booking-exception-handling/tasks.md @@ -4,21 +4,21 @@ ## 1. 業務例外與 Controller -- [ ] 1.1 [後端] 新增 `app/Exceptions/BookingRuleViolationException.php`(`final class`,繼承 `\RuntimeException`,不加額外方法);驗證:2.x 測試通過 -- [ ] 1.2 [後端] `app/Http/Controllers/API/MemberBookingController.php` 的 `store`:交易閉包內兩處 `throw new \RuntimeException(...)` 改為 `throw new BookingRuleViolationException(...)`,訊息文字不變;`catch (\RuntimeException $e)` 改為 `catch (BookingRuleViolationException $e)` -- [ ] 1.3 [後端] `app/Http/Controllers/API/ProviderBookingController.php` 的 `confirm`:同 1.2,處理 `名額不足,無法確認此預約` -- [ ] 1.4 [後端] 確認 `grep -rn "RuntimeException" app/` 只剩 `BookingRuleViolationException` 本身的繼承宣告 +- [x] 1.1 [後端] 新增 `app/Exceptions/BookingRuleViolationException.php`(`final class`,繼承 `\RuntimeException`,不加額外方法);驗證:2.x 測試通過 +- [x] 1.2 [後端] `app/Http/Controllers/API/MemberBookingController.php` 的 `store`:交易閉包內兩處 `throw new \RuntimeException(...)` 改為 `throw new BookingRuleViolationException(...)`,訊息文字不變;`catch (\RuntimeException $e)` 改為 `catch (BookingRuleViolationException $e)` +- [x] 1.3 [後端] `app/Http/Controllers/API/ProviderBookingController.php` 的 `confirm`:同 1.2,處理 `名額不足,無法確認此預約` +- [x] 1.4 [後端] 確認 `grep -rn "RuntimeException" app/` 只剩 `BookingRuleViolationException` 本身的繼承宣告 ## 2. 測試 -- [ ] 2.1 [整合測試] `test_duplicate_booking_returns_422_with_rule_message`:member 已有該時段 `pending` 預約,再次 `POST /api/member/bookings`,回 422,`assertExactJson(['status' => false, 'message' => '您已預約此時段'])`,該 member 在此時段的預約仍為 1 筆;測試開頭 `Exceptions::fake()`,最後 `Exceptions::assertNotReported(BookingRuleViolationException::class)`(對應 Scenario「重複預約仍回 422 與既定訊息」) -- [ ] 2.2 [整合測試] `test_capacity_lost_inside_transaction_returns_422_with_rule_message`:以 `CourseSchedule::retrieved` 事件在交易內把 `current_participants` 設為滿額(事件只能改動交易內 `lockForUpdate()->find()` 那次讀取;交易前的第一次讀取若被改動,會被前置檢查以「人數超過剩餘名額」攔下,測不到交易內路徑),`POST /api/member/bookings` 回 422,`assertExactJson(['status' => false, 'message' => '名額不足,請重新選擇'])`,`bookings` 筆數為 0;同 2.1 以 `Exceptions::fake()` 斷言未回報(對應 Scenario「交易內名額不足仍回 422 與既定訊息」) -- [ ] 2.3 [整合測試] `test_confirm_without_capacity_returns_422_with_rule_message`:provider 確認人數大於剩餘名額的 `pending` 預約,回 422,`assertExactJson(['status' => false, 'message' => '名額不足,無法確認此預約'])`,預約仍為 `pending`、`current_participants` 不變;同 2.1 以 `Exceptions::fake()` 斷言未回報(對應 Scenario「確認預約時名額不足仍回 422 與既定訊息」) -- [ ] 2.4 [整合測試] `test_database_error_on_create_returns_500_without_sql`:`config(['app.debug' => false])`、`Exceptions::fake()`,`Booking::created`(INSERT 已執行之後)拋出 SQL 含 `insert into "bookings"` 與欄位名稱的 `QueryException`;合法 `POST /api/member/bookings` 回 500,`assertExactJson(['message' => 'Server Error'])`,body 不含 `insert into`、`bookings`、`member_id`,`Exceptions::assertReported(QueryException::class)`,`bookings` 筆數為 0(INSERT 已發生,筆數為 0 才能證明 rollback)(對應 Scenario「建立預約時資料庫錯誤回 500 且不洩漏細節」) -- [ ] 2.5 [整合測試] `test_database_error_on_confirm_returns_500_without_sql`:同 2.4 的設定,`CourseSchedule::updating`(在交易內預約狀態已更新、`increment` 執行時觸發)拋出 SQL 含 `update "course_schedules"` 的 `QueryException`;provider 確認名額足夠的 `pending` 預約,回 500,`assertExactJson(['message' => 'Server Error'])`,body 不含 `update`、`course_schedules`、`current_participants`,`Exceptions::assertReported(QueryException::class)`,預約仍為 `pending`(狀態更新已執行,仍為 `pending` 才能證明 rollback)、`current_participants` 不變(對應 Scenario「確認預約時資料庫錯誤回 500 且不洩漏細節」) -- [ ] 2.6 [整合測試] 執行 `php artisan test --filter='BookingLifecycleTest|BookingOversellTest'`,確認既有 422 行為不變;完整 `php artisan test` 需在有 GD 的環境執行(見 `cfdive-test-gd` image 的做法) +- [x] 2.1 [整合測試] `test_duplicate_booking_returns_422_with_rule_message`:member 已有該時段 `pending` 預約,再次 `POST /api/member/bookings`,回 422,`assertExactJson(['status' => false, 'message' => '您已預約此時段'])`,該 member 在此時段的預約仍為 1 筆;測試開頭 `Exceptions::fake()`,最後 `Exceptions::assertNotReported(BookingRuleViolationException::class)`(對應 Scenario「重複預約仍回 422 與既定訊息」) +- [x] 2.2 [整合測試] `test_capacity_lost_inside_transaction_returns_422_with_rule_message`:以 `CourseSchedule::retrieved` 事件在交易內把 `current_participants` 設為滿額(事件只能改動交易內 `lockForUpdate()->find()` 那次讀取;交易前的第一次讀取若被改動,會被前置檢查以「人數超過剩餘名額」攔下,測不到交易內路徑),`POST /api/member/bookings` 回 422,`assertExactJson(['status' => false, 'message' => '名額不足,請重新選擇'])`,`bookings` 筆數為 0;同 2.1 以 `Exceptions::fake()` 斷言未回報(對應 Scenario「交易內名額不足仍回 422 與既定訊息」) +- [x] 2.3 [整合測試] `test_confirm_without_capacity_returns_422_with_rule_message`:provider 確認人數大於剩餘名額的 `pending` 預約,回 422,`assertExactJson(['status' => false, 'message' => '名額不足,無法確認此預約'])`,預約仍為 `pending`、`current_participants` 不變;同 2.1 以 `Exceptions::fake()` 斷言未回報(對應 Scenario「確認預約時名額不足仍回 422 與既定訊息」) +- [x] 2.4 [整合測試] `test_database_error_on_create_returns_500_without_sql`:`config(['app.debug' => false])`、`Exceptions::fake()`,`Booking::created`(INSERT 已執行之後)拋出 SQL 含 `insert into "bookings"` 與欄位名稱的 `QueryException`;合法 `POST /api/member/bookings` 回 500,`assertExactJson(['message' => 'Server Error'])`,body 不含 `insert into`、`bookings`、`member_id`,`Exceptions::assertReported(QueryException::class)`,`bookings` 筆數為 0(INSERT 已發生,筆數為 0 才能證明 rollback)(對應 Scenario「建立預約時資料庫錯誤回 500 且不洩漏細節」) +- [x] 2.5 [整合測試] `test_database_error_on_confirm_returns_500_without_sql`:同 2.4 的設定,`CourseSchedule::updating`(在交易內預約狀態已更新、`increment` 執行時觸發)拋出 SQL 含 `update "course_schedules"` 的 `QueryException`;provider 確認名額足夠的 `pending` 預約,回 500,`assertExactJson(['message' => 'Server Error'])`,body 不含 `update`、`course_schedules`、`current_participants`,`Exceptions::assertReported(QueryException::class)`,預約仍為 `pending`(狀態更新已執行,仍為 `pending` 才能證明 rollback)、`current_participants` 不變(對應 Scenario「確認預約時資料庫錯誤回 500 且不洩漏細節」) +- [x] 2.6 [整合測試] 執行 `php artisan test --filter='BookingLifecycleTest|BookingOversellTest'`,確認既有 422 行為不變;完整 `php artisan test` 需在有 GD 的環境執行(見 `cfdive-test-gd` image 的做法) ## 3. 手動驗證 -- [ ] 3.1 [手動驗證] 變異驗證(只在本機執行,不提交):暫時把 1.2、1.3 的 catch 改回 `\RuntimeException`,確認 2.4、2.5 失敗後再還原,證明測試抓得到原本的問題 +- [x] 3.1 [手動驗證] 變異驗證(只在本機執行,不提交):暫時把 1.2、1.3 的 catch 改回 `\RuntimeException`,確認 2.4、2.5 失敗後再還原,證明測試抓得到原本的問題 - [ ] 3.2 [手動驗證] 部署後在正式環境建立一筆預約、確認一筆預約,確認業務流程正常(201/200);Sentry 是否收到預約端點的 500 只能等實際錯誤發生,若期間沒有錯誤則記錄為「未觀察到」,不視為失敗(自動化驗證以 2.4、2.5 為準) diff --git a/tests/Feature/BookingExceptionHandlingTest.php b/tests/Feature/BookingExceptionHandlingTest.php new file mode 100644 index 0000000..99a99e1 --- /dev/null +++ b/tests/Feature/BookingExceptionHandlingTest.php @@ -0,0 +1,201 @@ +create(['role' => 'provider']); + + ProviderProfile::create([ + 'user_id' => $provider->id, + 'verification_status' => 'approved', + ]); + + $offer = DivingOffer::create([ + 'provider_id' => $provider->id, + 'title' => 'Exception Course', + 'location' => 'Test', + 'spot' => 'Test Spot', + 'price' => 1000, + 'region' => '南部', + 'rating' => 0, + 'reviews' => 0, + ]); + + $schedule = CourseSchedule::create([ + 'diving_offer_id' => $offer->id, + 'provider_id' => $provider->id, + 'scheduled_date' => now()->addDays(7)->toDateString(), + 'start_time' => '09:00', + 'max_participants' => $maxParticipants, + 'current_participants' => $currentParticipants, + 'status' => ScheduleStatus::Open, + ]); + + return [$provider, $schedule]; + } + + private function makePendingBooking(CourseSchedule $schedule, int $participants, ?User $member = null): Booking + { + return Booking::create([ + 'schedule_id' => $schedule->id, + 'member_id' => ($member ?? User::factory()->create(['role' => 'member']))->id, + 'participants' => $participants, + 'total_price' => 1000 * $participants, + 'status' => BookingStatus::Pending, + ]); + } + + // 刻意不用 "database is locked" 這類併發錯誤訊息:巢狀交易(RefreshDatabase)中 Laravel 會改拋 DeadlockException + private function makeQueryException(string $sql): QueryException + { + return new QueryException('sqlite', $sql, [], new \PDOException('SQLSTATE[23000]: Integrity constraint violation')); + } + + /** + * 業務失敗的訊息是前端直接顯示給使用者的文字,改型別不能改到它; + * 也不應回報到 Sentry,否則正常的業務拒絕會淹沒真正的伺服器故障。 + */ + public function test_duplicate_booking_returns_422_with_rule_message(): void + { + [, $schedule] = $this->makeProviderWithSchedule(5); + $member = User::factory()->create(['role' => 'member']); + $this->makePendingBooking($schedule, 1, $member); + + $this->actingAs($member) + ->postJson('/api/member/bookings', ['schedule_id' => $schedule->id, 'participants' => 1]) + ->assertStatus(422) + ->assertExactJson(['status' => false, 'message' => '您已預約此時段']); + + $this->assertSame(1, Booking::where('member_id', $member->id)->where('schedule_id', $schedule->id)->count()); + Exceptions::assertNotReported(BookingRuleViolationException::class); + } + + /** + * 交易內名額不足只在併發時發生:交易前的檢查通過後,另一筆預約搶先佔位。 + * 只改動第二次讀取(交易內 lockForUpdate),第一次讀取若被改動會被前置檢查攔下,測不到這條路徑。 + */ + public function test_capacity_lost_inside_transaction_returns_422_with_rule_message(): void + { + [, $schedule] = $this->makeProviderWithSchedule(2); + $member = User::factory()->create(['role' => 'member']); + + $retrievalCount = 0; + CourseSchedule::retrieved(function (CourseSchedule $retrieved) use (&$retrievalCount) { + $retrievalCount++; + if ($retrievalCount === 2) { + $retrieved->current_participants = $retrieved->max_participants; + } + }); + + $this->actingAs($member) + ->postJson('/api/member/bookings', ['schedule_id' => $schedule->id, 'participants' => 1]) + ->assertStatus(422) + ->assertExactJson(['status' => false, 'message' => '名額不足,請重新選擇']); + + $this->assertSame(0, Booking::count()); + Exceptions::assertNotReported(BookingRuleViolationException::class); + } + + /** + * 確認時的名額檢查是防超賣的最後一道防線,其 422 訊息與「不留下部分寫入」都必須維持。 + */ + public function test_confirm_without_capacity_returns_422_with_rule_message(): void + { + [$provider, $schedule] = $this->makeProviderWithSchedule(2, 1); + $booking = $this->makePendingBooking($schedule, 2); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$booking->id}/confirm") + ->assertStatus(422) + ->assertExactJson(['status' => false, 'message' => '名額不足,無法確認此預約']); + + $this->assertSame(BookingStatus::Pending, $booking->fresh()->status); + $this->assertSame(1, $schedule->fresh()->current_participants); + Exceptions::assertNotReported(BookingRuleViolationException::class); + } + + /** + * 資料庫錯誤曾以 422 把 SQL 回給使用者且不會送到 Sentry。 + * 例外在 INSERT 之後才拋出,筆數為 0 才能證明交易確實 rollback。 + */ + public function test_database_error_on_create_returns_500_without_sql(): void + { + config(['app.debug' => false]); + [, $schedule] = $this->makeProviderWithSchedule(5); + $member = User::factory()->create(['role' => 'member']); + + Booking::created(function () { + throw $this->makeQueryException('insert into "bookings" ("schedule_id", "member_id", "participants") values (?, ?, ?)'); + }); + + $this->actingAs($member) + ->postJson('/api/member/bookings', ['schedule_id' => $schedule->id, 'participants' => 1]) + ->assertStatus(500) + ->assertExactJson(['message' => 'Server Error']) + ->assertDontSee('insert into', false) + ->assertDontSee('bookings', false) + ->assertDontSee('member_id', false); + + Exceptions::assertReported(QueryException::class); + $this->assertSame(0, Booking::count()); + } + + /** + * 同上,針對確認預約。例外在預約狀態更新之後才拋出,預約仍為 pending 才能證明 rollback。 + */ + public function test_database_error_on_confirm_returns_500_without_sql(): void + { + config(['app.debug' => false]); + [$provider, $schedule] = $this->makeProviderWithSchedule(5); + $booking = $this->makePendingBooking($schedule, 2); + + CourseSchedule::updating(function () { + throw $this->makeQueryException('update "course_schedules" set "current_participants" = "current_participants" + 2 where "id" = ?'); + }); + + $this->actingAs($provider) + ->putJson("/api/provider/bookings/{$booking->id}/confirm") + ->assertStatus(500) + ->assertExactJson(['message' => 'Server Error']) + ->assertDontSee('update', false) + ->assertDontSee('course_schedules', false) + ->assertDontSee('current_participants', false); + + Exceptions::assertReported(QueryException::class); + $this->assertSame(BookingStatus::Pending, $booking->fresh()->status); + $this->assertSame(0, $schedule->fresh()->current_participants); + } +} -- 2.52.0 From 8042f0bafb1ca3545550ef26ab793100c1245813 Mon Sep 17 00:00:00 2001 From: Hank Date: Sun, 18 Oct 2026 01:35:38 +0800 Subject: [PATCH 2/2] =?UTF-8?q?style(booking):=20=E4=BB=A5=20pint=20?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E9=A0=90=E7=B4=84=20Controller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 純格式化,不含邏輯變更。 Co-Authored-By: Claude Opus 5.5 --- .../API/MemberBookingController.php | 55 ++++++++++--------- .../API/ProviderBookingController.php | 40 +++++++------- 2 files changed, 48 insertions(+), 47 deletions(-) diff --git a/app/Http/Controllers/API/MemberBookingController.php b/app/Http/Controllers/API/MemberBookingController.php index cefa048..8466780 100644 --- a/app/Http/Controllers/API/MemberBookingController.php +++ b/app/Http/Controllers/API/MemberBookingController.php @@ -8,8 +8,8 @@ use App\Exceptions\BookingRuleViolationException; use App\Http\Controllers\Controller; use App\Models\Booking; use App\Models\CourseSchedule; -use App\Notifications\BookingCreatedNotification; use App\Notifications\BookingCancelledNotification; +use App\Notifications\BookingCreatedNotification; use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -23,7 +23,7 @@ class MemberBookingController extends Controller ->where('member_id', $request->user()->id) ->orderByDesc('created_at') ->get() - ->map(fn($b) => $this->formatBooking($b)); + ->map(fn ($b) => $this->formatBooking($b)); return response()->json(['status' => true, 'data' => $bookings]); } @@ -41,9 +41,9 @@ class MemberBookingController extends Controller public function store(Request $request) { $data = $request->validate([ - 'schedule_id' => 'required|integer|exists:course_schedules,id', + 'schedule_id' => 'required|integer|exists:course_schedules,id', 'participants' => 'required|integer|min:1', - 'notes' => 'nullable|string|max:500', + 'notes' => 'nullable|string|max:500', ]); $schedule = CourseSchedule::with('divingOffer.provider.providerProfile')->findOrFail($data['schedule_id']); @@ -51,7 +51,7 @@ class MemberBookingController extends Controller // Layer 1:快速失敗 // 可見性繞過防護:課程教練未通過審核時不可建立新預約(既有預約不受影響,見 provider-verification 規格) $offer = $schedule->divingOffer; - if ($offer->provider_id !== null && !($offer->provider?->providerProfile?->is_verified)) { + if ($offer->provider_id !== null && ! ($offer->provider?->providerProfile?->is_verified)) { // is_verified 為 accessor(= verification_status === approved) return response()->json(['status' => false, 'message' => '此課程目前不開放預約'], 422); } @@ -82,12 +82,12 @@ class MemberBookingController extends Controller } return Booking::create([ - 'schedule_id' => $schedule->id, - 'member_id' => $memberId, + 'schedule_id' => $schedule->id, + 'member_id' => $memberId, 'participants' => $data['participants'], - 'total_price' => $schedule->divingOffer->price * $data['participants'], - 'status' => BookingStatus::Pending, - 'notes' => $data['notes'] ?? null, + 'total_price' => $schedule->divingOffer->price * $data['participants'], + 'status' => BookingStatus::Pending, + 'notes' => $data['notes'] ?? null, ]); }); } catch (BookingRuleViolationException $e) { @@ -99,13 +99,13 @@ class MemberBookingController extends Controller $provider = $booking->schedule->divingOffer->provider; $provider->notify(new BookingCreatedNotification($booking)); } catch (\Throwable $e) { - Log::error('BookingCreatedNotification failed: ' . $e->getMessage()); + Log::error('BookingCreatedNotification failed: '.$e->getMessage()); } return response()->json([ - 'status' => true, + 'status' => true, 'message' => '預約已送出,等待教練確認', - 'data' => $this->formatBooking($booking->fresh(['schedule.divingOffer'])), + 'data' => $this->formatBooking($booking->fresh(['schedule.divingOffer'])), ], 201); } @@ -117,13 +117,13 @@ class MemberBookingController extends Controller } $canCancelFrom = [BookingStatus::Pending, BookingStatus::Confirmed]; - if (!in_array($booking->status, $canCancelFrom)) { + 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); + $courseStart = Carbon::parse($schedule->scheduled_date->toDateString().' '.$schedule->start_time); if (now()->diffInHours($courseStart, false) < 24) { return response()->json(['status' => false, 'message' => '距課程開始不足 24 小時,無法取消,請聯繫教練'], 422); } @@ -149,7 +149,7 @@ class MemberBookingController extends Controller $provider = $booking->schedule->divingOffer->provider; $provider->notify(new BookingCancelledNotification($booking, cancelledBy: 'member')); } catch (\Throwable $e) { - Log::error('BookingCancelledNotification(member) failed: ' . $e->getMessage()); + Log::error('BookingCancelledNotification(member) failed: '.$e->getMessage()); } return response()->json(['status' => true, 'message' => '預約已取消']); @@ -158,20 +158,21 @@ class MemberBookingController extends Controller private function formatBooking(Booking $b): array { $offer = $b->schedule?->divingOffer; + return [ - 'id' => $b->id, - 'offer_id' => $offer?->id, - 'offer_title' => $offer?->title, + 'id' => $b->id, + 'offer_id' => $offer?->id, + 'offer_title' => $offer?->title, 'offer_location' => $offer?->location, - 'offer_region' => $offer?->region, - 'offer_price' => $offer?->price, + '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(), + '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(), ]; } } diff --git a/app/Http/Controllers/API/ProviderBookingController.php b/app/Http/Controllers/API/ProviderBookingController.php index 4384204..4d5afc8 100644 --- a/app/Http/Controllers/API/ProviderBookingController.php +++ b/app/Http/Controllers/API/ProviderBookingController.php @@ -21,10 +21,10 @@ class ProviderBookingController extends Controller { $provider = $request->user(); $bookings = Booking::with(['member', 'schedule.divingOffer']) - ->whereHas('schedule', fn($q) => $q->where('provider_id', $provider->id)) + ->whereHas('schedule', fn ($q) => $q->where('provider_id', $provider->id)) ->orderByDesc('created_at') ->get() - ->map(fn($b) => $this->formatBooking($b)); + ->map(fn ($b) => $this->formatBooking($b)); return response()->json(['status' => true, 'data' => $bookings]); } @@ -34,7 +34,7 @@ class ProviderBookingController extends Controller $booking = Booking::with('schedule')->findOrFail($id); $this->authorizeProvider($request, $booking); - if (!$booking->canTransitionTo(BookingStatus::Confirmed)) { + if (! $booking->canTransitionTo(BookingStatus::Confirmed)) { return response()->json(['status' => false, 'message' => '當前狀態無法確認'], 422); } @@ -63,7 +63,7 @@ class ProviderBookingController extends Controller $booking->load('member'); $booking->member->notify(new BookingConfirmedNotification($booking)); } catch (\Throwable $e) { - Log::error('BookingConfirmedNotification failed: ' . $e->getMessage()); + Log::error('BookingConfirmedNotification failed: '.$e->getMessage()); } return response()->json(['status' => true, 'message' => '預約已確認', 'data' => $this->formatBooking($booking->fresh(['member', 'schedule.divingOffer']))]); @@ -74,7 +74,7 @@ class ProviderBookingController extends Controller $booking = Booking::with('schedule')->findOrFail($id); $this->authorizeProvider($request, $booking); - if (!$booking->canTransitionTo(BookingStatus::Rejected)) { + if (! $booking->canTransitionTo(BookingStatus::Rejected)) { return response()->json(['status' => false, 'message' => '當前狀態無法拒絕'], 422); } @@ -84,7 +84,7 @@ class ProviderBookingController extends Controller $booking->load('member'); $booking->member->notify(new BookingRejectedNotification($booking)); } catch (\Throwable $e) { - Log::error('BookingRejectedNotification failed: ' . $e->getMessage()); + Log::error('BookingRejectedNotification failed: '.$e->getMessage()); } return response()->json(['status' => true, 'message' => '預約已拒絕']); @@ -95,7 +95,7 @@ class ProviderBookingController extends Controller $booking = Booking::with('schedule')->findOrFail($id); $this->authorizeProvider($request, $booking); - if (!$booking->canTransitionTo(BookingStatus::ProviderCancelled)) { + if (! $booking->canTransitionTo(BookingStatus::ProviderCancelled)) { return response()->json(['status' => false, 'message' => '當前狀態無法取消'], 422); } @@ -115,7 +115,7 @@ class ProviderBookingController extends Controller $booking->load('member'); $booking->member->notify(new BookingCancelledNotification($booking, cancelledBy: 'provider')); } catch (\Throwable $e) { - Log::error('BookingCancelledNotification(provider) failed: ' . $e->getMessage()); + Log::error('BookingCancelledNotification(provider) failed: '.$e->getMessage()); } return response()->json(['status' => true, 'message' => '預約已取消']); @@ -126,7 +126,7 @@ class ProviderBookingController extends Controller $booking = Booking::with('schedule')->findOrFail($id); $this->authorizeProvider($request, $booking); - if (!$booking->canTransitionTo(BookingStatus::Completed)) { + if (! $booking->canTransitionTo(BookingStatus::Completed)) { return response()->json(['status' => false, 'message' => '只有已確認的預約才能標記完成'], 422); } @@ -136,7 +136,7 @@ class ProviderBookingController extends Controller $booking->load('member'); $booking->member->notify(new BookingCompletedNotification($booking)); } catch (\Throwable $e) { - Log::error('BookingCompletedNotification failed: ' . $e->getMessage()); + Log::error('BookingCompletedNotification failed: '.$e->getMessage()); } return response()->json(['status' => true, 'message' => '預約已標記為完成']); @@ -152,17 +152,17 @@ class ProviderBookingController extends Controller private function formatBooking(Booking $b): array { return [ - 'id' => $b->id, - 'member_name' => $b->member?->name, - 'member_email' => $b->member?->email, - 'offer_title' => $b->schedule?->divingOffer?->title, + 'id' => $b->id, + 'member_name' => $b->member?->name, + 'member_email' => $b->member?->email, + 'offer_title' => $b->schedule?->divingOffer?->title, '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(), + '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(), ]; } } -- 2.52.0