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/`
|
- **Field Handlers**: 15+ specialized handlers in `src/Form/Service/`
|
||||||
- Registered via service tags with dependency resolution
|
- Registered via service tags with dependency resolution
|
||||||
- Process in dependency order via `ParticipantFieldHandlerRegistry`
|
- Process in dependency order via `ParticipantFieldHandlerRegistry`
|
||||||
|
- `ParticipantDateOfBirthFieldHandler` handles array input from BirthdayType widget
|
||||||
- **Conditional Fields**: Universal condition system (`FieldConditionInterface`)
|
- **Conditional Fields**: Universal condition system (`FieldConditionInterface`)
|
||||||
- Age-based, field-dependent, service-specific conditions
|
- Age-based, field-dependent, service-specific conditions
|
||||||
- Applied via `CreateFieldStateProvider` / `EditFieldStateProvider`
|
- Applied via `CreateFieldStateProvider` / `EditFieldStateProvider`
|
||||||
- **HTMX Integration**: Real-time updates for dynamic fields
|
- **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/`)
|
### Service Layer (`src/Service/`)
|
||||||
- `BookingService` - Core booking workflow
|
- `BookingService` - Core booking workflow
|
||||||
@@ -213,6 +221,8 @@ Critical for correct pricing and auto-reassignment:
|
|||||||
- Courses, additional services, board, insurance hidden until DOB provided
|
- Courses, additional services, board, insurance hidden until DOB provided
|
||||||
- Age evaluated at travel start date, not current date
|
- Age evaluated at travel start date, not current date
|
||||||
- Dual constraint types: absolute_age, birth_year, mixed
|
- 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
|
### Bulk Insurance Booking
|
||||||
- Applicant enables bulk → applies to all participants
|
- Applicant enables bulk → applies to all participants
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class Travel
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Travel booking status from BusProNet API.
|
* Travel booking status from BusProNet API.
|
||||||
* Possible values: 'Frei', 'Anfrage', 'Buchungsstop'
|
* Possible values: 'Frei', 'Anfrage', 'Buchungsstop'.
|
||||||
*/
|
*/
|
||||||
#[Groups(['api:single'])]
|
#[Groups(['api:single'])]
|
||||||
public ?string $status = null;
|
public ?string $status = null;
|
||||||
@@ -153,8 +153,12 @@ class Travel
|
|||||||
* Filters transportation services based on travel direction and optionally
|
* Filters transportation services based on travel direction and optionally
|
||||||
* by availability. Services are sorted by subtype.
|
* 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 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
|
* @return array<int, Service> The filtered and sorted transportation services
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -349,7 +349,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
// Outbound Transportation
|
// Outbound Transportation
|
||||||
$this->fieldOptionProviders['transportationOutbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
$this->fieldOptionProviders['transportationOutbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||||
'label' => 'Hinfahrt',
|
'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_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service),
|
||||||
'choice_value' => 'id',
|
'choice_value' => 'id',
|
||||||
'expanded' => true,
|
'expanded' => true,
|
||||||
@@ -767,4 +771,81 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
$travelPrice
|
$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