From 7ea52670b965d4ce734ba0de00f5ad118cdfed01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 6 Oct 2025 09:41:32 +0200 Subject: [PATCH] wip: submit booking to api --- docs/ADDRESS_FIELDS_IMPLEMENTATION.md | 190 +++++ docs/BOOKING_SUBMISSION_IMPLEMENTATION.md | 771 ++++++++++++++++++ docs/BOOKING_SUBMISSION_STATUS.md | 559 +++++++++++++ docs/LOADING_INDICATORS_IMPLEMENTATION.md | 285 +++++++ docs/REFACTORING_BOOKING_DATA_PROCESSOR.md | 183 +++++ src/BusProNet/ApiClient.php | 79 ++ src/BusProNet/Constants.php | 4 + .../DataProcessor/BookingDataProcessor.php | 363 +++++++++ src/BusProNet/Model/Address.php | 8 +- src/BusProNet/Model/Agency.php | 19 + src/BusProNet/Model/BookingResponse.php | 49 ++ src/BusProNet/Model/Insurance.php | 2 +- src/BusProNet/Model/PaymentTerms.php | 19 + src/BusProNet/Model/PriceItem.php | 26 + src/BusProNet/XmlLoader/AgencyLoader.php | 87 ++ src/BusProNet/XmlParser/AgencyParser.php | 41 + src/BusProNet/XmlParser/ApiResponseParser.php | 4 + .../XmlParser/BookingResponseParser.php | 113 +++ src/BusProNet/XmlParser/InsuranceParser.php | 2 +- .../Booking/BookingExceptionHandlerTrait.php | 9 +- .../Booking/BookingSuccessController.php | 28 + .../Booking/CreateInitController.php | 42 +- .../Booking/CreateStep3Controller.php | 79 +- .../Booking/CreateStep4Controller.php | 54 +- src/Form/AddressType.php | 49 ++ src/Form/BookingParticipantType.php | 12 +- src/Form/Model/BookingCreateDto.php | 2 + src/Form/Model/ParticipantDto.php | 11 + .../ParticipantBulkInsuranceFieldHandler.php | 45 +- src/Service/BookingService.php | 14 +- .../Constraints/ParticipantValidator.php | 46 ++ templates/booking/create_step_2.html.twig | 11 + templates/booking/success.html.twig | 30 + .../BusProNet/XmlParser/AgencyParserTest.php | 111 +++ 34 files changed, 3316 insertions(+), 31 deletions(-) create mode 100644 docs/ADDRESS_FIELDS_IMPLEMENTATION.md create mode 100644 docs/BOOKING_SUBMISSION_IMPLEMENTATION.md create mode 100644 docs/BOOKING_SUBMISSION_STATUS.md create mode 100644 docs/LOADING_INDICATORS_IMPLEMENTATION.md create mode 100644 docs/REFACTORING_BOOKING_DATA_PROCESSOR.md create mode 100644 src/BusProNet/Model/Agency.php create mode 100644 src/BusProNet/Model/BookingResponse.php create mode 100644 src/BusProNet/Model/PaymentTerms.php create mode 100644 src/BusProNet/Model/PriceItem.php create mode 100644 src/BusProNet/XmlLoader/AgencyLoader.php create mode 100644 src/BusProNet/XmlParser/AgencyParser.php create mode 100644 src/BusProNet/XmlParser/BookingResponseParser.php create mode 100644 src/Controller/Booking/BookingSuccessController.php create mode 100644 src/Form/AddressType.php create mode 100644 templates/booking/success.html.twig create mode 100644 tests/BusProNet/XmlParser/AgencyParserTest.php diff --git a/docs/ADDRESS_FIELDS_IMPLEMENTATION.md b/docs/ADDRESS_FIELDS_IMPLEMENTATION.md new file mode 100644 index 0000000..b391441 --- /dev/null +++ b/docs/ADDRESS_FIELDS_IMPLEMENTATION.md @@ -0,0 +1,190 @@ +# Address Fields Implementation + +**Date:** 2025-10-06 +**Status:** ✅ TESTED SUCCESSFULLY (2025-10-06) + +## Overview + +Added participant address collection to the booking create flow. Address is mandatory for the applicant (first participant) and optional for others. + +## Implementation + +### 1. Models + +**ParticipantDto** (`src/Form/Model/ParticipantDto.php`): +- Added `Address $address` property +- Made email field required (`@Assert\NotBlank`) +- Added constructor to initialize `Address` object +- Removed `@Assert\NotNull` constraint (validation handled by ParticipantValidator) + +**Address** (`src/BusProNet/Model/Address.php`): +- Already had `toPayload()` method for XML generation +- Properties: street, postCode, city, district, country +- No changes needed (existing model worked perfectly) + +### 2. Forms + +**AddressType** (`src/Form/AddressType.php`) - NEW: +```php +class AddressType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('street', TextType::class, [...]) + ->add('postCode', TextType::class, [...]) + ->add('city', TextType::class, [...]) + ->add('country', CountryType::class, [ + 'property' => 'country', + 'preferred_choices' => ['DE', 'AT', 'CH'], + ]); + } +} +``` + +**BookingParticipantType** (`src/Form/BookingParticipantType.php`): +- Added address field after mobile field +- `required => 0 === $participantIndex` (mandatory for applicant) +- Added 'address' to `$baseFields` array for rebuild handling + +### 3. Validation + +**ParticipantValidator** (`src/Validator/Constraints/ParticipantValidator.php`): +- Added `assertApplicantAddressValid()` method +- Validates street, postCode, city, country for applicant only +- Uses `$participant->isApplicant()` to check if validation should run +- Error messages: "Bitte angeben" for each missing field + +### 4. Payload Generation + +**BookingDataProcessor** (`src/BusProNet/DataProcessor/BookingDataProcessor.php`): + +**Applicant section** (lines 451-454): +```php +// Add address for applicant +if (null !== $firstParticipant->address) { + $payload['anmelder']['anschrift'] = $firstParticipant->address->toPayload(); +} +``` + +**Participant list** (lines 481-488): +```php +// Add address (always include structure, even if empty) +$participantData['anschrift'] = $participant->address?->toPayload() ?? [ + 'strasse' => null, + 'plz' => null, + 'ort' => null, + 'ortsteil' => null, + 'land' => null, +]; +``` + +**XML Output:** +```xml + + + Gablonzer Straße 32 + 53359 + Rheinbach + + D + + + + + + + Gablonzer Straße 32 + 53359 + Rheinbach + + D + + + +``` + +## Bug Fixes (Same Session) + +### 1. Room Quantity Fix +**Issue:** Room `@anzahl` was set to participant count instead of room quantity +**Fix:** Use `roomSelections[].quantity` from step 1 (lines 576-603) +```php +$roomQuantities = []; +foreach ($bookingDto->roomSelections as $selection) { + if ($selection->quantity > 0) { + $roomQuantities[$selection->roomId] = $selection->quantity; + } +} +$quantity = $roomQuantities[$roomId] ?? 1; +``` + +### 2. Insurance Selection for Dependent Participants +**Issue:** `ParticipantBulkInsuranceFieldHandler` cleared independent selections on every form submit +**Fix:** Added `wasBulkInsurancePreviouslyEnabled()` check (lines 90, 144-176) +```php +if (false === $isBulkEnabled && $this->wasBulkInsurancePreviouslyEnabled($bookingDto)) { + $this->clearDependentParticipantsInsurance($bookingDto); +} +``` + +## Files Modified + +1. `src/Form/Model/ParticipantDto.php` - Added address property, constructor, required email +2. `src/Form/AddressType.php` - NEW form type +3. `src/Form/BookingParticipantType.php` - Added address field +4. `src/Validator/Constraints/ParticipantValidator.php` - Added address validation +5. `src/BusProNet/DataProcessor/BookingDataProcessor.php` - Address in payload, room quantity fix, `isApplicant()` usage +6. `src/Form/Service/ParticipantBulkInsuranceFieldHandler.php` - Insurance clearing fix + +## Testing + +### Manual Testing Checklist: +- [x] Applicant address required (all fields) - ✅ PASSED +- [x] Dependent participant address optional - ✅ PASSED +- [x] Country dropdown shows DE, AT, CH first - ✅ PASSED +- [x] Address appears in XML for applicant - ✅ PASSED +- [x] Address appears in XML for all participants (empty if not provided) - ✅ PASSED +- [x] Email is mandatory for all participants - ✅ PASSED +- [x] Insurance selection works for dependent participants - ✅ PASSED +- [x] Room quantity matches selection from step 1 - ✅ PASSED + +### Expected XML: +- Applicant: Full address in `` +- All participants: Address structure in `` (may be empty) +- Room quantity: Matches quantity selected in step 1, not participant count + +## Notes + +- Address object initialized in ParticipantDto constructor +- Uses existing `Address::toPayload()` method for XML generation +- CountryType provides nationality dropdown with German country codes +- Validation runs via existing ParticipantValidator constraint +- Template integration happens automatically via Symfony form system + +--- + +**Implementation Status:** ✅ TESTED SUCCESSFULLY +**Code Quality:** ✅ PHP-CS-Fixer validated +**Test Date:** 2025-10-06 + +## Test Results + +**Test Date:** 2025-10-06 +**Environment:** DDEV sandbox with BusProNet API + +**Validated Features:** +- ✅ Applicant address validation (all fields mandatory) +- ✅ Dependent participant address optional +- ✅ Country dropdown with preferred choices (DE, AT, CH) +- ✅ Address correctly included in XML payload for applicant +- ✅ Address structure included for all participants (empty nodes for optional) +- ✅ Email mandatory validation working for all participants +- ✅ Mobile mandatory validation working for applicant only + +**Validated Bug Fixes:** +- ✅ Room quantity using correct value from step 1 selections +- ✅ Insurance selection working correctly for dependent participants +- ✅ Pickup locations included with correct quantities + +**Result:** All address field requirements working correctly in production-like environment. diff --git a/docs/BOOKING_SUBMISSION_IMPLEMENTATION.md b/docs/BOOKING_SUBMISSION_IMPLEMENTATION.md new file mode 100644 index 0000000..980bc7b --- /dev/null +++ b/docs/BOOKING_SUBMISSION_IMPLEMENTATION.md @@ -0,0 +1,771 @@ +# Booking Submission Implementation Guide + +## Overview + +This document describes the complete implementation of the two-phase booking submission system for the CREATE booking flow in MyEP Next Booking. + +**Implementation Date:** 2025-10-06 +**Status:** ✅ TESTED SUCCESSFULLY (2025-10-06) +**Related Files:** See "Files Modified/Created" section below + +## Architecture + +### Two-Phase Submission Flow + +The booking submission uses a two-phase commit pattern for safety and validation: + +1. **Phase 1: Inquiry (Anfrage) - Step 3** + - Executed at the end of payment method selection (Step 3) + - Validates all booking data with BusProNet API + - Returns pricing information for validation + - Compares API total price with calculated price (exact match required) + - No permanent changes made + - Request payload: `buchungsart => 'Anfrage'` + - Response status: `möglich` indicates valid + - Blocks progression to Step 4 if validation fails or prices don't match + +2. **Phase 2: Booking (Buchung) - Step 4** + - Executed when user confirms booking on Step 4 + - Creates actual booking in BPN system (already validated in Step 3) + - Returns transaction number (Vorgangsnummer) + - Request payload: `buchungsart => 'Buchung'` + - Response status: `erfolgt` indicates success + - Fast execution (no re-validation needed) + +### Request Payload Structure + +The payload generation follows the participant-centric data structure established in the CREATE flow: + +**Key Characteristics:** +- Services grouped by ID with participant assignments +- 1-based participant indexing (API requirement) +- Comma-separated participant lists in `zuordnung` attribute +- Includes ALL service types: transportation, rooms, additional services, pickups, insurances, parking +- Participant wishes (room remarks, license plate) included in `` section +- Agency ID resolution with fallback to default agency (code '0001') +- Price validation: API total must match calculated total exactly (1:1) + +**Service Grouping Example:** +```xml + + + + +``` + +**Full XML Structure:** +```xml + + USERNAME + HASH + + Anfrage|Buchung + F + 12345 + + + Herr + Max + Mustermann + Musterstraße 1 + 12345 + Musterstadt + max@example.com + +49123456789 + + + + + Herr + Max + Mustermann + 1990-01-01 + + + + + + + + + + + + + + + + + + + + + + + + + + 2|5 + Max Mustermann + DE89370400440532013000 + + +``` + +### Response Structure + +**Success Response:** +```xml + + + möglich|erfolgt + 321530 + + + + + 1500.00 + + + + + +``` + +**Error Response:** +```xml + + + Error message here + +``` + +## Implementation Details + +### 1. Response Models + +**File:** `src/BusProNet/Model/BookingResponse.php` + +```php +status; + } + + public function isBookingSuccessful(): bool + { + return 'erfolgt' === $this->status; + } +} +``` + +**File:** `src/BusProNet/Model/PriceItem.php` + +Individual price item from response for validation against calculated prices. + +```php +` node +- Transaction number from `` node +- All price items from `` nodes +- Total price from `` node +- Payment terms from `` node + +```php +getTextOrNull($node, 'buchung') ?? ''; + $transactionNumber = $this->getTextOrNull($node, 'vorgang'); + $priceItems = $this->parsePriceItems($node); + $totalPrice = $this->getFloatOrNull($node, 'gesamtpreis'); + $paymentTerms = $this->parsePaymentTerms($node); + + return new BookingResponse( + status: $status, + transactionNumber: $transactionNumber, + priceItems: $priceItems, + totalPrice: $totalPrice, + paymentTerms: $paymentTerms + ); + } + + private function parsePriceItems(Crawler $node): array + { + $priceItems = []; + $node->filterXPath('//preise/preis')->each(function (Crawler $priceNode) use (&$priceItems): void { + $priceItems[] = new PriceItem( + position: (int) $priceNode->attr('position'), + type: $priceNode->attr('art'), + subType: $priceNode->attr('unterart'), + label: $priceNode->attr('bezeichnung'), + dateFrom: $this->parseDate($priceNode->attr('datumvon')), + dateTo: $this->parseDate($priceNode->attr('datumbis')), + quantity: (int) $priceNode->attr('anzahl'), + assignment: $priceNode->attr('zuordnung'), + unitPrice: (float) $priceNode->attr('einzelpreis'), + totalPrice: (float) $priceNode->attr('gesamtpreis'), + id: $priceNode->attr('id') ? (int) $priceNode->attr('id') : null + ); + }); + + return $priceItems; + } + + private function parsePaymentTerms(Crawler $node): ?PaymentTerms + { + $termsNode = $node->filterXPath('//zahlungsbedingungen'); + if (0 === $termsNode->count()) { + return null; + } + + $depositAmount = (float) $termsNode->filterXPath('//anzahlung')->attr('betrag'); + $depositDate = $this->parseDate($termsNode->filterXPath('//anzahlung')->attr('datum')); + $finalAmount = (float) $termsNode->filterXPath('//restzahlung')->attr('betrag'); + $finalDate = $this->parseDate($termsNode->filterXPath('//restzahlung')->attr('datum')); + + if (null === $depositDate || null === $finalDate) { + return null; + } + + return new PaymentTerms( + depositAmount: $depositAmount, + depositDate: $depositDate, + finalPaymentAmount: $finalAmount, + finalPaymentDate: $finalDate + ); + } +} +``` + +### 3. Payload Generation + +**File:** `src/BusProNet/DataProcessor/BookingDataProcessor.php` + +**Main Method:** +```php +public function createBookingRequestPayload( + BookingCreateDto $bookingDto, + string $bookingType +): array +``` + +**Helper Methods:** +- `collectServiceMappings()` - Groups services by ID +- `collectTransportationMappings()` - Groups transportation services +- `collectRoomMappings()` - Groups room assignments +- `collectPickupMappings()` - Groups pickup locations +- `collectInsuranceMappings()` - Groups insurance selections +- `addServicesFromMap()` - Generic XML structure builder + +**Critical Implementation Details:** +- Participant indexing is 1-based (API requirement) +- Services grouped by ID with comma-separated participant assignments +- Insurance included in CREATE flow (unlike UPDATE flow) +- Payment type IDs: 2 for transfer, 5 for debit + +### 4. API Client Methods + +**File:** `src/BusProNet/ApiClient.php` + +**Constants Added:** +```php +public const TYPE_BOOKING = 'BUCHUNG'; +``` + +**Payment Type Constants (in Constants.php):** +```php +public const PAYMENT_TYPE_ID_TRANSFER = 2; +public const PAYMENT_TYPE_ID_DEBIT = 5; +``` + +**Methods Added:** +```php +public function createBookingInquiry( + BookingCreateDto $bookingDto, + bool $debug = false +): Notification|BookingResponse +{ + $payload = (new BookingDataProcessor())->createBookingRequestPayload($bookingDto, 'Anfrage'); + $data = [ + 'user' => $this->config['bpn_username'], + 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING), + 'satz' => ['@typ' => static::TYPE_BOOKING], + ...$payload, + ]; + + return $this->sendRequest(static::TYPE_BOOKING, $data, [], $debug); +} + +public function createBooking( + BookingCreateDto $bookingDto, + bool $debug = false +): Notification|BookingResponse +{ + $payload = (new BookingDataProcessor())->createBookingRequestPayload($bookingDto, 'Buchung'); + $data = [ + 'user' => $this->config['bpn_username'], + 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING), + 'satz' => ['@typ' => static::TYPE_BOOKING], + ...$payload, + ]; + + return $this->sendRequest(static::TYPE_BOOKING, $data, [], $debug); +} +``` + +Both methods: +- Use `BookingDataProcessor::createBookingRequestPayload()` +- Return either `Notification` (error) or `BookingResponse` (success) +- Support debug mode for XML dumping + +### 5. Response Routing + +**File:** `src/BusProNet/XmlParser/ApiResponseParser.php` + +Added routing for `TYPE_BOOKING` responses: +```php +case ApiClient::TYPE_BOOKING: + return (new BookingResponseParser())->parse($resultNode); +``` + +Error responses still return `Notification` objects via existing error handling. + +### 6. Controller Logic + +#### Step 3: Validation with Price Check + +**File:** `src/Controller/Booking/CreateStep3Controller.php` + +**Dependencies Injected:** +- `BookingService` - Session management +- `ApiClient` - API communication +- `BookingPriceCalculatorService` - Price calculation +- `LoggerInterface` - Error logging + +**Form Submission Flow:** +```php +if ($form->isSubmitted() && $form->isValid()) { + // Call inquiry API to validate booking + $inquiryResponse = $this->apiClient->createBookingInquiry($bookingCreateDto); + + if ($inquiryResponse instanceof Notification || !$inquiryResponse->isInquiryValid()) { + // Handle validation failure + } + + // Compare API price with calculated price (exact match required) + $apiTotal = $inquiryResponse->totalPrice ?? 0.0; + $calculatedTotal = $this->priceCalculator->calculateGrandTotal($bookingCreateDto); + + if ($apiTotal !== $calculatedTotal) { + $this->logger->error('Price mismatch detected - payload incomplete', [ + 'apiTotal' => $apiTotal, + 'calculatedTotal' => $calculatedTotal, + 'difference' => abs($apiTotal - $calculatedTotal), + ]); + $this->addFlash('error', 'Ein technischer Fehler ist aufgetreten.'); + return; // Block progression to Step 4 + } + + // Proceed to Step 4 (confirmation) + $bookingCreateDto->currentStep = 4; + return $this->redirectToRoute('app_booking_create_step_4'); +} +``` + +**Price Validation Logic:** +- Exact match required: `$apiTotal !== $calculatedTotal` +- No tolerance for rounding differences +- Mismatch indicates missing service in payload +- Logs full context for debugging + +#### Step 4: Final Booking Submission + +**File:** `src/Controller/Booking/CreateStep4Controller.php` + +**Dependencies Injected:** +- `BookingService` - Session management +- `ApiClient` - API communication +- `LoggerInterface` - Error logging + +**Form Submission Flow:** +```php +if ($form->isSubmitted() && $form->isValid()) { + try { + // Submit final booking (already validated in Step 3) + $bookingResponse = $this->apiClient->createBooking($bookingCreateDto); + + if ($bookingResponse instanceof Notification) { + $this->addFlash('error', $bookingResponse->message); + return $this->render('booking/create_step_4.html.twig', [...]); + } + + if (false === $bookingResponse->isBookingSuccessful()) { + $this->addFlash('error', 'Buchung konnte nicht erstellt werden.'); + return $this->render('booking/create_step_4.html.twig', [...]); + } + + // Success: Store booking number in flash and clear session + $this->addFlash('booking_number', $bookingResponse->transactionNumber); + $this->bookingService->clearBookingCreateDto($request); + + return $this->redirectToRoute('app_booking_success'); + } catch (\Exception $e) { + $this->logger->error('Booking creation failed', [...]); + $this->addFlash('error', 'Ein technischer Fehler ist aufgetreten.'); + return $this->render('booking/create_step_4.html.twig', [...]); + } +} +``` + +**Benefits:** +- Step 3: Validates early, catches payload errors before confirmation +- Step 4: Fast execution, no validation delay +- User experience: Reduced wait time on final submission + +**Error Handling:** +- API errors: Display `Notification::message` to user +- Validation failures: Display generic error message +- Price mismatch: Log detailed context, block with generic error +- Unexpected exceptions: Log full trace and display generic error + +### 7. Service Layer + +**File:** `src/Service/BookingService.php` + +**Method Added:** +```php +/** + * Clears the booking creation DTO from the session. + * + * This method removes only the booking DTO while preserving other session data. + * Used after successful booking submission to clear the booking flow state. + */ +public function clearBookingCreateDto(Request $request): void +{ + $request->getSession()->remove(self::BOOKING_CREATE_KEY); +} +``` + +Clears only the booking DTO (not baseline snapshot) after successful submission. + +### 8. Success Page + +**Controller:** `src/Controller/Booking/BookingSuccessController.php` + +```php +getSession()->getFlashBag()->get('booking_number')[0] ?? null; + + // Redirect to homepage if no booking number (direct access or refresh) + if (null === $bookingNumber) { + return $this->redirectToRoute('app_home'); + } + + return $this->render('booking/success.html.twig', [ + 'bookingNumber' => $bookingNumber, + ]); + } +} +``` + +**Implementation Details:** +- Booking number passed via flash message (not URL parameter) +- Flash message automatically cleared after first display +- Direct access or page refresh redirects to homepage +- Clean URL: `/bookings/success` (no sensitive data in URL) +- No persistent browser history with booking numbers + +**Template:** `templates/booking/success.html.twig` + +Displays: +- Success icon (green checkmark) +- Confirmation message +- Booking number (monospace font for easy copying) +- Information about email confirmation +- Link back to homepage + +## Testing Strategy + +### Integration Testing + +**Test Scenarios:** + +1. **Successful Booking:** + - Complete all 4 steps + - Submit confirmation form + - Verify inquiry call made + - Verify booking call made + - Verify redirect to success page + - Verify session cleared + +2. **Inquiry Validation Failure:** + - Submit invalid data + - Verify inquiry returns error + - Verify booking NOT called + - Verify user sees error message + - Verify session NOT cleared + +3. **Booking Commit Failure:** + - Inquiry succeeds but booking fails + - Verify appropriate error handling + - Verify session NOT cleared + +4. **API Error Response:** + - API returns Notification + - Verify error message displayed + - Verify session NOT cleared + +### Sandbox Testing + +**Prerequisites:** +- DDEV environment running +- BPN sandbox credentials configured in `.env.local` +- Valid travel data available + +**Test Checklist:** +- [x] Single participant booking - ✅ PASSED +- [x] Multiple participants booking - ✅ PASSED +- [x] All service types selected - ✅ PASSED (transportation, rooms, services, pickups, insurances) +- [x] Insurance selection - ✅ PASSED +- [x] Both payment methods - ✅ TRANSFER TESTED (debit not tested) +- [x] Applicant address mandatory - ✅ PASSED +- [x] Dependent participant address optional - ✅ PASSED +- [x] Email mandatory for all - ✅ PASSED +- [x] Mobile mandatory for applicant - ✅ PASSED +- [x] Room quantity matches step 1 - ✅ PASSED +- [x] Pickup location included - ✅ PASSED +- [x] Two-phase submission - ✅ PASSED (inquiry → booking) +- [x] Session cleared on success - ✅ PASSED +- [x] Success page with booking number - ✅ PASSED + +## Files Modified/Created + +### Created Files: +- `src/BusProNet/Model/BookingResponse.php` +- `src/BusProNet/Model/PriceItem.php` +- `src/BusProNet/Model/PaymentTerms.php` +- `src/BusProNet/Model/Agency.php` +- `src/BusProNet/XmlParser/BookingResponseParser.php` +- `src/BusProNet/XmlParser/AgencyParser.php` +- `src/BusProNet/XmlLoader/AgencyLoader.php` +- `src/Controller/Booking/BookingSuccessController.php` +- `templates/booking/success.html.twig` +- `tests/BusProNet/XmlParser/AgencyParserTest.php` +- `docs/BOOKING_SUBMISSION_IMPLEMENTATION.md` (this file) +- `docs/BOOKING_SUBMISSION_STATUS.md` +- `docs/REFACTORING_BOOKING_DATA_PROCESSOR.md` + +### Modified Files: +- `src/BusProNet/Constants.php` - Added payment type ID constants +- `src/BusProNet/DataProcessor/BookingDataProcessor.php` - Added `createBookingRequestPayload()` with parking and wishes section +- `src/BusProNet/ApiClient.php` - Added `TYPE_BOOKING`, `TYPE_AGENCIES`, `createBookingInquiry()`, `createBooking()`, `getAgencies()` +- `src/BusProNet/XmlParser/ApiResponseParser.php` - Added BUCHUNG and AGENTUREN routing +- `src/Controller/Booking/CreateStep3Controller.php` - Added inquiry API call with price validation +- `src/Controller/Booking/CreateStep4Controller.php` - Simplified to direct booking submission (validation moved to Step 3) +- `src/Controller/Booking/CreateInitController.php` - Added agency resolution with optional query parameter +- `src/Service/BookingService.php` - Added `clearBookingCreateDto()`, agency ID parameter in `startFreshBooking()` +- `src/Form/Model/BookingCreateDto.php` - Added `agencyId` property + +## Known Limitations + +1. **Price Validation:** + - ~~Pricing data is parsed but not automatically validated against calculated prices~~ ✅ IMPLEMENTED + - Exact price match validation implemented in Step 3 + +2. **Email/Password Fields:** + - Response contains `` and `` fields that are not currently parsed + - Can be added if needed for confirmation emails + +3. **Update Flow Refactoring:** + - UPDATE flow still uses different payload structure + - Future refactoring documented in `REFACTORING_BOOKING_DATA_PROCESSOR.md` + +4. **Contact Information:** + - Phone number (mobile) is mandatory for the applicant only + - Email is mandatory for all participants + +## Future Enhancements + +1. **Price Validation:** + - ~~Compare `$bookingResponse->totalPrice` with `BookingPriceCalculatorService` result~~ ✅ IMPLEMENTED + - ~~Warn if discrepancy detected~~ ✅ BLOCKS PROGRESSION + +2. **Email Confirmation:** + - Parse email/password fields from response + - Send custom confirmation email + - Include PDF password in email + +3. **Transaction Logging:** + - Log all inquiry/booking requests with responses + - Facilitate debugging and audit trail + +4. **Retry Logic:** + - Handle transient API failures + - Implement exponential backoff + +5. **Price Item Validation:** + - Compare individual price items with selections + - Detect unexpected charges + +## Troubleshooting + +### Issue: Inquiry succeeds but booking fails + +**Symptoms:** User sees error after successful validation + +**Debugging:** +1. Check application logs for exception details +2. Enable API debug mode to dump XML +3. Verify data hasn't changed between calls +4. Check BPN API logs in admin panel + +### Issue: Session cleared prematurely + +**Symptoms:** User redirected to init page + +**Debugging:** +1. Verify `clearBookingCreateDto()` only called after successful booking +2. Check for duplicate form submissions +3. Verify error handling re-renders without clearing session + +## References + +- BusProNet API Documentation: `docs/Beschreibung XMLAnfrage.pdf` +- Example Request Payload: `scratch_113.xml` +- Example Response: `scratch_111.xml` (with pricing), `scratch_112.xml` (minimal) +- Payment Step Implementation: `docs/BOOKING_PAYMENT_STEP.md` +- Refactoring Plan: `REFACTORING_BOOKING_DATA_PROCESSOR.md` +- Implementation Status: `BOOKING_SUBMISSION_STATUS.md` + +--- + +**Implementation Status:** ✅ TESTED SUCCESSFULLY +**Code Quality:** ✅ PHP-CS-Fixer validated, syntax checked +**Test Date:** 2025-10-06 +**Next Step:** Improvements and UPDATE flow refactoring (see REFACTORING_BOOKING_DATA_PROCESSOR.md) + +## Test Results Summary + +**Test Date:** 2025-10-06 +**Environment:** DDEV sandbox with BusProNet API + +**Successful Test Booking:** +- 2 participants with complete data +- All service types: transportation, rooms, additional services, pickups, insurance, parking +- Address validation working (mandatory for applicant, optional for others) +- Contact info validation working (email for all, mobile for applicant) +- Room quantity correctly using step 1 selections +- Two-phase submission successful (inquiry → booking) +- Session cleared after success +- Success page displaying booking number +- Agency ID resolved from optional query parameter with fallback to default (code '0001') +- Participant wishes (room remarks, license plate) included in payload + +**Bugs Fixed During Testing:** +1. Room quantity using participant count → Fixed to use roomSelections[].quantity +2. Insurance selection reset on dependent participants → Fixed bulk handler clearing logic +3. Pickup quantity issues → Simplified to use only outbound pickups +4. Missing contact info for non-applicants → Removed applicant-only restriction +5. Parking service not included in payload → Added to collectServiceMappings() +6. License plate and room remarks not submitted → Added wünsche section to participant payload + +**Result:** ✅ All critical features working correctly \ No newline at end of file diff --git a/docs/BOOKING_SUBMISSION_STATUS.md b/docs/BOOKING_SUBMISSION_STATUS.md new file mode 100644 index 0000000..f8046e8 --- /dev/null +++ b/docs/BOOKING_SUBMISSION_STATUS.md @@ -0,0 +1,559 @@ +# Booking Submission Implementation Status + +## Overview + +Implementation of two-phase booking submission for the booking creation flow. This allows users to create new bookings through inquiry validation followed by final booking commit. + +**Status:** ✅ 100% Complete - TESTED SUCCESSFULLY +**Last Updated:** 2025-10-06 +**First Successful Test Booking:** 2025-10-06 +**Related Documentation:** +- `docs/BOOKING_PAYMENT_STEP.md` - Payment step implementation +- `docs/REFACTORING_BOOKING_DATA_PROCESSOR.md` - Future refactoring plan + +## Architecture Decision + +**Participant-Centric Structure:** The CREATE flow uses a cleaner participant-centric data structure where services are attached directly to participants in the DTO, not centralized with mapping arrays. This is the new standard. + +**UPDATE Flow:** Currently uses a different structure (centralized services with mappings). Future refactoring will align it with the CREATE flow's participant-centric approach. + +## Completed Work (80%) + +### 1. Response Models ✅ + +**Created Files:** +- `src/BusProNet/Model/BookingResponse.php` +- `src/BusProNet/Model/PriceItem.php` +- `src/BusProNet/Model/PaymentTerms.php` + +**BookingResponse:** +- Represents API response from booking requests (inquiry or final) +- Properties: `status`, `transactionNumber`, `priceItems`, `totalPrice`, `paymentTerms` +- Methods: `isInquiryValid()`, `isBookingSuccessful()` +- Handles both `möglich` (inquiry valid) and `erfolgt` (booking created) statuses + +**PriceItem:** +- Represents individual price items from response +- Properties: `position`, `type`, `subType`, `label`, `dateFrom`, `dateTo`, `quantity`, `assignment`, `unitPrice`, `totalPrice`, `id` +- Used for price validation against calculated prices + +**PaymentTerms:** +- Represents payment schedule from response +- Properties: `depositAmount`, `depositDate`, `finalPaymentAmount`, `finalPaymentDate` + +### 2. Response Parser ✅ + +**File:** `src/BusProNet/XmlParser/BookingResponseParser.php` + +**Functionality:** +- Extends `AbstractParser` +- Parses BUCHUNG type responses +- Extracts booking status from `` node +- Extracts transaction number from `` node +- Parses all price items from `` nodes +- Parses total price from `` node +- Parses payment terms from `` node + +**XML Structure Handled:** +```xml + + + möglich|erfolgt + 321530 + + + + 671,78 + + + + + +``` + +### 3. Constants ✅ + +**File:** `src/BusProNet/Constants.php` + +**Added:** +- `PAYMENT_TYPE_ID_TRANSFER = 2` - Payment type ID for bank transfer +- `PAYMENT_TYPE_ID_DEBIT = 5` - Payment type ID for direct debit + +### 4. Payload Generation ✅ + +**File:** `src/BusProNet/DataProcessor/BookingDataProcessor.php` + +**New Method:** `createBookingRequestPayload(BookingCreateDto $bookingDto, string $bookingType): array` + +**Functionality:** +- Generates XML payload for new bookings (inquiry or final) +- Supports both 'Anfrage' (inquiry) and 'Buchung' (final booking) modes +- Participant-centric structure (services attached to participants) +- Includes ALL service types: board, ski passes, rentals, courses, additional services, transportation, pickups +- **INCLUDES INSURANCE** (critical difference from update flow) +- Proper room mapping via `assignedRoomId` +- Payment information with correct type IDs + +**Helper Methods:** +- `addServicesFromMap()` - Reusable helper for converting service maps to XML structure +- `collectServiceMappings()` - Groups all participant services by ID +- `collectTransportationMappings()` - Groups transportation services +- `collectRoomMappings()` - Groups room assignments +- `collectPickupMappings()` - Groups pickup selections +- `collectInsuranceMappings()` - Groups insurance selections (CREATE only!) + +**Payload Structure:** +```php +[ + 'buchungsart' => 'Anfrage|Buchung', + 'status' => 'F', + 'idreise' => $travelId, + 'anmelder' => [ + 'name' => '...', + 'vorname' => '...', + 'geschlecht' => '...', + 'nationalitaet' => '...', + 'geburtsdatum' => '...', + 'kommunikation' => ['email' => '...', 'telefonmobil' => '...'], + ], + 'teilnehmerliste' => ['teilnehmer' => [...]], + 'beförderungen' => ['beförderung' => [...]], + 'unterbringungen' => ['unterbringung' => [...]], + 'zusatzleistungen' => ['zusatzleistung' => [...]], + 'zustiege' => ['zustieg' => [...]], + 'versicherungen' => ['versicherung' => [...]], // CREATE only! + 'zahlung' => [ + '@idzahlungsart' => 2|5, + '@art' => 'EINZUG|UEBERWEISUNG', + 'bankverbindung' => [...], // if debit + ], +] +``` + +### 5. API Client Methods ✅ + +**File:** `src/BusProNet/ApiClient.php` + +**New Constant:** +- `TYPE_BOOKING = 'BUCHUNG'` + +**New Methods:** + +```php +public function createBookingInquiry( + BookingCreateDto $bookingDto, + bool $debug = false +): Notification|BookingResponse +``` +- First phase: validates booking data +- Returns pricing information +- Does not create actual booking + +```php +public function createBooking( + BookingCreateDto $bookingDto, + bool $debug = false +): Notification|BookingResponse +``` +- Second phase: creates actual booking +- Returns booking number (transaction number) +- Only called after successful inquiry + +**Both methods:** +- Use `BookingDataProcessor::createBookingRequestPayload()` +- Send request to BUCHUNG type endpoint +- Return `Notification` on error or `BookingResponse` on success +- Support debug mode for XML dumps + +### 6. Response Parser Integration ✅ + +**File:** `src/BusProNet/XmlParser/ApiResponseParser.php` + +**Updated:** +- Added case for `ApiClient::TYPE_BOOKING` +- Routes to `BookingResponseParser` +- Handles both inquiry and final booking responses + +## Remaining Work (0%) + +### 1. Controller Implementation ✅ + +**File:** `src/Controller/Booking/CreateStep4Controller.php` + +**Completed:** +- Imported `ApiClient` and injected via constructor +- Imported `LoggerInterface` and injected via constructor +- Implemented two-phase submission in form handler + +```php +if ($form->isSubmitted() && $form->isValid()) { + try { + // Phase 1: Inquiry (Validation) + $inquiryResponse = $this->apiClient->createBookingInquiry($bookingCreateDto); + + if ($inquiryResponse instanceof Notification) { + // API returned error notification + $this->addFlash('error', $inquiryResponse->message); + return $this->render(...); + } + + if (false === $inquiryResponse->isInquiryValid()) { + // Inquiry validation failed + $this->addFlash('error', 'Buchungsvalidierung fehlgeschlagen.'); + return $this->render(...); + } + + // Optional: Validate prices match our calculations + // Compare $inquiryResponse->totalPrice with calculated total + + // Phase 2: Booking (Commit) + $bookingResponse = $this->apiClient->createBooking($bookingCreateDto); + + if ($bookingResponse instanceof Notification) { + // API returned error notification + $this->addFlash('error', $bookingResponse->message); + return $this->render(...); + } + + if (false === $bookingResponse->isBookingSuccessful()) { + // Booking creation failed + $this->addFlash('error', 'Buchung konnte nicht erstellt werden.'); + return $this->render(...); + } + + // Success: Clear session and redirect + $this->bookingService->clearBookingCreateDto($request); + + return $this->redirectToRoute('app_booking_success', [ + 'bookingNumber' => $bookingResponse->transactionNumber, + ]); + + } catch (\Exception $e) { + $this->logger->error('Booking creation failed', [ + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $this->addFlash('error', 'Ein technischer Fehler ist aufgetreten.'); + return $this->render(...); + } +} +``` + +**Constructor Update:** +```php +public function __construct( + private readonly BookingService $bookingService, + private readonly ApiClient $apiClient, + private readonly LoggerInterface $logger, +) { +} +``` + +### 2. Service Layer ✅ + +**File:** `src/Service/BookingService.php` + +**Completed:** +```php +/** + * Clears the booking creation DTO from the session. + * + * This method removes only the booking DTO while preserving other session data. + * Used after successful booking submission to clear the booking flow state. + */ +public function clearBookingCreateDto(Request $request): void +{ + $request->getSession()->remove(self::BOOKING_CREATE_KEY); +} +``` + +### 3. Success Page ✅ + +**New Controller:** `src/Controller/Booking/BookingSuccessController.php` + +**Completed:** + +```php +render('booking/success.html.twig', [ + 'bookingNumber' => $bookingNumber, + ]); + } +} +``` + +**New Template:** `templates/booking/success.html.twig` + +```twig +{% extends 'layout.html.twig' %} + +{% block title %}Buchung erfolgreich{% endblock %} + +{% block content %} +
+
+ + + +
+ +

Buchung erfolgreich abgeschlossen

+ +

+ Ihre Buchungsnummer: {{ bookingNumber }} +

+ +
+

+ Sie erhalten in Kürze eine Bestätigungs-E-Mail mit allen Details zu Ihrer Buchung. +

+
+ + + Zurück zur Startseite + +
+{% endblock %} +``` + +### 4. Testing ⏳ + +**Sandbox Testing Checklist:** +- [ ] Test inquiry phase with valid data +- [ ] Test inquiry phase with invalid data (validation errors) +- [ ] Test booking phase after successful inquiry +- [ ] Test booking phase failure scenarios +- [ ] Verify pricing data matches calculations +- [ ] Test with different service combinations: + - [ ] With insurance + - [ ] Without insurance + - [ ] With transportation services + - [ ] With pickup locations + - [ ] With all service types + - [ ] Minimum services only +- [ ] Test payment methods: + - [ ] Direct debit (14+ days before travel) + - [ ] Bank transfer + - [ ] Direct debit blocked (<14 days) +- [ ] Test session clearing +- [ ] Verify booking number display +- [ ] Test error handling +- [ ] Verify XML dumps in debug mode + +## Key Implementation Notes + +### Two-Phase Process + +1. **Phase 1: Inquiry (`buchungsart => 'Anfrage'`)** + - Validates all booking data + - Returns pricing information + - Response: `möglich` + - No actual booking created + +2. **Phase 2: Booking (`buchungsart => 'Buchung'`)** + - Creates actual booking + - Returns booking number + - Response: `erfolgt` + - Only proceed if Phase 1 succeeded + +### Error Handling + +**API Errors:** +- API may return `Notification` object instead of `BookingResponse` +- Check instanceof before accessing BookingResponse methods +- Display error message from notification + +**Validation Errors:** +- Check `isInquiryValid()` on inquiry response +- Check `isBookingSuccessful()` on booking response +- Display appropriate error messages + +**Network/System Errors:** +- Catch all exceptions +- Log with full trace +- Display generic error message to user +- Do NOT clear session on error (allow retry) + +### Price Validation (Optional) + +**Inquiry response includes:** +- Individual price items with quantities and assignments +- Total price from API +- Payment terms (deposit/final payment) + +**Can compare:** +- API total vs calculated total +- Individual service prices +- Participant assignments + +**Implementation:** +```php +if (abs($inquiryResponse->totalPrice - $calculatedTotal) > 0.01) { + $this->logger->warning('Price mismatch', [ + 'api_price' => $inquiryResponse->totalPrice, + 'calculated_price' => $calculatedTotal, + ]); + // Decide: continue or abort +} +``` + +### Session Management + +**Important:** +- Only clear session on successful booking +- Keep session on errors (allows retry) +- Clear using `BookingService::clearBookingCreateDto()` + +### Logging + +**Log events:** +- Inquiry submission (info level) +- Inquiry success/failure (info/error) +- Booking submission (info level) +- Booking success/failure (info/error) +- Price mismatches (warning) +- Exceptions (error with full trace) + +**Context to include:** +- Travel ID +- Participant count +- Total price +- Payment method +- Error messages +- Exception traces + +## Testing Strategy + +### Unit Tests (Future) + +**BookingResponseParser:** +- Test parsing successful inquiry response +- Test parsing successful booking response +- Test parsing price items +- Test parsing payment terms +- Test handling missing optional fields + +**BookingDataProcessor:** +- Test payload generation with all services +- Test payload generation with minimum services +- Test insurance inclusion +- Test payment methods +- Test participant mappings + +### Integration Tests (Future) + +**ApiClient:** +- Mock socket communication +- Test inquiry request format +- Test booking request format +- Test response parsing +- Test error handling + +**Controller:** +- Test two-phase submission flow +- Test error scenarios +- Test session clearing +- Test redirects + +### Manual Testing (Immediate) + +**Use sandbox environment:** +- Current ddev setup points to sandbox +- XML dumps enabled for debugging +- Test with real travel data +- Verify all email notifications + +## File Locations Summary + +**Models:** +- `src/BusProNet/Model/BookingResponse.php` +- `src/BusProNet/Model/PriceItem.php` +- `src/BusProNet/Model/PaymentTerms.php` + +**Parsers:** +- `src/BusProNet/XmlParser/BookingResponseParser.php` +- `src/BusProNet/XmlParser/ApiResponseParser.php` (updated) + +**Data Processing:** +- `src/BusProNet/DataProcessor/BookingDataProcessor.php` (enhanced) + +**API:** +- `src/BusProNet/ApiClient.php` (enhanced) +- `src/BusProNet/Constants.php` (enhanced) + +**Controllers (to be updated/created):** +- `src/Controller/Booking/CreateStep4Controller.php` (update) +- `src/Controller/Booking/BookingSuccessController.php` (create) + +**Services (to be updated):** +- `src/Service/BookingService.php` (add method) + +**Templates (to be created):** +- `templates/booking/success.html.twig` + +**Documentation:** +- `docs/BOOKING_SUBMISSION_STATUS.md` (this file) +- `docs/REFACTORING_BOOKING_DATA_PROCESSOR.md` +- `docs/BOOKING_PAYMENT_STEP.md` +- `docs/Beschreibung XMLAnfrage.pdf` (API documentation) + +## Next Steps + +1. **Immediate:** + - Implement controller logic (20 minutes) + - Add session clearing method (5 minutes) + - Create success page (10 minutes) + - Test with sandbox (30 minutes) + +2. **Short-term:** + - Price validation logic (optional) + - Enhanced error messages + - Email confirmation integration + - PDF generation + +3. **Long-term:** + - Refactor UPDATE flow to use participant-centric structure + - Comprehensive test suite + - Performance optimization + - Enhanced logging and monitoring + +--- + +**Status:** ✅ Implementation Complete - Ready for Sandbox Testing +**Estimated Time to Test:** 30-60 minutes +**Blockers:** None +**Dependencies:** All completed + +## Implementation Summary + +All coding tasks have been completed: + +1. ✅ **Response Models** - BookingResponse, PriceItem, PaymentTerms created with full pricing support +2. ✅ **Response Parser** - BookingResponseParser parses all XML response data including prices +3. ✅ **Payload Generation** - createBookingRequestPayload() with participant-centric structure and all service types +4. ✅ **API Client Methods** - createBookingInquiry() and createBooking() methods implemented +5. ✅ **Response Routing** - ApiResponseParser updated to handle BUCHUNG type +6. ✅ **Controller Logic** - Two-phase submission with comprehensive error handling in CreateStep4Controller +7. ✅ **Service Method** - clearBookingCreateDto() added to BookingService +8. ✅ **Success Page** - BookingSuccessController and success.html.twig template created +9. ✅ **Code Quality** - All files validated with PHP-CS-Fixer and syntax checking + +**Next Step:** Sandbox testing with real API calls diff --git a/docs/LOADING_INDICATORS_IMPLEMENTATION.md b/docs/LOADING_INDICATORS_IMPLEMENTATION.md new file mode 100644 index 0000000..1a3b489 --- /dev/null +++ b/docs/LOADING_INDICATORS_IMPLEMENTATION.md @@ -0,0 +1,285 @@ +# Loading Indicators Implementation Plan + +## Overview + +Add loading indicators to Step 3 and Step 4 of the booking flow to provide visual feedback during slow API calls. + +**Date:** 2025-10-06 +**Status:** 📋 Planned (not yet implemented) + +## Problem Statement + +**Current User Experience:** +- Step 3: User clicks "Weiter" → 2-3 second wait (inquiry API) → No visual feedback +- Step 4: User clicks "Verbindlich buchen" → 1-2 second wait (booking API) → No visual feedback +- Users may click multiple times thinking the form didn't submit +- No indication that processing is happening + +## Solution + +Use HTMX for form submissions with built-in loading indicators. + +### Why HTMX? + +1. ✅ Already extensively used in the project (Step 2 form refreshes) +2. ✅ Built-in loading state management via `hx-indicator` +3. ✅ Better error handling (no page reload on validation errors) +4. ✅ Progressive enhancement (graceful degradation) +5. ✅ Consistent with existing architecture + +## Implementation Details + +### Step 1: Add HTMX Indicator Styles + +**File:** `assets/styles/app.css` + +Add global styles for HTMX loading indicators: + +```css +/* HTMX Loading Indicator */ +.htmx-indicator { + display: none; +} + +.htmx-request .htmx-indicator { + display: flex; +} + +.htmx-request.htmx-indicator { + display: flex; +} +``` + +### Step 2: Update Step 3 Form + +**File:** `templates/booking/create_step_3.html.twig` + +**Changes:** + +1. Add HTMX attributes to form: +```twig +{{ form_start(form, { + 'attr': { + 'novalidate': 'novalidate', + 'hx-post': path('app_booking_create_step_3'), + 'hx-swap': 'none', + 'hx-indicator': '#step3-loading' + } +}) }} +``` + +2. Add loading overlay before form close: +```twig +{# Loading indicator #} +
+
+
+ + + + + Buchung wird validiert... +
+
+
+ +{{ form_end(form) }} +``` + +**File:** `src/Controller/Booking/CreateStep3Controller.php` + +**Changes:** + +Add HTMX detection and response handling: + +```php +public function step3(Request $request): Response +{ + // ... existing validation logic ... + + if ($form->isSubmitted() && $form->isValid()) { + try { + // ... existing inquiry + price validation logic ... + + // Validation successful - proceed to confirmation step + $bookingCreateDto->currentStep = 4; + $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); + + // Handle HTMX requests + if ($request->headers->get('HX-Request')) { + return new Response('', 200, [ + 'HX-Redirect' => $this->generateUrl('app_booking_create_step_4') + ]); + } + + return $this->redirectToRoute('app_booking_create_step_4'); + } catch (\Exception $e) { + // ... existing error handling ... + } + } + + return $this->render('booking/create_step_3.html.twig', [ + 'bookingCreateDto' => $bookingCreateDto, + 'form' => $form->createView(), + ...$this->getSummaryVariables($bookingCreateDto), + ]); +} +``` + +### Step 3: Update Step 4 Form + +**File:** `templates/booking/create_step_4.html.twig` + +**Changes:** + +1. Add HTMX attributes to form (find `form_start`): +```twig +{{ form_start(form, { + 'attr': { + 'novalidate': 'novalidate', + 'hx-post': path('app_booking_create_step_4'), + 'hx-swap': 'none', + 'hx-indicator': '#step4-loading' + } +}) }} +``` + +2. Add loading overlay before submit button: +```twig +{# Loading indicator #} +
+
+
+ + + + + Buchung wird durchgeführt... +
+
+
+ +{{ form_end(form) }} +``` + +**File:** `src/Controller/Booking/CreateStep4Controller.php` + +**Changes:** + +Add HTMX response handling: + +```php +public function step4(Request $request): Response +{ + // ... existing code ... + + if ($form->isSubmitted() && $form->isValid()) { + try { + // Submit final booking (already validated in Step 3) + $bookingResponse = $this->apiClient->createBooking($bookingCreateDto); + + // ... existing error handling ... + + // Success: Store booking number in flash and clear session + $this->addFlash('booking_number', $bookingResponse->transactionNumber); + $this->bookingService->clearBookingCreateDto($request); + + // Handle HTMX requests + if ($request->headers->get('HX-Request')) { + return new Response('', 200, [ + 'HX-Redirect' => $this->generateUrl('app_booking_success') + ]); + } + + return $this->redirectToRoute('app_booking_success'); + } catch (\Exception $e) { + // ... existing error handling ... + } + } + + return $this->render('booking/create_step_4.html.twig', [ + 'bookingCreateDto' => $bookingCreateDto, + 'form' => $form->createView(), + ...$this->getSummaryVariables($bookingCreateDto), + ]); +} +``` + +## Alternative: Stimulus-Only Approach + +If HTMX is not desired, use the existing `loading_controller.js`: + +**Template:** +```twig +
+ {{ form_start(form, {'attr': {'data-action': 'submit->loading#toggle'}}) }} + + + + + + + {{ form_end(form) }} +
+``` + +**Pros:** Simpler, no controller changes +**Cons:** +- Loading indicator persists if server returns error +- Page reload happens anyway +- No error handling improvement + +## Files to Modify + +### Templates: +1. `templates/booking/create_step_3.html.twig` - Add HTMX attributes + loading indicator +2. `templates/booking/create_step_4.html.twig` - Add HTMX attributes + loading indicator + +### Controllers: +3. `src/Controller/Booking/CreateStep3Controller.php` - Add HTMX response handling +4. `src/Controller/Booking/CreateStep4Controller.php` - Add HTMX response handling + +### Styles: +5. `assets/styles/app.css` - Add `.htmx-indicator` styles (if not already present) + +## Benefits + +**User Experience:** +- ✅ Clear visual feedback during API calls +- ✅ Prevents duplicate submissions (button disabled during request) +- ✅ Professional loading experience +- ✅ Reduced user confusion and frustration + +**Technical:** +- ✅ Better error handling (no page reload on validation errors) +- ✅ Consistent with existing HTMX usage in Step 2 +- ✅ Progressive enhancement (works without JavaScript) +- ✅ Flash messages still work via HX-Redirect + +## Testing Checklist + +- [ ] Step 3: Loading indicator shows during inquiry API call +- [ ] Step 3: Form disabled during submission +- [ ] Step 3: Success redirects to Step 4 +- [ ] Step 3: Validation errors show inline without reload +- [ ] Step 4: Loading indicator shows during booking API call +- [ ] Step 4: Form disabled during submission +- [ ] Step 4: Success redirects to success page with flash message +- [ ] Step 4: Errors show inline without reload +- [ ] Works without JavaScript (graceful degradation) +- [ ] No duplicate submissions possible + +## Implementation Priority + +**High Priority** - Significantly improves UX during slow API operations + +## Notes + +- HTMX already included in project dependencies +- Loading indicators match existing design system +- Compatible with all existing validation logic +- No changes to backend business logic required \ No newline at end of file diff --git a/docs/REFACTORING_BOOKING_DATA_PROCESSOR.md b/docs/REFACTORING_BOOKING_DATA_PROCESSOR.md new file mode 100644 index 0000000..786fcc7 --- /dev/null +++ b/docs/REFACTORING_BOOKING_DATA_PROCESSOR.md @@ -0,0 +1,183 @@ +# Booking Data Processor Refactoring Plan + +## Current Status (2025-10-05) + +We discovered a structural inconsistency between the UPDATE and CREATE booking flows while implementing the booking submission feature. + +## Problem Statement + +The UPDATE and CREATE flows use fundamentally different data structures, leading to code duplication and complexity: + +### UPDATE Flow (Current) +- `BookingEditDto` contains a `Booking` object +- `Booking` has centralized service arrays with participant mappings: + - `booking.additionalServices` - array of Service objects with `mapping` property (0-based indices) + - `booking.transportationServices` - array of Service objects with `mapping` property + - `booking.pickupsOutbound` - array of Pickup objects with `mapping` property +- `BookingDataProcessor.createUpdateRequestPayload()`: + - Resets all service mappings + - Iterates through participants + - Rebuilds service mappings by looking up services in booking data + - Removes unused services + - Converts 0-based indices to 1-based for API + +### CREATE Flow (Current) +- `BookingCreateDto` contains only `participants` array +- Each `ParticipantDto` has direct service references: + - `courses`, `skiPass`, `additionalServices`, `board`, `rentals` + - `transportationOutbound`, `transportationInbound` + - `pickupOutbound`, `pickupInbound` + - `insurance` +- `BookingDataProcessor.createBookingRequestPayload()`: + - Collects services directly from participants + - Groups by service ID + - Converts to 1-based participant IDs for API + +## Root Cause + +The UPDATE flow was designed to work with API-sourced `Booking` objects that already have centralized service mappings. The CREATE flow was designed from scratch with a simpler participant-centric approach. + +## Proposed Solution + +**Align both flows to use the participant-centric structure:** + +1. **Both DTOs work the same way:** + - Both have `participants` array + - Services are attached directly to participants + - No centralized service objects with mappings + +2. **Unified payload generation:** + - Use same `collect*Mappings()` methods for both flows + - Use same `addServicesFromMap()` helper + - Remove complex service manipulation in update flow + +3. **Benefits:** + - Single source of truth for service mappings + - Less code duplication + - Easier to understand and maintain + - Consistent patterns across all booking operations + +## Implementation Steps + +### Phase 1: Refactor BookingEditDto.fromBooking() +- [x] Already populates participant services correctly +- [x] Services are already attached to participants +- [ ] Verify all service types are covered + +### Phase 2: Refactor BookingDataProcessor.createUpdateRequestPayload() +- [ ] Remove `resetServiceMappings()` +- [ ] Remove `processParticipantServices()` (complex service lookup) +- [ ] Remove `processAdditionalServices()` +- [ ] Remove `processTransportationServices()` +- [ ] Remove `processPickupLocations()` +- [ ] Remove `removeUnusedServices()` +- [ ] Use `collect*Mappings()` methods instead (same as create flow) +- [ ] Update `buildServicesPayload()` to use collected maps +- [ ] Update `buildPickupPayload()` to use collected maps + +### Phase 3: Add convertServicesToMap() Helper +- [ ] Create helper to convert service objects with mapping to ID => participant IDs map +- [ ] This bridges the gap between old structure (if needed) and new structure + +### Phase 4: Testing +- [ ] Test update flow with all service types +- [ ] Test create flow (should remain unchanged) +- [ ] Verify API payloads are identical before/after refactoring +- [ ] Test edge cases (no services, all services, mixed scenarios) + +### Phase 5: Cleanup +- [ ] Remove unused methods from BookingDataProcessor +- [ ] Remove unused properties from Booking model (if any) +- [ ] Update documentation + +## Risk Assessment + +**MEDIUM RISK** - This refactoring touches critical booking update functionality that is already working in production. + +### Risks: +1. Breaking existing update flow +2. Subtle bugs in service mapping +3. Data loss if participant service references are incorrect +4. Payment/bank account handling might break + +### Mitigation: +1. Comprehensive testing before deployment +2. Keep git history clean with atomic commits +3. Test with real booking data from sandbox +4. Verify XML payloads match exactly (before/after) +5. Have rollback plan ready + +## Decision Point + +**Should we refactor NOW or LATER?** + +### Arguments for NOW: +- We're already in BookingDataProcessor +- Fresh understanding of both flows +- Prevents further divergence +- Makes current task (booking submission) cleaner + +### Arguments for LATER: +- Current task (booking submission) is incomplete +- Refactoring is significant and risky +- Could introduce bugs in working update flow +- Should be separate PR with focused testing +- Current booking submission is more urgent + +## Decision + +**REFACTOR LATER** - Complete the booking submission task first, then do this refactoring as a separate focused effort. + +**AGREED:** The CREATE flow's participant-centric structure is the new standard. The UPDATE flow should adopt this architecture in the future refactoring. + +### Reasoning: +1. Booking submission is nearly complete and is the immediate business need +2. Update flow is working and tested - don't break what works +3. Refactoring deserves dedicated focus and testing +4. Can create comprehensive tests for both flows first +5. Allows for proper code review and QA + +### Short-term Solution: +- Keep both flows separate for now +- Add the `convertServicesToMap()` helper to bridge structures +- Complete booking submission with current architecture +- Document this technical debt + +### Long-term Plan: +- Create separate refactoring task/issue +- Write comprehensive tests for update flow first +- Perform refactoring in dedicated branch +- Extensive testing with sandbox data +- Separate PR with focused review + +## Current Task: Booking Submission + +We are 60% complete with booking submission implementation: + +### Completed: +- [x] BookingResponse, PriceItem, PaymentTerms models +- [x] BookingResponseParser with pricing data +- [x] createBookingRequestPayload() in BookingDataProcessor +- [x] Payment type ID constants +- [x] Helper method addServicesFromMap() + +### Remaining: +- [ ] Add TYPE_BOOKING constant to ApiClient +- [ ] Add createBookingInquiry() and createBooking() to ApiClient +- [ ] Update ApiResponseParser to handle BUCHUNG response type +- [ ] Implement two-phase submission in CreateStep4Controller +- [ ] Add clearBookingCreateDto() to BookingService +- [ ] Create BookingSuccessController and template +- [ ] Test with sandbox API + +## Next Steps + +1. **IMMEDIATE:** Continue with booking submission task +2. **AFTER COMPLETION:** Create refactoring issue/task +3. **FUTURE:** Dedicated refactoring effort with proper testing + +--- + +**Document Created:** 2025-10-05 +**Status:** Deferred - Continue with booking submission +**Related:** Booking submission implementation (in progress) \ No newline at end of file diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index a3b1ce1..dc640d8 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -7,6 +7,7 @@ use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ResponseParserException; use App\BusProNet\Model\BaseData; use App\BusProNet\Model\Booking; +use App\BusProNet\Model\BookingResponse; use App\BusProNet\Model\BookingUpdate; use App\BusProNet\Model\CrmAttributes; use App\BusProNet\Model\Notification; @@ -14,6 +15,7 @@ use App\BusProNet\Model\PersonalData; use App\BusProNet\Model\Travel; use App\BusProNet\Traits\ApiClientTrait; use App\BusProNet\XmlParser\ApiResponseParser; +use App\Form\Model\BookingCreateDto; use App\Form\Model\BookingEditDto; use App\Form\Model\RegistrationDto; use League\Flysystem\FilesystemException; @@ -34,8 +36,10 @@ class ApiClient public const TYPE_AVAILABILITY = 'VERFUEGBARKEIT'; public const TYPE_AVAILABILITY_HOTEL = 'VERFUEGBARKEITHOTEL'; public const TYPE_BOOKING_UPDATE = 'BUCHUNGAENDERUNG'; + public const TYPE_BOOKING = 'BUCHUNG'; public const TYPE_PRODUCTS = 'PRODUKTE'; public const TYPE_PRODUCT_DATA = 'PRODUKTDATEN'; + public const TYPE_AGENCIES = 'AGENTUREN'; private array $config; @@ -203,6 +207,60 @@ class ApiClient return $this->sendRequest(static::TYPE_BOOKING_UPDATE, $data, [], $debug); } + /** + * Submits a booking inquiry for validation. + * + * First phase of the two-phase booking process. Validates all booking data + * and returns pricing information without creating an actual booking. + * + * @param BookingCreateDto $bookingDto The booking creation form data + * @param bool $debug Enable debug mode (XML dumps) + * + * @return Notification|BookingResponse Notification on error, BookingResponse on success + * + * @throws ApiClientException If the API request fails + */ + public function createBookingInquiry(BookingCreateDto $bookingDto, bool $debug = false): Notification|BookingResponse + { + $payload = (new BookingDataProcessor())->createBookingRequestPayload($bookingDto, 'Anfrage'); + + $data = [ + 'user' => $this->config['bpn_username'], + 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING), + 'satz' => ['@typ' => static::TYPE_BOOKING], + ...$payload, + ]; + + return $this->sendRequest(static::TYPE_BOOKING, $data, [], $debug); + } + + /** + * Submits the final booking request. + * + * Second phase of the two-phase booking process. Creates the actual booking + * after successful inquiry validation. + * + * @param BookingCreateDto $bookingDto The booking creation form data + * @param bool $debug Enable debug mode (XML dumps) + * + * @return Notification|BookingResponse Notification on error, BookingResponse with booking number on success + * + * @throws ApiClientException If the API request fails + */ + public function createBooking(BookingCreateDto $bookingDto, bool $debug = false): Notification|BookingResponse + { + $payload = (new BookingDataProcessor())->createBookingRequestPayload($bookingDto, 'Buchung'); + + $data = [ + 'user' => $this->config['bpn_username'], + 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING), + 'satz' => ['@typ' => static::TYPE_BOOKING], + ...$payload, + ]; + + return $this->sendRequest(static::TYPE_BOOKING, $data, [], $debug); + } + /** * @throws ApiClientException */ @@ -328,6 +386,27 @@ class ApiClient return $this->sendRequest(static::TYPE_PRODUCTS, $data); } + /** + * Fetches all available agencies from the BusProNet API. + * + * Returns a list of all agencies with their contact information. + * This data is typically cached for long periods as it changes infrequently. + * + * @return Agency[]|Notification Array of Agency objects on success, Notification on error + * + * @throws ApiClientException If the API request fails + */ + public function getAgencies(): array|Notification + { + $data = [ + 'user' => $this->config['bpn_username'], + 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AGENCIES), + 'satz' => ['@typ' => static::TYPE_AGENCIES], + ]; + + return $this->sendRequest(static::TYPE_AGENCIES, $data); + } + /** * @throws ApiClientException */ diff --git a/src/BusProNet/Constants.php b/src/BusProNet/Constants.php index f415cb9..eaaa4ea 100644 --- a/src/BusProNet/Constants.php +++ b/src/BusProNet/Constants.php @@ -51,4 +51,8 @@ final class Constants // Payment methods public const PAYMENT_METHOD_TRANSFER = 'transfer'; public const PAYMENT_METHOD_DEBIT = 'debit'; + + // Payment type IDs for API + public const PAYMENT_TYPE_ID_TRANSFER = 2; + public const PAYMENT_TYPE_ID_DEBIT = 5; } diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index c865788..00842d2 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace App\BusProNet\DataProcessor; +use App\BusProNet\Constants; use App\BusProNet\Model\Communication; +use App\Form\Model\BookingCreateDto; use App\Form\Model\BookingEditDto; /** @@ -408,4 +410,365 @@ class BookingDataProcessor } } } + + /** + * Creates a booking request payload for new bookings (inquiry or final booking). + * + * Generates the array payload structure for creating new bookings through the BusProNet API. + * This includes all participant data, room selections, services (including insurance), and + * payment information. The booking type determines whether this is an inquiry validation + * ('Anfrage') or a final booking commit ('Buchung'). + * + * @param BookingCreateDto $bookingDto The booking creation form data + * @param string $bookingType Either 'Anfrage' (inquiry) or 'Buchung' (final booking) + * + * @return array The structured payload array for BusProNet API submission + */ + public function createBookingRequestPayload(BookingCreateDto $bookingDto, string $bookingType): array + { + $firstParticipant = $bookingDto->participants[0]; + + $payload = [ + 'buchungsart' => $bookingType, + 'status' => 'F', + 'idreise' => $bookingDto->travel->id, + 'idpartner' => $bookingDto->travel->hotelId, + 'idagentur' => $bookingDto->agencyId, + ]; + + // Add applicant (first participant data) + $payload['anmelder'] = [ + 'name' => $firstParticipant->lastName, + 'vorname' => $firstParticipant->firstName, + 'geschlecht' => $firstParticipant->gender ?? '', + 'nationalitaet' => $firstParticipant->nationality ?? '', + ]; + + if (null !== $firstParticipant->dateOfBirth) { + $payload['anmelder']['geburtsdatum'] = $firstParticipant->dateOfBirth->format('d.m.Y'); + } + + // Add address for applicant + if (null !== $firstParticipant->address) { + $payload['anmelder']['anschrift'] = $firstParticipant->address->toPayload(); + } + + if (null !== $firstParticipant->email || null !== $firstParticipant->mobile) { + $payload['anmelder']['kommunikation'] = []; + if (null !== $firstParticipant->email) { + $payload['anmelder']['kommunikation']['email'] = $firstParticipant->email; + } + if (null !== $firstParticipant->mobile) { + $payload['anmelder']['kommunikation']['telefonmobil'] = $firstParticipant->mobile; + } + } + + // Add participants + $payload['teilnehmerliste']['teilnehmer'] = []; + foreach ($bookingDto->participants as $index => $participant) { + $participantData = [ + '@id' => $index + 1, + 'name' => $participant->lastName, + 'vorname' => $participant->firstName, + 'geschlecht' => $participant->gender ?? '', + 'nationalitaet' => $participant->nationality ?? '', + ]; + + if (null !== $participant->dateOfBirth) { + $participantData['geburtsdatum'] = $participant->dateOfBirth->format('d.m.Y'); + } + + // Add address (always include structure, even if empty) + $participantData['anschrift'] = $participant->address?->toPayload() ?? [ + 'strasse' => null, + 'plz' => null, + 'ort' => null, + 'ortsteil' => null, + 'land' => null, + ]; + + // Add contact info for all participants + if (null !== $participant->email || null !== $participant->mobile) { + $participantData['kommunikation'] = []; + if (null !== $participant->email) { + $participantData['kommunikation']['email'] = $participant->email; + } + if (null !== $participant->mobile) { + $participantData['kommunikation']['telefonmobil'] = $participant->mobile; + } + } + + // Add wishes (room remarks and license plate) + if (null !== $participant->remarksRoom || null !== $participant->licensePlate) { + $participantData['wünsche'] = []; + if (null !== $participant->remarksRoom && '' !== trim($participant->remarksRoom)) { + $participantData['wünsche']['unterbringungswunsch'] = $participant->remarksRoom; + } + if (null !== $participant->licensePlate && '' !== trim($participant->licensePlate)) { + $participantData['wünsche']['beförderungswunsch'] = $participant->licensePlate; + } + } + + $payload['teilnehmerliste']['teilnehmer'][] = $participantData; + } + + // Collect and group all services by ID with participant mappings + $serviceMap = $this->collectServiceMappings($bookingDto); + $transportationMap = $this->collectTransportationMappings($bookingDto); + $roomMap = $this->collectRoomMappings($bookingDto); + $pickupMap = $this->collectPickupMappings($bookingDto); + $insuranceMap = $this->collectInsuranceMappings($bookingDto); + + // Add services using reusable helper methods + $this->addServicesFromMap($payload, 'beförderungen', 'beförderung', '@idleistung', $transportationMap); + $this->addRoomMappingsToPayload($payload, $roomMap, $bookingDto); + $this->addServicesFromMap($payload, 'zusatzleistungen', 'zusatzleistung', '@idleistung', $serviceMap); + $this->addServicesFromMap($payload, 'zustiege', 'zustieg', '@idzustieg', $pickupMap); + $this->addServicesFromMap($payload, 'versicherungen', 'versicherung', '@idversicherung', $insuranceMap); + + // Add payment information + $payload['zahlung'] = [ + '@idzahlungsart' => Constants::PAYMENT_METHOD_DEBIT === $bookingDto->paymentMethod + ? Constants::PAYMENT_TYPE_ID_DEBIT + : Constants::PAYMENT_TYPE_ID_TRANSFER, + '@art' => Constants::PAYMENT_METHOD_DEBIT === $bookingDto->paymentMethod ? 'EINZUG' : 'UEBERWEISUNG', + ]; + + if (Constants::PAYMENT_METHOD_DEBIT === $bookingDto->paymentMethod && null !== $bookingDto->bankAccount) { + $payload['zahlung']['bankverbindung'] = [ + '@iban' => $bookingDto->bankAccount->iban, + '@kontoinhaber' => $bookingDto->bankAccount->accountHolder, + ]; + } + + return $payload; + } + + /** + * Adds services from a mapping to the payload. + * + * Generic helper method that converts service ID => participant IDs mappings + * into XML payload structure. + * + * @param array $payload The payload array to modify + * @param string $sectionKey The section key (e.g., 'beförderungen', 'versicherungen') + * @param string $itemKey The item key (e.g., 'beförderung', 'versicherung') + * @param string $idAttributeName The ID attribute name (e.g., '@idleistung', '@idversicherung') + * @param array $serviceMap Map of service ID to participant IDs + */ + private function addServicesFromMap( + array &$payload, + string $sectionKey, + string $itemKey, + string $idAttributeName, + array $serviceMap, + ): void { + if (false === empty($serviceMap)) { + $payload[$sectionKey][$itemKey] = []; + foreach ($serviceMap as $serviceId => $participantIds) { + $payload[$sectionKey][$itemKey][] = [ + $idAttributeName => $serviceId, + '@anzahl' => count($participantIds), + '@zuordnung' => implode(',', $participantIds), + ]; + } + } + } + + /** + * Adds room mappings with detailed attributes to the payload. + * + * Rooms require special attributes beyond simple service mapping: + * - kategorie (room category code) + * - idverpflegung (board type ID) + * - anreise (arrival date) + * - abreise (departure date) + * - anzahl (number of rooms of this type booked) + * + * @param array $payload The payload array to modify + * @param array $roomMap Map of room ID to participant IDs + * @param BookingCreateDto $bookingDto The booking data for accessing room details and quantities + */ + private function addRoomMappingsToPayload(array &$payload, array $roomMap, BookingCreateDto $bookingDto): void + { + if (empty($roomMap)) { + return; + } + + $availableRooms = $bookingDto->travel->getAvailableRooms(); + $payload['ferienzielunterbringungen']['ferienzielunterbringung'] = []; + + // Build room selection quantity lookup + $roomQuantities = []; + foreach ($bookingDto->roomSelections as $selection) { + if ($selection->quantity > 0) { + $roomQuantities[$selection->roomId] = $selection->quantity; + } + } + + foreach ($roomMap as $roomId => $participantIds) { + $room = $availableRooms[$roomId] ?? null; + if (null === $room) { + continue; + } + + $quantity = $roomQuantities[$roomId] ?? 1; + + $payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [ + '@idzimmer' => $room->id, + '@kategorie' => $room->category, + '@idverpflegung' => $room->boardId, + '@anreise' => $bookingDto->travel->dateFrom->format('d.m.Y'), + '@abreise' => $bookingDto->travel->dateTo->format('d.m.Y'), + '@anzahl' => $quantity, + '@zuordnung' => implode(',', $participantIds), + ]; + } + } + + /** + * Collects room mappings. + * + * Groups participants by their assigned room ID. + * + * @return array> Map of room ID to participant IDs + */ + private function collectRoomMappings(BookingCreateDto $bookingDto): array + { + $roomMap = []; + + foreach ($bookingDto->participants as $index => $participant) { + $participantId = $index + 1; + + if (null !== $participant->assignedRoomId) { + $roomMap[$participant->assignedRoomId][] = $participantId; + } + } + + return $roomMap; + } + + /** + * Collects service mappings for the booking request. + * + * Groups board, ski passes, rentals, rental insurance, courses, parking, and additional services + * by service ID with their participant assignments (1-based). + * + * @return array> Map of service ID to participant IDs + */ + private function collectServiceMappings(BookingCreateDto $bookingDto): array + { + $serviceMap = []; + + foreach ($bookingDto->participants as $index => $participant) { + $participantId = $index + 1; + + // Board services + foreach ($participant->board as $board) { + $serviceMap[$board->id][] = $participantId; + } + + // Ski pass + if (null !== $participant->skiPass) { + $serviceMap[$participant->skiPass->id][] = $participantId; + } + + // Rentals + foreach ($participant->rentals as $rental) { + $serviceMap[$rental->id][] = $participantId; + } + + // Rental insurance + if (null !== $participant->rentalInsurance) { + $serviceMap[$participant->rentalInsurance->id][] = $participantId; + } + + // Courses + foreach ($participant->courses as $course) { + $serviceMap[$course->id][] = $participantId; + } + + // Parking service (for self-organized transportation) + if (true === $participant->parking && null !== $participant->parkingService) { + $serviceMap[$participant->parkingService->id][] = $participantId; + } + + // Additional services + foreach ($participant->additionalServices as $service) { + $serviceMap[$service->id][] = $participantId; + } + } + + return $serviceMap; + } + + /** + * Collects transportation service mappings. + * + * @return array> Map of transportation service ID to participant IDs + */ + private function collectTransportationMappings(BookingCreateDto $bookingDto): array + { + $transportationMap = []; + + foreach ($bookingDto->participants as $index => $participant) { + $participantId = $index + 1; + + if (null !== $participant->transportationOutbound) { + $transportationMap[$participant->transportationOutbound->id][] = $participantId; + } + + if (null !== $participant->transportationInbound) { + $transportationMap[$participant->transportationInbound->id][] = $participantId; + } + } + + return $transportationMap; + } + + /** + * Collects pickup location mappings. + * + * Only collects outbound pickups as the API doesn't support different pickups + * for inbound direction. Both directions use the same pickup location. + * + * @return array> Map of pickup ID to participant IDs + */ + private function collectPickupMappings(BookingCreateDto $bookingDto): array + { + $pickupMap = []; + + foreach ($bookingDto->participants as $index => $participant) { + $participantId = $index + 1; + + // Only use outbound pickups (inbound uses same location) + if (null !== $participant->pickupOutbound) { + $pickupMap[$participant->pickupOutbound->id][] = $participantId; + } + } + + return $pickupMap; + } + + /** + * Collects insurance mappings. + * + * CRITICAL: Insurance data is only included in CREATE flow, not in UPDATE flow. + * + * @return array> Map of insurance ID to participant IDs + */ + private function collectInsuranceMappings(BookingCreateDto $bookingDto): array + { + $insuranceMap = []; + + foreach ($bookingDto->participants as $index => $participant) { + $participantId = $index + 1; + + if (null !== $participant->insurance) { + $insuranceMap[$participant->insurance->id][] = $participantId; + } + } + + return $insuranceMap; + } } diff --git a/src/BusProNet/Model/Address.php b/src/BusProNet/Model/Address.php index 85bb5d3..5bd7b61 100644 --- a/src/BusProNet/Model/Address.php +++ b/src/BusProNet/Model/Address.php @@ -15,18 +15,18 @@ use Symfony\Component\Validator\Constraints as Assert; */ class Address { - #[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])] + #[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])] public ?string $street = null; - #[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])] + #[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])] public ?string $postCode = null; - #[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])] + #[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])] public ?string $city = null; public ?string $district = null; - #[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])] + #[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])] public ?string $country = null; /** diff --git a/src/BusProNet/Model/Agency.php b/src/BusProNet/Model/Agency.php new file mode 100644 index 0000000..d048bae --- /dev/null +++ b/src/BusProNet/Model/Agency.php @@ -0,0 +1,19 @@ +möglich
= inquiry validation successful + * - erfolgt = booking creation successful + * + * Error responses return Notification objects instead (typ="HINWEIS"). + */ +class BookingResponse +{ + /** + * @param string $status Booking status (möglich|erfolgt) + * @param string|null $transactionNumber Transaction number (vorgang) + * @param array $priceItems Individual price items from response + * @param float|null $totalPrice Total price (gesamtpreis) + * @param PaymentTerms|null $paymentTerms Payment terms (anzahlung/restzahlung) + */ + public function __construct( + public readonly string $status, + public readonly ?string $transactionNumber = null, + public readonly array $priceItems = [], + public readonly ?float $totalPrice = null, + public readonly ?PaymentTerms $paymentTerms = null, + ) { + } + + /** + * Returns true if the inquiry validation was successful. + */ + public function isInquiryValid(): bool + { + return 'möglich' === $this->status; + } + + /** + * Returns true if the booking was successfully created. + */ + public function isBookingSuccessful(): bool + { + return 'erfolgt' === $this->status; + } +} diff --git a/src/BusProNet/Model/Insurance.php b/src/BusProNet/Model/Insurance.php index cde1164..062a905 100644 --- a/src/BusProNet/Model/Insurance.php +++ b/src/BusProNet/Model/Insurance.php @@ -175,4 +175,4 @@ class Insurance return array_values(array_unique($urls)); } -} \ No newline at end of file +} diff --git a/src/BusProNet/Model/PaymentTerms.php b/src/BusProNet/Model/PaymentTerms.php new file mode 100644 index 0000000..a084879 --- /dev/null +++ b/src/BusProNet/Model/PaymentTerms.php @@ -0,0 +1,19 @@ +cache->get('bpn_agencies', function (ItemInterface $item) { + // Cache for 24 hours (agencies change infrequently) + $item->expiresAfter(24 * 60 * 60); + + $result = $this->apiClient->getAgencies(); + + if ($result instanceof Notification) { + $this->logger->error('Failed to fetch agencies from BPN API', [ + 'message' => $result->message, + ]); + + return []; + } + + return $result; + }); + } catch (InvalidArgumentException $e) { + $this->logger->error('Cache error while loading agencies', [ + 'exception' => $e->getMessage(), + ]); + + return []; + } + } + + public function loadById(int $id): ?Agency + { + $agencies = $this->loadAll(); + + foreach ($agencies as $agency) { + if ($agency->id === $id) { + return $agency; + } + } + + return null; + } + + public function loadByCode(string $code): ?Agency + { + $agencies = $this->loadAll(); + + foreach ($agencies as $agency) { + if ($agency->code === $code) { + return $agency; + } + } + + return null; + } + + public function loadDefault(): ?Agency + { + return $this->loadByCode(self::DEFAULT_AGENCY_CODE); + } +} diff --git a/src/BusProNet/XmlParser/AgencyParser.php b/src/BusProNet/XmlParser/AgencyParser.php new file mode 100644 index 0000000..8afe882 --- /dev/null +++ b/src/BusProNet/XmlParser/AgencyParser.php @@ -0,0 +1,41 @@ +filterXPath('//agenturen/agentur')->each(function (Crawler $agencyNode) use (&$agencies): void { + $id = (int) $agencyNode->attr('id'); + $name = $this->getStringOrNullValue($agencyNode->filterXPath('//name')) ?? ''; + $code = $this->getStringOrNullValue($agencyNode->filterXPath('//code')) ?? ''; + $street = $this->getStringOrNullValue($agencyNode->filterXPath('//strasse')); + $postCode = $this->getStringOrNullValue($agencyNode->filterXPath('//plz')); + $city = $this->getStringOrNullValue($agencyNode->filterXPath('//ort')); + $phone = $this->getStringOrNullValue($agencyNode->filterXPath('//telefon')); + + $agencies[] = new Agency( + id: $id, + name: $name, + code: $code, + street: $street, + postCode: $postCode, + city: $city, + phone: $phone + ); + }); + + return $agencies; + } +} diff --git a/src/BusProNet/XmlParser/ApiResponseParser.php b/src/BusProNet/XmlParser/ApiResponseParser.php index 35fb927..b088eac 100644 --- a/src/BusProNet/XmlParser/ApiResponseParser.php +++ b/src/BusProNet/XmlParser/ApiResponseParser.php @@ -59,12 +59,16 @@ class ApiResponseParser extends AbstractParser return (new AvailabilitiesParser())->parseRooms($resultNode); case ApiClient::TYPE_BOOKING_UPDATE: return (new BookingUpdateParser())->parse($resultNode); + case ApiClient::TYPE_BOOKING: + return (new BookingResponseParser())->parse($resultNode); case ApiClient::TYPE_PRODUCTS: return (new ProductsParser())->parse($resultNode); case ApiClient::TYPE_PRODUCT_DATA: $travelNode = $crawler->filterXPath('//reise/termin'); return (new TravelParser())->parse($travelNode->first(), ...$additionalArgs); + case ApiClient::TYPE_AGENCIES: + return (new AgencyParser())->parse($resultNode); } throw new ResponseParserException('Unable to parse XML response'); diff --git a/src/BusProNet/XmlParser/BookingResponseParser.php b/src/BusProNet/XmlParser/BookingResponseParser.php new file mode 100644 index 0000000..3ab2d99 --- /dev/null +++ b/src/BusProNet/XmlParser/BookingResponseParser.php @@ -0,0 +1,113 @@ + + * + * möglich|erfolgt + * 321530 + * + * + * + * 671,78 + * + * + * + * + * + */ +class BookingResponseParser extends AbstractParser +{ + public function parse(Crawler $node): BookingResponse + { + $status = $node->filterXPath('//buchung')->text(); + $transactionNumber = $this->getStringOrNullValue($node->filterXPath('//vorgang')); + $totalPrice = $this->getFloatOrNullValue($node->filterXPath('//gesamtpreis')); + + $priceItems = $this->parsePriceItems($node); + $paymentTerms = $this->parsePaymentTerms($node); + + return new BookingResponse( + status: $status, + transactionNumber: $transactionNumber, + priceItems: $priceItems, + totalPrice: $totalPrice, + paymentTerms: $paymentTerms + ); + } + + /** + * Parses individual price items from the response. + * + * @return array + */ + private function parsePriceItems(Crawler $node): array + { + $priceItems = []; + + $node->filterXPath('//preise/preis')->each(function (Crawler $priceNode) use (&$priceItems): void { + $priceItems[] = new PriceItem( + position: (int) $priceNode->attr('position'), + type: $priceNode->attr('art'), + subType: $priceNode->attr('unterart'), + label: $priceNode->attr('bezeichnung'), + dateFrom: $priceNode->attr('terminvon'), + dateTo: $priceNode->attr('terminbis'), + quantity: (int) $priceNode->attr('anzahl'), + assignment: $priceNode->attr('zuordnung'), + unitPrice: $this->stringToFloat($priceNode->attr('preis')), + totalPrice: $this->stringToFloat($priceNode->attr('gesamtpreis')), + id: $priceNode->attr('id') + ); + }); + + return $priceItems; + } + + /** + * Parses payment terms from the response. + */ + private function parsePaymentTerms(Crawler $node): ?PaymentTerms + { + $paymentNode = $node->filterXPath('//zahlungsbedingungen'); + + if (0 === $paymentNode->count()) { + return null; + } + + $depositAmount = null; + $depositDate = null; + $finalPaymentAmount = null; + $finalPaymentDate = null; + + $depositNode = $paymentNode->filterXPath('//anzahlung'); + if ($depositNode->count() > 0) { + $depositAmount = $this->stringToFloat($depositNode->attr('betrag')); + $depositDate = $depositNode->attr('termin'); + } + + $finalPaymentNode = $paymentNode->filterXPath('//restzahlung'); + if ($finalPaymentNode->count() > 0) { + $finalPaymentAmount = $this->stringToFloat($finalPaymentNode->attr('betrag')); + $finalPaymentDate = $finalPaymentNode->attr('termin'); + } + + return new PaymentTerms( + depositAmount: $depositAmount, + depositDate: $depositDate, + finalPaymentAmount: $finalPaymentAmount, + finalPaymentDate: $finalPaymentDate + ); + } +} diff --git a/src/BusProNet/XmlParser/InsuranceParser.php b/src/BusProNet/XmlParser/InsuranceParser.php index 86d09ef..2a95316 100644 --- a/src/BusProNet/XmlParser/InsuranceParser.php +++ b/src/BusProNet/XmlParser/InsuranceParser.php @@ -32,7 +32,7 @@ class InsuranceParser extends AbstractParser // Second pass: Parse individual insurances with conditional filtering $individualInsurances = []; $xmlContent->filterXPath('//versicherungen/versicherung') - ->each(function (Crawler $node) use (&$individualInsurances, &$insurances, $referencedIds) { + ->each(function (Crawler $node) use (&$individualInsurances, &$insurances) { $id = (int) $node->attr('idbuspro'); // Individual insurances have int IDs $isComplementary = $this->getBoolAttributeValue($node->attr('zusatzversicherung')); diff --git a/src/Controller/Booking/BookingExceptionHandlerTrait.php b/src/Controller/Booking/BookingExceptionHandlerTrait.php index 64b2321..ab64705 100644 --- a/src/Controller/Booking/BookingExceptionHandlerTrait.php +++ b/src/Controller/Booking/BookingExceptionHandlerTrait.php @@ -33,18 +33,23 @@ trait BookingExceptionHandlerTrait return $bookingService->getOrCreateBookingCreateDto($request); } catch (BookingSessionNotFoundException $e) { $this->addFlash('error', 'Ihre Buchungssitzung ist abgelaufen. Bitte starten Sie eine neue Buchung.'); + return $this->redirectToRoute('app_booking_create_error'); } catch (TravelNotFoundException $e) { $this->addFlash('error', 'Die angeforderte Reise wurde nicht gefunden.'); + return $this->redirectToRoute('app_booking_create_error'); } catch (HotelNotFoundException $e) { $this->addFlash('error', 'Das angeforderte Hotel wurde nicht gefunden.'); + return $this->redirectToRoute('app_booking_create_error'); } catch (HotelNotInTravelException $e) { $this->addFlash('error', 'Das Hotel ist für diese Reise nicht verfügbar.'); + return $this->redirectToRoute('app_booking_create_error'); } catch (NoRoomsAvailableException $e) { $this->addFlash('error', 'Für diese Reise sind aktuell keine Zimmer verfügbar.'); + return $this->redirectToRoute('app_booking_create_error'); } } @@ -59,8 +64,8 @@ trait BookingExceptionHandlerTrait { try { return $bookingService->getOrCreateBookingCreateDto($request); - } catch (BookingSessionNotFoundException | TravelNotFoundException | HotelNotFoundException | HotelNotInTravelException | NoRoomsAvailableException $e) { + } catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotFoundException|HotelNotInTravelException|NoRoomsAvailableException $e) { return new Response('', 400); } } -} \ No newline at end of file +} diff --git a/src/Controller/Booking/BookingSuccessController.php b/src/Controller/Booking/BookingSuccessController.php new file mode 100644 index 0000000..f263a0d --- /dev/null +++ b/src/Controller/Booking/BookingSuccessController.php @@ -0,0 +1,28 @@ +getSession()->getFlashBag()->get('booking_number')[0] ?? null; + + // Redirect to homepage if no booking number (direct access or refresh) + if (null === $bookingNumber) { + return $this->redirect('https://www.ep-reisen.de'); + } + + return $this->render('booking/success.html.twig', [ + 'bookingNumber' => $bookingNumber, + ]); + } +} diff --git a/src/Controller/Booking/CreateInitController.php b/src/Controller/Booking/CreateInitController.php index 1e18bd5..be18554 100644 --- a/src/Controller/Booking/CreateInitController.php +++ b/src/Controller/Booking/CreateInitController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Controller\Booking; +use App\BusProNet\XmlLoader\AgencyLoader; use App\Exception\HotelNotFoundException; use App\Exception\HotelNotInTravelException; use App\Exception\NoRoomsAvailableException; @@ -25,6 +26,7 @@ class CreateInitController extends AbstractController { public function __construct( private readonly BookingService $bookingService, + private readonly AgencyLoader $agencyLoader, ) { } @@ -34,6 +36,10 @@ class CreateInitController extends AbstractController * This endpoint provides a clean way to start the booking flow with just * dateId and hotelId parameters. It clears any existing booking session * and creates a fresh BookingCreateDto before redirecting to step 1. + * + * Optionally accepts an agency code parameter. If provided and valid, the + * corresponding agency ID is stored in the booking. If not provided or invalid, + * defaults to agency code '0001'. */ #[Route('/bookings/create/{dateId}/{hotelId}', name: 'app_booking_create_init', requirements: ['dateId' => '\d+', 'hotelId' => '\d+'])] public function init(Request $request, int $dateId, int $hotelId): Response @@ -42,8 +48,11 @@ class CreateInitController extends AbstractController // Clear any existing booking session to ensure fresh start $this->bookingService->clearBookingSession($request); + // Determine agency ID from optional query parameter + $agencyId = $this->resolveAgencyId($request->query->get('agency')); + // Create fresh booking session with the provided parameters - $this->bookingService->startFreshBooking($request, $dateId, $hotelId); + $this->bookingService->startFreshBooking($request, $dateId, $hotelId, $agencyId); // Redirect to step 1 of the booking flow return $this->redirectToRoute('app_booking_create_step_1'); @@ -58,6 +67,37 @@ class CreateInitController extends AbstractController } } + /** + * Resolves the agency ID from the provided agency code. + * + * If the code is null or the agency is not found, returns the default agency ID. + * + * @param string|null $agencyCode The agency code from the request parameter + * + * @return int|null The agency ID, or null if default agency not found + */ + private function resolveAgencyId(?string $agencyCode): ?int + { + // Use default agency if no code provided + if (null === $agencyCode || '' === trim($agencyCode)) { + $defaultAgency = $this->agencyLoader->loadDefault(); + + return $defaultAgency?->id; + } + + // Try to find agency by provided code + $agency = $this->agencyLoader->loadByCode($agencyCode); + + // Fall back to default agency if code not found + if (null === $agency) { + $defaultAgency = $this->agencyLoader->loadDefault(); + + return $defaultAgency?->id; + } + + return $agency->id; + } + /** * Displays user-friendly error messages for booking initialization failures. * diff --git a/src/Controller/Booking/CreateStep3Controller.php b/src/Controller/Booking/CreateStep3Controller.php index 5e4d285..cf67296 100644 --- a/src/Controller/Booking/CreateStep3Controller.php +++ b/src/Controller/Booking/CreateStep3Controller.php @@ -4,15 +4,17 @@ declare(strict_types=1); namespace App\Controller\Booking; -use App\BusProNet\Constants; +use App\BusProNet\ApiClient; +use App\BusProNet\Model\Notification; use App\Controller\Traits\HtmxControllerTrait; use App\Form\BookingCreateStep3Type; +use App\Service\BookingPriceCalculatorService; use App\Service\BookingService; +use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Security\Http\Attribute\IsGranted; /** * Handles the third step of the booking creation process (payment method selection). @@ -25,6 +27,9 @@ class CreateStep3Controller extends AbstractController public function __construct( private readonly BookingService $bookingService, + private readonly ApiClient $apiClient, + private readonly BookingPriceCalculatorService $priceCalculator, + private readonly LoggerInterface $logger, ) { } @@ -49,10 +54,74 @@ class CreateStep3Controller extends AbstractController $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $bookingCreateDto->currentStep = 4; - $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); + try { + // Validate booking data with API (inquiry) + $inquiryResponse = $this->apiClient->createBookingInquiry($bookingCreateDto); - return $this->redirectToRoute('app_booking_create_step_4'); + if ($inquiryResponse instanceof Notification) { + $this->logger->error('Booking inquiry failed', [ + 'message' => $inquiryResponse->message, + ]); + $this->addFlash('error', 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.'); + + return $this->render('booking/create_step_3.html.twig', [ + 'bookingCreateDto' => $bookingCreateDto, + 'form' => $form->createView(), + ...$this->getSummaryVariables($bookingCreateDto), + ]); + } + + if (false === $inquiryResponse->isInquiryValid()) { + $this->logger->error('Booking inquiry validation failed', [ + 'status' => $inquiryResponse->status, + ]); + $this->addFlash('error', 'Buchung konnte nicht validiert werden.'); + + return $this->render('booking/create_step_3.html.twig', [ + 'bookingCreateDto' => $bookingCreateDto, + 'form' => $form->createView(), + ...$this->getSummaryVariables($bookingCreateDto), + ]); + } + + // Validate price match (exact comparison) + $apiTotal = $inquiryResponse->totalPrice ?? 0.0; + $calculatedTotal = $this->priceCalculator->calculateGrandTotal($bookingCreateDto); + + if ($apiTotal !== $calculatedTotal) { + $this->logger->error('Price mismatch detected - payload incomplete', [ + 'apiTotal' => $apiTotal, + 'calculatedTotal' => $calculatedTotal, + 'difference' => abs($apiTotal - $calculatedTotal), + ]); + $this->addFlash('error', 'Ein technischer Fehler ist aufgetreten.'); + + return $this->render('booking/create_step_3.html.twig', [ + 'bookingCreateDto' => $bookingCreateDto, + 'form' => $form->createView(), + ...$this->getSummaryVariables($bookingCreateDto), + ]); + } + + // Validation successful - proceed to confirmation step + $bookingCreateDto->currentStep = 4; + $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); + + return $this->redirectToRoute('app_booking_create_step_4'); + } catch (\Exception $e) { + $this->logger->error('Booking inquiry exception', [ + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $this->addFlash('error', 'Ein technischer Fehler ist aufgetreten.'); + + return $this->render('booking/create_step_3.html.twig', [ + 'bookingCreateDto' => $bookingCreateDto, + 'form' => $form->createView(), + ...$this->getSummaryVariables($bookingCreateDto), + ]); + } } return $this->render('booking/create_step_3.html.twig', [ diff --git a/src/Controller/Booking/CreateStep4Controller.php b/src/Controller/Booking/CreateStep4Controller.php index 7040c75..170d66d 100644 --- a/src/Controller/Booking/CreateStep4Controller.php +++ b/src/Controller/Booking/CreateStep4Controller.php @@ -4,13 +4,17 @@ declare(strict_types=1); namespace App\Controller\Booking; +use App\BusProNet\ApiClient; +use App\BusProNet\Model\Notification; use App\Controller\Traits\HtmxControllerTrait; use App\Form\BookingCreateStep4Type; use App\Service\BookingService; +use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; + /** * Handles the fourth step of the booking creation process (confirmation). */ @@ -22,6 +26,8 @@ class CreateStep4Controller extends AbstractController public function __construct( private readonly BookingService $bookingService, + private readonly ApiClient $apiClient, + private readonly LoggerInterface $logger, ) { } @@ -46,13 +52,49 @@ class CreateStep4Controller extends AbstractController $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - // TODO: Perform inquiry API call - // TODO: If inquiry successful, perform booking API call - // TODO: Clear session and redirect to success page + try { + // Submit final booking (already validated in Step 3) + $bookingResponse = $this->apiClient->createBooking($bookingCreateDto); - $this->addFlash('success', 'Buchung erfolgreich abgeschlossen.'); + if ($bookingResponse instanceof Notification) { + $this->addFlash('error', $bookingResponse->message); - return $this->redirectToRoute('app_booking_create_step_4'); + return $this->render('booking/create_step_4.html.twig', [ + 'bookingCreateDto' => $bookingCreateDto, + 'form' => $form->createView(), + ...$this->getSummaryVariables($bookingCreateDto), + ]); + } + + if (false === $bookingResponse->isBookingSuccessful()) { + $this->addFlash('error', 'Buchung konnte nicht erstellt werden.'); + + return $this->render('booking/create_step_4.html.twig', [ + 'bookingCreateDto' => $bookingCreateDto, + 'form' => $form->createView(), + ...$this->getSummaryVariables($bookingCreateDto), + ]); + } + + // Success: Store booking number in flash and clear session + $this->addFlash('booking_number', $bookingResponse->transactionNumber); + $this->bookingService->clearBookingCreateDto($request); + + return $this->redirectToRoute('app_booking_success'); + } catch (\Exception $e) { + $this->logger->error('Booking creation failed', [ + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $this->addFlash('error', 'Ein technischer Fehler ist aufgetreten.'); + + return $this->render('booking/create_step_4.html.twig', [ + 'bookingCreateDto' => $bookingCreateDto, + 'form' => $form->createView(), + ...$this->getSummaryVariables($bookingCreateDto), + ]); + } } return $this->render('booking/create_step_4.html.twig', [ @@ -61,4 +103,4 @@ class CreateStep4Controller extends AbstractController ...$this->getSummaryVariables($bookingCreateDto), ]); } -} \ No newline at end of file +} diff --git a/src/Form/AddressType.php b/src/Form/AddressType.php new file mode 100644 index 0000000..39d0c66 --- /dev/null +++ b/src/Form/AddressType.php @@ -0,0 +1,49 @@ +add('street', TextType::class, [ + 'label' => 'Straße', + 'required' => $options['required'], + 'sanitize_html' => true, + ]) + ->add('postCode', TextType::class, [ + 'label' => 'PLZ', + 'required' => $options['required'], + 'sanitize_html' => true, + ]) + ->add('city', TextType::class, [ + 'label' => 'Ort', + 'required' => $options['required'], + 'sanitize_html' => true, + ]) + ->add('country', CountryType::class, [ + 'label' => 'Land', + 'property' => 'country', + 'required' => $options['required'], + 'preferred_choices' => ['DE', 'AT', 'CH'], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Address::class, + 'required' => false, + ]); + } +} diff --git a/src/Form/BookingParticipantType.php b/src/Form/BookingParticipantType.php index 790e421..d3a70b1 100644 --- a/src/Form/BookingParticipantType.php +++ b/src/Form/BookingParticipantType.php @@ -148,14 +148,16 @@ class BookingParticipantType extends AbstractType ], $getFieldState('nationality'))) ->add('email', EmailType::class, $this->mergeFieldState([ 'label' => 'E-Mail', - 'required' => false, - 'sanitize_html' => true, ], $getFieldState('email'))) ->add('mobile', TextType::class, $this->mergeFieldState([ 'label' => 'Telefon (mobil)', - 'required' => false, + 'required' => 0 === $participantIndex, 'sanitize_html' => true, - ], $getFieldState('mobile'))); + ], $getFieldState('mobile'))) + ->add('address', AddressType::class, $this->mergeFieldState([ + 'label' => 'Adresse', + 'required' => 0 === $participantIndex, + ], $getFieldState('address'))); // Add body dimensions with state handling - use shouldIncludeField method if ($this->fieldStateProvider->shouldIncludeField('bodyDimensions', $bookingDto, $participantIndex)) { @@ -194,7 +196,7 @@ class BookingParticipantType extends AbstractType // Clear the form and rebuild from scratch with updated states // Rebuild base fields with updated states - $baseFields = ['firstName', 'lastName', 'dateOfBirth', 'gender', 'nationality', 'email', 'mobile', 'bodyDimensions']; + $baseFields = ['firstName', 'lastName', 'dateOfBirth', 'gender', 'nationality', 'email', 'mobile', 'address', 'bodyDimensions']; foreach ($baseFields as $fieldName) { if ($form->has($fieldName)) { $form->remove($fieldName); diff --git a/src/Form/Model/BookingCreateDto.php b/src/Form/Model/BookingCreateDto.php index d3b3827..789276f 100644 --- a/src/Form/Model/BookingCreateDto.php +++ b/src/Form/Model/BookingCreateDto.php @@ -31,6 +31,8 @@ class BookingCreateDto implements BookingDtoInterface public ?BankAccountDto $bankAccount = null; + public ?int $agencyId = null; + public function __construct(public Travel $travel, public int $hotelId) { } diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index 35e59a2..b208307 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -2,6 +2,7 @@ namespace App\Form\Model; +use App\BusProNet\Model\Address; use App\BusProNet\Model\Insurance; use App\BusProNet\Model\PersonalData; use App\BusProNet\Model\Pickup; @@ -56,11 +57,16 @@ class ParticipantDto #[Assert\NotNull(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create_step_2'])] public ?\DateTimeImmutable $dateOfBirth = null; + #[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create_step_2'])] #[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create_step_2'])] public ?string $email = null; public ?string $mobile = null; + #[Assert\Valid(groups: ['booking_edit', 'booking_create_step_2'])] + #[Assert\NotNull(message: 'Bitte Adresse angeben', groups: ['applicant_address'])] + public ?Address $address = null; + #[Assert\NotNull(message: 'Bitte ein Zimmer auswählen', groups: ['booking_create_step_2'])] public ?int $assignedRoomId = null; @@ -104,6 +110,11 @@ class ParticipantDto */ public array $notifications = []; + public function __construct() + { + $this->address = new Address(); + } + public static function fromPersonalData(PersonalData $personalData): static { $instance = new static(); diff --git a/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php b/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php index 762a881..843e045 100644 --- a/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php +++ b/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php @@ -85,8 +85,9 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl $this->applyBulkInsuranceToAllParticipants($bookingDto, $participant->insurance); } - // If bulk insurance is disabled, clear dependent participants' insurances - if (false === $isBulkEnabled) { + // If bulk insurance was CHANGED from enabled to disabled, clear dependent participants' insurances + // Don't clear if it was never enabled (to allow independent insurance selection) + if (false === $isBulkEnabled && $this->wasBulkInsurancePreviouslyEnabled($bookingDto)) { $this->clearDependentParticipantsInsurance($bookingDto); } } @@ -97,8 +98,8 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl * Uses InsuranceMatchingService to find the appropriate price tier for each participant * based on their individual travel price and eligibility criteria. * - * @param BookingCreateDto $bookingDto The booking DTO with all participants - * @param object $applicantInsurance The insurance selected by the applicant + * @param BookingCreateDto $bookingDto The booking DTO with all participants + * @param object $applicantInsurance The insurance selected by the applicant */ private function applyBulkInsuranceToAllParticipants(BookingCreateDto $bookingDto, object $applicantInsurance): void { @@ -139,4 +140,38 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl $participant->insurance = null; } } -} \ No newline at end of file + + /** + * Checks if bulk insurance was previously enabled by checking if dependent participants + * have the same insurance type as the applicant. + * + * This prevents clearing independent insurance selections when the checkbox is simply unchecked + * without ever having been enabled. + * + * @param BookingCreateDto $bookingDto The booking DTO with all participants + * + * @return bool True if bulk was previously active (dependent participants have matching insurance) + */ + private function wasBulkInsurancePreviouslyEnabled(BookingCreateDto $bookingDto): bool + { + $applicant = $bookingDto->participants[0] ?? null; + if (null === $applicant || null === $applicant->insurance) { + return false; + } + + // Check if any dependent participant has insurance that matches the applicant + // If so, bulk was likely previously enabled + foreach ($bookingDto->participants as $index => $participant) { + if (0 === $index) { + continue; // Skip applicant + } + + if (null !== $participant->insurance) { + // If any dependent has insurance, assume bulk was previously enabled + return true; + } + } + + return false; + } +} diff --git a/src/Service/BookingService.php b/src/Service/BookingService.php index be3aeaf..a72b0d4 100644 --- a/src/Service/BookingService.php +++ b/src/Service/BookingService.php @@ -95,6 +95,17 @@ class BookingService $request->getSession()->set(self::BOOKING_CREATE_KEY, $bookingCreateDto); } + /** + * Clears the booking creation DTO from the session. + * + * This method removes only the booking DTO while preserving other session data. + * Used after successful booking submission to clear the booking flow state. + */ + public function clearBookingCreateDto(Request $request): void + { + $request->getSession()->remove(self::BOOKING_CREATE_KEY); + } + /** * Clears all booking-related session data. * @@ -115,7 +126,7 @@ class BookingService * and saves it to the session. It's designed to be called from the clean * booking entry point without requiring UID parameters. */ - public function startFreshBooking(Request $request, int $dateId, int $hotelId): BookingCreateDto + public function startFreshBooking(Request $request, int $dateId, int $hotelId, ?int $agencyId = null): BookingCreateDto { $travelData = $this->travelDataService->getTravelData($dateId, $hotelId); if (null === $travelData) { @@ -138,6 +149,7 @@ class BookingService $bookingCreateDto = new BookingCreateDto($travelData, $hotelId); $bookingCreateDto->roomSelections = $roomSelections; $bookingCreateDto->currentStep = 1; + $bookingCreateDto->agencyId = $agencyId; $this->saveBookingCreateDto($request, $bookingCreateDto); diff --git a/src/Validator/Constraints/ParticipantValidator.php b/src/Validator/Constraints/ParticipantValidator.php index 74b00bb..e7e3e36 100644 --- a/src/Validator/Constraints/ParticipantValidator.php +++ b/src/Validator/Constraints/ParticipantValidator.php @@ -16,6 +16,7 @@ class ParticipantValidator extends ConstraintValidator $this->assertBodyMeasurementsValid($participant); $this->assertTransportationSelected($participant); $this->assertPickupSelected($participant); + $this->assertApplicantAddressValid($participant); } public function assertBodyMeasurementsValid(ParticipantDto $participant): void @@ -64,4 +65,49 @@ class ParticipantValidator extends ConstraintValidator ; } } + + public function assertApplicantAddressValid(ParticipantDto $participant): void + { + // Address is only mandatory for the applicant (first participant) + if (false === $participant->isApplicant()) { + return; + } + + // Check each required address field for applicant + if (null === $participant->address->street || '' === trim($participant->address->street)) { + $this->context->buildViolation('Bitte angeben') + ->atPath('address.street') + ->addViolation() + ; + } + + if (null === $participant->address->postCode || '' === trim($participant->address->postCode)) { + $this->context->buildViolation('Bitte angeben') + ->atPath('address.postCode') + ->addViolation() + ; + } + + if (null === $participant->address->city || '' === trim($participant->address->city)) { + $this->context->buildViolation('Bitte angeben') + ->atPath('address.city') + ->addViolation() + ; + } + + if (null === $participant->address->country || '' === trim($participant->address->country)) { + $this->context->buildViolation('Bitte angeben') + ->atPath('address.country') + ->addViolation() + ; + } + + // Mobile/phone is mandatory for applicant + if (null === $participant->mobile || '' === trim($participant->mobile)) { + $this->context->buildViolation('Bitte angeben') + ->atPath('mobile') + ->addViolation() + ; + } + } } diff --git a/templates/booking/create_step_2.html.twig b/templates/booking/create_step_2.html.twig index c97d737..fd21928 100644 --- a/templates/booking/create_step_2.html.twig +++ b/templates/booking/create_step_2.html.twig @@ -102,6 +102,17 @@ {{ form_row(participant.email) }} {{ form_row(participant.mobile) }} + {% if participant.address is defined %} +
+ {{ participant.address.vars.label }} +
+ {{ form_row(participant.address.street) }} + {{ form_row(participant.address.postCode) }} + {{ form_row(participant.address.city) }} + {{ form_row(participant.address.country) }} +
+
+ {% endif %} {% if participant.bodyDimensions is defined %}
{{ form_row(participant.bodyDimensions.height) }} diff --git a/templates/booking/success.html.twig b/templates/booking/success.html.twig new file mode 100644 index 0000000..8986838 --- /dev/null +++ b/templates/booking/success.html.twig @@ -0,0 +1,30 @@ +{% extends 'layout.html.twig' %} + +{% block title %}Buchung erfolgreich{% endblock %} + +{% block content %} +
+
+ + + +
+ +

Buchung erfolgreich abgeschlossen

+ +

+ Ihre Buchungsnummer: {{ bookingNumber }} +

+ +
+

+ Sie erhalten in Kürze eine Bestätigungs-E-Mail mit allen Details zu Ihrer Buchung. +

+
+ + + Zurück zur Startseite + +
+{% endblock %} diff --git a/tests/BusProNet/XmlParser/AgencyParserTest.php b/tests/BusProNet/XmlParser/AgencyParserTest.php new file mode 100644 index 0000000..c9a4038 --- /dev/null +++ b/tests/BusProNet/XmlParser/AgencyParserTest.php @@ -0,0 +1,111 @@ +parser = new AgencyParser(); + } + + public function testParseAgencies(): void + { + $xmlContent = ' + + + + + ADAC Hansa e.V. + 295 + Musterstr. 123 + 99999 + Musterort + 0361-442930 + + + ADAC im Fördepark + 103 + Sebastianusstr. 24 + 41468 + Neuss + + +'; + + $crawler = new Crawler($xmlContent); + $resultNode = $crawler->filterXPath('//ergebnis'); + $agencies = $this->parser->parse($resultNode); + + $this->assertCount(2, $agencies); + + // Test first agency (complete data) + $this->assertEquals(124487, $agencies[0]->id); + $this->assertEquals('ADAC Hansa e.V.', $agencies[0]->name); + $this->assertEquals('295', $agencies[0]->code); + $this->assertEquals('Musterstr. 123', $agencies[0]->street); + $this->assertEquals('99999', $agencies[0]->postCode); + $this->assertEquals('Musterort', $agencies[0]->city); + $this->assertEquals('0361-442930', $agencies[0]->phone); + + // Test second agency (missing phone) + $this->assertEquals(75861, $agencies[1]->id); + $this->assertEquals('ADAC im Fördepark', $agencies[1]->name); + $this->assertEquals('103', $agencies[1]->code); + $this->assertEquals('Sebastianusstr. 24', $agencies[1]->street); + $this->assertEquals('41468', $agencies[1]->postCode); + $this->assertEquals('Neuss', $agencies[1]->city); + $this->assertNull($agencies[1]->phone); + } + + public function testParseMinimalAgency(): void + { + $xmlContent = ' + + + + + Test Agency + 001 + + +'; + + $crawler = new Crawler($xmlContent); + $resultNode = $crawler->filterXPath('//ergebnis'); + $agencies = $this->parser->parse($resultNode); + + $this->assertCount(1, $agencies); + $this->assertEquals(12345, $agencies[0]->id); + $this->assertEquals('Test Agency', $agencies[0]->name); + $this->assertEquals('001', $agencies[0]->code); + $this->assertNull($agencies[0]->street); + $this->assertNull($agencies[0]->postCode); + $this->assertNull($agencies[0]->city); + $this->assertNull($agencies[0]->phone); + } + + public function testParseEmptyAgenciesList(): void + { + $xmlContent = ' + + + + +'; + + $crawler = new Crawler($xmlContent); + $resultNode = $crawler->filterXPath('//ergebnis'); + $agencies = $this->parser->parse($resultNode); + + $this->assertCount(0, $agencies); + } +} \ No newline at end of file