feat: prefer discounted transportation services
addresses #869axbjbz
This commit is contained in:
@@ -43,10 +43,18 @@
|
||||
- **Field Handlers**: 15+ specialized handlers in `src/Form/Service/`
|
||||
- Registered via service tags with dependency resolution
|
||||
- Process in dependency order via `ParticipantFieldHandlerRegistry`
|
||||
- `ParticipantDateOfBirthFieldHandler` handles array input from BirthdayType widget
|
||||
- **Conditional Fields**: Universal condition system (`FieldConditionInterface`)
|
||||
- Age-based, field-dependent, service-specific conditions
|
||||
- Applied via `CreateFieldStateProvider` / `EditFieldStateProvider`
|
||||
- **HTMX Integration**: Real-time updates for dynamic fields
|
||||
- **Date of Birth Field**: Uses `BirthdayType` with three text inputs (day, month, year)
|
||||
- Widget: `widget: 'text'` renders separate text fields instead of dropdowns
|
||||
- Input format: `input: 'datetime_immutable'` for immutable date handling
|
||||
- Custom theme: `birthday_widget` block in `templates/forms.html.twig` handles rendering
|
||||
- German format: Day.Month.Year with hardcoded placeholders ("Tag", "Monat", "Jahr")
|
||||
- Field order explicitly set to `['day', 'month', 'year']` for German date convention
|
||||
- HTMX attributes applied to parent container for form refresh on change
|
||||
|
||||
### Service Layer (`src/Service/`)
|
||||
- `BookingService` - Core booking workflow
|
||||
@@ -213,6 +221,8 @@ Critical for correct pricing and auto-reassignment:
|
||||
- Courses, additional services, board, insurance hidden until DOB provided
|
||||
- Age evaluated at travel start date, not current date
|
||||
- Dual constraint types: absolute_age, birth_year, mixed
|
||||
- **Array input handling**: `ParticipantDateOfBirthFieldHandler` processes array `['day' => X, 'month' => Y, 'year' => Z]` from three-field widget
|
||||
- **Date normalization**: Handler converts array to `DateTimeImmutable` using `sprintf()` with proper formatting
|
||||
|
||||
### Bulk Insurance Booking
|
||||
- Applicant enables bulk → applies to all participants
|
||||
|
||||
@@ -48,7 +48,7 @@ class Travel
|
||||
|
||||
/**
|
||||
* Travel booking status from BusProNet API.
|
||||
* Possible values: 'Frei', 'Anfrage', 'Buchungsstop'
|
||||
* Possible values: 'Frei', 'Anfrage', 'Buchungsstop'.
|
||||
*/
|
||||
#[Groups(['api:single'])]
|
||||
public ?string $status = null;
|
||||
@@ -153,8 +153,12 @@ class Travel
|
||||
* Filters transportation services based on travel direction and optionally
|
||||
* by availability. Services are sorted by subtype.
|
||||
*
|
||||
* Note: PKW/CAR filtering based on booking context is NOT applied here.
|
||||
* That filtering happens dynamically in ParticipantFieldOptionsProvider using
|
||||
* per-booking availability calculations from ServiceAvailabilityCalculator.
|
||||
*
|
||||
* @param string $direction The travel direction to filter by
|
||||
* @param bool $filterAvailable Whether to include only available services
|
||||
* @param bool $filterAvailable Whether to include only available services (API availability)
|
||||
*
|
||||
* @return array<int, Service> The filtered and sorted transportation services
|
||||
*/
|
||||
|
||||
@@ -349,7 +349,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
// Outbound Transportation
|
||||
$this->fieldOptionProviders['transportationOutbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||
'label' => 'Hinfahrt',
|
||||
'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL),
|
||||
'choices' => $this->filterTransportationChoices(
|
||||
$bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
),
|
||||
'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service),
|
||||
'choice_value' => 'id',
|
||||
'expanded' => true,
|
||||
@@ -767,4 +771,81 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
$travelPrice
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters transportation choices to show only one PKW option based on per-booking availability.
|
||||
*
|
||||
* When both discounted and regular self-organized (PKW/CAR) options exist:
|
||||
* - If discounted option has remaining availability within this booking, show only discounted
|
||||
* - If discounted option is sold out within this booking, show only regular
|
||||
* - This creates dynamic fallback: as participants select/deselect, availability updates in real-time
|
||||
*
|
||||
* Business logic: Prefer showing discounted options while they have availability, but automatically
|
||||
* fall back to regular options when discounts are exhausted. This allows participants who initially
|
||||
* got the discount to change their mind (e.g., select bus instead), making the discount available
|
||||
* for others in the same booking session.
|
||||
*
|
||||
* @param array $services Transportation services from Travel model
|
||||
* @param BookingDto $bookingDto Current booking DTO with participant selections
|
||||
* @param int $participantIndex Current participant being processed
|
||||
*
|
||||
* @return array Filtered transportation choices with smart PKW option selection
|
||||
*/
|
||||
private function filterTransportationChoices(array $services, BookingDto $bookingDto, int $participantIndex): array
|
||||
{
|
||||
// Only apply filtering in create mode
|
||||
if (BookingDto::MODE_CREATE !== $bookingDto->getMode()) {
|
||||
return $services;
|
||||
}
|
||||
|
||||
// Separate PKW/CAR from other services (BUS, etc.)
|
||||
$pkwServices = [];
|
||||
$otherServices = [];
|
||||
|
||||
foreach ($services as $service) {
|
||||
if (in_array($service->subType, ['PKW', 'CAR'], true)) {
|
||||
$pkwServices[] = $service;
|
||||
} else {
|
||||
$otherServices[] = $service;
|
||||
}
|
||||
}
|
||||
|
||||
// If only one or no PKW service, no filtering needed
|
||||
if (count($pkwServices) <= 1) {
|
||||
return [...$otherServices, ...$pkwServices];
|
||||
}
|
||||
|
||||
// Find discounted (negative price) and regular (zero/positive price) PKW options
|
||||
$discountedPkw = null;
|
||||
$regularPkw = null;
|
||||
|
||||
foreach ($pkwServices as $pkw) {
|
||||
if (null !== $pkw->price && $pkw->price < 0) {
|
||||
$discountedPkw = $pkw;
|
||||
} else {
|
||||
$regularPkw = $pkw;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have both discounted and regular options, apply smart filtering
|
||||
if (null !== $discountedPkw && null !== $regularPkw) {
|
||||
// Check if discounted option is unavailable for this participant (per-booking availability)
|
||||
$discountedIsUnavailable = $this->serviceAvailabilityCalculator->isServiceUnavailable(
|
||||
$discountedPkw->id,
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
);
|
||||
|
||||
if ($discountedIsUnavailable) {
|
||||
// Discounted is sold out within this booking, show only regular
|
||||
return [...$otherServices, $regularPkw];
|
||||
} else {
|
||||
// Discounted has availability, show only discounted (hide regular)
|
||||
return [...$otherServices, $discountedPkw];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return all services if we don't have the expected discount/regular pair
|
||||
return [...$otherServices, ...$pkwServices];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\BusProNet\Model;
|
||||
|
||||
use App\BusProNet\Model\Service;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for Travel model transportation service retrieval.
|
||||
*
|
||||
* Note: Smart PKW filtering (discounted vs regular) is tested in
|
||||
* ParticipantFieldOptionsProviderTest, as that filtering now happens
|
||||
* at the field options provider level with per-booking availability context.
|
||||
*/
|
||||
class TravelTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Test that transportation services are filtered by direction.
|
||||
*/
|
||||
public function testServicesFilteredByDirection(): void
|
||||
{
|
||||
$travel = new Travel();
|
||||
|
||||
$outboundPkw = new Service();
|
||||
$outboundPkw->id = 1;
|
||||
$outboundPkw->subType = 'PKW';
|
||||
$outboundPkw->direction = 'HIN';
|
||||
$outboundPkw->label = 'Eigene Anreise';
|
||||
$outboundPkw->available = 10;
|
||||
|
||||
$inboundPkw = new Service();
|
||||
$inboundPkw->id = 2;
|
||||
$inboundPkw->subType = 'PKW';
|
||||
$inboundPkw->direction = 'RUECK';
|
||||
$inboundPkw->label = 'Eigene Abreise';
|
||||
$inboundPkw->available = 10;
|
||||
|
||||
$travel->transportationServices = [$outboundPkw, $inboundPkw];
|
||||
|
||||
$outboundResult = $travel->getTransportationServicesByDirection('HIN');
|
||||
$this->assertCount(1, $outboundResult);
|
||||
$this->assertSame($outboundPkw, $outboundResult[0]);
|
||||
|
||||
$inboundResult = $travel->getTransportationServicesByDirection('RUECK');
|
||||
$this->assertCount(1, $inboundResult);
|
||||
$this->assertSame($inboundPkw, $inboundResult[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that unavailable services are filtered out when filterAvailable is true.
|
||||
*/
|
||||
public function testUnavailableServicesFiltered(): void
|
||||
{
|
||||
$travel = new Travel();
|
||||
|
||||
$availablePkw = new Service();
|
||||
$availablePkw->id = 1;
|
||||
$availablePkw->subType = 'PKW';
|
||||
$availablePkw->direction = 'HIN';
|
||||
$availablePkw->label = 'Eigene Anreise';
|
||||
$availablePkw->available = 10;
|
||||
|
||||
$unavailablePkw = new Service();
|
||||
$unavailablePkw->id = 2;
|
||||
$unavailablePkw->subType = 'PKW';
|
||||
$unavailablePkw->direction = 'HIN';
|
||||
$unavailablePkw->label = 'Eigene Anreise mit Rabatt';
|
||||
$unavailablePkw->available = 0;
|
||||
|
||||
$travel->transportationServices = [$availablePkw, $unavailablePkw];
|
||||
|
||||
$result = $travel->getTransportationServicesByDirection('HIN', true);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame($availablePkw, $result[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that services with null availability are included.
|
||||
*/
|
||||
public function testNullAvailabilityIncluded(): void
|
||||
{
|
||||
$travel = new Travel();
|
||||
|
||||
$pkwWithNullAvailability = new Service();
|
||||
$pkwWithNullAvailability->id = 1;
|
||||
$pkwWithNullAvailability->subType = 'PKW';
|
||||
$pkwWithNullAvailability->direction = 'HIN';
|
||||
$pkwWithNullAvailability->label = 'Eigene Anreise';
|
||||
$pkwWithNullAvailability->available = null;
|
||||
|
||||
$travel->transportationServices = [$pkwWithNullAvailability];
|
||||
|
||||
$result = $travel->getTransportationServicesByDirection('HIN', true);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame($pkwWithNullAvailability, $result[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that multiple PKW services are all returned (no smart filtering at Travel level).
|
||||
*/
|
||||
public function testMultiplePkwServicesAllReturned(): void
|
||||
{
|
||||
$travel = new Travel();
|
||||
|
||||
$regularPkw = new Service();
|
||||
$regularPkw->id = 1;
|
||||
$regularPkw->subType = 'PKW';
|
||||
$regularPkw->direction = 'HIN';
|
||||
$regularPkw->label = 'Eigene Anreise';
|
||||
$regularPkw->price = 0.0;
|
||||
$regularPkw->available = 96;
|
||||
|
||||
$discountedPkw = new Service();
|
||||
$discountedPkw->id = 2;
|
||||
$discountedPkw->subType = 'PKW';
|
||||
$discountedPkw->direction = 'HIN';
|
||||
$discountedPkw->label = 'Eigene Anreise mit Rabatt';
|
||||
$discountedPkw->price = -30.0;
|
||||
$discountedPkw->available = 99;
|
||||
|
||||
$travel->transportationServices = [$regularPkw, $discountedPkw];
|
||||
|
||||
$result = $travel->getTransportationServicesByDirection('HIN');
|
||||
|
||||
// Both PKW services should be returned (filtering happens at field options provider level)
|
||||
$this->assertCount(2, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that services are sorted by subType.
|
||||
*/
|
||||
public function testServicesSortedBySubType(): void
|
||||
{
|
||||
$travel = new Travel();
|
||||
|
||||
$pkwService = new Service();
|
||||
$pkwService->id = 1;
|
||||
$pkwService->subType = 'PKW';
|
||||
$pkwService->direction = 'HIN';
|
||||
$pkwService->available = 10;
|
||||
|
||||
$busService = new Service();
|
||||
$busService->id = 2;
|
||||
$busService->subType = 'BUS';
|
||||
$busService->direction = 'HIN';
|
||||
$busService->available = 95;
|
||||
|
||||
// Add in reverse order to test sorting
|
||||
$travel->transportationServices = [$pkwService, $busService];
|
||||
|
||||
$result = $travel->getTransportationServicesByDirection('HIN');
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
// BUS comes before PKW alphabetically
|
||||
$this->assertSame('BUS', $result[0]->subType);
|
||||
$this->assertSame('PKW', $result[1]->subType);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user