diff --git a/config/services.yaml b/config/services.yaml index a5e2400..b5f933c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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' diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md index 1eb9f97..03accd2 100644 --- a/docs/PROJECT_OVERVIEW.md +++ b/docs/PROJECT_OVERVIEW.md @@ -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 diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index f111473..9d4a428 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -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, diff --git a/src/Form/Service/ParticipantTransportationDiscountReplacementFieldHandler.php b/src/Form/Service/ParticipantTransportationDiscountReplacementFieldHandler.php new file mode 100644 index 0000000..b9fc1e9 --- /dev/null +++ b/src/Form/Service/ParticipantTransportationDiscountReplacementFieldHandler.php @@ -0,0 +1,156 @@ +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 + ) + ); + } + } +} diff --git a/tests/Form/Service/ParticipantTransportationDiscountReplacementFieldHandlerTest.php b/tests/Form/Service/ParticipantTransportationDiscountReplacementFieldHandlerTest.php new file mode 100644 index 0000000..7139718 --- /dev/null +++ b/tests/Form/Service/ParticipantTransportationDiscountReplacementFieldHandlerTest.php @@ -0,0 +1,346 @@ +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; + } +}