diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index 9430afb..66fb1c8 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -204,7 +204,7 @@ class ApiClient 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING_UPDATE), 'satz' => ['@typ' => static::TYPE_BOOKING_UPDATE], - 'buchungsart' => 'Buchung', + 'buchungsart' => Constants::BOOKING_TYPE_BOOKING, ...$payload, ]; @@ -228,7 +228,7 @@ class ApiClient { // Forcibly override booking status to generate inquiry payload $bookingDto->bookingStatus = 'A'; - $payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, 'Anfrage'); + $payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, Constants::BOOKING_TYPE_INQUIRY); $data = [ 'user' => $this->config['bpn_username'], @@ -255,7 +255,7 @@ class ApiClient */ public function createBooking(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse { - $payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, 'Buchung'); + $payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, Constants::BOOKING_TYPE_BOOKING); $data = [ 'user' => $this->config['bpn_username'], diff --git a/src/BusProNet/Constants.php b/src/BusProNet/Constants.php index eaaa4ea..014a325 100644 --- a/src/BusProNet/Constants.php +++ b/src/BusProNet/Constants.php @@ -48,6 +48,10 @@ final class Constants public const STATUS_BLOCKED = 'Buchungsstop'; public const STATUS_ON_REQUEST = 'Anfrage'; + // Booking types for API requests + public const BOOKING_TYPE_INQUIRY = 'Anfrage'; + public const BOOKING_TYPE_BOOKING = 'Buchung'; + // Payment methods public const PAYMENT_METHOD_TRANSFER = 'transfer'; public const PAYMENT_METHOD_DEBIT = 'debit'; diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index 3b0d848..5a4b96b 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -688,11 +688,16 @@ class BookingDataProcessor } } - // Add promo voucher or goodwill voucher as aktionscode - // Goodwill vouchers (Kulanz) take precedence over promo vouchers - $aktionscode = $this->getPromoVoucherCodeForParticipant($dto); - if (null !== $aktionscode) { - $participantPayload['aktionscode'] = $aktionscode; + // Add promotional voucher as promotionalCode + $promotionalCode = $this->getPromoVoucherCodeForParticipant($dto); + if (null !== $promotionalCode) { + $participantPayload['aktionscode'] = $promotionalCode; + } + + // Add goodwill voucher as participant-level einloesecode + $goodwillCode = $this->getGoodwillVoucherCodeForParticipant($dto); + if (null !== $goodwillCode) { + $participantPayload['einloesecode'] = $goodwillCode; } } @@ -835,6 +840,9 @@ class BookingDataProcessor } } + // Determine if this is an inquiry booking for voucher handling + $isInquiryBooking = 'A' === $bookingDto->bookingStatus; + // Add participants $payload['teilnehmerliste']['teilnehmer'] = []; foreach ($bookingDto->participants as $index => $participant) { @@ -885,11 +893,19 @@ class BookingDataProcessor } } - // Add promo voucher or goodwill voucher as aktionscode - // Goodwill vouchers (Kulanz) take precedence over promo vouchers - $aktionscode = $this->getPromoVoucherCodeForParticipant($participant); - if (null !== $aktionscode) { - $participantData['aktionscode'] = $aktionscode; + // Add promotional voucher as aktionscode (allowed for inquiry bookings) + $promotionalCode = $this->getPromoVoucherCodeForParticipant($participant); + if (null !== $promotionalCode) { + $participantData['aktionscode'] = $promotionalCode; + } + + // Add goodwill voucher as participant-level einloesecode + // Goodwill vouchers are excluded from inquiry bookings and only allowed in final booking requests + if (false === $isInquiryBooking && Constants::BOOKING_TYPE_BOOKING === $bookingType) { + $goodwillCode = $this->getGoodwillVoucherCodeForParticipant($participant); + if (null !== $goodwillCode) { + $participantData['einloesecode'] = $goodwillCode; + } } $payload['teilnehmerliste']['teilnehmer'][] = $participantData; @@ -909,7 +925,9 @@ class BookingDataProcessor $this->addServicesFromMap($payload, 'zustiege', 'zustieg', '@idzustieg', $pickupMap); $this->addServicesFromMap($payload, 'versicherungen', 'versicherung', '@idversicherung', $insuranceMap); - // Add purchase vouchers if any exist + // Add purchase vouchers (regular purchase vouchers, excluding goodwill vouchers) + // Purchase vouchers are allowed for both inquiry and final bookings + // Goodwill vouchers are handled separately and excluded from inquiry bookings $purchaseVouchers = $this->collectPurchaseVouchers($bookingDto); if (false === empty($purchaseVouchers)) { $payload['gutscheine']['gutschein'] = []; @@ -1196,22 +1214,16 @@ class BookingDataProcessor } /** - * Gets the aktionscode for a participant. + * Gets the aktionscode (promotional voucher code) for a participant. * - * Checks if the participant has a goodwill (Kulanz) purchase voucher first. - * If yes, returns that code (goodwill vouchers override promo vouchers). - * Otherwise, returns the promo voucher code if present. + * Returns the promotional voucher code if present. + * Goodwill vouchers are handled separately via getGoodwillVoucherCodeForParticipant(). * * @return string|null The aktionscode to use, or null if none */ private function getPromoVoucherCodeForParticipant(ParticipantDto $participant): ?string { - // Check for goodwill voucher first (takes precedence) - if (true === $participant->hasGoodwillVoucher && null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) { - return trim($participant->purchaseVoucherCode); - } - - // Fall back to promo voucher if present + // Only return promotional voucher code (goodwill vouchers are handled separately) if (null !== $participant->promoVoucherCode && '' !== trim($participant->promoVoucherCode)) { return trim($participant->promoVoucherCode); } @@ -1219,6 +1231,21 @@ class BookingDataProcessor return null; } + /** + * Extracts goodwill voucher code for a participant. + * + * Goodwill vouchers are sent as per participant (not in aggregate), + * allowing them to coexist with promotional codes. + */ + private function getGoodwillVoucherCodeForParticipant(ParticipantDto $participant): ?string + { + if (true === $participant->hasGoodwillVoucher && null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) { + return trim($participant->purchaseVoucherCode); + } + + return null; + } + /** * Applies bulk insurance assignment if the applicant has enabled it. * diff --git a/src/Controller/Booking/Create/Step3Controller.php b/src/Controller/Booking/Create/Step3Controller.php index d8d544d..cb6b797 100644 --- a/src/Controller/Booking/Create/Step3Controller.php +++ b/src/Controller/Booking/Create/Step3Controller.php @@ -68,6 +68,11 @@ class Step3Controller extends AbstractController $form->handleRequest($request); if (true === $form->isSubmitted() && true === $form->isValid()) { + // Notify user if goodwill vouchers cannot be redeemed for inquiry bookings + if ($bookingCreateDto->isInquiryBooking() && $bookingCreateDto->hasGoodwillVouchers()) { + $this->addFlash('warning', 'Hinweis: Bei Anfragebuchungen können keine Kulanz-Gutscheine eingelöst werden. Kauf- und Aktionsgutscheine werden berücksichtigt.'); + } + try { // Validate booking data with API by submitting an inquiry booking $inquiryResponse = $this->apiClient->createBookingInquiry($bookingCreateDto); diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php index 857f150..90d1ceb 100644 --- a/src/Form/Model/BookingDto.php +++ b/src/Form/Model/BookingDto.php @@ -219,4 +219,52 @@ class BookingDto ->addViolation(); } } + + /** + * Checks if this is an inquiry booking (status='A'). + * + * Inquiry bookings occur when a travel is fully booked but still accepts inquiries. + */ + public function isInquiryBooking(): bool + { + return 'A' === $this->bookingStatus; + } + + /** + * Checks if any participants have entered voucher codes. + * + * @return bool True if any promotional, purchase, or goodwill vouchers are present + */ + public function hasVouchers(): bool + { + foreach ($this->participants as $participant) { + if (null !== $participant->promoVoucherCode && '' !== trim($participant->promoVoucherCode)) { + return true; + } + if (null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) { + return true; + } + } + + return false; + } + + /** + * Checks if any participants have entered goodwill vouchers. + * + * Goodwill vouchers (Kulanz) are a special type of purchase voucher that cannot be + * redeemed in inquiry bookings. This method is used to display appropriate warnings. + * + * @return bool True if any goodwill vouchers are present + */ + public function hasGoodwillVouchers(): bool + { + foreach ($this->participants as $participant) { + if (true === $participant->hasGoodwillVoucher) { + return true; + } + } + + return false; + } } diff --git a/templates/booking/_summary.html.twig b/templates/booking/_summary.html.twig index 7400f28..d2faa7c 100644 --- a/templates/booking/_summary.html.twig +++ b/templates/booking/_summary.html.twig @@ -1,6 +1,13 @@

Buchungsübersicht

+ {# Inquiry booking notification #} + {% if bookingCreateDto is defined and bookingCreateDto.isInquiryBooking() %} +
+ Anfragebuchung: Diese Reise ist ausgebucht. Ihre Buchung wird als Anfrage verarbeitet. +
+ {% endif %} + {# Mutability information (edit mode only) #} {% if mutableData is defined %}
diff --git a/tests/Form/Model/ParticipantEditDtoTest.php b/tests/Form/Model/ParticipantEditDtoTest.php index e76d48e..bfdcdce 100644 --- a/tests/Form/Model/ParticipantEditDtoTest.php +++ b/tests/Form/Model/ParticipantEditDtoTest.php @@ -8,7 +8,14 @@ use App\BusProNet\Model\Travel; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantEditDto; +use App\Service\BookingPriceCalculatorService; +use App\Service\VoucherValidationService; +use App\Validator\Constraints\PromoVoucherValidator; +use App\Validator\Constraints\PurchaseVoucherValidator; use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidatorFactoryInterface; +use Symfony\Component\Validator\ConstraintValidatorInterface; use Symfony\Component\Validator\Validation; use Symfony\Component\Validator\Validator\ValidatorInterface; @@ -25,8 +32,37 @@ class ParticipantEditDtoTest extends TestCase protected function setUp(): void { + // Create mock services for validator dependencies + $mockVoucherService = $this->createMock(VoucherValidationService::class); + $mockPriceCalculatorService = $this->createMock(BookingPriceCalculatorService::class); + + // Create custom validator factory that can inject dependencies + $validatorFactory = new class($mockVoucherService, $mockPriceCalculatorService) implements ConstraintValidatorFactoryInterface { + public function __construct( + private readonly VoucherValidationService $voucherService, + private readonly BookingPriceCalculatorService $priceCalculatorService, + ) { + } + + public function getInstance(Constraint $constraint): ConstraintValidatorInterface + { + $className = $constraint->validatedBy(); + + if (PurchaseVoucherValidator::class === $className) { + return new PurchaseVoucherValidator($this->voucherService); + } + + if (PromoVoucherValidator::class === $className) { + return new PromoVoucherValidator($this->voucherService, $this->priceCalculatorService); + } + + return new $className(); + } + }; + $this->validator = Validation::createValidatorBuilder() ->enableAttributeMapping() + ->setConstraintValidatorFactory($validatorFactory) ->getValidator(); }