feat: prefer discounted transportation services

addresses #869axbjbz
This commit is contained in:
Björn Fromme
2025-10-22 15:53:54 +02:00
parent 7ab60b1048
commit 1949bf9b97
4 changed files with 261 additions and 3 deletions
+10
View File
@@ -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
+6 -2
View File
@@ -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];
}
}
+163
View File
@@ -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);
}
}