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>
This commit is contained in:
+8
-1
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Models\Booking;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CompleteFinishedBookings extends Command
|
||||
{
|
||||
protected $signature = 'app:complete-finished-bookings';
|
||||
protected $description = '將課程日期已過的 confirmed 預約標記為 completed';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$count = Booking::where('status', BookingStatus::Confirmed->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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Models\Booking;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ExpirePendingBookings extends Command
|
||||
{
|
||||
protected $signature = 'app:expire-pending-bookings';
|
||||
protected $description = '將超過 48 小時未確認的 pending 預約標記為 expired';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$count = Booking::where('status', BookingStatus::Pending->value)
|
||||
->where('created_at', '<=', now()->subHours(48))
|
||||
->update(['status' => BookingStatus::Expired->value]);
|
||||
|
||||
Log::info("ExpirePendingBookings: {$count} expired");
|
||||
$this->info("ExpirePendingBookings: {$count} bookings expired.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum BookingStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Confirmed = 'confirmed';
|
||||
case Completed = 'completed';
|
||||
case Rejected = 'rejected';
|
||||
case Expired = 'expired';
|
||||
case MemberCancelled = 'member_cancelled';
|
||||
case ProviderCancelled = 'provider_cancelled';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ScheduleStatus: string
|
||||
{
|
||||
case Open = 'open';
|
||||
case Full = 'full';
|
||||
case Cancelled = 'cancelled';
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Enums\ScheduleStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Booking;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProviderBookingController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$provider = $request->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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Booking extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'schedule_id',
|
||||
'member_id',
|
||||
'participants',
|
||||
'total_price',
|
||||
'status',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'participants' => '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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ScheduleStatus;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CourseSchedule extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'diving_offer_id',
|
||||
'provider_id',
|
||||
'scheduled_date',
|
||||
'start_time',
|
||||
'max_participants',
|
||||
'current_participants',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'scheduled_date' => '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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,4 +30,9 @@ class DivingOffer extends Model
|
||||
'price' => 'integer',
|
||||
'reviews'=> 'integer',
|
||||
];
|
||||
|
||||
public function schedules()
|
||||
{
|
||||
return $this->hasMany(CourseSchedule::class, 'diving_offer_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('course_schedules', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('bookings', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -110,5 +110,9 @@ fi
|
||||
|
||||
echo "✅ CFDivePlatform 初始化完成!"
|
||||
|
||||
# 啟動 cron daemon(Laravel Scheduler)
|
||||
echo "⏰ 啟動 Laravel Scheduler cron..."
|
||||
service cron start || cron || true
|
||||
|
||||
# 執行傳入的命令
|
||||
exec "$@"
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -18,8 +18,10 @@ async function handleLogout() {
|
||||
<RouterLink to="/coach/dashboard" class="text-lg font-bold tracking-wide hover:text-gray-300 transition">
|
||||
🤿 Coach Portal
|
||||
</RouterLink>
|
||||
<RouterLink to="/coach/dashboard" class="text-sm hover:text-gray-300 transition">我的課程</RouterLink>
|
||||
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
|
||||
<RouterLink to="/coach/dashboard" class="text-sm hover:text-gray-300 transition">我的課程</RouterLink>
|
||||
<RouterLink to="/coach/schedules" class="text-sm hover:text-gray-300 transition">時段管理</RouterLink>
|
||||
<RouterLink to="/coach/bookings" class="text-sm hover:text-gray-300 transition">預約管理</RouterLink>
|
||||
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
|
||||
@@ -25,6 +25,7 @@ async function handleLogout() {
|
||||
<span class="text-ocean-200 hidden sm:inline">
|
||||
👤 {{ auth.user?.name }}
|
||||
</span>
|
||||
<RouterLink to="/my-bookings" class="hover:text-ocean-100 transition">我的預約</RouterLink>
|
||||
<RouterLink to="/profile" class="hover:text-ocean-100 transition">個人資料</RouterLink>
|
||||
<button
|
||||
@click="handleLogout"
|
||||
|
||||
@@ -11,7 +11,8 @@ const routes = [
|
||||
{ path: '/login', component: () => import('../views/LoginView.vue') },
|
||||
{ path: '/register', component: () => import('../views/RegisterView.vue') },
|
||||
{ path: '/auth/callback', component: () => import('../views/AuthCallbackView.vue') },
|
||||
{ path: '/profile', component: () => import('../views/ProfileView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: '/profile', component: () => import('../views/ProfileView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: '/my-bookings', component: () => import('../views/MyBookingsView.vue'), meta: { requiresAuth: true } },
|
||||
|
||||
// Coach (public)
|
||||
{ path: '/coach/login', component: () => import('../views/coach/LoginView.vue') },
|
||||
@@ -26,6 +27,8 @@ const routes = [
|
||||
{ path: 'offers/new', component: () => import('../views/coach/OfferFormView.vue') },
|
||||
{ path: 'offers/:id/edit', component: () => import('../views/coach/OfferFormView.vue') },
|
||||
{ path: 'profile', component: () => import('../views/coach/ProfileView.vue') },
|
||||
{ path: 'schedules', component: () => import('../views/coach/ScheduleManagerView.vue') },
|
||||
{ path: 'bookings', component: () => import('../views/coach/BookingManagerView.vue') },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -2,24 +2,47 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '../api/axios'
|
||||
import { getSchedulesByOffer } from '../api/scheduleApi'
|
||||
import { createBooking } from '../api/bookingApi'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const offer = ref(null)
|
||||
const loading = ref(true)
|
||||
const notFound = ref(false)
|
||||
const offer = ref(null)
|
||||
const loading = ref(true)
|
||||
const notFound = ref(false)
|
||||
const schedules = ref([])
|
||||
const selected = ref(null)
|
||||
const participants = ref(1)
|
||||
const booking = ref({ loading: false, success: false, error: '' })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.get(`/diving-offers/${route.params.id}`)
|
||||
offer.value = res.data.data
|
||||
const sRes = await getSchedulesByOffer(route.params.id)
|
||||
schedules.value = sRes.data.data
|
||||
} catch (e) {
|
||||
notFound.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function submitBooking() {
|
||||
if (!selected.value) return
|
||||
booking.value = { loading: true, success: false, error: '' }
|
||||
try {
|
||||
await createBooking({ schedule_id: selected.value.id, participants: participants.value })
|
||||
booking.value.success = true
|
||||
} catch (e) {
|
||||
booking.value.error = e.response?.data?.message || '預約失敗,請稍後再試'
|
||||
} finally {
|
||||
booking.value.loading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -65,14 +88,71 @@ onMounted(async () => {
|
||||
<p class="text-gray-700 leading-relaxed whitespace-pre-wrap">{{ offer.description || '暫無課程說明。' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between bg-ocean-50 rounded-2xl p-6">
|
||||
<div class="flex items-center justify-between bg-ocean-50 rounded-2xl p-6 mb-6">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">課程費用</p>
|
||||
<p class="text-3xl font-bold text-ocean-800">NT$ {{ offer.price.toLocaleString() }}</p>
|
||||
</div>
|
||||
<button class="bg-ocean-700 hover:bg-ocean-600 text-white font-semibold px-8 py-3 rounded-full transition">
|
||||
立即洽詢
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 可用時段 -->
|
||||
<div class="bg-white rounded-2xl shadow p-6 mb-6">
|
||||
<div class="flex items-start gap-2 mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-800">可預約時段</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-4 text-sm text-amber-700">
|
||||
<span>⚠️</span>
|
||||
<span>送出預約後需等待教練確認,確認後才算預約成功。</span>
|
||||
</div>
|
||||
|
||||
<div v-if="schedules.length === 0" class="text-gray-400 text-sm">目前沒有開放時段</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<label
|
||||
v-for="s in schedules"
|
||||
:key="s.id"
|
||||
class="flex items-center justify-between border rounded-xl px-4 py-3 cursor-pointer transition"
|
||||
:class="selected?.id === s.id ? 'border-ocean-600 bg-ocean-50' : 'border-gray-200 hover:border-ocean-400'"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="radio" :value="s" v-model="selected" class="accent-ocean-600" />
|
||||
<div>
|
||||
<p class="font-medium text-gray-800">{{ s.scheduled_date }} {{ s.start_time }}</p>
|
||||
<p class="text-sm text-gray-500">剩餘名額:{{ s.remaining_spots }} 人</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-ocean-700 font-semibold">NT$ {{ (offer.price * participants).toLocaleString() }}</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 人數選擇與預約按鈕 -->
|
||||
<div v-if="selected" class="mt-5 border-t pt-4">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<label class="text-sm text-gray-600">預約人數</label>
|
||||
<input
|
||||
v-model.number="participants"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="selected.remaining_spots"
|
||||
class="border rounded-lg px-3 py-1 w-20 text-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="booking.success" class="text-green-600 text-sm mb-3">✓ 預約已送出!請等待教練確認。前往 <RouterLink to="/my-bookings" class="underline">我的預約</RouterLink> 查看。</div>
|
||||
<div v-if="booking.error" class="text-red-500 text-sm mb-3">{{ booking.error }}</div>
|
||||
|
||||
<div v-if="!auth.isLoggedIn" class="text-sm text-gray-500">
|
||||
請先 <RouterLink to="/login" class="text-ocean-600 underline">登入</RouterLink> 才能預約
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
@click="submitBooking"
|
||||
:disabled="booking.loading || booking.success"
|
||||
class="w-full bg-ocean-700 hover:bg-ocean-600 disabled:opacity-50 text-white font-semibold py-3 rounded-full transition"
|
||||
>
|
||||
{{ booking.loading ? '送出中...' : booking.success ? '已送出預約' : '立即預約' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getMyBookings, cancelBooking } from '../api/bookingApi'
|
||||
|
||||
const bookings = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const expanded = ref(new Set())
|
||||
|
||||
const STATUS_LABEL = {
|
||||
pending: { text: '待教練確認', color: 'bg-yellow-100 text-yellow-700', hint: '等待教練確認中,確認後才完成預約' },
|
||||
confirmed: { text: '預約成功', color: 'bg-green-100 text-green-700', hint: '教練已確認,請準時出席' },
|
||||
completed: { text: '已完成', color: 'bg-gray-100 text-gray-600', hint: '' },
|
||||
rejected: { text: '已拒絕', color: 'bg-red-100 text-red-600', hint: '教練無法接受此預約' },
|
||||
expired: { text: '已過期', color: 'bg-gray-100 text-gray-400', hint: '超過 48 小時未獲確認,預約自動取消' },
|
||||
member_cancelled: { text: '已取消', color: 'bg-gray-100 text-gray-500', hint: '' },
|
||||
provider_cancelled: { text: '教練取消', color: 'bg-orange-100 text-orange-600', hint: '教練因故取消此預約' },
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await getMyBookings()
|
||||
bookings.value = res.data.data
|
||||
} catch {
|
||||
error.value = '無法載入預約記錄'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function toggle(id) {
|
||||
if (expanded.value.has(id)) expanded.value.delete(id)
|
||||
else expanded.value.add(id)
|
||||
}
|
||||
|
||||
async function doCancel(booking) {
|
||||
if (!confirm('確定要取消此預約?')) return
|
||||
try {
|
||||
await cancelBooking(booking.id)
|
||||
booking.status = 'member_cancelled'
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '取消失敗')
|
||||
}
|
||||
}
|
||||
|
||||
function canCancel(status) {
|
||||
return status === 'pending' || status === 'confirmed'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}/${String(d.getMonth()+1).padStart(2,'0')}/${String(d.getDate()).padStart(2,'0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="max-w-3xl mx-auto px-4 py-10">
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-6">我的預約</h1>
|
||||
|
||||
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
|
||||
<div v-else-if="error" class="text-center text-red-500 py-10">{{ error }}</div>
|
||||
<div v-else-if="bookings.length === 0" class="text-center text-gray-400 py-20">
|
||||
目前沒有預約記錄。<RouterLink to="/courses" class="text-ocean-600 underline">瀏覽課程</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="b in bookings"
|
||||
:key="b.id"
|
||||
class="bg-white rounded-2xl shadow border border-gray-100 overflow-hidden"
|
||||
>
|
||||
<!-- 摘要列(點擊展開) -->
|
||||
<button
|
||||
class="w-full text-left px-5 py-4 flex items-center justify-between gap-4 hover:bg-gray-50 transition"
|
||||
@click="toggle(b.id)"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-gray-800 truncate">{{ b.offer_title }}</p>
|
||||
<p class="text-sm text-gray-500 mt-0.5">
|
||||
{{ b.scheduled_date }} {{ b.start_time }}
|
||||
・{{ b.participants }} 人
|
||||
・NT$ {{ b.total_price?.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
|
||||
{{ STATUS_LABEL[b.status]?.text || b.status }}
|
||||
</span>
|
||||
<span class="text-gray-400 text-sm">{{ expanded.has(b.id) ? '▲' : '▼' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- 展開詳情 -->
|
||||
<div v-if="expanded.has(b.id)" class="border-t border-gray-100 px-5 py-4 space-y-4 bg-gray-50">
|
||||
|
||||
<!-- 狀態說明 -->
|
||||
<div v-if="STATUS_LABEL[b.status]?.hint"
|
||||
class="flex items-center gap-2 text-sm rounded-lg px-3 py-2"
|
||||
:class="b.status === 'pending' ? 'bg-yellow-50 text-yellow-700' : b.status === 'confirmed' ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'"
|
||||
>
|
||||
<span>{{ b.status === 'pending' ? '⏳' : b.status === 'confirmed' ? '✅' : 'ℹ️' }}</span>
|
||||
<span>{{ STATUS_LABEL[b.status].hint }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 課程與時段資訊 -->
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
<div>
|
||||
<p class="text-gray-400 text-xs mb-0.5">課程名稱</p>
|
||||
<p class="text-gray-700 font-medium">{{ b.offer_title }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-xs mb-0.5">地點</p>
|
||||
<p class="text-gray-700">{{ b.offer_location || '—' }}
|
||||
<span v-if="b.offer_region" class="text-gray-400">・{{ b.offer_region }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-xs mb-0.5">上課日期</p>
|
||||
<p class="text-gray-700">{{ b.scheduled_date }} {{ b.start_time }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-xs mb-0.5">預約人數</p>
|
||||
<p class="text-gray-700">{{ b.participants }} 人</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-xs mb-0.5">課程單價</p>
|
||||
<p class="text-gray-700">NT$ {{ b.offer_price?.toLocaleString() }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-xs mb-0.5">總金額</p>
|
||||
<p class="text-gray-800 font-semibold">NT$ {{ b.total_price?.toLocaleString() }}</p>
|
||||
</div>
|
||||
<div v-if="b.notes" class="col-span-2">
|
||||
<p class="text-gray-400 text-xs mb-0.5">備注</p>
|
||||
<p class="text-gray-600">{{ b.notes }}</p>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<p class="text-gray-400 text-xs mb-0.5">預約時間</p>
|
||||
<p class="text-gray-500 text-xs">{{ b.created_at ? new Date(b.created_at).toLocaleString('zh-TW') : '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按鈕列 -->
|
||||
<div class="flex items-center justify-between pt-1">
|
||||
<RouterLink
|
||||
v-if="b.offer_id"
|
||||
:to="`/courses/${b.offer_id}`"
|
||||
class="text-sm text-ocean-600 hover:text-ocean-800 hover:underline"
|
||||
>
|
||||
查看課程介紹 →
|
||||
</RouterLink>
|
||||
<span v-else></span>
|
||||
<button
|
||||
v-if="canCancel(b.status)"
|
||||
@click="doCancel(b)"
|
||||
class="text-sm text-red-500 hover:text-red-700 underline"
|
||||
>
|
||||
取消預約
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -72,6 +72,7 @@ async function submit() {
|
||||
minlength="8"
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-ocean-400"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">至少 8 個字元</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 mb-1">確認密碼</label>
|
||||
@@ -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' : ''"
|
||||
/>
|
||||
<p v-if="confirm && confirm !== password" class="text-xs text-red-500 mt-1">密碼不一致</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { getProviderBookings, confirmBooking, rejectBooking, cancelBooking } from '../../api/coachBookingApi'
|
||||
|
||||
const bookings = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const STATUS_LABEL = {
|
||||
pending: { text: '待確認', color: 'bg-yellow-100 text-yellow-700' },
|
||||
confirmed: { text: '已確認', color: 'bg-green-100 text-green-700' },
|
||||
completed: { text: '已完成', color: 'bg-gray-100 text-gray-600' },
|
||||
rejected: { text: '已拒絕', color: 'bg-red-100 text-red-600' },
|
||||
expired: { text: '已過期', color: 'bg-gray-100 text-gray-400' },
|
||||
member_cancelled: { text: '學員取消', color: 'bg-gray-100 text-gray-500' },
|
||||
provider_cancelled: { text: '教練取消', color: 'bg-orange-100 text-orange-600' },
|
||||
}
|
||||
|
||||
// 依課程名稱分組,同課程再依時段日期排序
|
||||
const groupedByOffer = computed(() => {
|
||||
const map = {}
|
||||
for (const b of bookings.value) {
|
||||
const key = b.offer_title || '未知課程'
|
||||
if (!map[key]) map[key] = []
|
||||
map[key].push(b)
|
||||
}
|
||||
// 每組內依日期排序
|
||||
for (const key of Object.keys(map)) {
|
||||
map[key].sort((a, b) => (a.scheduled_date + a.start_time).localeCompare(b.scheduled_date + b.start_time))
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const pendingCount = computed(() => bookings.value.filter(b => b.status === 'pending').length)
|
||||
|
||||
onMounted(fetchBookings)
|
||||
|
||||
async function fetchBookings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getProviderBookings()
|
||||
bookings.value = res.data.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doAction(booking, action) {
|
||||
const labels = { confirm: '確認', reject: '拒絕', cancel: '取消' }
|
||||
if (!confirm(`確定要${labels[action]}此預約?`)) return
|
||||
try {
|
||||
if (action === 'confirm') await confirmBooking(booking.id)
|
||||
if (action === 'reject') await rejectBooking(booking.id)
|
||||
if (action === 'cancel') await cancelBooking(booking.id)
|
||||
await fetchBookings()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '操作失敗')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">預約管理</h1>
|
||||
<span v-if="pendingCount > 0"
|
||||
class="bg-yellow-100 text-yellow-700 text-sm font-medium px-3 py-1 rounded-full">
|
||||
{{ pendingCount }} 筆待確認
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
|
||||
<div v-else-if="bookings.length === 0" class="text-center text-gray-400 py-20">目前沒有任何預約</div>
|
||||
|
||||
<div v-else class="space-y-8">
|
||||
<!-- 依課程分組 -->
|
||||
<div v-for="(group, offerTitle) in groupedByOffer" :key="offerTitle">
|
||||
|
||||
<!-- 課程標題列 -->
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="h-px flex-1 bg-gray-200"></div>
|
||||
<h2 class="text-sm font-semibold text-gray-500 whitespace-nowrap px-1">🤿 {{ offerTitle }}</h2>
|
||||
<div class="h-px flex-1 bg-gray-200"></div>
|
||||
</div>
|
||||
|
||||
<!-- 同課程的預約列表 -->
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="b in group"
|
||||
:key="b.id"
|
||||
class="bg-white rounded-xl border px-5 py-4 flex items-start justify-between flex-wrap gap-3"
|
||||
:class="b.status === 'pending' ? 'border-yellow-200 shadow-sm' : 'border-gray-100'"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-700">
|
||||
{{ b.scheduled_date }} {{ b.start_time }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-0.5">
|
||||
{{ b.member_name }}
|
||||
<span class="text-gray-400">({{ b.member_email }})</span>
|
||||
・{{ b.participants }} 人・NT$ {{ b.total_price?.toLocaleString() }}
|
||||
</p>
|
||||
<p v-if="b.notes" class="text-xs text-gray-400 mt-1">備注:{{ b.notes }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end gap-2 shrink-0">
|
||||
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
|
||||
{{ STATUS_LABEL[b.status]?.text || b.status }}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button v-if="b.status === 'pending'" @click="doAction(b, 'confirm')"
|
||||
class="text-xs bg-green-600 hover:bg-green-500 text-white px-3 py-1 rounded-full transition">
|
||||
確認
|
||||
</button>
|
||||
<button v-if="b.status === 'pending'" @click="doAction(b, 'reject')"
|
||||
class="text-xs bg-red-500 hover:bg-red-400 text-white px-3 py-1 rounded-full transition">
|
||||
拒絕
|
||||
</button>
|
||||
<button v-if="b.status === 'confirmed'" @click="doAction(b, 'cancel')"
|
||||
class="text-xs text-orange-500 hover:text-orange-700 underline">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -66,6 +66,7 @@ onMounted(fetchOffers)
|
||||
<th class="px-6 py-3 text-left">地點</th>
|
||||
<th class="px-6 py-3 text-left">地區</th>
|
||||
<th class="px-6 py-3 text-right">價格</th>
|
||||
<th class="px-6 py-3 text-center">時段</th>
|
||||
<th class="px-6 py-3 text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -75,6 +76,12 @@ onMounted(fetchOffers)
|
||||
<td class="px-6 py-4 text-gray-500">{{ offer.location }}</td>
|
||||
<td class="px-6 py-4 text-gray-500">{{ offer.region }}</td>
|
||||
<td class="px-6 py-4 text-right font-medium">NT$ {{ offer.price?.toLocaleString() }}</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<RouterLink :to="`/coach/schedules?offer_id=${offer.id}`"
|
||||
class="text-xs bg-ocean-50 hover:bg-ocean-100 text-ocean-700 px-3 py-1 rounded-lg transition font-medium">
|
||||
管理時段
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<div class="flex justify-center gap-2">
|
||||
<RouterLink :to="`/coach/offers/${offer.id}/edit`"
|
||||
|
||||
@@ -70,10 +70,12 @@ async function submit() {
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await coachApi.put(`/provider/offers/${route.params.id}`, payload)
|
||||
router.push('/coach/dashboard')
|
||||
} else {
|
||||
await coachApi.post('/provider/offers', payload)
|
||||
const res = await coachApi.post('/provider/offers', payload)
|
||||
const newId = res.data.data?.id
|
||||
router.push(`/coach/schedules?offer_id=${newId}&new=1`)
|
||||
}
|
||||
router.push('/coach/dashboard')
|
||||
} catch (e) {
|
||||
const data = e.response?.data
|
||||
error.value = data?.message || '儲存失敗'
|
||||
|
||||
@@ -76,13 +76,16 @@ async function submit() {
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 mb-1">密碼 <span class="text-red-400">*</span></label>
|
||||
<input v-model="form.password" type="password" required minlength="6"
|
||||
<input v-model="form.password" type="password" required minlength="8"
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400" />
|
||||
<p class="text-xs text-gray-400 mt-1">至少 8 個字元</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 mb-1">確認密碼 <span class="text-red-400">*</span></label>
|
||||
<input v-model="form.password_confirmation" type="password" required
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400" />
|
||||
class="w-full border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400"
|
||||
:class="form.password_confirmation && form.password_confirmation !== form.password ? 'border-red-400' : 'border-gray-300'" />
|
||||
<p v-if="form.password_confirmation && form.password_confirmation !== form.password" class="text-xs text-red-500 mt-1">密碼不一致</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getSchedules, createSchedule, deleteSchedule } from '../../api/coachScheduleApi'
|
||||
import coachApi from '../../api/coachAxios'
|
||||
|
||||
const route = useRoute()
|
||||
const schedules = ref([])
|
||||
const offers = ref([])
|
||||
const loading = ref(true)
|
||||
const showForm = ref(false)
|
||||
const formError = ref('')
|
||||
const isNewCourse = ref(route.query.new === '1')
|
||||
const form = ref({
|
||||
diving_offer_id: route.query.offer_id ? Number(route.query.offer_id) : '',
|
||||
scheduled_date: '',
|
||||
start_time: '',
|
||||
max_participants: 1,
|
||||
})
|
||||
|
||||
// 時間選擇器
|
||||
const timePeriod = ref('AM')
|
||||
const timeHour = ref('08')
|
||||
const timeMinute = ref('00')
|
||||
const HOURS_AM = ['06','07','08','09','10','11']
|
||||
const HOURS_PM = ['12','13','14','15','16','17','18']
|
||||
const MINUTES = ['00','30']
|
||||
|
||||
const hourOptions = computed(() => timePeriod.value === 'AM' ? HOURS_AM : HOURS_PM)
|
||||
|
||||
function syncTime() {
|
||||
if (timePeriod.value === 'AM' && !HOURS_AM.includes(timeHour.value)) timeHour.value = '08'
|
||||
if (timePeriod.value === 'PM' && !HOURS_PM.includes(timeHour.value)) timeHour.value = '13'
|
||||
form.value.start_time = `${timeHour.value}:${timeMinute.value}`
|
||||
}
|
||||
|
||||
watch([timePeriod, timeHour, timeMinute], syncTime, { immediate: true })
|
||||
|
||||
const today = computed(() => new Date().toISOString().split('T')[0])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [sRes, oRes] = await Promise.all([
|
||||
getSchedules(),
|
||||
coachApi.get('/provider/offers'),
|
||||
])
|
||||
schedules.value = sRes.data.data
|
||||
offers.value = oRes.data.data
|
||||
if (isNewCourse.value) showForm.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function submitForm() {
|
||||
formError.value = ''
|
||||
try {
|
||||
const res = await createSchedule(form.value)
|
||||
schedules.value.unshift(res.data.data)
|
||||
showForm.value = false
|
||||
form.value = { diving_offer_id: '', scheduled_date: '', start_time: '', max_participants: 1 }
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || '建立失敗'
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete(schedule) {
|
||||
if (!confirm(`確定取消「${schedule.offer_title} ${schedule.scheduled_date}」這個時段?\n該時段下的預約將自動取消。`)) return
|
||||
try {
|
||||
await deleteSchedule(schedule.id)
|
||||
schedule.status = 'cancelled'
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || '操作失敗')
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_COLOR = {
|
||||
open: 'bg-green-100 text-green-700',
|
||||
full: 'bg-yellow-100 text-yellow-700',
|
||||
cancelled: 'bg-gray-100 text-gray-400',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-4xl mx-auto">
|
||||
<!-- 新建課程引導提示 -->
|
||||
<div v-if="isNewCourse" class="bg-ocean-50 border border-ocean-200 rounded-xl px-5 py-4 mb-6 flex items-start gap-3">
|
||||
<span class="text-2xl">🎉</span>
|
||||
<div>
|
||||
<p class="font-semibold text-ocean-800">課程建立成功!</p>
|
||||
<p class="text-sm text-ocean-700 mt-0.5">請為課程新增開課時段,學員才能看到可預約的日期。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">時段管理</h1>
|
||||
<button
|
||||
@click="showForm = !showForm"
|
||||
class="bg-ocean-700 hover:bg-ocean-600 text-white px-5 py-2 rounded-full text-sm font-medium transition"
|
||||
>
|
||||
{{ showForm ? '取消' : '+ 新增時段' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 新增表單 -->
|
||||
<div v-if="showForm" class="bg-ocean-50 rounded-2xl p-6 mb-6 border border-ocean-200">
|
||||
<h2 class="font-semibold text-gray-700 mb-4">新增開課時段</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 mb-1">課程</label>
|
||||
<select v-model="form.diving_offer_id" class="w-full border rounded-lg px-3 py-2 text-sm">
|
||||
<option value="" disabled>請選擇課程</option>
|
||||
<option v-for="o in offers" :key="o.id" :value="o.id">{{ o.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 mb-1">日期</label>
|
||||
<input v-model="form.scheduled_date" type="date" :min="today" class="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 mb-1">開始時間</label>
|
||||
<div class="flex gap-2">
|
||||
<select v-model="timePeriod" @change="syncTime"
|
||||
class="border rounded-lg px-3 py-2 text-sm w-24 bg-white">
|
||||
<option value="AM">上午</option>
|
||||
<option value="PM">下午</option>
|
||||
</select>
|
||||
<select v-model="timeHour" @change="syncTime"
|
||||
class="border rounded-lg px-3 py-2 text-sm flex-1 bg-white">
|
||||
<option v-for="h in hourOptions" :key="h" :value="h">{{ h }} 時</option>
|
||||
</select>
|
||||
<select v-model="timeMinute" @change="syncTime"
|
||||
class="border rounded-lg px-3 py-2 text-sm w-24 bg-white">
|
||||
<option value="00">00 分</option>
|
||||
<option value="30">30 分</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">已選:{{ form.start_time }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 mb-1">人數上限</label>
|
||||
<input v-model.number="form.max_participants" type="number" min="1" class="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="formError" class="text-red-500 text-sm mt-3">{{ formError }}</p>
|
||||
<button @click="submitForm" class="mt-4 bg-ocean-700 hover:bg-ocean-600 text-white px-6 py-2 rounded-full text-sm font-medium transition">
|
||||
建立時段
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
|
||||
|
||||
<div v-else-if="schedules.length === 0" class="text-center text-gray-400 py-20">尚未建立任何時段</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="s in schedules"
|
||||
:key="s.id"
|
||||
class="bg-white rounded-xl shadow px-5 py-4 flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium text-gray-800">{{ s.offer_title }}</p>
|
||||
<p class="text-sm text-gray-500 mt-0.5">{{ s.scheduled_date }} {{ s.start_time }}・剩餘 {{ s.remaining_spots }}/{{ s.max_participants }} 人</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_COLOR[s.status]">
|
||||
{{ { open: '開放', full: '已滿', cancelled: '已取消' }[s.status] || s.status }}
|
||||
</span>
|
||||
<button
|
||||
v-if="s.status !== 'cancelled'"
|
||||
@click="doDelete(s)"
|
||||
class="text-sm text-red-400 hover:text-red-600 underline"
|
||||
>
|
||||
取消時段
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-11
|
||||
@@ -0,0 +1,3 @@
|
||||
# booking-system
|
||||
|
||||
預約系統:時段制課程預約、狀態機管理、教練排程
|
||||
@@ -0,0 +1,385 @@
|
||||
## Context
|
||||
|
||||
CFDivePlatform 是 Laravel 11 + Vue 3 的潛水課程媒合平台。目前 Member 只能瀏覽課程,無法完成預約。需要在現有 Sanctum 認證體系上新增預約流程,並引入 Laravel Scheduler 處理自動狀態轉換。
|
||||
|
||||
現有關鍵資料模型:`users`(三角色)、`diving_offers`(含 `price`、`provider_id`)。本次不修改任何現有資料表。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Provider 可建立/管理開課時段(`course_schedules`)
|
||||
- Member 可對時段送出預約(`bookings`)
|
||||
- 七狀態狀態機,含自動過期(48h)與自動完成(課程後)
|
||||
- 前後端完整串接
|
||||
|
||||
**Non-Goals:**
|
||||
- 金流整合(payment 欄位預留但不串接)
|
||||
- 推播通知(Email/SMS)
|
||||
- 管理員預約管理介面(Admin Panel 待後續)
|
||||
- 退款流程(取消後僅改狀態,不觸發退款)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 決策一:狀態機實作方式 — PHP BackedEnum + string 欄位
|
||||
|
||||
**選擇**:DB 欄位用 `string`(非 MySQL ENUM),應用層用 PHP `BackedEnum` 管理合法值。
|
||||
|
||||
```php
|
||||
// app/Enums/BookingStatus.php
|
||||
enum BookingStatus: string {
|
||||
case Pending = 'pending';
|
||||
case Confirmed = 'confirmed';
|
||||
case Completed = 'completed';
|
||||
case Rejected = 'rejected';
|
||||
case Expired = 'expired';
|
||||
case MemberCancelled = 'member_cancelled';
|
||||
case ProviderCancelled = 'provider_cancelled';
|
||||
}
|
||||
|
||||
// app/Enums/ScheduleStatus.php
|
||||
enum ScheduleStatus: string {
|
||||
case Open = 'open';
|
||||
case Full = 'full';
|
||||
case Cancelled = 'cancelled';
|
||||
}
|
||||
```
|
||||
|
||||
Migration 使用 `$table->string('status')->default('pending')`,Model 用 `$casts = ['status' => BookingStatus::class]`。
|
||||
|
||||
在 `Booking` Model 定義 `VALID_TRANSITIONS` 常數,transition 前統一驗證合法性:
|
||||
|
||||
```php
|
||||
const VALID_TRANSITIONS = [
|
||||
'pending' => ['confirmed', 'rejected', 'expired', 'member_cancelled'],
|
||||
'confirmed' => ['completed', 'member_cancelled', 'provider_cancelled'],
|
||||
'completed' => [],
|
||||
'rejected' => [],
|
||||
'expired' => [],
|
||||
'member_cancelled' => [],
|
||||
'provider_cancelled' => [],
|
||||
];
|
||||
```
|
||||
|
||||
**理由**:MySQL ENUM 加欄位值需要 `ALTER TABLE`(鎖表),在大資料量下有停機風險。用 string 欄位,未來加狀態只需改 PHP Enum,零 Migration。PHP 8.1 BackedEnum 提供 IDE 自動補全與型別安全,兼顧可維護性。
|
||||
|
||||
**放棄**:DB ENUM → 維護成本高,每次加狀態都要 Migration;引入狀態機套件 → 過度設計,transition 數量不值得。
|
||||
|
||||
---
|
||||
|
||||
### 決策二:人數計數 — DB 欄位 + 悲觀鎖
|
||||
|
||||
**選擇**:`course_schedules.current_participants` 實體欄位,更新時用 `lockForUpdate()`。
|
||||
|
||||
```
|
||||
DB::transaction(function () use ($booking, $schedule) {
|
||||
$schedule = CourseSchedule::lockForUpdate()->find($schedule->id);
|
||||
// 驗證剩餘名額...
|
||||
$schedule->increment('current_participants', $booking->participants);
|
||||
});
|
||||
```
|
||||
|
||||
**理由**:比每次 COUNT(bookings) 查詢效率高;悲觀鎖防止超賣 race condition。
|
||||
|
||||
**放棄**:樂觀鎖(version column)→ 需要 retry 邏輯,複雜度不值得。
|
||||
|
||||
---
|
||||
|
||||
### 決策三:Scheduler 頻率
|
||||
|
||||
| Job | 頻率 | 原因 |
|
||||
|-----|------|------|
|
||||
| `ExpirePendingBookings` | 每小時 | 過期精確度到小時即可 |
|
||||
| `CompleteFinishedBookings` | 每日凌晨 | 課程完成以「日」為單位 |
|
||||
|
||||
---
|
||||
|
||||
### 決策四:價格快照
|
||||
|
||||
**選擇**:建立 Booking 時將 `diving_offer.price × participants` 存入 `bookings.total_price`。
|
||||
|
||||
**理由**:Provider 日後調整課程價格不應影響已建立的預約;金流整合時直接使用此欄位。
|
||||
|
||||
---
|
||||
|
||||
### 決策五:取消時段的 Cascade 實作
|
||||
|
||||
**選擇**:在 `ScheduleController::destroy()` 內用單一 DB transaction 同時更新時段與相關 Booking。
|
||||
|
||||
```
|
||||
DB::transaction(function () use ($schedule) {
|
||||
$schedule->update(['status' => ScheduleStatus::Cancelled]);
|
||||
$schedule->bookings()
|
||||
->whereIn('status', [BookingStatus::Pending, BookingStatus::Confirmed])
|
||||
->update(['status' => BookingStatus::ProviderCancelled]);
|
||||
});
|
||||
```
|
||||
|
||||
**理由**:Booking cascade 必須與時段取消原子性完成,避免時段已取消但 Booking 仍掛 `confirmed` 的髒狀態。批次 `update` 不逐筆觸發 Model Event,效率優先(MVP 規模不需要逐筆通知)。
|
||||
|
||||
**放棄**:逐筆呼叫 `$booking->transitionTo(ProviderCancelled)` → 在大量預約時效率差,且 Model Event 觸發通知屬於未來功能。
|
||||
|
||||
---
|
||||
|
||||
### 決策六:Member 取消截止時間
|
||||
|
||||
**選擇**:課程開始前 24 小時為截止點,計算方式為 `$schedule->scheduled_date + $schedule->start_time`(Carbon datetime)。
|
||||
|
||||
```php
|
||||
$courseStart = Carbon::parse($schedule->scheduled_date . ' ' . $schedule->start_time);
|
||||
if (now()->diffInHours($courseStart, false) < 24) {
|
||||
return response()->json(['status' => false, 'message' => '距課程開始不足 24 小時,無法取消'], 422);
|
||||
}
|
||||
```
|
||||
|
||||
**理由**:潛水課程有實際的人力與設備準備成本,24h 截止是業界常見標準。`pending` 狀態同樣受此限制,避免 Member 在課程即將開始時仍送出再取消的操作。
|
||||
|
||||
**放棄**:只限制 `confirmed` 取消、`pending` 不限 → 業務上應一致處理,24h 截止對兩種狀態同樣適用。
|
||||
|
||||
---
|
||||
|
||||
### 決策七:Participants 驗證時機
|
||||
|
||||
**選擇**:分兩個階段各做一次名額驗證。
|
||||
|
||||
**階段 A — 建立 pending 時(早期拒絕,`current_participants` = 已確認人數)**
|
||||
```
|
||||
Layer 1 (Controller,進 transaction 前):
|
||||
$remaining = $schedule->max_participants - $schedule->current_participants;
|
||||
if ($participants > $remaining) return 422; // 連確認的名額都滿了,早期拒絕
|
||||
|
||||
Layer 2 (transaction 內,lockForUpdate 後):
|
||||
$schedule = CourseSchedule::lockForUpdate()->find($id);
|
||||
$remaining = $schedule->max_participants - $schedule->current_participants;
|
||||
if ($participants > $remaining) throw new InsufficientSlotsException();
|
||||
// 通過後只建立 Booking,不 increment(pending 不佔位)
|
||||
```
|
||||
|
||||
**階段 B — Provider 確認時(真正佔位,lockForUpdate + increment)**
|
||||
```
|
||||
DB::transaction(function () use ($booking) {
|
||||
$schedule = CourseSchedule::lockForUpdate()->find($booking->schedule_id);
|
||||
$remaining = $schedule->max_participants - $schedule->current_participants;
|
||||
if ($booking->participants > $remaining) throw new InsufficientSlotsException();
|
||||
$booking->update(['status' => 'confirmed']);
|
||||
$schedule->increment('current_participants', $booking->participants);
|
||||
$schedule->refresh();
|
||||
if ($schedule->current_participants >= $schedule->max_participants) {
|
||||
$schedule->update(['status' => 'full']);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**理由**:`current_participants` 只計算 confirmed 人數。pending 是「申請」不是「保留」,Provider 確認時才真正佔位。階段 A 的早期拒絕防止在所有 confirmed 額度滿後仍接受新 pending;階段 B 的 lockForUpdate 是真正防超賣機制。
|
||||
|
||||
---
|
||||
|
||||
### 決策八:重複預約防護 — 應用層 transaction 內檢查
|
||||
|
||||
**選擇**:不使用 DB UNIQUE constraint,改在建立 Booking 的 DB transaction 內執行重複性檢查。
|
||||
|
||||
```php
|
||||
DB::transaction(function () use ($memberId, $scheduleId, ...) {
|
||||
// 在 lockForUpdate 取得 schedule 的同一 transaction 內檢查
|
||||
$duplicate = Booking::where('member_id', $memberId)
|
||||
->where('schedule_id', $scheduleId)
|
||||
->whereIn('status', [BookingStatus::Pending, BookingStatus::Confirmed])
|
||||
->exists();
|
||||
if ($duplicate) {
|
||||
throw new DuplicateBookingException();
|
||||
}
|
||||
// ... 建立 Booking
|
||||
});
|
||||
```
|
||||
|
||||
**理由**:DB UNIQUE(member_id, schedule_id) 會阻止 Member 在取消後重新預約同一時段(如原 pending 取消後想改約),業務上不合理。應用層只檢查活躍狀態(pending/confirmed),允許對同一時段在取消後再次預約。將檢查放在 transaction 內確保與建立操作原子性,避免 TOCTOU race condition。
|
||||
|
||||
**放棄**:DB UNIQUE constraint → 語意過強,阻擋合法的取消後重訂;Controller 層(transaction 外)檢查 → 有 TOCTOU 風險。
|
||||
|
||||
---
|
||||
|
||||
### 決策九:公開時段 API 的明確過濾條件
|
||||
|
||||
**選擇**:`GET /api/diving-offers/{id}/schedules` 回傳條件為:
|
||||
```sql
|
||||
WHERE diving_offer_id = :id
|
||||
AND status = 'open'
|
||||
AND scheduled_date >= CURDATE()
|
||||
ORDER BY scheduled_date ASC, start_time ASC
|
||||
```
|
||||
|
||||
`full` 和 `cancelled` 時段不回傳;過去日期時段不回傳。
|
||||
|
||||
**理由**:只回傳 `open` 確保 Member 看到的全是可預約的時段,前端不需要再做客戶端過濾。`full` 不顯示(MVP 不做候補名單功能),`scheduled_date < today` 的時段對 Member 無意義。
|
||||
|
||||
**放棄**:回傳 `open` + `full`(前端再過濾)→ 增加前端複雜度,且 full 時段對無候補功能的 MVP 無用。
|
||||
|
||||
---
|
||||
|
||||
### 決策十:Coach / Provider 命名慣例
|
||||
|
||||
**現況**:codebase 存在兩套命名:前端用 `coach`,後端 API 和 DB 用 `provider`。這是前期開發的歷史遺留,本次不重構。
|
||||
|
||||
**決策**:
|
||||
- **前端路由**:維持 `/coach/*`(已存在,不破壞現有 URL)
|
||||
- **後端 API 路由**:統一用 `/provider/*`(與 DB role 欄位值一致)
|
||||
- **DB `users.role` 欄位值**:`'provider'`(PHP 端 `isProvider()` 方法判斷)
|
||||
- **本次新增程式碼**:後端一律用 `provider` 命名(Controller、Policy、Middleware);前端新增頁面放在 `/coach/*` 路由下
|
||||
|
||||
新加入開發者應知道:前端 `/coach/*` = 後端 `/api/provider/*` = DB `role = 'provider'`,三者指同一群用戶。
|
||||
|
||||
---
|
||||
|
||||
### 決策十一:審計追蹤(已知限制)
|
||||
|
||||
**現況**:`bookings` 表只有 `created_at`/`updated_at`,無法從 DB 層直接查「何時確認」「何時取消」。
|
||||
|
||||
**MVP 決策**:接受此限制。`updated_at` 可作為最後一次狀態變更的時間戳,精確度足夠 MVP 使用。
|
||||
|
||||
**未來(金流整合前必須處理)**:加入 `booking_status_logs` 歷史表,記錄每次 status transition 的 `from_status`、`to_status`、`changed_by`(user_id)、`changed_at`。屆時新增一個 Migration 即可,不影響現有 `bookings` 結構。
|
||||
|
||||
---
|
||||
|
||||
### 決策十三:名額回收與時段狀態自動轉換
|
||||
|
||||
**`current_participants` 增減規則**(只計算 confirmed 人數):
|
||||
|
||||
| 觸發動作 | current_participants | 說明 |
|
||||
| ------- | ------------------- | ---- |
|
||||
| pending → confirmed(Provider 確認) | +participants | 確認時才佔位 |
|
||||
| confirmed → member_cancelled | -participants | Member 取消,釋放名額 |
|
||||
| confirmed → provider_cancelled | -participants | Provider 取消時段或單筆取消,釋放名額 |
|
||||
| confirmed → completed | 不變 | 課程已完成,名額已消耗 |
|
||||
| pending → rejected | 不變 | pending 從未佔位,無需釋放 |
|
||||
| pending → expired | 不變 | pending 從未佔位,無需釋放 |
|
||||
| pending → member_cancelled | 不變 | pending 從未佔位,無需釋放 |
|
||||
|
||||
**`course_schedules.status` 自動轉換規則**(在 confirm/cancel 的同一 DB transaction 內執行):
|
||||
|
||||
```php
|
||||
// confirm 後 increment,檢查是否 full:
|
||||
if ($schedule->current_participants >= $schedule->max_participants) {
|
||||
$schedule->update(['status' => ScheduleStatus::Full]);
|
||||
}
|
||||
|
||||
// cancel 後 decrement,檢查是否回 open:
|
||||
if ($schedule->current_participants < $schedule->max_participants
|
||||
&& $schedule->status === ScheduleStatus::Full) {
|
||||
$schedule->update(['status' => ScheduleStatus::Open]);
|
||||
}
|
||||
```
|
||||
|
||||
`cancelled` 是終態,不受上述規則影響。
|
||||
|
||||
**理由**:與 `specs/course-scheduling/spec.md` 一致,`current_participants` 反映已確認(實際佔用)的人數。pending 是申請,不保留名額;Provider 確認時才真正佔位。
|
||||
|
||||
---
|
||||
|
||||
### 決策十四:`ExpirePendingBookings` 過期條件
|
||||
|
||||
**選擇**:過期條件為 `status = 'pending'` 且 `created_at <= now() - 48 hours`。批次 update 即可,無需碰 `current_participants`(pending 從未佔位)。
|
||||
|
||||
```php
|
||||
$count = Booking::where('status', BookingStatus::Pending)
|
||||
->where('created_at', '<=', now()->subHours(48))
|
||||
->update(['status' => BookingStatus::Expired]);
|
||||
|
||||
Log::info("ExpirePendingBookings: {$count} expired");
|
||||
```
|
||||
|
||||
**理由**:pending 確認時才佔位(決策十三),因此過期只需改狀態,`current_participants` 與時段 `status` 均不受影響。批次 `update` 效率優於逐筆 transaction。
|
||||
|
||||
---
|
||||
|
||||
### 決策十二:前端路由新增
|
||||
|
||||
```
|
||||
Member 新路由:
|
||||
/courses/:id → 課程詳情(新增時段選擇區塊)
|
||||
/my-bookings → 我的預約列表
|
||||
|
||||
Coach 新路由(在現有 /coach/* 下):
|
||||
/coach/schedules → 時段管理
|
||||
/coach/bookings → 預約管理
|
||||
```
|
||||
|
||||
## 資料表設計
|
||||
|
||||
### course_schedules
|
||||
```
|
||||
id bigint PK
|
||||
diving_offer_id bigint FK → diving_offers.id
|
||||
provider_id bigint FK → users.id
|
||||
scheduled_date date NOT NULL
|
||||
start_time time NOT NULL
|
||||
max_participants int NOT NULL (≥1)
|
||||
current_participants int DEFAULT 0
|
||||
status string DEFAULT 'open' ← PHP ScheduleStatus BackedEnum
|
||||
created_at timestamp
|
||||
updated_at timestamp
|
||||
|
||||
索引:
|
||||
idx_offer_status_date (diving_offer_id, status, scheduled_date) ← 公開 API 查詢
|
||||
idx_provider_id (provider_id) ← Provider 管理頁
|
||||
```
|
||||
|
||||
### bookings
|
||||
```
|
||||
id bigint PK
|
||||
schedule_id bigint FK → course_schedules.id
|
||||
member_id bigint FK → users.id
|
||||
participants int NOT NULL DEFAULT 1
|
||||
total_price int NOT NULL (快照,單位:元)
|
||||
status string DEFAULT 'pending' ← PHP BookingStatus BackedEnum
|
||||
notes text nullable
|
||||
created_at timestamp
|
||||
updated_at timestamp
|
||||
|
||||
索引:
|
||||
idx_member_status (member_id, status) ← Member 預約列表
|
||||
idx_schedule_status (schedule_id, status) ← 重複預約檢查、人數統計
|
||||
idx_status_created_at (status, created_at) ← ExpirePendingBookings Scheduler
|
||||
idx_status_sched (status, schedule_id) ← CompleteFinishedBookings Scheduler
|
||||
```
|
||||
|
||||
## API 路由總覽
|
||||
|
||||
```
|
||||
公開
|
||||
GET /api/diving-offers/{id}/schedules → 取得課程可用時段
|
||||
|
||||
Member (auth:sanctum)
|
||||
GET /api/member/bookings → 我的預約列表
|
||||
POST /api/member/bookings → 建立預約
|
||||
GET /api/member/bookings/{id} → 預約詳情
|
||||
DELETE /api/member/bookings/{id} → 取消預約
|
||||
|
||||
Provider (auth:sanctum)
|
||||
GET /api/provider/schedules → 我的時段列表
|
||||
POST /api/provider/schedules → 建立時段
|
||||
PUT /api/provider/schedules/{id} → 更新時段
|
||||
DELETE /api/provider/schedules/{id} → 取消時段
|
||||
GET /api/provider/bookings → 課程預約列表
|
||||
PUT /api/provider/bookings/{id}/confirm → 確認預約
|
||||
PUT /api/provider/bookings/{id}/reject → 拒絕預約
|
||||
PUT /api/provider/bookings/{id}/cancel → 取消預約
|
||||
```
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Race condition on 最後一個名額** → 已用 `lockForUpdate()` 在 DB transaction 內處理
|
||||
|
||||
- **Scheduler 停擺(高風險)**
|
||||
Scheduler 若未啟動,`pending` 預約永遠不過期、`confirmed` 課程永遠不完成,資料持續累積髒狀態。
|
||||
Mitigation 三層:
|
||||
1. **日誌**:每次 Job 執行結尾記錄 `Log::info("ExpirePendingBookings: {$count} expired")`,可在 Laravel log 中查驗
|
||||
2. **可觀測性**:開發環境啟用 Laravel Telescope 監控 Schedule 執行;生產環境至少保留 `storage/logs/laravel.log` 並定期 rotate
|
||||
3. **手動補跑**:兩支 Artisan Command 須可獨立執行(`php artisan app:expire-pending-bookings`),維運人員可在 Scheduler 異常時手動補跑,不依賴 cron
|
||||
|
||||
- **取消後無退款** → 目前僅狀態標記,金流整合時需補充退款邏輯
|
||||
- **`completed` 自動轉換** → 課程當天仍進行中的預約到凌晨才會轉 completed,業務上可接受
|
||||
- **string status 未加 DB CHECK constraint** → 合法值由應用層 BackedEnum 控制,若繞過 ORM 直接寫 DB 可能插入非法值;可接受此 trade-off,未來有需要可加 DB constraint
|
||||
|
||||
## Closed Questions
|
||||
|
||||
- **Q1:`notes` 欄位是否強制填寫?** → **已決定:nullable**。Member 預約時不強制填寫,`notes` 保留為選填欄位供日後使用。
|
||||
- **Q2:Provider 取消時段時,confirmed Booking 的通知方式?** → **已決定:只改狀態**。取消後 Booking 狀態變為 `provider_cancelled`,本次不觸發任何通知。Email/推播通知留給未來 Email 模組實作。
|
||||
@@ -0,0 +1,42 @@
|
||||
## Why
|
||||
|
||||
CFDivePlatform 目前只有課程瀏覽,Member 無法預約課程,Provider 無法管理開課時段,平台缺少核心商業閉環。預約系統是金流整合與平台商業化的前置條件,必須優先實作。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增 `course_schedules` 資料表:Provider 建立開課時段(日期、時間、人數上限)
|
||||
- 新增 `bookings` 資料表:記錄 Member 預約紀錄,含價格快照
|
||||
- 新增 Member API:查詢可用時段、送出預約、取消預約
|
||||
- 新增 Provider API:管理開課時段 CRUD、接受/拒絕/取消預約
|
||||
- 新增 Laravel Scheduler:pending 超 48 小時自動 expired;課程日期過後自動 completed
|
||||
- 新增前端頁面:Member 課程詳情頁加入時段選擇與預約流程;Provider Dashboard 加入時段管理與預約管理
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `course-scheduling`:Provider 建立與管理開課時段,含日期、時間、人數上限、狀態(open/full/cancelled)
|
||||
- `booking-lifecycle`:Member 送出預約、取消預約;Provider 確認/拒絕/取消預約;系統自動過期與完成;七狀態狀態機(pending / confirmed / completed / rejected / expired / member_cancelled / provider_cancelled)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
(無既有 spec 受影響)
|
||||
|
||||
## Impact
|
||||
|
||||
**後端**
|
||||
- 新增 Migration:`course_schedules`、`bookings`
|
||||
- 新增 Model:`CourseSchedule`、`Booking`
|
||||
- 新增 Controller:`ScheduleController`(Provider)、`BookingController`(Member/Provider)
|
||||
- 新增 Laravel Scheduler:`ExpirePendingBookings`、`CompleteFinishedBookings`
|
||||
- 更新 `api.php`:新增 `/member/bookings`、`/member/schedules`、`/provider/schedules`、`/provider/bookings` 路由群組
|
||||
|
||||
**前端**
|
||||
- 更新 `CourseDetail.vue`(或新建):加入時段列表與預約按鈕
|
||||
- 新增 `src/pages/member/MyBookings.vue`:我的預約列表
|
||||
- 新增 Coach Dashboard 子頁面:`ScheduleManager.vue`、`BookingManager.vue`
|
||||
- 新增 `src/api/bookingApi.js`:封裝預約相關 API 呼叫
|
||||
|
||||
**資料庫**
|
||||
- 兩張新資料表,無現有資料表結構變更
|
||||
- `diving_offers.price` 作為預約時的價格快照來源
|
||||
@@ -0,0 +1,104 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Member 送出預約
|
||||
Member SHALL 能選擇一個開放時段送出預約,系統記錄價格快照。
|
||||
|
||||
#### Scenario: 成功建立預約
|
||||
- **WHEN** 已登入 Member 送出 `POST /api/member/bookings`,指定 `schedule_id` 與 `participants`(≥1)
|
||||
- **THEN** 系統建立 Booking,status 為 `pending`,`total_price` 快照為 `diving_offer.price × participants`,回傳 201
|
||||
|
||||
#### Scenario: 時段已滿無法預約
|
||||
- **WHEN** 指定時段 status 為 `full` 或 `cancelled`
|
||||
- **THEN** 系統回傳 422,告知時段不可用
|
||||
|
||||
#### Scenario: 超過剩餘名額(API 層快速驗證)
|
||||
- **WHEN** `participants` 大於時段當前剩餘名額(`max_participants - current_participants`),在進入 DB transaction 前
|
||||
- **THEN** 系統回傳 422,告知人數超過上限,不進入 lockForUpdate 流程
|
||||
|
||||
#### Scenario: 超過剩餘名額(DB 層二次驗證)
|
||||
- **WHEN** API 層通過但 lockForUpdate 後重新計算剩餘名額仍不足(race condition 情境)
|
||||
- **THEN** 系統 rollback transaction,回傳 422,告知名額不足
|
||||
|
||||
#### Scenario: 不可重複預約同一時段
|
||||
- **WHEN** Member 對同一 `schedule_id` 已有 `pending` 或 `confirmed` 狀態的 Booking
|
||||
- **THEN** 系統回傳 422,告知已有預約
|
||||
|
||||
### Requirement: 預約狀態機
|
||||
系統 SHALL 維護七個合法狀態,且只允許以下轉換:
|
||||
- `pending` → `confirmed`(Provider 確認)
|
||||
- `pending` → `rejected`(Provider 拒絕)
|
||||
- `pending` → `member_cancelled`(Member 取消)
|
||||
- `pending` → `expired`(Scheduler 超時)
|
||||
- `confirmed` → `completed`(Scheduler 課程後自動)
|
||||
- `confirmed` → `member_cancelled`(Member 取消)
|
||||
- `confirmed` → `provider_cancelled`(Provider 取消)
|
||||
|
||||
#### Scenario: 非法狀態轉換被拒絕
|
||||
- **WHEN** 任何角色嘗試執行上述以外的狀態轉換
|
||||
- **THEN** 系統回傳 422,說明當前狀態不允許此操作
|
||||
|
||||
### Requirement: Provider 確認或拒絕預約
|
||||
Provider SHALL 能對自己課程的 `pending` 預約執行確認或拒絕。
|
||||
|
||||
#### Scenario: 確認預約
|
||||
- **WHEN** Provider 送出 `PUT /api/provider/bookings/{id}/confirm`
|
||||
- **THEN** Booking status 改為 `confirmed`,時段 `current_participants` 更新
|
||||
|
||||
#### Scenario: 拒絕預約
|
||||
- **WHEN** Provider 送出 `PUT /api/provider/bookings/{id}/reject`
|
||||
- **THEN** Booking status 改為 `rejected`
|
||||
|
||||
#### Scenario: 只能操作自己課程的預約
|
||||
- **WHEN** Provider 嘗試操作不屬於自己課程的 Booking
|
||||
- **THEN** 系統回傳 403 Forbidden
|
||||
|
||||
### Requirement: Provider 取消已確認預約
|
||||
Provider SHALL 能取消 `confirmed` 狀態的預約(例如天氣因素)。
|
||||
|
||||
#### Scenario: Provider 取消確認中預約
|
||||
- **WHEN** Provider 送出 `PUT /api/provider/bookings/{id}/cancel`
|
||||
- **THEN** Booking status 改為 `provider_cancelled`,時段名額釋放
|
||||
|
||||
### Requirement: Member 取消預約
|
||||
Member SHALL 能取消自己的 `pending` 或 `confirmed` 預約,但須在課程開始前 24 小時之前提出。
|
||||
|
||||
#### Scenario: 取消 pending 預約(期限內)
|
||||
- **WHEN** Member 送出 `DELETE /api/member/bookings/{id}`,Booking status 為 `pending`,且當前時間早於 `scheduled_date + start_time - 24h`
|
||||
- **THEN** Booking status 改為 `member_cancelled`
|
||||
|
||||
#### Scenario: 取消 confirmed 預約(期限內)
|
||||
- **WHEN** Member 送出 `DELETE /api/member/bookings/{id}`,Booking status 為 `confirmed`,且當前時間早於 `scheduled_date + start_time - 24h`
|
||||
- **THEN** Booking status 改為 `member_cancelled`,時段名額釋放
|
||||
|
||||
#### Scenario: 課程開始前 24h 內不可取消
|
||||
- **WHEN** Member 送出 `DELETE /api/member/bookings/{id}`,但當前時間距 `scheduled_date + start_time` 不足 24 小時
|
||||
- **THEN** 系統回傳 422,告知「距課程開始不足 24 小時,無法取消,請聯繫教練」;Booking 狀態不變
|
||||
|
||||
#### Scenario: 不可取消已終態預約
|
||||
- **WHEN** Booking status 為 `completed`、`rejected`、`expired`、`provider_cancelled`
|
||||
- **THEN** 系統回傳 422,告知無法取消
|
||||
|
||||
### Requirement: 系統自動過期 pending 預約
|
||||
Scheduler SHALL 每小時掃描 `pending` 超過 48 小時的 Booking 並標記為 `expired`。
|
||||
|
||||
#### Scenario: 過期觸發
|
||||
- **WHEN** Booking status 為 `pending` 且 `created_at` 早於 48 小時前
|
||||
- **THEN** Scheduler 將 status 改為 `expired`
|
||||
|
||||
### Requirement: 系統自動完成 confirmed 預約
|
||||
Scheduler SHALL 每日掃描課程日期已過的 `confirmed` Booking 並標記為 `completed`。
|
||||
|
||||
#### Scenario: 自動完成
|
||||
- **WHEN** Booking status 為 `confirmed`,對應 `course_schedule.scheduled_date` 早於今天
|
||||
- **THEN** Scheduler 將 status 改為 `completed`
|
||||
|
||||
### Requirement: Member 查看自己的預約列表
|
||||
Member SHALL 能查詢自己所有預約的列表及詳情。
|
||||
|
||||
#### Scenario: 取得預約列表
|
||||
- **WHEN** 已登入 Member 送出 `GET /api/member/bookings`
|
||||
- **THEN** 系統回傳該 Member 所有 Booking,含課程名稱、時段日期、狀態、金額
|
||||
|
||||
#### Scenario: 取得單一預約詳情
|
||||
- **WHEN** 已登入 Member 送出 `GET /api/member/bookings/{id}`
|
||||
- **THEN** 系統回傳該 Booking 詳情;若非本人預約則回傳 403
|
||||
@@ -0,0 +1,72 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Provider 建立開課時段
|
||||
Provider SHALL 能為自己擁有的 DivingOffer 建立開課時段,指定日期、開始時間、人數上限。
|
||||
|
||||
#### Scenario: 成功建立時段
|
||||
- **WHEN** Provider 送出 `POST /api/provider/schedules`,包含合法的 `diving_offer_id`、`scheduled_date`(未來日期)、`start_time`、`max_participants`(≥1)
|
||||
- **THEN** 系統建立 CourseSchedule,status 為 `open`,回傳 201 與新時段資料
|
||||
|
||||
#### Scenario: 不可為他人課程建立時段
|
||||
- **WHEN** Provider 送出的 `diving_offer_id` 屬於其他 Provider
|
||||
- **THEN** 系統回傳 403 Forbidden
|
||||
|
||||
#### Scenario: 日期不可為過去
|
||||
- **WHEN** `scheduled_date` 早於今天
|
||||
- **THEN** 系統回傳 422,錯誤訊息指出日期無效
|
||||
|
||||
### Requirement: Provider 管理既有時段
|
||||
Provider SHALL 能更新或取消自己的開課時段。
|
||||
|
||||
#### Scenario: 更新時段資訊
|
||||
- **WHEN** Provider 送出 `PUT /api/provider/schedules/{id}`,修改 `start_time` 或 `max_participants`
|
||||
- **THEN** 系統更新時段資料,回傳更新後內容
|
||||
|
||||
#### Scenario: 取消時段
|
||||
- **WHEN** Provider 送出 `DELETE /api/provider/schedules/{id}`
|
||||
- **THEN** 系統將時段 status 改為 `cancelled`,不實體刪除;cascade 處理所有相關 Booking(詳見下方 Requirement)
|
||||
|
||||
#### Scenario: 不可修改他人時段
|
||||
- **WHEN** Provider 嘗試修改不屬於自己的時段
|
||||
- **THEN** 系統回傳 403 Forbidden
|
||||
|
||||
### Requirement: 取消時段的 Booking Cascade 處理
|
||||
Provider 取消時段時,系統 SHALL 在同一 DB transaction 內處理該時段下所有活躍 Booking,並明確定義各狀態的 cascade 規則。
|
||||
|
||||
#### Scenario: pending Booking cascade 為 provider_cancelled
|
||||
- **WHEN** Provider 取消時段,時段下存在 status 為 `pending` 的 Booking
|
||||
- **THEN** 這些 Booking status 全部改為 `provider_cancelled`
|
||||
|
||||
#### Scenario: confirmed Booking cascade 為 provider_cancelled
|
||||
- **WHEN** Provider 取消時段,時段下存在 status 為 `confirmed` 的 Booking
|
||||
- **THEN** 這些 Booking status 全部改為 `provider_cancelled`,`current_participants` 不需調整(時段已取消)
|
||||
|
||||
#### Scenario: 終態 Booking 不受 cascade 影響
|
||||
- **WHEN** Provider 取消時段,時段下存在 status 為 `completed`、`rejected`、`expired`、`member_cancelled`、`provider_cancelled` 的 Booking
|
||||
- **THEN** 這些 Booking status 維持不變
|
||||
|
||||
#### Scenario: cascade 在同一 transaction 內完成
|
||||
- **WHEN** Provider 取消時段
|
||||
- **THEN** 時段狀態更新與所有 Booking cascade 更新在同一 DB transaction 內完成;任一失敗則全部 rollback,API 回傳 500
|
||||
|
||||
### Requirement: 時段人數自動管理
|
||||
系統 SHALL 在預約確認時自動累計 `current_participants`,並於額滿時將時段 status 改為 `full`。
|
||||
|
||||
#### Scenario: 預約確認後人數更新
|
||||
- **WHEN** Provider 確認一筆 Booking(confirmed),booking 的 `participants` 為 N
|
||||
- **THEN** `course_schedules.current_participants` 增加 N;若達到 `max_participants` 則 status 改為 `full`
|
||||
|
||||
#### Scenario: 預約取消後人數釋放
|
||||
- **WHEN** 一筆 `confirmed` 狀態的 Booking 被取消(member_cancelled 或 provider_cancelled)
|
||||
- **THEN** `current_participants` 減少對應人數;若原本為 `full` 則 status 改回 `open`
|
||||
|
||||
### Requirement: Member 查詢可用時段
|
||||
Member SHALL 能查詢指定課程的可用開課時段列表。
|
||||
|
||||
#### Scenario: 取得開放時段
|
||||
- **WHEN** 任何人(含未登入)送出 `GET /api/diving-offers/{id}/schedules`
|
||||
- **THEN** 系統回傳該課程 status 為 `open`、日期未過的時段列表(含剩餘名額)
|
||||
|
||||
#### Scenario: 已滿時段不顯示
|
||||
- **WHEN** 時段 status 為 `full`
|
||||
- **THEN** 不包含在上述列表中
|
||||
@@ -0,0 +1,77 @@
|
||||
## 1. 資料庫層
|
||||
|
||||
- [x] 1.1 [後端] 建立 Migration `create_course_schedules_table`:欄位含 `diving_offer_id`、`provider_id`、`scheduled_date`、`start_time`、`max_participants`、`current_participants`、`status` string(非 enum);加索引 `(diving_offer_id, status, scheduled_date)` 與 `(provider_id)`
|
||||
- [x] 1.2 [後端] 建立 Migration `create_bookings_table`:欄位含 `schedule_id`、`member_id`、`participants`、`total_price`、`status` string(非 enum)、`notes`;加索引 `(member_id, status)`、`(schedule_id, status)`、`(status, created_at)`
|
||||
- [x] 1.3 [後端] 執行 Migration,確認 DB schema 與索引正確(`SHOW INDEX FROM course_schedules`)
|
||||
|
||||
## 2. Enum 與 Model 層
|
||||
|
||||
- [x] 2.1 [後端] 建立 `app/Enums/BookingStatus.php`:PHP BackedEnum,七個 case(pending / confirmed / completed / rejected / expired / member_cancelled / provider_cancelled)
|
||||
- [x] 2.2 [後端] 建立 `app/Enums/ScheduleStatus.php`:PHP BackedEnum,三個 case(open / full / cancelled)
|
||||
- [x] 2.3 [後端] 建立 `app/Models/CourseSchedule.php`:fillable、`casts = ['status' => ScheduleStatus::class]`、關聯(belongsTo DivingOffer / belongsTo User as provider、hasMany Booking)
|
||||
- [x] 2.4 [後端] 建立 `app/Models/Booking.php`:fillable、`casts = ['status' => BookingStatus::class]`、`VALID_TRANSITIONS` 常數(pending→{confirmed,rejected,expired,member_cancelled};confirmed→{completed,member_cancelled,provider_cancelled};其餘終態→[])、`canTransitionTo()` 驗證方法、關聯(belongsTo CourseSchedule / belongsTo User as member)
|
||||
- [x] 2.5 [後端] 在 `DivingOffer` Model 新增 `hasMany CourseSchedule` 關聯
|
||||
|
||||
## 3. Provider 時段管理 API
|
||||
|
||||
- [x] 3.1 [後端] 建立 `app/Http/Controllers/API/ScheduleController.php`:`index`、`store`(含所有權驗證、日期驗證)、`update`、`destroy`(DB transaction:時段 → cancelled + 批次 cascade pending/confirmed Booking → provider_cancelled)
|
||||
- [x] 3.2 [後端] 在 `routes/api.php` 新增 `/provider/schedules` 路由群組(CRUD)
|
||||
- [x] 3.3 [後端] 在 `routes/api.php` 新增公開路由 `GET /diving-offers/{id}/schedules`;Controller 查詢條件:`status = 'open' AND scheduled_date >= CURDATE()` ORDER BY `scheduled_date ASC, start_time ASC`;回傳每筆含 `remaining_spots = max_participants - current_participants`
|
||||
|
||||
## 4. Provider 預約管理 API
|
||||
|
||||
- [x] 4.1 [後端] 建立 `app/Http/Controllers/API/ProviderBookingController.php`:
|
||||
- `confirm`(階段 B 佔位):DB transaction + lockForUpdate 取得 schedule → 重新計算剩餘名額(`max - current_participants`)→ 不足則 422 → 更新 Booking status=confirmed + `increment('current_participants')` → 若達滿則 schedule status=full
|
||||
- `reject`:Booking status → rejected,不動 current_participants(pending 本來就未佔位)
|
||||
- `cancel`:Booking status → provider_cancelled + `decrement('current_participants')` + 若原為 full 則 schedule status 改回 open(僅 confirmed 才需釋放)
|
||||
- `index`:列出自己課程的預約
|
||||
- [x] 4.2 [後端] 在 `routes/api.php` 新增 `/provider/bookings` 路由群組(index / confirm / reject / cancel)
|
||||
|
||||
## 5. Member 預約 API
|
||||
|
||||
- [x] 5.1 [後端] 建立 `app/Http/Controllers/API/MemberBookingController.php`:
|
||||
- `store`(階段 A,pending 不佔位):Layer 1 快速名額檢查(`max - current_participants`,422 early return)→ DB transaction 內:lockForUpdate 取得 schedule + Layer 2 名額再次驗證 + 重複預約檢查(同一 member_id + schedule_id 是否已有 pending/confirmed)→ 建立 Booking + 價格快照;**不 increment `current_participants`**
|
||||
- `destroy`:24h 截止驗證(Carbon datetime 比較)→ 合法則改 member_cancelled;**僅 confirmed 狀態需 decrement `current_participants` + 若原為 full 改回 open**;pending 取消不動人數
|
||||
- `index`、`show`:一般查詢
|
||||
- [x] 5.2 [後端] 在 `routes/api.php` 新增 `/member/bookings` 路由群組(CRUD)
|
||||
|
||||
## 6. Scheduler 自動任務
|
||||
|
||||
- [x] 6.1 [後端] 建立 `app/Console/Commands/ExpirePendingBookings.php`:查詢 `status=pending` 且 `created_at < now()-48h`(利用索引 `status, created_at`),批次更新為 `expired`;執行結尾 `Log::info("ExpirePendingBookings: {$count} expired")`
|
||||
- [x] 6.2 [後端] 建立 `app/Console/Commands/CompleteFinishedBookings.php`:查詢 `status=confirmed` 且 join schedule 的 `scheduled_date < today()`,批次更新為 `completed`;執行結尾 `Log::info("CompleteFinishedBookings: {$count} completed")`
|
||||
- [x] 6.3 [後端] 在 `routes/console.php` 註冊:`ExpirePendingBookings` 每小時(`->hourly()`)、`CompleteFinishedBookings` 每日凌晨(`->dailyAt('00:05')`)
|
||||
- [x] 6.4 [後端] 在 Docker `cfdive-app` 容器的 Dockerfile / entrypoint 加入 cron job:`* * * * * php /var/www/html/artisan schedule:run >> /dev/null 2>&1`;確認 cron daemon 已啟動
|
||||
- [x] 6.5 [後端] 手動驗證兩支 Command 可獨立執行:`php artisan app:expire-pending-bookings`、`php artisan app:complete-finished-bookings` 不報錯,且 `storage/logs/laravel.log` 有對應紀錄
|
||||
|
||||
## 7. 前端 API 封裝
|
||||
|
||||
- [x] 7.1 [前端] 建立 `frontend/src/api/scheduleApi.js`:封裝 `getSchedulesByOffer(offerId)`
|
||||
- [x] 7.2 [前端] 建立 `frontend/src/api/bookingApi.js`(member):`getMyBookings()`、`getBooking(id)`、`createBooking(payload)`、`cancelBooking(id)`
|
||||
- [x] 7.3 [前端] 建立 `frontend/src/api/coachBookingApi.js`(provider):`getProviderBookings()`、`confirmBooking(id)`、`rejectBooking(id)`、`cancelBooking(id)`
|
||||
- [x] 7.4 [前端] 建立 `frontend/src/api/coachScheduleApi.js`:`getSchedules()`、`createSchedule(payload)`、`updateSchedule(id, payload)`、`deleteSchedule(id)`
|
||||
|
||||
## 8. Member 前端頁面
|
||||
|
||||
- [x] 8.1 [前端] 更新課程詳情頁(`frontend/src/views/CourseDetailView.vue`):新增「可用時段」區塊,顯示日期、時間、剩餘名額,含「立即預約」按鈕(呼叫 createBooking)
|
||||
- [x] 8.2 [前端] 新增 `frontend/src/views/MyBookingsView.vue`:列出 Member 所有預約,顯示狀態 Badge(七種狀態對應不同顏色),含取消按鈕(pending/confirmed)
|
||||
- [x] 8.3 [前端] 在 Member Navbar 加入「我的預約」連結,路由 `/my-bookings`
|
||||
- [x] 8.4 [前端] 在 `frontend/src/router/index.js` 新增 `/my-bookings` 路由(requiresAuth)
|
||||
|
||||
## 9. Coach 前端頁面
|
||||
|
||||
- [x] 9.1 [前端] 新增 `frontend/src/views/coach/ScheduleManagerView.vue`:時段列表(含狀態、剩餘名額)、建立時段表單(日期選擇、時間、人數上限)、取消時段按鈕
|
||||
- [x] 9.2 [前端] 新增 `frontend/src/views/coach/BookingManagerView.vue`:預約列表(依課程分組或全部列出)、顯示 Member 姓名、人數、金額、狀態、確認/拒絕/取消按鈕
|
||||
- [x] 9.3 [前端] 在 Coach Navbar(`CoachNavBar.vue`)加入「時段管理」與「預約管理」連結
|
||||
- [x] 9.4 [前端] 在 `frontend/src/router/index.js` 新增 `/coach/schedules`、`/coach/bookings` 路由(requiresCoach)
|
||||
|
||||
## 10. 整合驗證
|
||||
|
||||
- [x] 10.1 [整合測試] 完整流程測試:Provider 建立時段 → Member 預約 → Provider 確認 → 驗證人數扣減
|
||||
- [x] 10.2 [整合測試] 超賣防護測試:最後一個名額同時送出兩筆預約,驗證只有一筆成功(Layer 2 lockForUpdate 生效)
|
||||
- [x] 10.3 [整合測試] 取消流程測試:①Member 取消 confirmed 預約 → current_participants 減少、schedule 若原為 full 改回 open;②Member 取消 pending 預約 → current_participants **不變**(pending 本來就未佔位)
|
||||
- [x] 10.4 [整合測試] Scheduler 測試:手動執行 `php artisan app:expire-pending-bookings`、`php artisan app:complete-finished-bookings`,驗證狀態正確更新
|
||||
- [x] 10.5 [整合測試] Cascade 測試:Provider 取消時段後驗證 pending/confirmed Booking 全部變 provider_cancelled;completed/rejected Booking 狀態不變
|
||||
- [x] 10.6 [整合測試] 取消截止測試:建立一筆課程開始前 12h 的 confirmed 預約,Member 嘗試取消應回傳 422;課程前 36h 的預約應可取消
|
||||
- [x] 10.7 [整合測試] Participants 雙層驗證測試:超過剩餘名額的預約在 Layer 1 被攔截,回傳 422 且不進入 DB transaction
|
||||
- [x] 10.8 [整合測試] 重複預約防護測試:Member 對同一時段送出第二筆 pending 預約應回傳 422;第一筆取消後再送出第三筆應成功
|
||||
- [x] 10.9 [整合測試] 公開 API 過濾測試:`GET /api/diving-offers/{id}/schedules` 不回傳 status=full、cancelled 及過去日期時段;回傳時段含正確的 remaining_spots
|
||||
@@ -0,0 +1,102 @@
|
||||
### Requirement: Member 送出預約
|
||||
Member SHALL 能選擇一個開放時段送出預約,系統記錄價格快照。pending 狀態不佔用時段名額。
|
||||
|
||||
#### Scenario: 成功建立預約
|
||||
- **WHEN** 已登入 Member 送出 `POST /api/member/bookings`,指定 `schedule_id` 與 `participants`(≥1)
|
||||
- **THEN** 系統建立 Booking,status 為 `pending`,`total_price` 快照為 `diving_offer.price × participants`,回傳 201
|
||||
|
||||
#### Scenario: 時段已滿無法預約
|
||||
- **WHEN** 指定時段 status 為 `full` 或 `cancelled`
|
||||
- **THEN** 系統回傳 422,告知時段不可用
|
||||
|
||||
#### Scenario: 超過剩餘名額(API 層快速驗證)
|
||||
- **WHEN** `participants` 大於時段當前剩餘名額(`max_participants - current_participants`),在進入 DB transaction 前
|
||||
- **THEN** 系統回傳 422,告知人數超過上限,不進入 lockForUpdate 流程
|
||||
|
||||
#### Scenario: 超過剩餘名額(DB 層二次驗證)
|
||||
- **WHEN** API 層通過但 lockForUpdate 後重新計算剩餘名額仍不足(race condition 情境)
|
||||
- **THEN** 系統 rollback transaction,回傳 422,告知名額不足
|
||||
|
||||
#### Scenario: 不可重複預約同一時段
|
||||
- **WHEN** Member 對同一 `schedule_id` 已有 `pending` 或 `confirmed` 狀態的 Booking
|
||||
- **THEN** 系統回傳 422,告知已有預約(取消後可重新預約)
|
||||
|
||||
### Requirement: 預約狀態機
|
||||
系統 SHALL 維護七個合法狀態,且只允許以下轉換:
|
||||
- `pending` → `confirmed`(Provider 確認)
|
||||
- `pending` → `rejected`(Provider 拒絕)
|
||||
- `pending` → `member_cancelled`(Member 取消)
|
||||
- `pending` → `expired`(Scheduler 超時)
|
||||
- `confirmed` → `completed`(Scheduler 課程後自動)
|
||||
- `confirmed` → `member_cancelled`(Member 取消)
|
||||
- `confirmed` → `provider_cancelled`(Provider 取消)
|
||||
|
||||
#### Scenario: 非法狀態轉換被拒絕
|
||||
- **WHEN** 任何角色嘗試執行上述以外的狀態轉換
|
||||
- **THEN** 系統回傳 422,說明當前狀態不允許此操作
|
||||
|
||||
### Requirement: Provider 確認或拒絕預約
|
||||
Provider SHALL 能對自己課程的 `pending` 預約執行確認或拒絕。確認時才真正佔用時段名額。
|
||||
|
||||
#### Scenario: 確認預約
|
||||
- **WHEN** Provider 送出 `PUT /api/provider/bookings/{id}/confirm`
|
||||
- **THEN** Booking status 改為 `confirmed`,時段 `current_participants` 增加對應人數
|
||||
|
||||
#### Scenario: 拒絕預約
|
||||
- **WHEN** Provider 送出 `PUT /api/provider/bookings/{id}/reject`
|
||||
- **THEN** Booking status 改為 `rejected`,`current_participants` 不變(pending 未佔位)
|
||||
|
||||
#### Scenario: 只能操作自己課程的預約
|
||||
- **WHEN** Provider 嘗試操作不屬於自己課程的 Booking
|
||||
- **THEN** 系統回傳 403 Forbidden
|
||||
|
||||
### Requirement: Provider 取消已確認預約
|
||||
Provider SHALL 能取消 `confirmed` 狀態的預約(例如天氣因素)。
|
||||
|
||||
#### Scenario: Provider 取消確認中預約
|
||||
- **WHEN** Provider 送出 `PUT /api/provider/bookings/{id}/cancel`
|
||||
- **THEN** Booking status 改為 `provider_cancelled`,時段名額釋放
|
||||
|
||||
### Requirement: Member 取消預約
|
||||
Member SHALL 能取消自己的 `pending` 或 `confirmed` 預約,但須在課程開始前 24 小時之前提出。
|
||||
|
||||
#### Scenario: 取消 pending 預約(期限內)
|
||||
- **WHEN** Member 送出 `DELETE /api/member/bookings/{id}`,Booking status 為 `pending`,且當前時間早於 `scheduled_date + start_time - 24h`
|
||||
- **THEN** Booking status 改為 `member_cancelled`,`current_participants` 不變
|
||||
|
||||
#### Scenario: 取消 confirmed 預約(期限內)
|
||||
- **WHEN** Member 送出 `DELETE /api/member/bookings/{id}`,Booking status 為 `confirmed`,且當前時間早於 `scheduled_date + start_time - 24h`
|
||||
- **THEN** Booking status 改為 `member_cancelled`,時段名額釋放
|
||||
|
||||
#### Scenario: 課程開始前 24h 內不可取消
|
||||
- **WHEN** Member 送出 `DELETE /api/member/bookings/{id}`,但當前時間距 `scheduled_date + start_time` 不足 24 小時
|
||||
- **THEN** 系統回傳 422,告知「距課程開始不足 24 小時,無法取消,請聯繫教練」;Booking 狀態不變
|
||||
|
||||
#### Scenario: 不可取消已終態預約
|
||||
- **WHEN** Booking status 為 `completed`、`rejected`、`expired`、`provider_cancelled`
|
||||
- **THEN** 系統回傳 422,告知無法取消
|
||||
|
||||
### Requirement: 系統自動過期 pending 預約
|
||||
Scheduler SHALL 每小時掃描 `pending` 超過 48 小時的 Booking 並標記為 `expired`。
|
||||
|
||||
#### Scenario: 過期觸發
|
||||
- **WHEN** Booking status 為 `pending` 且 `created_at` 早於 48 小時前
|
||||
- **THEN** Scheduler 將 status 改為 `expired`,`current_participants` 不變(pending 未佔位)
|
||||
|
||||
### Requirement: 系統自動完成 confirmed 預約
|
||||
Scheduler SHALL 每日掃描課程日期已過的 `confirmed` Booking 並標記為 `completed`。
|
||||
|
||||
#### Scenario: 自動完成
|
||||
- **WHEN** Booking status 為 `confirmed`,對應 `course_schedule.scheduled_date` 早於今天
|
||||
- **THEN** Scheduler 將 status 改為 `completed`
|
||||
|
||||
### Requirement: Member 查看自己的預約列表
|
||||
Member SHALL 能查詢自己所有預約的列表及詳情,含課程連結與完整課程資訊。
|
||||
|
||||
#### Scenario: 取得預約列表
|
||||
- **WHEN** 已登入 Member 送出 `GET /api/member/bookings`
|
||||
- **THEN** 系統回傳該 Member 所有 Booking,含 offer_id、課程名稱、地點、時段日期、狀態、金額
|
||||
|
||||
#### Scenario: 取得單一預約詳情
|
||||
- **WHEN** 已登入 Member 送出 `GET /api/member/bookings/{id}`
|
||||
- **THEN** 系統回傳該 Booking 詳情;若非本人預約則回傳 403
|
||||
@@ -0,0 +1,70 @@
|
||||
### Requirement: Provider 建立開課時段
|
||||
Provider SHALL 能為自己擁有的 DivingOffer 建立開課時段,指定日期、開始時間、人數上限。
|
||||
|
||||
#### Scenario: 成功建立時段
|
||||
- **WHEN** Provider 送出 `POST /api/provider/schedules`,包含合法的 `diving_offer_id`、`scheduled_date`(未來日期)、`start_time`、`max_participants`(≥1)
|
||||
- **THEN** 系統建立 CourseSchedule,status 為 `open`,回傳 201 與新時段資料
|
||||
|
||||
#### Scenario: 不可為他人課程建立時段
|
||||
- **WHEN** Provider 送出的 `diving_offer_id` 屬於其他 Provider
|
||||
- **THEN** 系統回傳 403 Forbidden
|
||||
|
||||
#### Scenario: 日期不可為過去
|
||||
- **WHEN** `scheduled_date` 早於今天
|
||||
- **THEN** 系統回傳 422,錯誤訊息指出日期無效
|
||||
|
||||
### Requirement: Provider 管理既有時段
|
||||
Provider SHALL 能更新或取消自己的開課時段。
|
||||
|
||||
#### Scenario: 更新時段資訊
|
||||
- **WHEN** Provider 送出 `PUT /api/provider/schedules/{id}`,修改 `start_time` 或 `max_participants`
|
||||
- **THEN** 系統更新時段資料,回傳更新後內容
|
||||
|
||||
#### Scenario: 取消時段
|
||||
- **WHEN** Provider 送出 `DELETE /api/provider/schedules/{id}`
|
||||
- **THEN** 系統將時段 status 改為 `cancelled`,不實體刪除;cascade 處理所有相關 Booking(詳見下方 Requirement)
|
||||
|
||||
#### Scenario: 不可修改他人時段
|
||||
- **WHEN** Provider 嘗試修改不屬於自己的時段
|
||||
- **THEN** 系統回傳 403 Forbidden
|
||||
|
||||
### Requirement: 取消時段的 Booking Cascade 處理
|
||||
Provider 取消時段時,系統 SHALL 在同一 DB transaction 內處理該時段下所有活躍 Booking,並明確定義各狀態的 cascade 規則。
|
||||
|
||||
#### Scenario: pending Booking cascade 為 provider_cancelled
|
||||
- **WHEN** Provider 取消時段,時段下存在 status 為 `pending` 的 Booking
|
||||
- **THEN** 這些 Booking status 全部改為 `provider_cancelled`
|
||||
|
||||
#### Scenario: confirmed Booking cascade 為 provider_cancelled
|
||||
- **WHEN** Provider 取消時段,時段下存在 status 為 `confirmed` 的 Booking
|
||||
- **THEN** 這些 Booking status 全部改為 `provider_cancelled`,`current_participants` 不需調整(時段已取消)
|
||||
|
||||
#### Scenario: 終態 Booking 不受 cascade 影響
|
||||
- **WHEN** Provider 取消時段,時段下存在 status 為 `completed`、`rejected`、`expired`、`member_cancelled`、`provider_cancelled` 的 Booking
|
||||
- **THEN** 這些 Booking status 維持不變
|
||||
|
||||
#### Scenario: cascade 在同一 transaction 內完成
|
||||
- **WHEN** Provider 取消時段
|
||||
- **THEN** 時段狀態更新與所有 Booking cascade 更新在同一 DB transaction 內完成;任一失敗則全部 rollback,API 回傳 500
|
||||
|
||||
### Requirement: 時段人數自動管理
|
||||
系統 SHALL 在預約確認時自動累計 `current_participants`,並於額滿時將時段 status 改為 `full`。`current_participants` 只計算 confirmed 人數,pending 不佔位。
|
||||
|
||||
#### Scenario: 預約確認後人數更新
|
||||
- **WHEN** Provider 確認一筆 Booking(confirmed),booking 的 `participants` 為 N
|
||||
- **THEN** `course_schedules.current_participants` 增加 N;若達到 `max_participants` 則 status 改為 `full`
|
||||
|
||||
#### Scenario: 預約取消後人數釋放
|
||||
- **WHEN** 一筆 `confirmed` 狀態的 Booking 被取消(member_cancelled 或 provider_cancelled)
|
||||
- **THEN** `current_participants` 減少對應人數;若原本為 `full` 則 status 改回 `open`
|
||||
|
||||
### Requirement: Member 查詢可用時段
|
||||
Member SHALL 能查詢指定課程的可用開課時段列表。
|
||||
|
||||
#### Scenario: 取得開放時段
|
||||
- **WHEN** 任何人(含未登入)送出 `GET /api/diving-offers/{id}/schedules`
|
||||
- **THEN** 系統回傳該課程 status 為 `open`、日期未過的時段列表(含剩餘名額 `remaining_spots`),依日期升冪排序
|
||||
|
||||
#### Scenario: 已滿時段不顯示
|
||||
- **WHEN** 時段 status 為 `full`
|
||||
- **THEN** 不包含在上述列表中
|
||||
+19
-3
@@ -4,6 +4,9 @@ use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\API\AuthController;
|
||||
use App\Http\Controllers\API\DivingOfferController;
|
||||
use App\Http\Controllers\API\ProviderOfferController;
|
||||
use App\Http\Controllers\API\ScheduleController;
|
||||
use App\Http\Controllers\API\ProviderBookingController;
|
||||
use App\Http\Controllers\API\MemberBookingController;
|
||||
use App\Http\Controllers\API\AdminStatsController;
|
||||
use App\Http\Controllers\API\AdminUserController;
|
||||
use App\Http\Controllers\API\AdminOfferController;
|
||||
@@ -16,6 +19,7 @@ Route::get('/ping', function () {
|
||||
// 潛水課程(公開)
|
||||
Route::get('/diving-offers', [DivingOfferController::class, 'index']);
|
||||
Route::get('/diving-offers/{id}', [DivingOfferController::class, 'show']);
|
||||
Route::get('/diving-offers/{id}/schedules', [ScheduleController::class, 'publicList']);
|
||||
|
||||
// 你可以在這裡繼續新增 API 路由
|
||||
Route::post('/testpost', function () {
|
||||
@@ -43,9 +47,11 @@ Route::middleware(['auth:sanctum'])->prefix('member')->group(function () {
|
||||
Route::put('/profile', [AuthController::class, 'updateMemberProfile']);
|
||||
// 修改密碼
|
||||
Route::put('/change-password', [AuthController::class, 'changeMemberPassword']);
|
||||
// 你可以再加上訂單、收藏、通知等API
|
||||
// Route::get('/orders', [OrderController::class, 'memberOrders']);
|
||||
// Route::get('/favorites', [FavoriteController::class, 'memberFavorites']);
|
||||
// 預約
|
||||
Route::get('/bookings', [MemberBookingController::class, 'index']);
|
||||
Route::post('/bookings', [MemberBookingController::class, 'store']);
|
||||
Route::get('/bookings/{id}', [MemberBookingController::class, 'show']);
|
||||
Route::delete('/bookings/{id}', [MemberBookingController::class, 'destroy']);
|
||||
});
|
||||
|
||||
// 服務提供者註冊/登入
|
||||
@@ -68,6 +74,16 @@ Route::middleware(['auth:sanctum'])->prefix('provider')->group(function () {
|
||||
Route::get('/offers/{id}', [ProviderOfferController::class, 'show']);
|
||||
Route::put('/offers/{id}', [ProviderOfferController::class, 'update']);
|
||||
Route::delete('/offers/{id}', [ProviderOfferController::class, 'destroy']);
|
||||
// 時段管理
|
||||
Route::get('/schedules', [ScheduleController::class, 'index']);
|
||||
Route::post('/schedules', [ScheduleController::class, 'store']);
|
||||
Route::put('/schedules/{id}', [ScheduleController::class, 'update']);
|
||||
Route::delete('/schedules/{id}', [ScheduleController::class, 'destroy']);
|
||||
// 預約管理
|
||||
Route::get('/bookings', [ProviderBookingController::class, 'index']);
|
||||
Route::put('/bookings/{id}/confirm', [ProviderBookingController::class, 'confirm']);
|
||||
Route::put('/bookings/{id}/reject', [ProviderBookingController::class, 'reject']);
|
||||
Route::put('/bookings/{id}/cancel', [ProviderBookingController::class, 'cancel']);
|
||||
});
|
||||
|
||||
// 管理員註冊/登入
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote')->hourly();
|
||||
|
||||
Schedule::command('app:expire-pending-bookings')->hourly();
|
||||
Schedule::command('app:complete-finished-bookings')->dailyAt('00:05');
|
||||
|
||||
Reference in New Issue
Block a user