feat: automatic unselection of discounted transportation

This commit is contained in:
Björn Fromme
2025-10-24 09:04:20 +02:00
parent 9140b97a71
commit a01e966d6d
5 changed files with 524 additions and 2 deletions
+2
View File
@@ -71,6 +71,7 @@ services:
# Insurance Field Handlers with dependencies
App\Form\Service\ParticipantInsuranceFieldHandler: ~
App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~
App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler: ~
# Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services
App\Form\Service\ParticipantFieldHandlerRegistry:
@@ -92,5 +93,6 @@ services:
- 'App\Form\Service\ParticipantRentalInsuranceFieldHandler'
- 'App\Form\Service\ParticipantLicensePlateFieldHandler'
# Complex handlers with dependencies - use service references
- '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler'
- '@App\Form\Service\ParticipantBulkInsuranceFieldHandler'
- '@App\Form\Service\ParticipantInsuranceFieldHandler'
+10 -2
View File
@@ -231,6 +231,13 @@ Critical for correct pricing and auto-reassignment:
- **Unified pickup field**: Single field for both directions (BPN API limitation)
- **Conditional visibility**: Pickup vs parking fields mutually exclusive
- **Direction mapping**: `DirectionMapper` translates API ↔ internal codes
- **Discount replacement logic**: Automatic correction of incompatible transportation combinations
- When inbound bus selected with discounted PKW outbound: system automatically replaces with regular PKW
- When inbound changes from bus to PKW: system automatically restores discounted PKW if available
- Form options update dynamically to show only valid PKW variant
- User notified via toast messages about automatic changes
- Implemented via `ParticipantTransportationDiscountReplacementFieldHandler`
- Prevents invalid pricing where user gets both PKW discount and bus pricing
## Important Field Dependencies
@@ -430,8 +437,9 @@ Edit mode session requires proper cleanup to prevent dirty state persistence:
- `src/Form/Service/EditFieldStateProvider.php` - Edit mode mutability constraints
### Field Handlers
- `src/Form/Service/Participant*FieldHandler.php` (15+ handlers)
- Transportation, pickup, parking, ski pass, rentals, insurance, bulk insurance, etc.
- `src/Form/Service/Participant*FieldHandler.php` (16+ handlers)
- Transportation (outbound, inbound, discount replacement), pickup, parking, ski pass, rentals, insurance, bulk insurance, etc.
- `ParticipantTransportationDiscountReplacementFieldHandler` - Automatic discount replacement for invalid transportation combinations
## Testing
@@ -844,6 +844,16 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// If we have both discounted and regular options, apply smart filtering
if (null !== $discountedPkw && null !== $regularPkw) {
// Get participant to check inbound transportation
$participant = $bookingDto->getParticipant($participantIndex);
$inboundIsBus = null !== $participant?->transportationInbound
&& DirectionMapper::SUBTYPE_BUS_API === $participant->transportationInbound->subType;
// If inbound is BUS, always show regular PKW (not discounted)
if ($inboundIsBus) {
return [...$otherServices, $regularPkw];
}
// Check if discounted option is unavailable for this participant (per-booking availability)
$discountedIsUnavailable = $this->serviceAvailabilityCalculator->isServiceUnavailable(
$discountedPkw->id,
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\ServiceAvailabilityCalculator;
class ParticipantTransportationDiscountReplacementFieldHandler extends AbstractParticipantFieldHandler
{
public function __construct(
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
) {
}
public function getFieldName(): string
{
return 'transportationDiscountReplacement'; // Virtual field name
}
public function getDependencies(): array
{
return ['transportationOutbound', 'transportationInbound'];
}
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
return true; // Always process in all modes
}
/**
* Processes transportation discount replacement logic.
*
* Detects incompatible transportation combinations and automatically replaces
* discounted PKW with regular variant when inbound bus is selected. Also restores
* discounted PKW when inbound changes back to PKW and discount is still available.
*
* Business Rules:
* - Inbound BUS + Outbound discounted PKW → Replace with regular PKW (notification: warning)
* - Inbound PKW + Outbound regular PKW → Restore discounted PKW if available (notification: success)
* - Only processes PKW variants, never touches BUS or other services
*/
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
// Step 1: Get participant and validate prerequisites
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant) {
return;
}
// Need both transportation fields to process
if (null === $participant->transportationOutbound) {
return;
}
// Step 2: Detect inbound transportation type
$inboundIsBus = null !== $participant->transportationInbound
&& DirectionMapper::SUBTYPE_BUS_API === $participant->transportationInbound->subType;
// Step 3: Get all outbound PKW services
// Get ALL outbound services (not filtered by availability or smart filtering)
$allOutboundServices = $bookingDto->travel->getTransportationServicesByDirection(
DirectionMapper::OUTBOUND_TRAVEL,
false // Do NOT filter by per-booking availability
);
// Find discounted and regular PKW services
$discountedPkw = null;
$regularPkw = null;
foreach ($allOutboundServices as $service) {
if (false === DirectionMapper::isCar($service->subType)) {
continue; // Skip non-PKW services
}
if (null !== $service->price && $service->price < 0) {
$discountedPkw = $service;
} elseif (null === $service->price || $service->price >= 0) {
$regularPkw = $service;
}
}
// Edge case: no regular PKW found (impossible in production, but handle gracefully)
if (null === $regularPkw) {
return;
}
// Step 4a: Handle inbound BUS scenario (force regular PKW)
if (true === $inboundIsBus) {
// Check if outbound is currently DISCOUNTED PKW
$outboundIsDiscountedPkw = DirectionMapper::isCar($participant->transportationOutbound->subType)
&& null !== $participant->transportationOutbound->price
&& $participant->transportationOutbound->price < 0;
if ($outboundIsDiscountedPkw) {
// Store original for notification
$originalLabel = $participant->transportationOutbound->label;
// Replace with regular PKW
$participant->transportationOutbound = $regularPkw;
// Notify user about replacement
$participant->addNotification(
'warning',
sprintf(
'Hinfahrt automatisch angepasst: Rabattierte Anreise nicht möglich bei Busrückfahrt. Geändert von "%s" zu "%s".',
$originalLabel,
$regularPkw->label
)
);
}
return; // Done processing for BUS inbound scenario
}
// Step 4b: Handle non-BUS inbound scenario (restore discount if available)
// Inbound is NOT BUS - try to restore discounted PKW if available
// Check if outbound is currently REGULAR PKW
$outboundIsRegularPkw = DirectionMapper::isCar($participant->transportationOutbound->subType)
&& (null === $participant->transportationOutbound->price
|| $participant->transportationOutbound->price >= 0);
if (false === $outboundIsRegularPkw || null === $discountedPkw) {
return; // Not eligible for discount restoration
}
// Check per-booking availability of discounted PKW
$discountedIsAvailable = false === $this->serviceAvailabilityCalculator->isServiceUnavailable(
$discountedPkw->id,
$bookingDto,
$participantIndex
);
if ($discountedIsAvailable) {
// Store original for notification
$originalLabel = $participant->transportationOutbound->label;
// Restore discounted PKW
$participant->transportationOutbound = $discountedPkw;
// Notify user about restoration
$participant->addNotification(
'success',
sprintf(
'Hinfahrt automatisch angepasst: Rabattierte Anreise wieder verfügbar. Geändert von "%s" zu "%s".',
$originalLabel,
$discountedPkw->label
)
);
}
}
}
@@ -0,0 +1,346 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Service;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler;
use App\Service\ServiceAvailabilityCalculator;
use PHPUnit\Framework\TestCase;
class ParticipantTransportationDiscountReplacementFieldHandlerTest extends TestCase
{
private ParticipantTransportationDiscountReplacementFieldHandler $handler;
private ServiceAvailabilityCalculator $serviceAvailabilityCalculator;
protected function setUp(): void
{
$this->serviceAvailabilityCalculator = $this->createMock(ServiceAvailabilityCalculator::class);
$this->handler = new ParticipantTransportationDiscountReplacementFieldHandler(
$this->serviceAvailabilityCalculator
);
}
public function testGetFieldName(): void
{
$this->assertSame('transportationDiscountReplacement', $this->handler->getFieldName());
}
public function testGetDependencies(): void
{
$this->assertSame(['transportationOutbound', 'transportationInbound'], $this->handler->getDependencies());
}
public function testShouldProcessAlwaysReturnsTrue(): void
{
$this->assertTrue($this->handler->shouldProcess([], BookingDto::MODE_CREATE, 0));
$this->assertTrue($this->handler->shouldProcess(['some' => 'data'], BookingDto::MODE_EDIT, 5));
}
public function testProcessFieldReplacesDiscountedWithRegularWhenInboundIsBus(): void
{
// Setup services
$discountedPkw = $this->createPkwService(100, 'Selbstorganisiert (-15€ Rabatt)', -15.0);
$regularPkw = $this->createPkwService(101, 'Selbstorganisiert', 0.0);
$busInbound = $this->createBusService(200, 'Bus', DirectionMapper::INBOUND_TRAVEL);
// Setup participant with discounted PKW outbound and BUS inbound
$participant = new ParticipantDto();
$participant->transportationOutbound = $discountedPkw;
$participant->transportationInbound = $busInbound;
// Setup booking with travel services
$travel = $this->createTravelWithServices([$discountedPkw, $regularPkw, $busInbound]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
$submittedData = [];
// Process
$this->handler->processField($submittedData, $bookingDto, 0);
// Assert outbound was replaced with regular PKW
$this->assertSame($regularPkw->id, $participant->transportationOutbound->id);
$this->assertSame($regularPkw->label, $participant->transportationOutbound->label);
// Assert notification was generated
$this->assertCount(1, $participant->notifications);
$notification = reset($participant->notifications); // Get first notification (keyed by MD5 hash)
$this->assertSame('warning', $notification['type']);
$this->assertStringContainsString('Hinfahrt automatisch angepasst', $notification['message']);
$this->assertStringContainsString('Selbstorganisiert (-15€ Rabatt)', $notification['message']);
$this->assertStringContainsString('Selbstorganisiert', $notification['message']);
}
public function testProcessFieldRestoresDiscountedPkwWhenInboundChangesToPkw(): void
{
// Setup services
$discountedPkw = $this->createPkwService(100, 'Selbstorganisiert (-15€ Rabatt)', -15.0);
$regularPkw = $this->createPkwService(101, 'Selbstorganisiert', 0.0);
$pkwInbound = $this->createPkwService(201, 'Selbstorganisiert', 0.0, DirectionMapper::INBOUND_TRAVEL);
// Setup participant with regular PKW outbound and PKW inbound
$participant = new ParticipantDto();
$participant->transportationOutbound = $regularPkw;
$participant->transportationInbound = $pkwInbound;
// Setup booking with travel services
$travel = $this->createTravelWithServices([$discountedPkw, $regularPkw, $pkwInbound]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
// Mock availability: discounted PKW is available
$this->serviceAvailabilityCalculator
->expects($this->once())
->method('isServiceUnavailable')
->with($discountedPkw->id, $bookingDto, 0)
->willReturn(false);
$submittedData = [];
// Process
$this->handler->processField($submittedData, $bookingDto, 0);
// Assert outbound was replaced with discounted PKW
$this->assertSame($discountedPkw->id, $participant->transportationOutbound->id);
$this->assertSame($discountedPkw->label, $participant->transportationOutbound->label);
// Assert notification was generated
$this->assertCount(1, $participant->notifications);
$notification = reset($participant->notifications); // Get first notification (keyed by MD5 hash)
$this->assertSame('success', $notification['type']);
$this->assertStringContainsString('Hinfahrt automatisch angepasst', $notification['message']);
$this->assertStringContainsString('wieder verfügbar', $notification['message']);
}
public function testProcessFieldDoesNotRestoreDiscountWhenUnavailable(): void
{
// Setup services
$discountedPkw = $this->createPkwService(100, 'Selbstorganisiert (-15€ Rabatt)', -15.0);
$regularPkw = $this->createPkwService(101, 'Selbstorganisiert', 0.0);
$pkwInbound = $this->createPkwService(201, 'Selbstorganisiert', 0.0, DirectionMapper::INBOUND_TRAVEL);
// Setup participant with regular PKW outbound and PKW inbound
$participant = new ParticipantDto();
$participant->transportationOutbound = $regularPkw;
$participant->transportationInbound = $pkwInbound;
// Setup booking with travel services
$travel = $this->createTravelWithServices([$discountedPkw, $regularPkw, $pkwInbound]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
// Mock availability: discounted PKW is sold out
$this->serviceAvailabilityCalculator
->expects($this->once())
->method('isServiceUnavailable')
->with($discountedPkw->id, $bookingDto, 0)
->willReturn(true);
$submittedData = [];
// Process
$this->handler->processField($submittedData, $bookingDto, 0);
// Assert outbound remains regular PKW
$this->assertSame($regularPkw, $participant->transportationOutbound);
// Assert no notification was generated
$this->assertEmpty($participant->notifications);
}
public function testProcessFieldNoReplacementWhenOutboundIsBus(): void
{
// Setup services
$busOutbound = $this->createBusService(100, 'Bus', DirectionMapper::OUTBOUND_TRAVEL);
$busInbound = $this->createBusService(200, 'Bus', DirectionMapper::INBOUND_TRAVEL);
$regularPkw = $this->createPkwService(101, 'Selbstorganisiert', 0.0);
// Setup participant with BUS outbound and BUS inbound
$participant = new ParticipantDto();
$participant->transportationOutbound = $busOutbound;
$participant->transportationInbound = $busInbound;
// Setup booking with travel services
$travel = $this->createTravelWithServices([$busOutbound, $regularPkw, $busInbound]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
$submittedData = [];
// Process
$this->handler->processField($submittedData, $bookingDto, 0);
// Assert outbound remains BUS (no replacement)
$this->assertSame($busOutbound, $participant->transportationOutbound);
// Assert no notification was generated
$this->assertEmpty($participant->notifications);
}
public function testProcessFieldNoReplacementWhenOutboundIsRegularPkwAndInboundIsBus(): void
{
// Setup services
$discountedPkw = $this->createPkwService(100, 'Selbstorganisiert (-15€ Rabatt)', -15.0);
$regularPkw = $this->createPkwService(101, 'Selbstorganisiert', 0.0);
$busInbound = $this->createBusService(200, 'Bus', DirectionMapper::INBOUND_TRAVEL);
// Setup participant with regular PKW outbound and BUS inbound
$participant = new ParticipantDto();
$participant->transportationOutbound = $regularPkw;
$participant->transportationInbound = $busInbound;
// Setup booking with travel services
$travel = $this->createTravelWithServices([$discountedPkw, $regularPkw, $busInbound]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
$submittedData = [];
// Process
$this->handler->processField($submittedData, $bookingDto, 0);
// Assert outbound remains regular PKW (already correct)
$this->assertSame($regularPkw, $participant->transportationOutbound);
// Assert no notification was generated
$this->assertEmpty($participant->notifications);
}
public function testProcessFieldNoReplacementWhenOutboundIsDiscountedAndInboundIsPkw(): void
{
// Setup services
$discountedPkw = $this->createPkwService(100, 'Selbstorganisiert (-15€ Rabatt)', -15.0);
$regularPkw = $this->createPkwService(101, 'Selbstorganisiert', 0.0);
$pkwInbound = $this->createPkwService(201, 'Selbstorganisiert', 0.0, DirectionMapper::INBOUND_TRAVEL);
// Setup participant with discounted PKW outbound and PKW inbound (optimal state)
$participant = new ParticipantDto();
$participant->transportationOutbound = $discountedPkw;
$participant->transportationInbound = $pkwInbound;
// Setup booking with travel services
$travel = $this->createTravelWithServices([$discountedPkw, $regularPkw, $pkwInbound]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
$submittedData = [];
// Process
$this->handler->processField($submittedData, $bookingDto, 0);
// Assert outbound remains discounted PKW (already optimal)
$this->assertSame($discountedPkw, $participant->transportationOutbound);
// Assert no notification was generated
$this->assertEmpty($participant->notifications);
}
public function testProcessFieldHandlesNullOutbound(): void
{
// Setup participant with null outbound
$participant = new ParticipantDto();
$participant->transportationOutbound = null;
$participant->transportationInbound = $this->createBusService(200, 'Bus', DirectionMapper::INBOUND_TRAVEL);
// Setup booking
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
$submittedData = [];
// Process - should handle gracefully
$this->handler->processField($submittedData, $bookingDto, 0);
// Assert no error and no notification
$this->assertNull($participant->transportationOutbound);
$this->assertEmpty($participant->notifications);
}
public function testProcessFieldHandlesNullInbound(): void
{
// Setup services
$discountedPkw = $this->createPkwService(100, 'Selbstorganisiert (-15€ Rabatt)', -15.0);
$regularPkw = $this->createPkwService(101, 'Selbstorganisiert', 0.0);
// Setup participant with regular PKW outbound and null inbound
$participant = new ParticipantDto();
$participant->transportationOutbound = $regularPkw;
$participant->transportationInbound = null;
// Setup booking with travel services
$travel = $this->createTravelWithServices([$discountedPkw, $regularPkw]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant];
// Mock availability: discounted PKW is available
$this->serviceAvailabilityCalculator
->expects($this->once())
->method('isServiceUnavailable')
->with($discountedPkw->id, $bookingDto, 0)
->willReturn(false);
$submittedData = [];
// Process - null inbound is treated as non-BUS, should attempt restoration
$this->handler->processField($submittedData, $bookingDto, 0);
// Assert outbound was replaced with discounted PKW
$this->assertSame($discountedPkw->id, $participant->transportationOutbound->id);
$this->assertSame($discountedPkw->label, $participant->transportationOutbound->label);
}
public function testProcessFieldHandlesNullParticipant(): void
{
// Setup booking with no participants
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [];
$submittedData = [];
// Process - should handle gracefully
$this->handler->processField($submittedData, $bookingDto, 0);
// Should not throw error
$this->expectNotToPerformAssertions();
}
private function createPkwService(int $id, string $label, float $price, string $direction = DirectionMapper::OUTBOUND_TRAVEL): Service
{
$service = new Service();
$service->id = $id;
$service->label = $label;
$service->subType = DirectionMapper::SUBTYPE_CAR_API; // 'PKW'
$service->direction = $direction;
$service->price = $price;
return $service;
}
private function createBusService(int $id, string $label, string $direction): Service
{
$service = new Service();
$service->id = $id;
$service->label = $label;
$service->subType = DirectionMapper::SUBTYPE_BUS_API; // 'BUS'
$service->direction = $direction;
$service->price = 0.0;
return $service;
}
private function createTravelWithServices(array $services): Travel
{
$travel = new Travel();
$travel->transportationServices = $services;
return $travel;
}
}