From 2aeed762fd1ebced463ae80367b5319fd87930bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 21 Jan 2026 10:16:57 +0100 Subject: [PATCH] feat: extensible participant status determination for booking creation Adds a rule-based system to determine participant status in CREATE payloads. Participants selecting a 'Begleitperson' service now receive status 'O' (Option), all others default to 'F' (Final). - Add ParticipantStatusRuleInterface for defining status rules - Add ParticipantStatusRuleRegistry for priority-based rule evaluation - Add ChaperonServiceStatusRule for Begleitperson detection - Integrate status evaluation into BookingPayloadBuilder --- config/services.yaml | 9 + .../DataProcessor/BookingPayloadBuilder.php | 3 + .../ParticipantStatusRuleInterface.php | 52 ++++++ .../Service/ParticipantStatusRuleRegistry.php | 71 ++++++++ .../StatusRule/ChaperonServiceStatusRule.php | 95 +++++++++++ .../Booking/Create/Step3Controller.php | 2 +- .../ParticipantFieldOptionsProvider.php | 4 +- .../BookingDataProcessorTest.php | 4 +- .../ParticipantStatusRuleRegistryTest.php | 129 ++++++++++++++ .../ChaperonServiceStatusRuleTest.php | 160 ++++++++++++++++++ 10 files changed, 525 insertions(+), 4 deletions(-) create mode 100644 src/BusProNet/Service/Contract/ParticipantStatusRuleInterface.php create mode 100644 src/BusProNet/Service/ParticipantStatusRuleRegistry.php create mode 100644 src/BusProNet/Service/StatusRule/ChaperonServiceStatusRule.php create mode 100644 tests/BusProNet/Service/ParticipantStatusRuleRegistryTest.php create mode 100644 tests/BusProNet/Service/StatusRule/ChaperonServiceStatusRuleTest.php diff --git a/config/services.yaml b/config/services.yaml index a8ace4e..44c9487 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -128,6 +128,15 @@ services: - '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler' - '@App\Form\Service\ParticipantPromoVoucherFieldHandler' + # Participant Status Rules + App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule: ~ + + # Participant Status Rule Registry + App\BusProNet\Service\ParticipantStatusRuleRegistry: + arguments: + $rules: + - '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule' + App\Service\CmsDataService: arguments: $httpClient: '@typo3.client' diff --git a/src/BusProNet/DataProcessor/BookingPayloadBuilder.php b/src/BusProNet/DataProcessor/BookingPayloadBuilder.php index 7eba616..56991c9 100644 --- a/src/BusProNet/DataProcessor/BookingPayloadBuilder.php +++ b/src/BusProNet/DataProcessor/BookingPayloadBuilder.php @@ -6,6 +6,7 @@ namespace App\BusProNet\DataProcessor; use App\BusProNet\Constants; use App\BusProNet\Model\Booking; +use App\BusProNet\Service\ParticipantStatusRuleRegistry; use App\Form\Model\BookingDto; /** @@ -18,6 +19,7 @@ class BookingPayloadBuilder { public function __construct( private readonly ServiceMappingCollector $mappingCollector, + private readonly ParticipantStatusRuleRegistry $statusRuleRegistry, ) { } @@ -275,6 +277,7 @@ class BookingPayloadBuilder foreach ($bookingDto->participants as $index => $participant) { $participantData = [ '@id' => $index + 1, + 'status' => $this->statusRuleRegistry->evaluateStatus($participant), 'name' => $participant->lastName, 'vorname' => $participant->firstName, 'geschlecht' => $participant->gender ?? '', diff --git a/src/BusProNet/Service/Contract/ParticipantStatusRuleInterface.php b/src/BusProNet/Service/Contract/ParticipantStatusRuleInterface.php new file mode 100644 index 0000000..d9e5946 --- /dev/null +++ b/src/BusProNet/Service/Contract/ParticipantStatusRuleInterface.php @@ -0,0 +1,52 @@ +sortedRules = $this->sortRulesByPriority($rules); + } + + /** + * Evaluates all rules to determine the participant's status. + * + * Rules are evaluated in priority order (highest first). The first rule + * that matches determines the status. Returns 'F' if no rules match. + * + * @param ParticipantDto $participant The participant to evaluate + * + * @return string The determined status code + */ + public function evaluateStatus(ParticipantDto $participant): string + { + foreach ($this->sortedRules as $rule) { + if (true === $rule->evaluate($participant)) { + return $rule->getStatus(); + } + } + + return self::DEFAULT_STATUS; + } + + /** + * Sorts rules by priority in descending order. + * + * @param ParticipantStatusRuleInterface[] $rules The rules to sort + * + * @return ParticipantStatusRuleInterface[] The sorted rules + */ + private function sortRulesByPriority(array $rules): array + { + usort($rules, static fn ( + ParticipantStatusRuleInterface $a, + ParticipantStatusRuleInterface $b, + ): int => $b->getPriority() <=> $a->getPriority()); + + return $rules; + } +} diff --git a/src/BusProNet/Service/StatusRule/ChaperonServiceStatusRule.php b/src/BusProNet/Service/StatusRule/ChaperonServiceStatusRule.php new file mode 100644 index 0000000..b3cd341 --- /dev/null +++ b/src/BusProNet/Service/StatusRule/ChaperonServiceStatusRule.php @@ -0,0 +1,95 @@ +additionalServices, + $participant->courses, + $participant->board, + $participant->rentals, + ]; + + foreach ($arrayServices as $services) { + foreach ($services as $service) { + if ($service instanceof Service && true === $this->containsSearchTerm($service->label)) { + return true; + } + } + } + + // Check single service properties + $singleServices = [ + $participant->skiPass, + $participant->veg, + ]; + + foreach ($singleServices as $service) { + if ($service instanceof Service && true === $this->containsSearchTerm($service->label)) { + return true; + } + } + + return false; + } + + /** + * @see ParticipantStatusRuleInterface::getStatus() + */ + public function getStatus(): string + { + return self::STATUS; + } + + /** + * @see ParticipantStatusRuleInterface::getPriority() + */ + public function getPriority(): int + { + return self::PRIORITY; + } + + /** + * @see ParticipantStatusRuleInterface::getDescription() + */ + public function getDescription(): string + { + return 'Assigns Option status when a Begleitperson service is selected'; + } + + /** + * Checks if the label contains the search term (case-insensitive). + */ + private function containsSearchTerm(?string $label): bool + { + if (null === $label) { + return false; + } + + return false !== stripos($label, self::SEARCH_TERM); + } +} diff --git a/src/Controller/Booking/Create/Step3Controller.php b/src/Controller/Booking/Create/Step3Controller.php index f0f13d4..8ae9fb9 100644 --- a/src/Controller/Booking/Create/Step3Controller.php +++ b/src/Controller/Booking/Create/Step3Controller.php @@ -75,7 +75,7 @@ class Step3Controller extends AbstractController return $this->handleApiError( 'Booking inquiry failed', ['message' => $inquiryResponse->message], - $inquiryResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.', + $inquiryResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.', $bookingCreateDto, $form ); diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 7ab420b..4831214 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -285,9 +285,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider } $priceLabel = (null === $service->price || 0.0 === $service->price) ? 'inkl.' - : number_format($service->price, 2, ',', '.') . ' €'; + : number_format($service->price, 2, ',', '.').' €'; - return $service->label . ' (' . $priceLabel . ')'; + return $service->label.' ('.$priceLabel.')'; }, 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { diff --git a/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php b/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php index 1dcf367..e882561 100644 --- a/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php +++ b/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php @@ -18,6 +18,7 @@ use App\BusProNet\Model\Pickup; use App\BusProNet\Model\Room; use App\BusProNet\Model\Service; use App\BusProNet\Model\Travel; +use App\BusProNet\Service\ParticipantStatusRuleRegistry; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Service\BookingPriceCalculatorService; @@ -52,7 +53,8 @@ class BookingDataProcessorTest extends TestCase // Create the new dependencies $mappingCollector = new ServiceMappingCollector(); $serviceProcessor = new ParticipantServiceProcessor(new NullLogger()); - $payloadBuilder = new BookingPayloadBuilder($mappingCollector); + $statusRuleRegistry = new ParticipantStatusRuleRegistry([]); + $payloadBuilder = new BookingPayloadBuilder($mappingCollector, $statusRuleRegistry); $personalDataSynchronizer = new PersonalDataSynchronizer(); $this->processor = new BookingDataProcessor( diff --git a/tests/BusProNet/Service/ParticipantStatusRuleRegistryTest.php b/tests/BusProNet/Service/ParticipantStatusRuleRegistryTest.php new file mode 100644 index 0000000..9724d3a --- /dev/null +++ b/tests/BusProNet/Service/ParticipantStatusRuleRegistryTest.php @@ -0,0 +1,129 @@ +createMock(ParticipantStatusRuleInterface::class); + $rule->method('evaluate')->willReturn(false); + $rule->method('getPriority')->willReturn(100); + + $registry = new ParticipantStatusRuleRegistry([$rule]); + $participant = new ParticipantDto(); + + $this->assertSame('F', $registry->evaluateStatus($participant)); + } + + public function testReturnsMatchingRuleStatus(): void + { + $rule = $this->createMock(ParticipantStatusRuleInterface::class); + $rule->method('evaluate')->willReturn(true); + $rule->method('getStatus')->willReturn('O'); + $rule->method('getPriority')->willReturn(100); + + $registry = new ParticipantStatusRuleRegistry([$rule]); + $participant = new ParticipantDto(); + + $this->assertSame('O', $registry->evaluateStatus($participant)); + } + + public function testHigherPriorityRuleTakesPrecedence(): void + { + $lowPriorityRule = $this->createMock(ParticipantStatusRuleInterface::class); + $lowPriorityRule->method('evaluate')->willReturn(true); + $lowPriorityRule->method('getStatus')->willReturn('L'); + $lowPriorityRule->method('getPriority')->willReturn(50); + + $highPriorityRule = $this->createMock(ParticipantStatusRuleInterface::class); + $highPriorityRule->method('evaluate')->willReturn(true); + $highPriorityRule->method('getStatus')->willReturn('H'); + $highPriorityRule->method('getPriority')->willReturn(100); + + // Register low priority first, high priority second + $registry = new ParticipantStatusRuleRegistry([$lowPriorityRule, $highPriorityRule]); + $participant = new ParticipantDto(); + + // High priority rule should be evaluated first and match + $this->assertSame('H', $registry->evaluateStatus($participant)); + } + + public function testEmptyRulesArrayReturnsDefaultStatus(): void + { + $registry = new ParticipantStatusRuleRegistry([]); + $participant = new ParticipantDto(); + + $this->assertSame('F', $registry->evaluateStatus($participant)); + } + + public function testFirstMatchingRuleWins(): void + { + $firstMatchingRule = $this->createMock(ParticipantStatusRuleInterface::class); + $firstMatchingRule->method('evaluate')->willReturn(true); + $firstMatchingRule->method('getStatus')->willReturn('A'); + $firstMatchingRule->method('getPriority')->willReturn(100); + + $secondMatchingRule = $this->createMock(ParticipantStatusRuleInterface::class); + $secondMatchingRule->method('evaluate')->willReturn(true); + $secondMatchingRule->method('getStatus')->willReturn('B'); + $secondMatchingRule->method('getPriority')->willReturn(100); + + $registry = new ParticipantStatusRuleRegistry([$firstMatchingRule, $secondMatchingRule]); + $participant = new ParticipantDto(); + + // First rule with same priority registered first should win + $this->assertSame('A', $registry->evaluateStatus($participant)); + } + + public function testNonMatchingHighPriorityRuleDoesNotBlockLowerPriority(): void + { + $highPriorityRule = $this->createMock(ParticipantStatusRuleInterface::class); + $highPriorityRule->method('evaluate')->willReturn(false); + $highPriorityRule->method('getStatus')->willReturn('H'); + $highPriorityRule->method('getPriority')->willReturn(100); + + $lowPriorityRule = $this->createMock(ParticipantStatusRuleInterface::class); + $lowPriorityRule->method('evaluate')->willReturn(true); + $lowPriorityRule->method('getStatus')->willReturn('L'); + $lowPriorityRule->method('getPriority')->willReturn(50); + + $registry = new ParticipantStatusRuleRegistry([$highPriorityRule, $lowPriorityRule]); + $participant = new ParticipantDto(); + + // High priority doesn't match, so low priority rule should be used + $this->assertSame('L', $registry->evaluateStatus($participant)); + } + + public function testMultipleRulesWithVariousPrioritiesAreSortedCorrectly(): void + { + $lowRule = $this->createMock(ParticipantStatusRuleInterface::class); + $lowRule->method('evaluate')->willReturn(true); + $lowRule->method('getStatus')->willReturn('LOW'); + $lowRule->method('getPriority')->willReturn(10); + + $mediumRule = $this->createMock(ParticipantStatusRuleInterface::class); + $mediumRule->method('evaluate')->willReturn(true); + $mediumRule->method('getStatus')->willReturn('MED'); + $mediumRule->method('getPriority')->willReturn(50); + + $highRule = $this->createMock(ParticipantStatusRuleInterface::class); + $highRule->method('evaluate')->willReturn(true); + $highRule->method('getStatus')->willReturn('HIGH'); + $highRule->method('getPriority')->willReturn(100); + + // Register in random order + $registry = new ParticipantStatusRuleRegistry([$mediumRule, $lowRule, $highRule]); + $participant = new ParticipantDto(); + + // Highest priority rule should match first + $this->assertSame('HIGH', $registry->evaluateStatus($participant)); + } +} diff --git a/tests/BusProNet/Service/StatusRule/ChaperonServiceStatusRuleTest.php b/tests/BusProNet/Service/StatusRule/ChaperonServiceStatusRuleTest.php new file mode 100644 index 0000000..4d3ec92 --- /dev/null +++ b/tests/BusProNet/Service/StatusRule/ChaperonServiceStatusRuleTest.php @@ -0,0 +1,160 @@ +rule = new ChaperonServiceStatusRule(); + } + + public function testReturnsTrueWhenBegleitpersonInAdditionalServices(): void + { + $participant = new ParticipantDto(); + $service = new Service(); + $service->label = 'Skibegleitperson Kurs'; + $participant->additionalServices = [$service]; + + $this->assertTrue($this->rule->evaluate($participant)); + } + + public function testReturnsTrueWhenBegleitpersonInCourses(): void + { + $participant = new ParticipantDto(); + $service = new Service(); + $service->label = 'Begleitperson Anfängerkurs'; + $participant->courses = [$service]; + + $this->assertTrue($this->rule->evaluate($participant)); + } + + public function testReturnsTrueWhenBegleitpersonInBoard(): void + { + $participant = new ParticipantDto(); + $service = new Service(); + $service->label = 'Halbpension Begleitperson'; + $participant->board = [$service]; + + $this->assertTrue($this->rule->evaluate($participant)); + } + + public function testReturnsTrueWhenBegleitpersonInRentals(): void + { + $participant = new ParticipantDto(); + $service = new Service(); + $service->label = 'Skiset Begleitperson'; + $participant->rentals = [$service]; + + $this->assertTrue($this->rule->evaluate($participant)); + } + + public function testReturnsTrueWhenBegleitpersonInSkiPass(): void + { + $participant = new ParticipantDto(); + $service = new Service(); + $service->label = 'Skipass Begleitperson 6 Tage'; + $participant->skiPass = $service; + + $this->assertTrue($this->rule->evaluate($participant)); + } + + public function testReturnsTrueWhenBegleitpersonInVeg(): void + { + $participant = new ParticipantDto(); + $service = new Service(); + $service->label = 'Vegetarisch Begleitperson'; + $participant->veg = $service; + + $this->assertTrue($this->rule->evaluate($participant)); + } + + public function testReturnsFalseWhenNoBegleitpersonService(): void + { + $participant = new ParticipantDto(); + $service = new Service(); + $service->label = 'Skipass 6 Tage Erwachsene'; + $participant->skiPass = $service; + + $this->assertFalse($this->rule->evaluate($participant)); + } + + public function testReturnsFalseWithEmptyServices(): void + { + $participant = new ParticipantDto(); + + $this->assertFalse($this->rule->evaluate($participant)); + } + + public function testCaseInsensitiveMatch(): void + { + $participant = new ParticipantDto(); + $service = new Service(); + $service->label = 'BEGLEITPERSON Kurs'; + $participant->additionalServices = [$service]; + + $this->assertTrue($this->rule->evaluate($participant)); + } + + public function testMixedCaseMatch(): void + { + $participant = new ParticipantDto(); + $service = new Service(); + $service->label = 'beGleitPerson Kurs'; + $participant->courses = [$service]; + + $this->assertTrue($this->rule->evaluate($participant)); + } + + public function testReturnsStatusO(): void + { + $this->assertSame('O', $this->rule->getStatus()); + } + + public function testGetPriorityReturns100(): void + { + $this->assertSame(100, $this->rule->getPriority()); + } + + public function testGetDescriptionReturnsNonEmptyString(): void + { + $description = $this->rule->getDescription(); + + $this->assertIsString($description); + $this->assertNotEmpty($description); + } + + public function testHandlesNullLabel(): void + { + $participant = new ParticipantDto(); + $service = new Service(); + $service->label = null; + $participant->additionalServices = [$service]; + + $this->assertFalse($this->rule->evaluate($participant)); + } + + public function testMultipleServicesWithOneBegleitperson(): void + { + $participant = new ParticipantDto(); + + $normalService = new Service(); + $normalService->label = 'Skipass 6 Tage'; + + $begleitpersonService = new Service(); + $begleitpersonService->label = 'Skipass Begleitperson'; + + $participant->additionalServices = [$normalService, $begleitpersonService]; + + $this->assertTrue($this->rule->evaluate($participant)); + } +}