5b68f4fa38
- 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)
153 lines
6.0 KiB
PHP
153 lines
6.0 KiB
PHP
<?php
|
|
|
|
use Illuminate\Support\Facades\Event;
|
|
use Modules\Booking\Actions\CreateBookingAction;
|
|
use Modules\Booking\Data\CreateBookingData;
|
|
use Modules\Booking\Data\VehicleSelectionData;
|
|
use Modules\Booking\Enums\BookingChannel;
|
|
use Modules\Booking\Enums\BookingStatus;
|
|
use Modules\Booking\Events\BookingCreated;
|
|
use Modules\Booking\Exceptions\InvalidVehicleSelectionException;
|
|
use Modules\Booking\Models\Booking;
|
|
use Modules\Catalog\Models\DepartureTimeSlot;
|
|
use Modules\Routing\Models\EvRoute;
|
|
use Modules\Routing\Models\RoutePricing;
|
|
use Modules\Shared\Enums\VehicleOption;
|
|
|
|
/**
|
|
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
|
|
*/
|
|
function makeBookableRoute(array $pricedOptions): array
|
|
{
|
|
$route = EvRoute::factory()->create();
|
|
$timeSlot = DepartureTimeSlot::factory()->create();
|
|
|
|
foreach ($pricedOptions as [$vehicleOption, $price]) {
|
|
RoutePricing::factory()->create([
|
|
'ev_route_id' => $route->id,
|
|
'vehicle_option' => $vehicleOption,
|
|
'price' => $price,
|
|
]);
|
|
}
|
|
|
|
return [$route, $timeSlot];
|
|
}
|
|
|
|
function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections): CreateBookingData
|
|
{
|
|
return new CreateBookingData(
|
|
evRouteId: $route->id,
|
|
departureTimeSlotId: $timeSlot->id,
|
|
travelDate: now()->addDay()->toDateString(),
|
|
selections: $selections,
|
|
passengerName: 'Jane Doe',
|
|
passengerPhone: '+959123456789',
|
|
pickupAddress: '123 Pickup St',
|
|
dropoffAddress: '456 Dropoff Ave',
|
|
createdByChannel: BookingChannel::MiniApp,
|
|
openid: 'mini-app-openid-123',
|
|
);
|
|
}
|
|
|
|
test('it persists a pending_payment booking with the price snapshotted from PricingService', function () {
|
|
config(['booking.back_seat_enabled' => true]);
|
|
|
|
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '15000.00']]);
|
|
|
|
$booking = app(CreateBookingAction::class)->handle(
|
|
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)])
|
|
);
|
|
|
|
expect($booking->exists)->toBeTrue()
|
|
->and($booking->booking_ref)->toBe('EVB-AAAAA1')
|
|
->and($booking->status)->toBe(BookingStatus::PendingPayment)
|
|
->and($booking->price)->toEqual('15000.00')
|
|
->and($booking->ev_route_id)->toBe($route->id)
|
|
->and($booking->departure_time_slot_id)->toBe($timeSlot->id)
|
|
->and($booking->openid)->toBe('mini-app-openid-123')
|
|
->and($booking->vehicleOptions)->toHaveCount(1)
|
|
->and($booking->vehicleOptions->first()->vehicle_option)->toBe(VehicleOption::BackSeat)
|
|
->and($booking->vehicleOptions->first()->unit_price)->toEqual('15000.00');
|
|
});
|
|
|
|
test('it books front seat and back seat together and sums the price across both lines', function () {
|
|
config(['booking.back_seat_enabled' => true]);
|
|
|
|
[$route, $timeSlot] = makeBookableRoute([
|
|
[VehicleOption::FrontSeat, '12000.00'],
|
|
[VehicleOption::BackSeat, '9000.00'],
|
|
]);
|
|
|
|
$booking = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [
|
|
new VehicleSelectionData(VehicleOption::FrontSeat, 1),
|
|
new VehicleSelectionData(VehicleOption::BackSeat, 2),
|
|
]));
|
|
|
|
expect($booking->price)->toEqual('30000.00') // 12000 + (9000 * 2)
|
|
->and($booking->vehicleOptions)->toHaveCount(2);
|
|
|
|
$frontSeatLine = $booking->vehicleOptions->firstWhere('vehicle_option', VehicleOption::FrontSeat);
|
|
$backSeatLine = $booking->vehicleOptions->firstWhere('vehicle_option', VehicleOption::BackSeat);
|
|
|
|
expect($frontSeatLine->passenger_count)->toBe(1)
|
|
->and($frontSeatLine->line_total)->toEqual('12000.00')
|
|
->and($backSeatLine->passenger_count)->toBe(2)
|
|
->and($backSeatLine->line_total)->toEqual('18000.00');
|
|
});
|
|
|
|
test('it dispatches BookingCreated', function () {
|
|
Event::fake([BookingCreated::class]);
|
|
config(['booking.whole_vehicle_enabled' => true]);
|
|
|
|
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::WholeVehicle, '30000.00']]);
|
|
|
|
$booking = app(CreateBookingAction::class)->handle(
|
|
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::WholeVehicle)])
|
|
);
|
|
|
|
Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($booking));
|
|
});
|
|
|
|
test('it rejects a disabled vehicle option before touching the database', function () {
|
|
config(['booking.whole_vehicle_enabled' => false]);
|
|
|
|
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::WholeVehicle, '30000.00']]);
|
|
|
|
expect(fn () => app(CreateBookingAction::class)->handle(
|
|
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::WholeVehicle)])
|
|
))->toThrow(InvalidVehicleSelectionException::class);
|
|
|
|
expect(Booking::count())->toBe(0);
|
|
});
|
|
|
|
test('it rejects mixing whole vehicle with another option before touching the database', function () {
|
|
config([
|
|
'booking.back_seat_enabled' => true,
|
|
'booking.whole_vehicle_enabled' => true,
|
|
]);
|
|
|
|
[$route, $timeSlot] = makeBookableRoute([
|
|
[VehicleOption::WholeVehicle, '30000.00'],
|
|
[VehicleOption::BackSeat, '9000.00'],
|
|
]);
|
|
|
|
expect(fn () => app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [
|
|
new VehicleSelectionData(VehicleOption::WholeVehicle),
|
|
new VehicleSelectionData(VehicleOption::BackSeat),
|
|
])))->toThrow(InvalidVehicleSelectionException::class);
|
|
|
|
expect(Booking::count())->toBe(0);
|
|
});
|
|
|
|
test('each booking created gets a unique, sequential booking_ref', function () {
|
|
config(['booking.back_seat_enabled' => true]);
|
|
|
|
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
|
|
|
$first = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)]));
|
|
$second = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)]));
|
|
|
|
expect($first->booking_ref)->toBe('EVB-AAAAA1')
|
|
->and($second->booking_ref)->toBe('EVB-AAAAA2');
|
|
});
|