diff --git a/Dockerfile b/Dockerfile index 4854636..1cd5bc4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,8 @@ RUN apt-get update && apt-get install -y \ libzip-dev \ default-mysql-client \ netcat-openbsd \ - grep + grep \ + cron # 清理 apt 快取以減小鏡像大小 RUN apt-get clean && rm -rf /var/lib/apt/lists/* @@ -48,6 +49,12 @@ COPY docker/php/local.ini /usr/local/etc/php/conf.d/local.ini COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh +# 加入 Laravel Scheduler cron job +RUN echo "* * * * * www-data php /var/www/artisan schedule:run >> /var/log/laravel-scheduler.log 2>&1" \ + > /etc/cron.d/laravel-scheduler \ + && chmod 0644 /etc/cron.d/laravel-scheduler \ + && crontab /etc/cron.d/laravel-scheduler + # 設置容器啟動時執行的入口點 # 這將在 CMD 指令之前執行 ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/app/Console/Commands/CompleteFinishedBookings.php b/app/Console/Commands/CompleteFinishedBookings.php new file mode 100644 index 0000000..3ae414b --- /dev/null +++ b/app/Console/Commands/CompleteFinishedBookings.php @@ -0,0 +1,24 @@ +value) + ->whereHas('schedule', fn($q) => $q->whereDate('scheduled_date', '<', now()->toDateString())) + ->update(['status' => BookingStatus::Completed->value]); + + Log::info("CompleteFinishedBookings: {$count} completed"); + $this->info("CompleteFinishedBookings: {$count} bookings completed."); + } +} diff --git a/app/Console/Commands/ExpirePendingBookings.php b/app/Console/Commands/ExpirePendingBookings.php new file mode 100644 index 0000000..649a564 --- /dev/null +++ b/app/Console/Commands/ExpirePendingBookings.php @@ -0,0 +1,24 @@ +value) + ->where('created_at', '<=', now()->subHours(48)) + ->update(['status' => BookingStatus::Expired->value]); + + Log::info("ExpirePendingBookings: {$count} expired"); + $this->info("ExpirePendingBookings: {$count} bookings expired."); + } +} diff --git a/app/Enums/BookingStatus.php b/app/Enums/BookingStatus.php new file mode 100644 index 0000000..5bf4ebd --- /dev/null +++ b/app/Enums/BookingStatus.php @@ -0,0 +1,14 @@ +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(), + ]; + } +} diff --git a/app/Http/Controllers/API/ProviderBookingController.php b/app/Http/Controllers/API/ProviderBookingController.php new file mode 100644 index 0000000..1adef81 --- /dev/null +++ b/app/Http/Controllers/API/ProviderBookingController.php @@ -0,0 +1,120 @@ +user(); + $bookings = Booking::with(['member', 'schedule.divingOffer']) + ->whereHas('schedule', fn($q) => $q->where('provider_id', $provider->id)) + ->orderByDesc('created_at') + ->get() + ->map(fn($b) => $this->formatBooking($b)); + + return response()->json(['status' => true, 'data' => $bookings]); + } + + public function confirm(Request $request, int $id) + { + $booking = Booking::with('schedule')->findOrFail($id); + $this->authorizeProvider($request, $booking); + + if (!$booking->canTransitionTo(BookingStatus::Confirmed)) { + return response()->json(['status' => false, 'message' => '當前狀態無法確認'], 422); + } + + try { + DB::transaction(function () use ($booking) { + $schedule = $booking->schedule()->lockForUpdate()->first(); + $remaining = $schedule->max_participants - $schedule->current_participants; + + if ($booking->participants > $remaining) { + throw new \RuntimeException('名額不足,無法確認此預約'); + } + + $booking->update(['status' => BookingStatus::Confirmed]); + $schedule->increment('current_participants', $booking->participants); + $schedule->refresh(); + + if ($schedule->current_participants >= $schedule->max_participants) { + $schedule->update(['status' => ScheduleStatus::Full]); + } + }); + } catch (\RuntimeException $e) { + return response()->json(['status' => false, 'message' => $e->getMessage()], 422); + } + + return response()->json(['status' => true, 'message' => '預約已確認', 'data' => $this->formatBooking($booking->fresh(['member', 'schedule.divingOffer']))]); + } + + public function reject(Request $request, int $id) + { + $booking = Booking::with('schedule')->findOrFail($id); + $this->authorizeProvider($request, $booking); + + if (!$booking->canTransitionTo(BookingStatus::Rejected)) { + return response()->json(['status' => false, 'message' => '當前狀態無法拒絕'], 422); + } + + $booking->update(['status' => BookingStatus::Rejected]); + + return response()->json(['status' => true, 'message' => '預約已拒絕']); + } + + public function cancel(Request $request, int $id) + { + $booking = Booking::with('schedule')->findOrFail($id); + $this->authorizeProvider($request, $booking); + + if (!$booking->canTransitionTo(BookingStatus::ProviderCancelled)) { + return response()->json(['status' => false, 'message' => '當前狀態無法取消'], 422); + } + + DB::transaction(function () use ($booking) { + $schedule = $booking->schedule()->lockForUpdate()->first(); + $booking->update(['status' => BookingStatus::ProviderCancelled]); + $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 authorizeProvider(Request $request, Booking $booking): void + { + if ($booking->schedule->provider_id !== $request->user()->id) { + abort(403, '無權操作此預約'); + } + } + + 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, + '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(), + ]; + } +} diff --git a/app/Http/Controllers/API/ScheduleController.php b/app/Http/Controllers/API/ScheduleController.php new file mode 100644 index 0000000..c6142ea --- /dev/null +++ b/app/Http/Controllers/API/ScheduleController.php @@ -0,0 +1,131 @@ +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, + ]; + } +} diff --git a/app/Models/Booking.php b/app/Models/Booking.php new file mode 100644 index 0000000..f6644ed --- /dev/null +++ b/app/Models/Booking.php @@ -0,0 +1,51 @@ + 'integer', + 'total_price' => 'integer', + 'status' => BookingStatus::class, + ]; + + const VALID_TRANSITIONS = [ + 'pending' => ['confirmed', 'rejected', 'expired', 'member_cancelled'], + 'confirmed' => ['completed', 'member_cancelled', 'provider_cancelled'], + 'completed' => [], + 'rejected' => [], + 'expired' => [], + 'member_cancelled' => [], + 'provider_cancelled' => [], + ]; + + public function canTransitionTo(BookingStatus $newStatus): bool + { + $current = $this->status->value; + $allowed = self::VALID_TRANSITIONS[$current] ?? []; + return in_array($newStatus->value, $allowed); + } + + public function schedule() + { + return $this->belongsTo(CourseSchedule::class, 'schedule_id'); + } + + public function member() + { + return $this->belongsTo(User::class, 'member_id'); + } +} diff --git a/app/Models/CourseSchedule.php b/app/Models/CourseSchedule.php new file mode 100644 index 0000000..2d39181 --- /dev/null +++ b/app/Models/CourseSchedule.php @@ -0,0 +1,54 @@ + 'date', + 'max_participants' => 'integer', + 'current_participants' => 'integer', + 'status' => ScheduleStatus::class, + ]; + + public function divingOffer() + { + return $this->belongsTo(DivingOffer::class); + } + + public function provider() + { + return $this->belongsTo(User::class, 'provider_id'); + } + + public function bookings() + { + return $this->hasMany(Booking::class, 'schedule_id'); + } + + public function remainingSpots(): int + { + return max(0, $this->max_participants - $this->current_participants); + } + + protected function startTime(): Attribute + { + return Attribute::make( + get: fn($value) => $value ? substr($value, 0, 5) : $value, + ); + } +} diff --git a/app/Models/DivingOffer.php b/app/Models/DivingOffer.php index d5fbe6d..e10047f 100644 --- a/app/Models/DivingOffer.php +++ b/app/Models/DivingOffer.php @@ -30,4 +30,9 @@ class DivingOffer extends Model 'price' => 'integer', 'reviews'=> 'integer', ]; + + public function schedules() + { + return $this->hasMany(CourseSchedule::class, 'diving_offer_id'); + } } diff --git a/database/migrations/2026_05_11_144005_create_course_schedules_table.php b/database/migrations/2026_05_11_144005_create_course_schedules_table.php new file mode 100644 index 0000000..92e5e6c --- /dev/null +++ b/database/migrations/2026_05_11_144005_create_course_schedules_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete(); + $table->foreignId('provider_id')->constrained('users')->cascadeOnDelete(); + $table->date('scheduled_date'); + $table->time('start_time'); + $table->unsignedInteger('max_participants'); + $table->unsignedInteger('current_participants')->default(0); + $table->string('status')->default('open'); + $table->timestamps(); + + $table->index(['diving_offer_id', 'status', 'scheduled_date'], 'idx_offer_status_date'); + $table->index('provider_id', 'idx_provider_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('course_schedules'); + } +}; diff --git a/database/migrations/2026_05_11_144028_create_bookings_table.php b/database/migrations/2026_05_11_144028_create_bookings_table.php new file mode 100644 index 0000000..48e5495 --- /dev/null +++ b/database/migrations/2026_05_11_144028_create_bookings_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('schedule_id')->constrained('course_schedules')->cascadeOnDelete(); + $table->foreignId('member_id')->constrained('users')->cascadeOnDelete(); + $table->unsignedInteger('participants')->default(1); + $table->unsignedInteger('total_price'); + $table->string('status')->default('pending'); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['member_id', 'status'], 'idx_member_status'); + $table->index(['schedule_id', 'status'], 'idx_schedule_status'); + $table->index(['status', 'created_at'], 'idx_status_created_at'); + $table->index(['status', 'schedule_id'], 'idx_status_sched'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bookings'); + } +}; diff --git a/docker/php/docker-entrypoint.sh b/docker/php/docker-entrypoint.sh index 26d2eac..e2ffe9e 100644 --- a/docker/php/docker-entrypoint.sh +++ b/docker/php/docker-entrypoint.sh @@ -110,5 +110,9 @@ fi echo "✅ CFDivePlatform 初始化完成!" +# 啟動 cron daemon(Laravel Scheduler) +echo "⏰ 啟動 Laravel Scheduler cron..." +service cron start || cron || true + # 執行傳入的命令 exec "$@" diff --git a/frontend/src/api/bookingApi.js b/frontend/src/api/bookingApi.js new file mode 100644 index 0000000..bdd29e7 --- /dev/null +++ b/frontend/src/api/bookingApi.js @@ -0,0 +1,17 @@ +import api from './axios' + +export function getMyBookings() { + return api.get('/member/bookings') +} + +export function getBooking(id) { + return api.get(`/member/bookings/${id}`) +} + +export function createBooking(payload) { + return api.post('/member/bookings', payload) +} + +export function cancelBooking(id) { + return api.delete(`/member/bookings/${id}`) +} diff --git a/frontend/src/api/coachBookingApi.js b/frontend/src/api/coachBookingApi.js new file mode 100644 index 0000000..39a2ab4 --- /dev/null +++ b/frontend/src/api/coachBookingApi.js @@ -0,0 +1,17 @@ +import coachApi from './coachAxios' + +export function getProviderBookings() { + return coachApi.get('/provider/bookings') +} + +export function confirmBooking(id) { + return coachApi.put(`/provider/bookings/${id}/confirm`) +} + +export function rejectBooking(id) { + return coachApi.put(`/provider/bookings/${id}/reject`) +} + +export function cancelBooking(id) { + return coachApi.put(`/provider/bookings/${id}/cancel`) +} diff --git a/frontend/src/api/coachScheduleApi.js b/frontend/src/api/coachScheduleApi.js new file mode 100644 index 0000000..202d96d --- /dev/null +++ b/frontend/src/api/coachScheduleApi.js @@ -0,0 +1,17 @@ +import coachApi from './coachAxios' + +export function getSchedules() { + return coachApi.get('/provider/schedules') +} + +export function createSchedule(payload) { + return coachApi.post('/provider/schedules', payload) +} + +export function updateSchedule(id, payload) { + return coachApi.put(`/provider/schedules/${id}`, payload) +} + +export function deleteSchedule(id) { + return coachApi.delete(`/provider/schedules/${id}`) +} diff --git a/frontend/src/api/scheduleApi.js b/frontend/src/api/scheduleApi.js new file mode 100644 index 0000000..d4155d9 --- /dev/null +++ b/frontend/src/api/scheduleApi.js @@ -0,0 +1,10 @@ +import axios from 'axios' + +const publicApi = axios.create({ + baseURL: import.meta.env.VITE_API_URL + '/api', + headers: { Accept: 'application/json' }, +}) + +export function getSchedulesByOffer(offerId) { + return publicApi.get(`/diving-offers/${offerId}/schedules`) +} diff --git a/frontend/src/components/CoachNavBar.vue b/frontend/src/components/CoachNavBar.vue index 6dc72c6..1b866a4 100644 --- a/frontend/src/components/CoachNavBar.vue +++ b/frontend/src/components/CoachNavBar.vue @@ -18,8 +18,10 @@ async function handleLogout() { 🤿 Coach Portal - 我的課程 - 個人資料 + 我的課程 + 時段管理 + 預約管理 + 個人資料
diff --git a/frontend/src/components/NavBar.vue b/frontend/src/components/NavBar.vue index 1cdfab0..4287af1 100644 --- a/frontend/src/components/NavBar.vue +++ b/frontend/src/components/NavBar.vue @@ -25,6 +25,7 @@ async function handleLogout() { + 我的預約 個人資料
@@ -80,7 +81,9 @@ async function submit() { type="password" required class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-ocean-400" + :class="confirm && confirm !== password ? 'border-red-400' : ''" /> +

密碼不一致