Generalize per-vehicle-option booking rules to Front Seat too
Front Seat previously had a hardcoded max-per-booking check with no enable toggle; now every Vehicle Option (front_seat/back_seat/whole_vehicle) gets the same config-driven pair — an on/off toggle and a max passenger_count — surfaced via new BOOKING_*_ENABLED/BOOKING_*_MAX_PER_BOOKING env vars, editable from ManageAppSettings, and exposed on route pricing as max_per_booking.
This commit is contained in:
+5
-2
@@ -53,9 +53,12 @@ REDIS_HOST=127.0.0.1
|
|||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
BOOKING_BACK_SEAT_ENABLED=true
|
BOOKING_FRONT_SEAT_ENABLED=true
|
||||||
BOOKING_WHOLE_VEHICLE_ENABLED=true
|
|
||||||
BOOKING_FRONT_SEAT_MAX_PER_BOOKING=1
|
BOOKING_FRONT_SEAT_MAX_PER_BOOKING=1
|
||||||
|
BOOKING_BACK_SEAT_ENABLED=true
|
||||||
|
BOOKING_BACK_SEAT_MAX_PER_BOOKING=3
|
||||||
|
BOOKING_WHOLE_VEHICLE_ENABLED=true
|
||||||
|
BOOKING_WHOLE_VEHICLE_MAX_PER_BOOKING=4
|
||||||
|
|
||||||
BOOKING_ADMIN_EMAILS="example@gmail.com"
|
BOOKING_ADMIN_EMAILS="example@gmail.com"
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ use RuntimeException;
|
|||||||
|
|
||||||
class InvalidVehicleSelectionException extends RuntimeException
|
class InvalidVehicleSelectionException extends RuntimeException
|
||||||
{
|
{
|
||||||
public static function frontSeatLimitExceeded(int $requested, int $max): self
|
public static function passengerLimitExceeded(VehicleOption $vehicleOption, int $requested, int $max): self
|
||||||
{
|
{
|
||||||
return new self("Front seat request [{$requested}] exceeds the max of [{$max}] per booking.");
|
return new self("Vehicle option [{$vehicleOption->value}] passenger count [{$requested}] exceeds the max of [{$max}] per booking.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function optionDisabled(VehicleOption $vehicleOption): self
|
public static function optionDisabled(VehicleOption $vehicleOption): self
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ use Modules\Shared\Enums\VehicleOption;
|
|||||||
class BookingService
|
class BookingService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Enforces the only v1 inventory rule (max Front Seats per booking), the
|
* Enforces the same two blunt, config-driven rules for every Vehicle
|
||||||
* blunt config toggles for Back Seat / Whole Vehicle availability, and
|
* Option — an on/off toggle and a max passenger_count per booking (see
|
||||||
* shape rules around combining options in one booking (no duplicate
|
* domain.md §2) — plus shape rules around combining options in one
|
||||||
* option lines, Whole Vehicle can't be mixed with anything else since it
|
* booking (no duplicate option lines, Whole Vehicle can't be mixed with
|
||||||
* already covers the whole car).
|
* anything else since it already covers the whole car).
|
||||||
*
|
*
|
||||||
* Deliberately does not check real capacity/availability — that's an
|
* Deliberately does not check real capacity/availability — that's an
|
||||||
* explicitly deferred future phase (domain.md §2, §7).
|
* explicitly deferred future phase (domain.md §2, §7).
|
||||||
@@ -43,26 +43,23 @@ class BookingService
|
|||||||
|
|
||||||
private function validateOption(VehicleOption $vehicleOption, int $passengerCount): void
|
private function validateOption(VehicleOption $vehicleOption, int $passengerCount): void
|
||||||
{
|
{
|
||||||
match ($vehicleOption) {
|
$this->validateEnabled($vehicleOption);
|
||||||
VehicleOption::FrontSeat => $this->validateFrontSeat($passengerCount),
|
$this->validateMax($vehicleOption, $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
|
private function validateEnabled(VehicleOption $vehicleOption): void
|
||||||
{
|
{
|
||||||
$max = config('booking.front_seat_max_per_booking');
|
if (! config("booking.{$vehicleOption->value}_enabled")) {
|
||||||
|
|
||||||
if ($passengerCount > $max) {
|
|
||||||
throw InvalidVehicleSelectionException::frontSeatLimitExceeded($passengerCount, $max);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function validateEnabled(VehicleOption $vehicleOption, string $configKey): void
|
|
||||||
{
|
|
||||||
if (! config($configKey)) {
|
|
||||||
throw InvalidVehicleSelectionException::optionDisabled($vehicleOption);
|
throw InvalidVehicleSelectionException::optionDisabled($vehicleOption);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function validateMax(VehicleOption $vehicleOption, int $passengerCount): void
|
||||||
|
{
|
||||||
|
$max = config("booking.{$vehicleOption->value}_max_per_booking");
|
||||||
|
|
||||||
|
if ($passengerCount > $max) {
|
||||||
|
throw InvalidVehicleSelectionException::passengerLimitExceeded($vehicleOption, $passengerCount, $max);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,37 @@ test('front-seat-limit rejection surfaces as 422', function () {
|
|||||||
['vehicle_option' => 'front_seat', 'passenger_count' => 2],
|
['vehicle_option' => 'front_seat', 'passenger_count' => 2],
|
||||||
]))
|
]))
|
||||||
->assertStatus(422)
|
->assertStatus(422)
|
||||||
->assertJsonPath('message', 'Front seat request [2] exceeds the max of [1] per booking.');
|
->assertJsonPath('message', 'Vehicle option [front_seat] passenger count [2] exceeds the max of [1] per booking.');
|
||||||
|
|
||||||
|
expect(Booking::count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('back-seat-limit rejection surfaces as 422', function () {
|
||||||
|
config(['booking.back_seat_max_per_booking' => 1]);
|
||||||
|
|
||||||
|
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||||
|
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||||
|
['vehicle_option' => 'back_seat', 'passenger_count' => 2],
|
||||||
|
]))
|
||||||
|
->assertStatus(422)
|
||||||
|
->assertJsonPath('message', 'Vehicle option [back_seat] passenger count [2] exceeds the max of [1] per booking.');
|
||||||
|
|
||||||
|
expect(Booking::count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('whole-vehicle-limit rejection surfaces as 422', function () {
|
||||||
|
config(['booking.whole_vehicle_max_per_booking' => 1]);
|
||||||
|
|
||||||
|
[$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' => 2],
|
||||||
|
]))
|
||||||
|
->assertStatus(422)
|
||||||
|
->assertJsonPath('message', 'Vehicle option [whole_vehicle] passenger count [2] exceeds the max of [1] per booking.');
|
||||||
|
|
||||||
expect(Booking::count())->toBe(0);
|
expect(Booking::count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,40 +30,52 @@ test('front seat and back seat can be selected together in one booking', functio
|
|||||||
]))->not->toThrow(InvalidVehicleSelectionException::class);
|
]))->not->toThrow(InvalidVehicleSelectionException::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('requesting more front seats than the configured max is rejected', function () {
|
test('requesting more passengers than the configured max is rejected', function (VehicleOption $option) {
|
||||||
|
config(["booking.{$option->value}_max_per_booking" => 1]);
|
||||||
|
|
||||||
|
$service = new BookingService;
|
||||||
|
|
||||||
|
expect(fn () => $service->validateSelections([new VehicleSelectionData($option, 2)]))
|
||||||
|
->toThrow(InvalidVehicleSelectionException::class);
|
||||||
|
})->with([
|
||||||
|
'front_seat' => [VehicleOption::FrontSeat],
|
||||||
|
'back_seat' => [VehicleOption::BackSeat],
|
||||||
|
'whole_vehicle' => [VehicleOption::WholeVehicle],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('requesting passengers up to the configured max passes', function (VehicleOption $option) {
|
||||||
|
config(["booking.{$option->value}_max_per_booking" => 2]);
|
||||||
|
|
||||||
|
$service = new BookingService;
|
||||||
|
|
||||||
|
expect(fn () => $service->validateSelections([new VehicleSelectionData($option, 2)]))
|
||||||
|
->not->toThrow(InvalidVehicleSelectionException::class);
|
||||||
|
})->with([
|
||||||
|
'front_seat' => [VehicleOption::FrontSeat],
|
||||||
|
'back_seat' => [VehicleOption::BackSeat],
|
||||||
|
'whole_vehicle' => [VehicleOption::WholeVehicle],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('an option is rejected when disabled via config', function (VehicleOption $option) {
|
||||||
|
config(["booking.{$option->value}_enabled" => false]);
|
||||||
|
|
||||||
|
$service = new BookingService;
|
||||||
|
|
||||||
|
expect(fn () => $service->validateSelections([new VehicleSelectionData($option)]))
|
||||||
|
->toThrow(InvalidVehicleSelectionException::class, "Vehicle option [{$option->value}] is not currently available for booking.");
|
||||||
|
})->with([
|
||||||
|
'front_seat' => [VehicleOption::FrontSeat],
|
||||||
|
'back_seat' => [VehicleOption::BackSeat],
|
||||||
|
'whole_vehicle' => [VehicleOption::WholeVehicle],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('exceeding the max produces the expected message', function () {
|
||||||
config(['booking.front_seat_max_per_booking' => 1]);
|
config(['booking.front_seat_max_per_booking' => 1]);
|
||||||
|
|
||||||
$service = new BookingService;
|
$service = new BookingService;
|
||||||
|
|
||||||
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::FrontSeat, 2)]))
|
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::FrontSeat, 2)]))
|
||||||
->toThrow(InvalidVehicleSelectionException::class);
|
->toThrow(InvalidVehicleSelectionException::class, 'Vehicle option [front_seat] passenger count [2] exceeds the max of [1] per booking.');
|
||||||
});
|
|
||||||
|
|
||||||
test('requesting front seats up to the configured max passes', function () {
|
|
||||||
config(['booking.front_seat_max_per_booking' => 2]);
|
|
||||||
|
|
||||||
$service = new BookingService;
|
|
||||||
|
|
||||||
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::FrontSeat, 2)]))
|
|
||||||
->not->toThrow(InvalidVehicleSelectionException::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('back seat is rejected when disabled via config', function () {
|
|
||||||
config(['booking.back_seat_enabled' => false]);
|
|
||||||
|
|
||||||
$service = new BookingService;
|
|
||||||
|
|
||||||
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::BackSeat)]))
|
|
||||||
->toThrow(InvalidVehicleSelectionException::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('whole vehicle is rejected when disabled via config', function () {
|
|
||||||
config(['booking.whole_vehicle_enabled' => false]);
|
|
||||||
|
|
||||||
$service = new BookingService;
|
|
||||||
|
|
||||||
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::WholeVehicle)]))
|
|
||||||
->toThrow(InvalidVehicleSelectionException::class);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the same vehicle option cannot be selected twice in one booking', function () {
|
test('the same vehicle option cannot be selected twice in one booking', function () {
|
||||||
|
|||||||
@@ -60,9 +60,12 @@ class ManageAppSettings extends Page
|
|||||||
'support_phone' => config('app.support_phone'),
|
'support_phone' => config('app.support_phone'),
|
||||||
'timezone' => config('app.timezone'),
|
'timezone' => config('app.timezone'),
|
||||||
'currency' => config('app.currency'),
|
'currency' => config('app.currency'),
|
||||||
'back_seat_enabled' => (bool) config('booking.back_seat_enabled'),
|
'front_seat_enabled' => (bool) config('booking.front_seat_enabled'),
|
||||||
'whole_vehicle_enabled' => (bool) config('booking.whole_vehicle_enabled'),
|
|
||||||
'front_seat_max_per_booking' => config('booking.front_seat_max_per_booking'),
|
'front_seat_max_per_booking' => config('booking.front_seat_max_per_booking'),
|
||||||
|
'back_seat_enabled' => (bool) config('booking.back_seat_enabled'),
|
||||||
|
'back_seat_max_per_booking' => config('booking.back_seat_max_per_booking'),
|
||||||
|
'whole_vehicle_enabled' => (bool) config('booking.whole_vehicle_enabled'),
|
||||||
|
'whole_vehicle_max_per_booking' => config('booking.whole_vehicle_max_per_booking'),
|
||||||
'booking_admin_emails' => config('booking.admin_emails'),
|
'booking_admin_emails' => config('booking.admin_emails'),
|
||||||
'sms_enabled' => (bool) config('services.sms.enabled'),
|
'sms_enabled' => (bool) config('services.sms.enabled'),
|
||||||
'sms_server' => config('services.sms.sms_poh.server'),
|
'sms_server' => config('services.sms.sms_poh.server'),
|
||||||
@@ -106,23 +109,39 @@ class ManageAppSettings extends Page
|
|||||||
->columns(2),
|
->columns(2),
|
||||||
Tab::make('Booking')
|
Tab::make('Booking')
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('back_seat_enabled')
|
Toggle::make('front_seat_enabled')
|
||||||
->label('Back Seat Enabled')
|
->label('Front Seat Enabled')
|
||||||
->helperText('Whether customers can select Back Seat at all right now.'),
|
->helperText('Whether customers can select Front Seat at all right now.'),
|
||||||
Toggle::make('whole_vehicle_enabled')
|
|
||||||
->label('Whole Vehicle Enabled')
|
|
||||||
->helperText('Whether customers can select Whole Vehicle at all right now.'),
|
|
||||||
TextInput::make('front_seat_max_per_booking')
|
TextInput::make('front_seat_max_per_booking')
|
||||||
->label('Front Seat Max Per Booking')
|
->label('Front Seat Max Per Booking')
|
||||||
->numeric()
|
->numeric()
|
||||||
->minValue(1)
|
->minValue(1)
|
||||||
->required()
|
->required()
|
||||||
->helperText('Max Front Seats a single booking may request.'),
|
->helperText('Max Front Seats a single booking may request.'),
|
||||||
|
Toggle::make('back_seat_enabled')
|
||||||
|
->label('Back Seat Enabled')
|
||||||
|
->helperText('Whether customers can select Back Seat at all right now.'),
|
||||||
|
TextInput::make('back_seat_max_per_booking')
|
||||||
|
->label('Back Seat Max Per Booking')
|
||||||
|
->numeric()
|
||||||
|
->minValue(1)
|
||||||
|
->required()
|
||||||
|
->helperText('Max Back Seats a single booking may request.'),
|
||||||
|
Toggle::make('whole_vehicle_enabled')
|
||||||
|
->label('Whole Vehicle Enabled')
|
||||||
|
->helperText('Whether customers can select Whole Vehicle at all right now.'),
|
||||||
|
TextInput::make('whole_vehicle_max_per_booking')
|
||||||
|
->label('Whole Vehicle Max Per Booking')
|
||||||
|
->numeric()
|
||||||
|
->minValue(1)
|
||||||
|
->required()
|
||||||
|
->helperText('Max Whole Vehicle passenger count a single booking may request.'),
|
||||||
TagsInput::make('booking_admin_emails')
|
TagsInput::make('booking_admin_emails')
|
||||||
->label('Admin Emails')
|
->label('Admin Emails')
|
||||||
->required()
|
->required()
|
||||||
->helperText('Notified on booking events. Press enter after each address.'),
|
->helperText('Notified on booking events. Press enter after each address.'),
|
||||||
]),
|
])
|
||||||
|
->columns(2),
|
||||||
Tab::make('SMS')
|
Tab::make('SMS')
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('sms_enabled')
|
Toggle::make('sms_enabled')
|
||||||
@@ -170,9 +189,12 @@ class ManageAppSettings extends Page
|
|||||||
'SUPPORT_PHONE' => $state['support_phone'],
|
'SUPPORT_PHONE' => $state['support_phone'],
|
||||||
'APP_TIMEZONE' => $state['timezone'],
|
'APP_TIMEZONE' => $state['timezone'],
|
||||||
'APP_CURRENCY' => $state['currency'],
|
'APP_CURRENCY' => $state['currency'],
|
||||||
'BOOKING_BACK_SEAT_ENABLED' => (bool) $state['back_seat_enabled'],
|
'BOOKING_FRONT_SEAT_ENABLED' => (bool) $state['front_seat_enabled'],
|
||||||
'BOOKING_WHOLE_VEHICLE_ENABLED' => (bool) $state['whole_vehicle_enabled'],
|
|
||||||
'BOOKING_FRONT_SEAT_MAX_PER_BOOKING' => (int) $state['front_seat_max_per_booking'],
|
'BOOKING_FRONT_SEAT_MAX_PER_BOOKING' => (int) $state['front_seat_max_per_booking'],
|
||||||
|
'BOOKING_BACK_SEAT_ENABLED' => (bool) $state['back_seat_enabled'],
|
||||||
|
'BOOKING_BACK_SEAT_MAX_PER_BOOKING' => (int) $state['back_seat_max_per_booking'],
|
||||||
|
'BOOKING_WHOLE_VEHICLE_ENABLED' => (bool) $state['whole_vehicle_enabled'],
|
||||||
|
'BOOKING_WHOLE_VEHICLE_MAX_PER_BOOKING' => (int) $state['whole_vehicle_max_per_booking'],
|
||||||
'BOOKING_ADMIN_EMAILS' => implode(',', $state['booking_admin_emails'] ?? []),
|
'BOOKING_ADMIN_EMAILS' => implode(',', $state['booking_admin_emails'] ?? []),
|
||||||
'SMS_ENABLED' => (bool) $state['sms_enabled'],
|
'SMS_ENABLED' => (bool) $state['sms_enabled'],
|
||||||
'SMS_SERVER' => $state['sms_server'],
|
'SMS_SERVER' => $state['sms_server'],
|
||||||
|
|||||||
@@ -40,9 +40,12 @@ test('a super_admin can view and save app settings, writing them to .env', funct
|
|||||||
'support_phone' => '+95912345678',
|
'support_phone' => '+95912345678',
|
||||||
'timezone' => 'Asia/Yangon',
|
'timezone' => 'Asia/Yangon',
|
||||||
'currency' => 'MMK',
|
'currency' => 'MMK',
|
||||||
'back_seat_enabled' => false,
|
'front_seat_enabled' => false,
|
||||||
'whole_vehicle_enabled' => true,
|
|
||||||
'front_seat_max_per_booking' => 2,
|
'front_seat_max_per_booking' => 2,
|
||||||
|
'back_seat_enabled' => false,
|
||||||
|
'back_seat_max_per_booking' => 5,
|
||||||
|
'whole_vehicle_enabled' => true,
|
||||||
|
'whole_vehicle_max_per_booking' => 6,
|
||||||
'booking_admin_emails' => ['ops@evbooking.test', 'dispatch@evbooking.test'],
|
'booking_admin_emails' => ['ops@evbooking.test', 'dispatch@evbooking.test'],
|
||||||
'sms_enabled' => true,
|
'sms_enabled' => true,
|
||||||
'sms_server' => 'https://sms.example.test/send',
|
'sms_server' => 'https://sms.example.test/send',
|
||||||
@@ -59,9 +62,12 @@ test('a super_admin can view and save app settings, writing them to .env', funct
|
|||||||
->toContain('SUPPORT_EMAIL=help@evbooking.test')
|
->toContain('SUPPORT_EMAIL=help@evbooking.test')
|
||||||
->toContain('APP_TIMEZONE=Asia/Yangon')
|
->toContain('APP_TIMEZONE=Asia/Yangon')
|
||||||
->toContain('APP_CURRENCY=MMK')
|
->toContain('APP_CURRENCY=MMK')
|
||||||
->toContain('BOOKING_BACK_SEAT_ENABLED=false')
|
->toContain('BOOKING_FRONT_SEAT_ENABLED=false')
|
||||||
->toContain('BOOKING_WHOLE_VEHICLE_ENABLED=true')
|
|
||||||
->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2')
|
->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2')
|
||||||
|
->toContain('BOOKING_BACK_SEAT_ENABLED=false')
|
||||||
|
->toContain('BOOKING_BACK_SEAT_MAX_PER_BOOKING=5')
|
||||||
|
->toContain('BOOKING_WHOLE_VEHICLE_ENABLED=true')
|
||||||
|
->toContain('BOOKING_WHOLE_VEHICLE_MAX_PER_BOOKING=6')
|
||||||
->toContain('BOOKING_ADMIN_EMAILS=ops@evbooking.test,dispatch@evbooking.test')
|
->toContain('BOOKING_ADMIN_EMAILS=ops@evbooking.test,dispatch@evbooking.test')
|
||||||
->toContain('SMS_ENABLED=true')
|
->toContain('SMS_ENABLED=true')
|
||||||
->toContain('SMS_SERVER=https://sms.example.test/send')
|
->toContain('SMS_SERVER=https://sms.example.test/send')
|
||||||
@@ -80,13 +86,17 @@ test('sms server and token are required once sms is enabled', function () {
|
|||||||
->assertHasFormErrors(['sms_server', 'sms_token']);
|
->assertHasFormErrors(['sms_server', 'sms_token']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('front seat max per booking must be at least 1', function () {
|
test('max per booking fields must be at least 1', function (string $field) {
|
||||||
$superAdmin = User::factory()->create();
|
$superAdmin = User::factory()->create();
|
||||||
$superAdmin->assignRole('super_admin');
|
$superAdmin->assignRole('super_admin');
|
||||||
$this->actingAs($superAdmin);
|
$this->actingAs($superAdmin);
|
||||||
|
|
||||||
Livewire::test(ManageAppSettings::class)
|
Livewire::test(ManageAppSettings::class)
|
||||||
->fillForm(['front_seat_max_per_booking' => 0])
|
->fillForm([$field => 0])
|
||||||
->call('save')
|
->call('save')
|
||||||
->assertHasFormErrors(['front_seat_max_per_booking']);
|
->assertHasFormErrors([$field]);
|
||||||
});
|
})->with([
|
||||||
|
'front_seat_max_per_booking',
|
||||||
|
'back_seat_max_per_booking',
|
||||||
|
'whole_vehicle_max_per_booking',
|
||||||
|
]);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class RoutePricingResource extends JsonResource
|
|||||||
'vehicle_option' => $this->vehicle_option->value,
|
'vehicle_option' => $this->vehicle_option->value,
|
||||||
'price' => (string) $this->price,
|
'price' => (string) $this->price,
|
||||||
'is_blocked' => $this->is_blocked,
|
'is_blocked' => $this->is_blocked,
|
||||||
|
'max_per_booking' => config("booking.{$this->vehicle_option->value}_max_per_booking"),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ test('searches active routes with nested company, destinations, time slots and p
|
|||||||
->assertJsonPath('routes.data.0.time_slots.0.is_active', true)
|
->assertJsonPath('routes.data.0.time_slots.0.is_active', true)
|
||||||
->assertJsonPath('routes.data.0.pricing.0.vehicle_option', 'front_seat')
|
->assertJsonPath('routes.data.0.pricing.0.vehicle_option', 'front_seat')
|
||||||
->assertJsonPath('routes.data.0.pricing.0.price', '12000.00')
|
->assertJsonPath('routes.data.0.pricing.0.price', '12000.00')
|
||||||
|
->assertJsonPath('routes.data.0.pricing.0.max_per_booking', config('booking.front_seat_max_per_booking'))
|
||||||
->assertJsonCount(0, 'return_routes.data');
|
->assertJsonCount(0, 'return_routes.data');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -311,8 +312,17 @@ test('lists a route\'s pricing including blocked options', function () {
|
|||||||
->getJson("/api/v1/routes/{$route->id}/pricing")
|
->getJson("/api/v1/routes/{$route->id}/pricing")
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertJsonCount(2, 'data')
|
->assertJsonCount(2, 'data')
|
||||||
->assertJsonFragment(['vehicle_option' => 'front_seat', 'price' => '12000.00', 'is_blocked' => false])
|
->assertJsonFragment([
|
||||||
->assertJsonFragment(['vehicle_option' => 'whole_vehicle', 'is_blocked' => true]);
|
'vehicle_option' => 'front_seat',
|
||||||
|
'price' => '12000.00',
|
||||||
|
'is_blocked' => false,
|
||||||
|
'max_per_booking' => config('booking.front_seat_max_per_booking'),
|
||||||
|
])
|
||||||
|
->assertJsonFragment([
|
||||||
|
'vehicle_option' => 'whole_vehicle',
|
||||||
|
'is_blocked' => true,
|
||||||
|
'max_per_booking' => config('booking.whole_vehicle_max_per_booking'),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('lists a route\'s time slots with the pivot active flag', function () {
|
test('lists a route\'s time slots with the pivot active flag', function () {
|
||||||
|
|||||||
+10
-16
@@ -4,30 +4,24 @@ return [
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Vehicle Option Toggles
|
| Vehicle Option Rules
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
|
||||||
| Blunt on/off switches for Vehicle Options that have no real inventory
|
| Every Vehicle Option gets the same two blunt, config-driven rules — no
|
||||||
| tracking in v1 (see domain.md §2). Front Seat has no toggle — it is
|
| real inventory tracking in v1 (see domain.md §2): an on/off switch for
|
||||||
| always selectable, only capped per-booking by the max below.
|
| whether it can be selected at all right now, and a max passenger_count
|
||||||
|
| a single booking may request for it.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
'front_seat_enabled' => env('BOOKING_FRONT_SEAT_ENABLED', true),
|
||||||
|
'front_seat_max_per_booking' => env('BOOKING_FRONT_SEAT_MAX_PER_BOOKING', 1),
|
||||||
|
|
||||||
'back_seat_enabled' => env('BOOKING_BACK_SEAT_ENABLED', true),
|
'back_seat_enabled' => env('BOOKING_BACK_SEAT_ENABLED', true),
|
||||||
|
'back_seat_max_per_booking' => env('BOOKING_BACK_SEAT_MAX_PER_BOOKING', 3),
|
||||||
|
|
||||||
'whole_vehicle_enabled' => env('BOOKING_WHOLE_VEHICLE_ENABLED', true),
|
'whole_vehicle_enabled' => env('BOOKING_WHOLE_VEHICLE_ENABLED', true),
|
||||||
|
'whole_vehicle_max_per_booking' => env('BOOKING_WHOLE_VEHICLE_MAX_PER_BOOKING', 4),
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Front Seat Limit
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| The only inventory rule enforced in v1: max Front Seats a single
|
|
||||||
| booking may request.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'front_seat_max_per_booking' => env('BOOKING_FRONT_SEAT_MAX_PER_BOOKING', 1),
|
|
||||||
|
|
||||||
// comma-separated list of admin emails to notify on booking events
|
// comma-separated list of admin emails to notify on booking events
|
||||||
'admin_emails' => explode(',', env('BOOKING_ADMIN_EMAILS', 'admin@example.com')),
|
'admin_emails' => explode(',', env('BOOKING_ADMIN_EMAILS', 'admin@example.com')),
|
||||||
|
|||||||
@@ -27,11 +27,10 @@ Unlike a bus-booking system, **there is no seat map and no capacity tracking in
|
|||||||
- A Booking can select **more than one Vehicle Option** in the same booking (e.g. `front_seat` + `back_seat` for a customer traveling with a companion) — stored as one row per selected option in `booking_vehicle_options` (`booking_id`, `vehicle_option`, `passenger_count`, `unit_price`, `line_total`), not a single column on `bookings`. `bookings.price` is the sum of every line's `line_total`.
|
- A Booking can select **more than one Vehicle Option** in the same booking (e.g. `front_seat` + `back_seat` for a customer traveling with a companion) — stored as one row per selected option in `booking_vehicle_options` (`booking_id`, `vehicle_option`, `passenger_count`, `unit_price`, `line_total`), not a single column on `bookings`. `bookings.price` is the sum of every line's `line_total`.
|
||||||
- Each Vehicle Option can appear **at most once per booking** (no two separate `front_seat` lines — bump `passenger_count` instead). `whole_vehicle` cannot be combined with any other option in the same booking, since it already covers the entire vehicle.
|
- Each Vehicle Option can appear **at most once per booking** (no two separate `front_seat` lines — bump `passenger_count` instead). `whole_vehicle` cannot be combined with any other option in the same booking, since it already covers the entire vehicle.
|
||||||
- Any number of *different bookings* can book the same Route + Date + Time Slot. The system does not check whether a "Whole Vehicle" or "Back Seat" is already taken by someone else.
|
- Any number of *different bookings* can book the same Route + Date + Time Slot. The system does not check whether a "Whole Vehicle" or "Back Seat" is already taken by someone else.
|
||||||
- The **only inventory rule enforced in code** is: **max Front Seats per booking**, checked against `passenger_count` on the `front_seat` line (`BOOKING_FRONT_SEAT_MAX_PER_BOOKING`, currently `1` — a per-booking constraint, not a per-trip inventory check).
|
- Every Vehicle Option is governed by the same two **blunt config-driven rules**, not database rows: an on/off toggle for whether it can be selected at all right now, and a max `passenger_count` a single booking may request for it (a per-booking constraint, not a per-trip inventory check):
|
||||||
- Back Seat and Whole Vehicle availability are controlled by **blunt config toggles**, not database rows:
|
- `BOOKING_FRONT_SEAT_ENABLED` / `BOOKING_FRONT_SEAT_MAX_PER_BOOKING` (currently `1`)
|
||||||
- `BOOKING_BACK_SEAT_ENABLED` — whether Back Seat can be selected at all right now.
|
- `BOOKING_BACK_SEAT_ENABLED` / `BOOKING_BACK_SEAT_MAX_PER_BOOKING` (currently `3`)
|
||||||
- `BOOKING_WHOLE_VEHICLE_ENABLED` — whether Whole Vehicle can be selected at all right now.
|
- `BOOKING_WHOLE_VEHICLE_ENABLED` / `BOOKING_WHOLE_VEHICLE_MAX_PER_BOOKING` (currently `4`)
|
||||||
- `BOOKING_FRONT_SEAT_MAX_PER_BOOKING` — currently `1`, expressed as config in case it ever needs to change.
|
|
||||||
- This is a **deliberate v1 simplification**, not an oversight. Real per-route/date/time-slot capacity holding (e.g. "only 1 Whole Vehicle booking allowed per trip") is an explicitly deferred future phase — see §7. `booking_vehicle_options` is deliberately shaped so that phase can be built as a new query against it (`sum(passenger_count) group by vehicle_option` for a route/date/time-slot) rather than a schema rework.
|
- This is a **deliberate v1 simplification**, not an oversight. Real per-route/date/time-slot capacity holding (e.g. "only 1 Whole Vehicle booking allowed per trip") is an explicitly deferred future phase — see §7. `booking_vehicle_options` is deliberately shaped so that phase can be built as a new query against it (`sum(passenger_count) group by vehicle_option` for a route/date/time-slot) rather than a schema rework.
|
||||||
- Consequence: double-booking of "Whole Vehicle" is possible by design until that future phase ships. Admins reconcile manually via the Filament Booking list (filterable by route + date + time).
|
- Consequence: double-booking of "Whole Vehicle" is possible by design until that future phase ships. Admins reconcile manually via the Filament Booking list (filterable by route + date + time).
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user