Add Booking module: model, create/read/cancel API, Filament resource (T4.1-T4.7)
- Booking model with booking_vehicle_options line items (supports mixing vehicle options like front_seat + back_seat in one booking), price snapshot, status machine, and driver/car assignment fields - BookingService: front-seat max, disabled-option toggles, duplicate-option and whole-vehicle-exclusivity guards - CreateBookingAction, CancelBookingAction, AssignDriverAction - BookingRefGenerator: sequential EVB-AAAAA1-style refs via row lock - POST/GET/cancel booking API endpoints (Sanctum, ownership + admin policy) - BookingPlugin + Filament BookingResource: list, detail view, Cancel and Assign Driver actions (shared between table and detail page) - domain.md updated for multi-vehicle-option bookings (§2) and driver/ vehicle assignment (§5a)
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Actions;
|
||||
|
||||
use Modules\Booking\Data\AssignDriverData;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Driver/vehicle details only make sense once a booking is confirmed
|
||||
* (paid) — dispatch assigns who's actually doing the trip at that point,
|
||||
* not before. Re-running this (e.g. reassigning a different driver) is
|
||||
* allowed as long as the booking is still confirmed.
|
||||
*/
|
||||
class AssignDriverAction
|
||||
{
|
||||
public function handle(Booking $booking, AssignDriverData $data): Booking
|
||||
{
|
||||
if ($booking->status !== BookingStatus::Confirmed) {
|
||||
throw DriverAssignmentNotAllowedException::notConfirmed($booking);
|
||||
}
|
||||
|
||||
$booking->update([
|
||||
'driver_name' => $data->driverName,
|
||||
'driver_phone' => $data->driverPhone,
|
||||
'car_plate_number' => $data->carPlateNumber,
|
||||
'car_model' => $data->carModel,
|
||||
]);
|
||||
|
||||
return $booking;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Actions;
|
||||
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\BookingCannotBeCancelledException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Unpaid path only — a pending_payment booking has no money moved yet, so
|
||||
* it can be cancelled directly. A confirmed (paid) booking must go through
|
||||
* a refund first; this action explicitly guards against bypassing that
|
||||
* (domain.md §5). Wired into that refund path in T5.12.
|
||||
*/
|
||||
class CancelBookingAction
|
||||
{
|
||||
public function handle(Booking $booking): Booking
|
||||
{
|
||||
if ($booking->status !== BookingStatus::PendingPayment) {
|
||||
throw BookingCannotBeCancelledException::notPendingPayment($booking);
|
||||
}
|
||||
|
||||
$booking->update(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
return $booking;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Actions;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Booking\Data\CreateBookingData;
|
||||
use Modules\Booking\Data\VehicleSelectionData;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\BookingCreated;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Services\BookingRefGenerator;
|
||||
use Modules\Booking\Services\BookingService;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Routing\Services\PricingService;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
class CreateBookingAction
|
||||
{
|
||||
public function __construct(
|
||||
private BookingService $bookingService,
|
||||
private PricingService $pricingService,
|
||||
private BookingRefGenerator $bookingRefGenerator,
|
||||
) {}
|
||||
|
||||
public function handle(CreateBookingData $data): Booking
|
||||
{
|
||||
$this->bookingService->validateSelections($data->selections);
|
||||
|
||||
return DB::transaction(function () use ($data) {
|
||||
$route = EvRoute::findOrFail($data->evRouteId);
|
||||
|
||||
$lines = array_map(
|
||||
fn (VehicleSelectionData $selection) => $this->priceSelection($route, $selection),
|
||||
$data->selections,
|
||||
);
|
||||
|
||||
$totalPrice = array_reduce(
|
||||
$lines,
|
||||
fn (string $carry, array $line) => bcadd($carry, $line['line_total'], 2),
|
||||
'0.00',
|
||||
);
|
||||
|
||||
$booking = Booking::create([
|
||||
'booking_ref' => $this->bookingRefGenerator->generate(),
|
||||
'user_id' => $data->userId,
|
||||
'openid' => $data->openid,
|
||||
'ev_route_id' => $data->evRouteId,
|
||||
'departure_time_slot_id' => $data->departureTimeSlotId,
|
||||
'travel_date' => $data->travelDate,
|
||||
'passenger_name' => $data->passengerName,
|
||||
'passenger_phone' => $data->passengerPhone,
|
||||
'pickup_address' => $data->pickupAddress,
|
||||
'pickup_lat' => $data->pickupLat,
|
||||
'pickup_lng' => $data->pickupLng,
|
||||
'dropoff_address' => $data->dropoffAddress,
|
||||
'dropoff_lat' => $data->dropoffLat,
|
||||
'dropoff_lng' => $data->dropoffLng,
|
||||
'price' => $totalPrice,
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
'is_round_trip' => $data->isRoundTrip,
|
||||
'return_travel_date' => $data->returnTravelDate,
|
||||
'created_by_channel' => $data->createdByChannel,
|
||||
]);
|
||||
|
||||
$booking->vehicleOptions()->createMany($lines);
|
||||
|
||||
BookingCreated::dispatch($booking);
|
||||
|
||||
return $booking;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{vehicle_option: VehicleOption, passenger_count: int, unit_price: string, line_total: string}
|
||||
*/
|
||||
private function priceSelection(EvRoute $route, VehicleSelectionData $selection): array
|
||||
{
|
||||
$quote = $this->pricingService->quote($route, $selection->vehicleOption);
|
||||
|
||||
return [
|
||||
'vehicle_option' => $selection->vehicleOption,
|
||||
'passenger_count' => $selection->passengerCount,
|
||||
'unit_price' => $quote->price,
|
||||
'line_total' => bcmul($quote->price, (string) $selection->passengerCount, 2),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking;
|
||||
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
class BookingPlugin implements Plugin
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return 'booking';
|
||||
}
|
||||
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
$panel
|
||||
->discoverResources(
|
||||
in: __DIR__.'/Filament/Resources',
|
||||
for: 'Modules\Booking\Filament\Resources',
|
||||
)
|
||||
->discoverPages(
|
||||
in: __DIR__.'/Filament/Pages',
|
||||
for: 'Modules\Booking\Filament\Pages',
|
||||
)
|
||||
->discoverWidgets(
|
||||
in: __DIR__.'/Filament/Widgets',
|
||||
for: 'Modules\Booking\Filament\Widgets',
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(Panel $panel): void {}
|
||||
|
||||
public static function make(): static
|
||||
{
|
||||
return app(static::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Data;
|
||||
|
||||
readonly class AssignDriverData
|
||||
{
|
||||
public function __construct(
|
||||
public string $driverName,
|
||||
public string $driverPhone,
|
||||
public string $carPlateNumber,
|
||||
public ?string $carModel = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Data;
|
||||
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
|
||||
readonly class CreateBookingData
|
||||
{
|
||||
/**
|
||||
* @param list<VehicleSelectionData> $selections One or more Vehicle Option
|
||||
* selections (e.g. front_seat + back_seat) — domain.md §2.
|
||||
*/
|
||||
public function __construct(
|
||||
public int $evRouteId,
|
||||
public int $departureTimeSlotId,
|
||||
public string $travelDate,
|
||||
public array $selections,
|
||||
public string $passengerName,
|
||||
public string $passengerPhone,
|
||||
public string $pickupAddress,
|
||||
public string $dropoffAddress,
|
||||
public BookingChannel $createdByChannel,
|
||||
public ?int $userId = null,
|
||||
public ?string $openid = null,
|
||||
public ?float $pickupLat = null,
|
||||
public ?float $pickupLng = null,
|
||||
public ?float $dropoffLat = null,
|
||||
public ?float $dropoffLng = null,
|
||||
public bool $isRoundTrip = false,
|
||||
public ?string $returnTravelDate = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Data;
|
||||
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
readonly class VehicleSelectionData
|
||||
{
|
||||
public function __construct(
|
||||
public VehicleOption $vehicleOption,
|
||||
public int $passengerCount = 1,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Enums;
|
||||
|
||||
/**
|
||||
* Which external actor created the booking (domain.md §8).
|
||||
*/
|
||||
enum BookingChannel: string
|
||||
{
|
||||
case MiniApp = 'mini_app';
|
||||
case Android = 'android';
|
||||
case Ios = 'ios';
|
||||
case Web = 'web';
|
||||
case Agent = 'agent';
|
||||
case Admin = 'admin';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Enums;
|
||||
|
||||
enum BookingStatus: string
|
||||
{
|
||||
case PendingPayment = 'pending_payment';
|
||||
case Confirmed = 'confirmed';
|
||||
case Cancelled = 'cancelled';
|
||||
case Expired = 'expired';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
class BookingCreated
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(public Booking $booking) {}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Exceptions;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use RuntimeException;
|
||||
|
||||
class BookingCannotBeCancelledException extends RuntimeException
|
||||
{
|
||||
public static function notPendingPayment(Booking $booking): self
|
||||
{
|
||||
return new self(
|
||||
"Booking [{$booking->booking_ref}] cannot be cancelled directly because its status is [{$booking->status->value}]."
|
||||
.($booking->status === BookingStatus::Confirmed
|
||||
? ' A confirmed (paid) booking must go through a refund first.'
|
||||
: '')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A rejected cancel attempt is a client input problem, not a server
|
||||
* error — surface it as 422 rather than the default 500.
|
||||
*/
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $this->getMessage()], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Exceptions;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use RuntimeException;
|
||||
|
||||
class DriverAssignmentNotAllowedException extends RuntimeException
|
||||
{
|
||||
public static function notConfirmed(Booking $booking): self
|
||||
{
|
||||
return new self(
|
||||
"Booking [{$booking->booking_ref}] cannot have a driver assigned because its status is [{$booking->status->value}], not confirmed."
|
||||
);
|
||||
}
|
||||
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $this->getMessage()], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Exceptions;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
use RuntimeException;
|
||||
|
||||
class InvalidVehicleSelectionException extends RuntimeException
|
||||
{
|
||||
public static function frontSeatLimitExceeded(int $requested, int $max): self
|
||||
{
|
||||
return new self("Front seat request [{$requested}] exceeds the max of [{$max}] per booking.");
|
||||
}
|
||||
|
||||
public static function optionDisabled(VehicleOption $vehicleOption): self
|
||||
{
|
||||
return new self("Vehicle option [{$vehicleOption->value}] is not currently available for booking.");
|
||||
}
|
||||
|
||||
public static function duplicateOption(VehicleOption $vehicleOption): self
|
||||
{
|
||||
return new self("Vehicle option [{$vehicleOption->value}] was selected more than once — combine it into a single selection.");
|
||||
}
|
||||
|
||||
public static function wholeVehicleCannotBeCombined(): self
|
||||
{
|
||||
return new self('Whole Vehicle cannot be combined with other vehicle options in the same booking.');
|
||||
}
|
||||
|
||||
/**
|
||||
* A rejected vehicle selection is a client input problem, not a server
|
||||
* error — surface it as 422 rather than the default 500. A broader JSON
|
||||
* error envelope for all of api/* is Phase 6 (T6.3); this keeps the
|
||||
* mapping local until that lands.
|
||||
*/
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $this->getMessage()], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Modules\Booking\Actions\AssignDriverAction;
|
||||
use Modules\Booking\Data\AssignDriverData;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Shared between BookingsTable (row action) and ViewBooking (header action)
|
||||
* so both surfaces stay in sync — one definition, not two.
|
||||
*/
|
||||
class AssignDriverTableAction
|
||||
{
|
||||
public static function make(): Action
|
||||
{
|
||||
return Action::make('assignDriver')
|
||||
->label('Assign Driver')
|
||||
->icon(Heroicon::OutlinedTruck)
|
||||
->color('primary')
|
||||
->visible(fn (Booking $record): bool => $record->status === BookingStatus::Confirmed
|
||||
&& (auth()->user()?->can('manage_bookings') ?? false))
|
||||
->schema([
|
||||
TextInput::make('driver_name')->required(),
|
||||
TextInput::make('driver_phone')->required(),
|
||||
TextInput::make('car_plate_number')->required(),
|
||||
TextInput::make('car_model'),
|
||||
])
|
||||
->fillForm(fn (Booking $record): array => [
|
||||
'driver_name' => $record->driver_name,
|
||||
'driver_phone' => $record->driver_phone,
|
||||
'car_plate_number' => $record->car_plate_number,
|
||||
'car_model' => $record->car_model,
|
||||
])
|
||||
->action(function (array $data, Booking $record, AssignDriverAction $assignDriverAction) {
|
||||
try {
|
||||
$assignDriverAction->handle($record, new AssignDriverData(
|
||||
driverName: $data['driver_name'],
|
||||
driverPhone: $data['driver_phone'],
|
||||
carPlateNumber: $data['car_plate_number'],
|
||||
carModel: $data['car_model'] ?: null,
|
||||
));
|
||||
|
||||
Notification::make()
|
||||
->title('Driver assigned')
|
||||
->success()
|
||||
->send();
|
||||
} catch (DriverAssignmentNotAllowedException $exception) {
|
||||
Notification::make()
|
||||
->title('Cannot assign driver')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Modules\Booking\Actions\CancelBookingAction;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\BookingCannotBeCancelledException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Shared between BookingsTable (row action) and ViewBooking (header action)
|
||||
* so both surfaces stay in sync — one definition, not two.
|
||||
*/
|
||||
class CancelBookingTableAction
|
||||
{
|
||||
public static function make(): Action
|
||||
{
|
||||
return Action::make('cancel')
|
||||
->label('Cancel')
|
||||
->icon(Heroicon::OutlinedXCircle)
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Booking $record): bool => Gate::allows('cancel', $record))
|
||||
->disabled(fn (Booking $record): bool => $record->status !== BookingStatus::PendingPayment)
|
||||
->action(function (Booking $record, CancelBookingAction $cancelBookingAction) {
|
||||
try {
|
||||
$cancelBookingAction->handle($record);
|
||||
|
||||
Notification::make()
|
||||
->title('Booking cancelled')
|
||||
->success()
|
||||
->send();
|
||||
} catch (BookingCannotBeCancelledException $exception) {
|
||||
Notification::make()
|
||||
->title('Cannot cancel booking')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Pages\ListBookings;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Pages\ViewBooking;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Schemas\BookingInfolist;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Tables\BookingsTable;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Read-mostly by design: bookings are created through the API (T4.4), not
|
||||
* hand-entered in the admin — so this resource has no create/edit form, just
|
||||
* a list with filters and a status-gated Cancel action (T4.6).
|
||||
*/
|
||||
class BookingResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Booking::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedTicket;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Operations';
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return BookingsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return BookingInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListBookings::route('/'),
|
||||
'view' => ViewBooking::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
|
||||
|
||||
class ListBookings extends ListRecords
|
||||
{
|
||||
protected static string $resource = BookingResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
// No CreateAction — bookings are created through the API (T4.4), not
|
||||
// hand-entered here.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
|
||||
|
||||
class ViewBooking extends ViewRecord
|
||||
{
|
||||
protected static string $resource = BookingResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
AssignDriverTableAction::make(),
|
||||
CancelBookingTableAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Schemas;
|
||||
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
|
||||
class BookingInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Booking')
|
||||
->schema([
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextEntry::make('booking_ref')->label('Ref'),
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->color(fn (BookingStatus $state) => match ($state) {
|
||||
BookingStatus::PendingPayment => 'warning',
|
||||
BookingStatus::Confirmed => 'success',
|
||||
BookingStatus::Cancelled => 'gray',
|
||||
BookingStatus::Expired => 'danger',
|
||||
}),
|
||||
TextEntry::make('created_by_channel')->badge(),
|
||||
TextEntry::make('created_at')->dateTime(),
|
||||
]),
|
||||
]),
|
||||
Section::make('Trip')
|
||||
->schema([
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
TextEntry::make('route.company.name')->label('Company'),
|
||||
TextEntry::make('route.fromDestination.name')->label('From'),
|
||||
TextEntry::make('route.toDestination.name')->label('To'),
|
||||
TextEntry::make('timeSlot.label')->label('Time Slot'),
|
||||
TextEntry::make('travel_date')->date(),
|
||||
TextEntry::make('is_round_trip')->label('Round Trip')->badge(),
|
||||
TextEntry::make('return_travel_date')->date()
|
||||
->visible(fn ($record) => $record->is_round_trip),
|
||||
]),
|
||||
]),
|
||||
Section::make('Vehicle Options')
|
||||
->schema([
|
||||
RepeatableEntry::make('vehicleOptions')
|
||||
->label('')
|
||||
->schema([
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextEntry::make('vehicle_option')->badge(),
|
||||
TextEntry::make('passenger_count'),
|
||||
TextEntry::make('unit_price')->numeric(2),
|
||||
TextEntry::make('line_total')->numeric(2),
|
||||
]),
|
||||
]),
|
||||
TextEntry::make('price')->label('Total Price')->numeric(2),
|
||||
]),
|
||||
Section::make('Passenger')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextEntry::make('passenger_name'),
|
||||
TextEntry::make('passenger_phone'),
|
||||
]),
|
||||
]),
|
||||
Section::make('Pickup & Dropoff')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextEntry::make('pickup_address'),
|
||||
TextEntry::make('dropoff_address'),
|
||||
TextEntry::make('pickup_lat')->label('Pickup Lat')->placeholder('—'),
|
||||
TextEntry::make('dropoff_lat')->label('Dropoff Lat')->placeholder('—'),
|
||||
TextEntry::make('pickup_lng')->label('Pickup Lng')->placeholder('—'),
|
||||
TextEntry::make('dropoff_lng')->label('Dropoff Lng')->placeholder('—'),
|
||||
]),
|
||||
]),
|
||||
Section::make('Driver & Vehicle')
|
||||
->description('Filled in by staff once the booking is confirmed — see the Assign Driver action.')
|
||||
->schema([
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextEntry::make('driver_name')->label('Driver')->placeholder('Not yet assigned'),
|
||||
TextEntry::make('driver_phone')->label('Driver Phone')->placeholder('Not yet assigned'),
|
||||
TextEntry::make('car_plate_number')->label('Car Plate')->placeholder('Not yet assigned'),
|
||||
TextEntry::make('car_model')->label('Car Model')->placeholder('—'),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Tables;
|
||||
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\EvCompany;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
|
||||
class BookingsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with([
|
||||
'route.company', 'route.fromDestination', 'route.toDestination', 'timeSlot', 'vehicleOptions',
|
||||
]))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('booking_ref')
|
||||
->label('Ref')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (BookingStatus $state) => match ($state) {
|
||||
BookingStatus::PendingPayment => 'warning',
|
||||
BookingStatus::Confirmed => 'success',
|
||||
BookingStatus::Cancelled => 'gray',
|
||||
BookingStatus::Expired => 'danger',
|
||||
}),
|
||||
TextColumn::make('route.company.name')
|
||||
->label('Company')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('route.fromDestination.name')
|
||||
->label('From'),
|
||||
TextColumn::make('route.toDestination.name')
|
||||
->label('To'),
|
||||
TextColumn::make('travel_date')
|
||||
->date()
|
||||
->sortable(),
|
||||
TextColumn::make('timeSlot.label')
|
||||
->label('Time Slot'),
|
||||
TextColumn::make('vehicleOptions')
|
||||
->label('Vehicle Options')
|
||||
->state(fn (Booking $record) => $record->vehicleOptions
|
||||
->map(fn ($line) => str($line->vehicle_option->value)->headline().' x'.$line->passenger_count)
|
||||
->all())
|
||||
->listWithLineBreaks(),
|
||||
TextColumn::make('price')
|
||||
->numeric(2)
|
||||
->sortable(),
|
||||
TextColumn::make('passenger_name')
|
||||
->label('Passenger')
|
||||
->description(fn (Booking $record) => $record->passenger_phone)
|
||||
->searchable(['passenger_name', 'passenger_phone'])
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('created_by_channel')
|
||||
->badge()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('driver_name')
|
||||
->label('Driver')
|
||||
->placeholder('Not yet assigned')
|
||||
->description(fn (Booking $record) => collect([$record->driver_phone, $record->car_plate_number, $record->car_model])
|
||||
->filter()
|
||||
->join(' • ') ?: null)
|
||||
->searchable(['driver_name', 'driver_phone', 'car_plate_number', 'car_model'])
|
||||
->toggleable(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->options(array_combine(
|
||||
array_map(fn (BookingStatus $status) => $status->value, BookingStatus::cases()),
|
||||
array_map(fn (BookingStatus $status) => str($status->value)->headline()->toString(), BookingStatus::cases()),
|
||||
)),
|
||||
Filter::make('travel_date')
|
||||
->schema([
|
||||
DatePicker::make('travel_date'),
|
||||
])
|
||||
->query(fn (Builder $query, array $data) => $query->when(
|
||||
$data['travel_date'] ?? null,
|
||||
fn (Builder $q, $date) => $q->whereDate('travel_date', $date),
|
||||
)),
|
||||
SelectFilter::make('ev_route_id')
|
||||
->label('Route')
|
||||
->options(fn () => EvRoute::with(['fromDestination', 'toDestination'])->get()
|
||||
->mapWithKeys(fn (EvRoute $route) => [
|
||||
$route->id => "{$route->fromDestination?->name} → {$route->toDestination?->name}",
|
||||
]))
|
||||
->searchable(),
|
||||
SelectFilter::make('company')
|
||||
->options(fn () => EvCompany::pluck('name', 'id'))
|
||||
->searchable()
|
||||
->query(fn (Builder $query, array $data) => $query->when(
|
||||
$data['value'] ?? null,
|
||||
fn (Builder $q, $companyId) => $q->whereHas('route', fn (Builder $rq) => $rq->where('ev_company_id', $companyId)),
|
||||
)),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
AssignDriverTableAction::make(),
|
||||
CancelBookingTableAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Modules\Booking\Actions\CancelBookingAction;
|
||||
use Modules\Booking\Actions\CreateBookingAction;
|
||||
use Modules\Booking\Data\CreateBookingData;
|
||||
use Modules\Booking\Data\VehicleSelectionData;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Http\Requests\StoreBookingRequest;
|
||||
use Modules\Booking\Http\Resources\BookingResource;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
class BookingController extends Controller
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const EAGER_LOADS = ['route', 'timeSlot', 'vehicleOptions'];
|
||||
|
||||
public function __construct(
|
||||
private CreateBookingAction $createBookingAction,
|
||||
private CancelBookingAction $cancelBookingAction,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
Gate::authorize('viewAny', Booking::class);
|
||||
|
||||
$bookings = Booking::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->with(self::EAGER_LOADS)
|
||||
->latest()
|
||||
->paginate();
|
||||
|
||||
return BookingResource::collection($bookings);
|
||||
}
|
||||
|
||||
public function show(Booking $booking): BookingResource
|
||||
{
|
||||
Gate::authorize('view', $booking);
|
||||
|
||||
return new BookingResource($booking->load(self::EAGER_LOADS));
|
||||
}
|
||||
|
||||
public function store(StoreBookingRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$selections = array_map(
|
||||
fn (array $selection) => new VehicleSelectionData(
|
||||
vehicleOption: VehicleOption::from($selection['vehicle_option']),
|
||||
passengerCount: $selection['passenger_count'],
|
||||
),
|
||||
$validated['selections'],
|
||||
);
|
||||
|
||||
$booking = $this->createBookingAction->handle(new CreateBookingData(
|
||||
evRouteId: $validated['ev_route_id'],
|
||||
departureTimeSlotId: $validated['departure_time_slot_id'],
|
||||
travelDate: $validated['travel_date'],
|
||||
selections: $selections,
|
||||
passengerName: $validated['passenger_name'],
|
||||
passengerPhone: $validated['passenger_phone'],
|
||||
pickupAddress: $validated['pickup_address'],
|
||||
dropoffAddress: $validated['dropoff_address'],
|
||||
createdByChannel: isset($validated['created_by_channel'])
|
||||
? BookingChannel::from($validated['created_by_channel'])
|
||||
: BookingChannel::MiniApp,
|
||||
userId: $request->user()?->id,
|
||||
openid: $validated['openid'] ?? null,
|
||||
pickupLat: $validated['pickup_lat'] ?? null,
|
||||
pickupLng: $validated['pickup_lng'] ?? null,
|
||||
dropoffLat: $validated['dropoff_lat'] ?? null,
|
||||
dropoffLng: $validated['dropoff_lng'] ?? null,
|
||||
isRoundTrip: $validated['is_round_trip'] ?? false,
|
||||
returnTravelDate: $validated['return_travel_date'] ?? null,
|
||||
));
|
||||
|
||||
return (new BookingResource($booking->load(self::EAGER_LOADS)))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function cancel(Booking $booking): BookingResource
|
||||
{
|
||||
Gate::authorize('cancel', $booking);
|
||||
|
||||
$this->cancelBookingAction->handle($booking);
|
||||
|
||||
return new BookingResource($booking->load(self::EAGER_LOADS));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
/**
|
||||
* Shape validation only — business rules (front-seat limit, disabled vehicle
|
||||
* options, pricing) stay in BookingService/PricingService, not here.
|
||||
*/
|
||||
class StoreBookingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'ev_route_id' => ['required', 'integer', 'exists:ev_routes,id'],
|
||||
'departure_time_slot_id' => ['required', 'integer', 'exists:departure_time_slots,id'],
|
||||
'travel_date' => ['required', 'date'],
|
||||
// One or more Vehicle Option lines — e.g. front_seat + back_seat together
|
||||
// (domain.md §2). Duplicate-option/whole-vehicle-exclusivity rules stay in
|
||||
// BookingService, not here.
|
||||
'selections' => ['required', 'array', 'min:1'],
|
||||
'selections.*.vehicle_option' => ['required', Rule::enum(VehicleOption::class)],
|
||||
'selections.*.passenger_count' => ['required', 'integer', 'min:1'],
|
||||
'passenger_name' => ['required', 'string', 'max:255'],
|
||||
'passenger_phone' => ['required', 'string', 'max:50'],
|
||||
'pickup_address' => ['required', 'string', 'max:500'],
|
||||
'pickup_lat' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'pickup_lng' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
'dropoff_address' => ['required', 'string', 'max:500'],
|
||||
'dropoff_lat' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'dropoff_lng' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
'openid' => ['nullable', 'string', 'max:255'],
|
||||
'is_round_trip' => ['sometimes', 'boolean'],
|
||||
'return_travel_date' => ['nullable', 'date', 'required_if:is_round_trip,true'],
|
||||
// Admin-created bookings go through the Filament resource (T4.7), not this API.
|
||||
'created_by_channel' => ['sometimes', Rule::enum(BookingChannel::class)->except(BookingChannel::Admin)],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class BookingResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'booking_ref' => $this->booking_ref,
|
||||
'status' => $this->status,
|
||||
'travel_date' => $this->travel_date?->toDateString(),
|
||||
'is_round_trip' => $this->is_round_trip,
|
||||
'return_travel_date' => $this->return_travel_date?->toDateString(),
|
||||
'passenger_name' => $this->passenger_name,
|
||||
'passenger_phone' => $this->passenger_phone,
|
||||
'pickup_address' => $this->pickup_address,
|
||||
'pickup_lat' => $this->pickup_lat,
|
||||
'pickup_lng' => $this->pickup_lng,
|
||||
'dropoff_address' => $this->dropoff_address,
|
||||
'dropoff_lat' => $this->dropoff_lat,
|
||||
'dropoff_lng' => $this->dropoff_lng,
|
||||
'price' => $this->price,
|
||||
'created_by_channel' => $this->created_by_channel,
|
||||
// Only ever populated once status is confirmed — see AssignDriverAction.
|
||||
'driver_name' => $this->driver_name,
|
||||
'driver_phone' => $this->driver_phone,
|
||||
'car_plate_number' => $this->car_plate_number,
|
||||
'car_model' => $this->car_model,
|
||||
'vehicle_options' => $this->whenLoaded('vehicleOptions', fn () => $this->vehicleOptions->map(fn ($selection) => [
|
||||
'vehicle_option' => $selection->vehicle_option,
|
||||
'passenger_count' => $selection->passenger_count,
|
||||
'unit_price' => $selection->unit_price,
|
||||
'line_total' => $selection->line_total,
|
||||
])),
|
||||
'route' => $this->whenLoaded('route', fn () => [
|
||||
'id' => $this->route->id,
|
||||
'ev_company_id' => $this->route->ev_company_id,
|
||||
'from_destination_id' => $this->route->from_destination_id,
|
||||
'to_destination_id' => $this->route->to_destination_id,
|
||||
]),
|
||||
'time_slot' => $this->whenLoaded('timeSlot', fn () => [
|
||||
'id' => $this->timeSlot->id,
|
||||
'label' => $this->timeSlot->label,
|
||||
'time' => $this->timeSlot->time?->format('H:i'),
|
||||
]),
|
||||
'created_at' => $this->created_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\Booking\Database\Factories\BookingFactory;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
|
||||
class Booking extends Model
|
||||
{
|
||||
/** @use HasFactory<BookingFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'booking_ref',
|
||||
'user_id',
|
||||
'openid',
|
||||
'ev_route_id',
|
||||
'departure_time_slot_id',
|
||||
'travel_date',
|
||||
'passenger_name',
|
||||
'passenger_phone',
|
||||
'pickup_address',
|
||||
'pickup_lat',
|
||||
'pickup_lng',
|
||||
'dropoff_address',
|
||||
'dropoff_lat',
|
||||
'dropoff_lng',
|
||||
'price',
|
||||
'status',
|
||||
'is_round_trip',
|
||||
'return_travel_date',
|
||||
'created_by_channel',
|
||||
'driver_name',
|
||||
'driver_phone',
|
||||
'car_plate_number',
|
||||
'car_model',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'travel_date' => 'date',
|
||||
'pickup_lat' => 'decimal:7',
|
||||
'pickup_lng' => 'decimal:7',
|
||||
'dropoff_lat' => 'decimal:7',
|
||||
'dropoff_lng' => 'decimal:7',
|
||||
'price' => 'decimal:2',
|
||||
'status' => BookingStatus::class,
|
||||
'is_round_trip' => 'boolean',
|
||||
'return_travel_date' => 'date',
|
||||
'created_by_channel' => BookingChannel::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function route(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvRoute::class, 'ev_route_id');
|
||||
}
|
||||
|
||||
public function timeSlot(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DepartureTimeSlot::class, 'departure_time_slot_id');
|
||||
}
|
||||
|
||||
public function vehicleOptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(BookingVehicleOption::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Booking\Database\Factories\BookingVehicleOptionFactory;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
/**
|
||||
* One Vehicle Option line on a Booking (e.g. "back_seat x2"). A booking can
|
||||
* have more than one of these — see domain.md §2.
|
||||
*/
|
||||
class BookingVehicleOption extends Model
|
||||
{
|
||||
/** @use HasFactory<BookingVehicleOptionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'booking_id',
|
||||
'vehicle_option',
|
||||
'passenger_count',
|
||||
'unit_price',
|
||||
'line_total',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'vehicle_option' => VehicleOption::class,
|
||||
'passenger_count' => 'integer',
|
||||
'unit_price' => 'decimal:2',
|
||||
'line_total' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function booking(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Booking::class);
|
||||
}
|
||||
}
|
||||
@@ -3,22 +3,28 @@
|
||||
namespace Modules\Booking\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Skeleton only — role/permission gates for now. Per-booking ownership
|
||||
* checks (e.g. a customer may only view/cancel their own booking) are
|
||||
* filled in against the real Booking model once it exists (Phase 4).
|
||||
*/
|
||||
class BookingPolicy
|
||||
{
|
||||
/**
|
||||
* Listing is always scoped to the caller's own bookings at the query
|
||||
* level (BookingController::index) — any authenticated user may look at
|
||||
* their own list. Staff get the full, unscoped list via the Filament
|
||||
* BookingResource (T4.7), not this gate.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->can('view_bookings');
|
||||
return true;
|
||||
}
|
||||
|
||||
public function view(User $user, mixed $booking): bool
|
||||
/**
|
||||
* A booking's owner may always view it; anyone else needs the
|
||||
* view_bookings permission (admin/support roles).
|
||||
*/
|
||||
public function view(User $user, Booking $booking): bool
|
||||
{
|
||||
return $user->can('view_bookings');
|
||||
return $user->id === $booking->user_id || $user->can('view_bookings');
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
@@ -26,9 +32,14 @@ class BookingPolicy
|
||||
return true;
|
||||
}
|
||||
|
||||
public function cancel(User $user, mixed $booking): bool
|
||||
/**
|
||||
* A booking's owner may cancel their own (still pending_payment only —
|
||||
* enforced by CancelBookingAction, not here); staff can cancel any
|
||||
* booking via manage_bookings (domain.md §8).
|
||||
*/
|
||||
public function cancel(User $user, Booking $booking): bool
|
||||
{
|
||||
return $user->can('manage_bookings');
|
||||
return $user->id === $booking->user_id || $user->can('manage_bookings');
|
||||
}
|
||||
|
||||
public function refund(User $user, mixed $booking): bool
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Services;
|
||||
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
class BookingRefGenerator
|
||||
{
|
||||
private const PREFIX = 'EVB';
|
||||
|
||||
private const CHARS = '123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
/**
|
||||
* Must be called inside the same DB::transaction() as the booking insert
|
||||
* — the row lock on the latest booking is what keeps concurrent callers
|
||||
* from generating the same ref, and it only holds for the transaction's
|
||||
* lifetime.
|
||||
*/
|
||||
public function generate(): string
|
||||
{
|
||||
// Lock the latest row so concurrent transactions can't read the same ref.
|
||||
$latest = Booking::lockForUpdate()->orderByDesc('id')->value('booking_ref');
|
||||
|
||||
// If the latest ref doesn't match the expected format, start the sequence fresh.
|
||||
if ($latest && preg_match('/^[A-Z]+-[A-Z0-9]+$/', $latest)) {
|
||||
return $this->incrementRef($latest);
|
||||
}
|
||||
|
||||
return self::PREFIX.'-AAAAA1';
|
||||
}
|
||||
|
||||
private function incrementRef(string $ref): string
|
||||
{
|
||||
preg_match('/^(.*)-([A-Z0-9]+)$/', $ref, $matches);
|
||||
|
||||
$prefix = $matches[1];
|
||||
$suffix = str_split($matches[2]);
|
||||
$base = strlen(self::CHARS);
|
||||
$i = count($suffix) - 1;
|
||||
$carry = true;
|
||||
|
||||
while ($i >= 0 && $carry) {
|
||||
$idx = strpos(self::CHARS, $suffix[$i]);
|
||||
|
||||
if ($idx + 1 < $base) {
|
||||
$suffix[$i] = self::CHARS[$idx + 1];
|
||||
$carry = false;
|
||||
} else {
|
||||
$suffix[$i] = self::CHARS[0];
|
||||
}
|
||||
$i--;
|
||||
}
|
||||
|
||||
if ($carry) {
|
||||
array_unshift($suffix, self::CHARS[0]);
|
||||
}
|
||||
|
||||
return $prefix.'-'.implode('', $suffix);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Services;
|
||||
|
||||
use Modules\Booking\Data\VehicleSelectionData;
|
||||
use Modules\Booking\Exceptions\InvalidVehicleSelectionException;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
class BookingService
|
||||
{
|
||||
/**
|
||||
* Enforces the only v1 inventory rule (max Front Seats per booking), the
|
||||
* blunt config toggles for Back Seat / Whole Vehicle availability, and
|
||||
* shape rules around combining options in one booking (no duplicate
|
||||
* option lines, Whole Vehicle can't be mixed with anything else since it
|
||||
* already covers the whole car).
|
||||
*
|
||||
* Deliberately does not check real capacity/availability — that's an
|
||||
* explicitly deferred future phase (domain.md §2, §7).
|
||||
*
|
||||
* @param list<VehicleSelectionData> $selections
|
||||
*
|
||||
* @throws InvalidVehicleSelectionException
|
||||
*/
|
||||
public function validateSelections(array $selections): void
|
||||
{
|
||||
$seen = [];
|
||||
|
||||
foreach ($selections as $selection) {
|
||||
if (isset($seen[$selection->vehicleOption->value])) {
|
||||
throw InvalidVehicleSelectionException::duplicateOption($selection->vehicleOption);
|
||||
}
|
||||
|
||||
$seen[$selection->vehicleOption->value] = true;
|
||||
|
||||
$this->validateOption($selection->vehicleOption, $selection->passengerCount);
|
||||
}
|
||||
|
||||
if (isset($seen[VehicleOption::WholeVehicle->value]) && count($seen) > 1) {
|
||||
throw InvalidVehicleSelectionException::wholeVehicleCannotBeCombined();
|
||||
}
|
||||
}
|
||||
|
||||
private function validateOption(VehicleOption $vehicleOption, int $passengerCount): void
|
||||
{
|
||||
match ($vehicleOption) {
|
||||
VehicleOption::FrontSeat => $this->validateFrontSeat($passengerCount),
|
||||
VehicleOption::BackSeat => $this->validateEnabled($vehicleOption, 'booking.back_seat_enabled'),
|
||||
VehicleOption::WholeVehicle => $this->validateEnabled($vehicleOption, 'booking.whole_vehicle_enabled'),
|
||||
};
|
||||
}
|
||||
|
||||
private function validateFrontSeat(int $passengerCount): void
|
||||
{
|
||||
$max = config('booking.front_seat_max_per_booking');
|
||||
|
||||
if ($passengerCount > $max) {
|
||||
throw InvalidVehicleSelectionException::frontSeatLimitExceeded($passengerCount, $max);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateEnabled(VehicleOption $vehicleOption, string $configKey): void
|
||||
{
|
||||
if (! config($configKey)) {
|
||||
throw InvalidVehicleSelectionException::optionDisabled($vehicleOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user