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:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user