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:
Nyan Lin Paing
2026-08-08 21:43:15 +07:00
parent 4da9ecfe7d
commit 5b68f4fa38
51 changed files with 2698 additions and 42 deletions
@@ -0,0 +1,178 @@
<?php
use App\Models\User;
use Modules\Booking\Enums\BookingStatus;
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;
beforeEach(function () {
$this->token = User::factory()->create()->createToken('test-token')->plainTextToken;
});
/**
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
*/
function bookableRouteAndSlot(array $pricedOptions): array
{
$route = EvRoute::factory()->create(['is_active' => true]);
$timeSlot = DepartureTimeSlot::factory()->create();
$route->timeSlots()->attach($timeSlot->id, ['is_active' => true]);
foreach ($pricedOptions as [$vehicleOption, $price]) {
RoutePricing::factory()->create([
'ev_route_id' => $route->id,
'vehicle_option' => $vehicleOption,
'price' => $price,
]);
}
return [$route, $timeSlot];
}
/**
* @param array<int, array{vehicle_option: string, passenger_count: int}> $selections
*/
function bookingPayload(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections): array
{
return [
'ev_route_id' => $route->id,
'departure_time_slot_id' => $timeSlot->id,
'travel_date' => now()->addDay()->toDateString(),
'selections' => $selections,
'passenger_name' => 'Jane Doe',
'passenger_phone' => '+959123456789',
'pickup_address' => '123 Pickup St',
'dropoff_address' => '456 Dropoff Ave',
];
}
test('happy path: it creates a pending_payment booking with a snapshotted price', function () {
config(['booking.back_seat_enabled' => true]);
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
]))
->assertCreated()
->assertJsonPath('data.status', BookingStatus::PendingPayment->value)
->assertJsonPath('data.price', '15000.00')
->assertJsonPath('data.vehicle_options.0.vehicle_option', VehicleOption::BackSeat->value)
->assertJsonPath('data.route.id', $route->id)
->assertJsonPath('data.time_slot.id', $timeSlot->id);
expect(Booking::count())->toBe(1);
});
test('happy path: front seat and back seat can be booked together', function () {
config(['booking.back_seat_enabled' => true]);
[$route, $timeSlot] = bookableRouteAndSlot([
[VehicleOption::FrontSeat, '12000.00'],
[VehicleOption::BackSeat, '9000.00'],
]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
['vehicle_option' => 'front_seat', 'passenger_count' => 1],
['vehicle_option' => 'back_seat', 'passenger_count' => 2],
]))
->assertCreated()
->assertJsonPath('data.price', '30000.00')
->assertJsonCount(2, 'data.vehicle_options');
});
test('front-seat-limit rejection surfaces as 422', function () {
config(['booking.front_seat_max_per_booking' => 1]);
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::FrontSeat, '12000.00']]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
['vehicle_option' => 'front_seat', 'passenger_count' => 2],
]))
->assertStatus(422)
->assertJsonPath('message', 'Front seat request [2] exceeds the max of [1] per booking.');
expect(Booking::count())->toBe(0);
});
test('disabled-vehicle-option rejection surfaces as 422', function () {
config(['booking.whole_vehicle_enabled' => false]);
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::WholeVehicle, '30000.00']]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
['vehicle_option' => 'whole_vehicle', 'passenger_count' => 1],
]))
->assertStatus(422)
->assertJsonPath('message', 'Vehicle option [whole_vehicle] is not currently available for booking.');
expect(Booking::count())->toBe(0);
});
test('mixing whole vehicle with another option surfaces as 422', function () {
config([
'booking.back_seat_enabled' => true,
'booking.whole_vehicle_enabled' => true,
]);
[$route, $timeSlot] = bookableRouteAndSlot([
[VehicleOption::WholeVehicle, '30000.00'],
[VehicleOption::BackSeat, '9000.00'],
]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
['vehicle_option' => 'whole_vehicle', 'passenger_count' => 1],
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
]))
->assertStatus(422);
expect(Booking::count())->toBe(0);
});
test('unauthenticated requests are rejected', function () {
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
$this->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
]))->assertUnauthorized();
});
test('shape validation rejects a missing required field', function () {
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', [])
->assertStatus(422)
->assertJsonValidationErrors([
'ev_route_id', 'departure_time_slot_id', 'travel_date', 'selections',
'passenger_name', 'passenger_phone', 'pickup_address', 'dropoff_address',
]);
});
test('shape validation rejects an invalid vehicle_option value', function () {
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
$payload = bookingPayload($route, $timeSlot, [
['vehicle_option' => 'business_class', 'passenger_count' => 1],
]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', $payload)
->assertStatus(422)
->assertJsonValidationErrors(['selections.0.vehicle_option']);
});
test('shape validation rejects an empty selections array', function () {
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, []))
->assertStatus(422)
->assertJsonValidationErrors(['selections']);
});