wip: submit booking to api

This commit is contained in:
Björn Fromme
2025-10-06 11:22:02 +02:00
parent 139ec3c1db
commit 7f1ef4059d
34 changed files with 3316 additions and 31 deletions
+190
View File
@@ -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
<anmelder>
<anschrift>
<strasse>Gablonzer Straße 32</strasse>
<plz>53359</plz>
<ort>Rheinbach</ort>
<ortsteil />
<land>D</land>
</anschrift>
</anmelder>
<teilnehmerliste>
<teilnehmer id="1">
<anschrift>
<strasse>Gablonzer Straße 32</strasse>
<plz>53359</plz>
<ort>Rheinbach</ort>
<ortsteil />
<land>D</land>
</anschrift>
</teilnehmer>
</teilnehmerliste>
```
## 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 `<anmelder><anschrift>`
- All participants: Address structure in `<teilnehmer><anschrift>` (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.
+771
View File
@@ -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: `<buchung>möglich</buchung>` 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: `<buchung>erfolgt</buchung>` 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 `<wünsche>` 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
<versicherungen>
<versicherung idversicherung="20040" anzahl="1" zuordnung="1" />
<versicherung idversicherung="42" anzahl="2" zuordnung="2,3" />
</versicherungen>
```
**Full XML Structure:**
```xml
<xml>
<user>USERNAME</user>
<key>HASH</key>
<satz typ="BUCHUNG" />
<buchungsart>Anfrage|Buchung</buchungsart>
<status>F</status>
<idreise>12345</idreise>
<anmelder>
<anrede>Herr</anrede>
<vorname>Max</vorname>
<name>Mustermann</name>
<strasse>Musterstraße 1</strasse>
<plz>12345</plz>
<ort>Musterstadt</ort>
<email>[email protected]</email>
<telefon>+49123456789</telefon>
</anmelder>
<teilnehmerliste>
<teilnehmer position="1">
<anrede>Herr</anrede>
<vorname>Max</vorname>
<name>Mustermann</name>
<geburtsdatum>1990-01-01</geburtsdatum>
</teilnehmer>
<!-- Additional participants... -->
</teilnehmerliste>
<beförderungen>
<beförderung idbefoerderung="123" anzahl="1" zuordnung="1" />
</beförderungen>
<unterbringungen>
<unterbringung idunterbringung="456" anzahl="2" zuordnung="1,2" />
</unterbringungen>
<zusatzleistungen>
<zusatzleistung idzusatzleistung="789" anzahl="1" zuordnung="1" />
</zusatzleistungen>
<zustiege>
<zustieg idzustieg="101" anzahl="1" zuordnung="1" />
</zustiege>
<versicherungen>
<versicherung idversicherung="202" anzahl="1" zuordnung="1" />
</versicherungen>
<zahlungsart>
<art>2|5</art> <!-- 2=Überweisung, 5=Lastschrift -->
<kontoinhaber>Max Mustermann</kontoinhaber>
<iban>DE89370400440532013000</iban>
</zahlungsart>
</xml>
```
### Response Structure
**Success Response:**
```xml
<ergebnis>
<satz typ="BUCHUNG" />
<buchung>möglich|erfolgt</buchung>
<vorgang>321530</vorgang>
<preise>
<preis position="1" art="UNT" unterart="DZ" bezeichnung="Doppelzimmer"
datumvon="2025-03-15" datumbis="2025-03-22" anzahl="1" zuordnung="1,2"
einzelpreis="450.00" gesamtpreis="900.00" id="456" />
<!-- More price items... -->
</preise>
<gesamtpreis>1500.00</gesamtpreis>
<zahlungsbedingungen>
<anzahlung betrag="500.00" datum="2025-02-01" />
<restzahlung betrag="1000.00" datum="2025-03-01" />
</zahlungsbedingungen>
</ergebnis>
```
**Error Response:**
```xml
<ergebnis>
<satz typ="NOTIFICATION" />
<nachricht typ="fehler">Error message here</nachricht>
</ergebnis>
```
## Implementation Details
### 1. Response Models
**File:** `src/BusProNet/Model/BookingResponse.php`
```php
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
final readonly class BookingResponse
{
public function __construct(
public string $status, // 'möglich' or 'erfolgt'
public ?string $transactionNumber = null, // Vorgangsnummer
public array $priceItems = [], // PriceItem[]
public ?float $totalPrice = null, // Gesamtpreis
public ?PaymentTerms $paymentTerms = null, // Payment schedule
) {
}
public function isInquiryValid(): bool
{
return 'möglich' === $this->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
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
final readonly class PriceItem
{
public function __construct(
public int $position,
public string $type,
public ?string $subType,
public string $label,
public ?\DateTimeImmutable $dateFrom,
public ?\DateTimeImmutable $dateTo,
public int $quantity,
public string $assignment,
public float $unitPrice,
public float $totalPrice,
public ?int $id,
) {
}
}
```
**File:** `src/BusProNet/Model/PaymentTerms.php`
Payment schedule with deposit and final payment amounts/dates.
```php
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
final readonly class PaymentTerms
{
public function __construct(
public float $depositAmount,
public \DateTimeImmutable $depositDate,
public float $finalPaymentAmount,
public \DateTimeImmutable $finalPaymentDate,
) {
}
}
```
### 2. XML Parser
**File:** `src/BusProNet/XmlParser/BookingResponseParser.php`
Extends `AbstractParser` and parses:
- Booking status from `<buchung>` node
- Transaction number from `<vorgang>` node
- All price items from `<preise><preis>` nodes
- Total price from `<gesamtpreis>` node
- Payment terms from `<zahlungsbedingungen>` node
```php
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\PaymentTerms;
use App\BusProNet\Model\PriceItem;
use Symfony\Component\DomCrawler\Crawler;
final class BookingResponseParser extends AbstractParser
{
public function parse(Crawler $node): BookingResponse
{
$status = $this->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
<?php
declare(strict_types=1);
namespace App\Controller\Booking;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class BookingSuccessController extends AbstractController
{
#[Route('/bookings/success', name: 'app_booking_success')]
public function success(Request $request): Response
{
$bookingNumber = $request->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 `<email>` and `<pdfpasswort>` 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
+559
View File
@@ -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 `<buchung>` node
- Extracts transaction number from `<vorgang>` node
- Parses all price items from `<preise><preis>` nodes
- Parses total price from `<gesamtpreis>` node
- Parses payment terms from `<zahlungsbedingungen>` node
**XML Structure Handled:**
```xml
<ergebnis>
<satz typ="BUCHUNG" />
<buchung>möglich|erfolgt</buchung>
<vorgang>321530</vorgang>
<preise>
<preis position="1" art="BEF" unterart="BUS" bezeichnung="..."
terminvon="..." terminbis="..." anzahl="2" zuordnung="1,2"
preis="50.00" gesamtpreis="100.00" id="123" />
</preise>
<gesamtpreis>671,78</gesamtpreis>
<zahlungsbedingungen>
<anzahlung betrag="128,00" termin="21.03.2017" />
<restzahlung betrag="543,78" termin="12.11.2017" />
</zahlungsbedingungen>
</ergebnis>
```
### 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
<?php
declare(strict_types=1);
namespace App\Controller\Booking;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class BookingSuccessController extends AbstractController
{
#[Route('/bookings/success/{bookingNumber}', name: 'app_booking_success')]
public function success(string $bookingNumber): Response
{
return $this->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 %}
<div class="max-w-2xl mx-auto text-center py-12">
<div class="mb-8">
<svg class="w-24 h-24 text-green-500 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<h1 class="text-3xl font-bold mb-4">Buchung erfolgreich abgeschlossen</h1>
<p class="text-xl mb-8">
Ihre Buchungsnummer: <strong class="font-mono">{{ bookingNumber }}</strong>
</p>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
<p class="text-gray-700">
Sie erhalten in Kürze eine Bestätigungs-E-Mail mit allen Details zu Ihrer Buchung.
</p>
</div>
<a href="{{ path('app_home') }}" class="button bg-button bg-button--primary">
Zurück zur Startseite
</a>
</div>
{% 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: `<buchung>möglich</buchung>`
- No actual booking created
2. **Phase 2: Booking (`buchungsart => 'Buchung'`)**
- Creates actual booking
- Returns booking number
- Response: `<buchung>erfolgt</buchung>`
- 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
+285
View File
@@ -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 #}
<div id="step3-loading" class="htmx-indicator fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-8 shadow-xl">
<div class="flex items-center space-x-4">
<svg class="animate-spin h-8 w-8 text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-lg font-medium">Buchung wird validiert...</span>
</div>
</div>
</div>
{{ 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 #}
<div id="step4-loading" class="htmx-indicator fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-8 shadow-xl">
<div class="flex items-center space-x-4">
<svg class="animate-spin h-8 w-8 text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-lg font-medium">Buchung wird durchgeführt...</span>
</div>
</div>
</div>
{{ 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
<div data-controller="loading" data-loading-hidden-class="hidden">
{{ form_start(form, {'attr': {'data-action': 'submit->loading#toggle'}}) }}
<div data-loading-target="indicator" class="hidden fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-8 shadow-xl">
<!-- Loading spinner -->
</div>
</div>
<!-- Form fields -->
<button type="submit">Weiter</button>
{{ form_end(form) }}
</div>
```
**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
+183
View File
@@ -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)
+79
View File
@@ -7,6 +7,7 @@ use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ResponseParserException; use App\BusProNet\Exception\ResponseParserException;
use App\BusProNet\Model\BaseData; use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Booking; use App\BusProNet\Model\Booking;
use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\BookingUpdate; use App\BusProNet\Model\BookingUpdate;
use App\BusProNet\Model\CrmAttributes; use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
@@ -14,6 +15,7 @@ use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\BusProNet\Traits\ApiClientTrait; use App\BusProNet\Traits\ApiClientTrait;
use App\BusProNet\XmlParser\ApiResponseParser; use App\BusProNet\XmlParser\ApiResponseParser;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingEditDto; use App\Form\Model\BookingEditDto;
use App\Form\Model\RegistrationDto; use App\Form\Model\RegistrationDto;
use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemException;
@@ -34,8 +36,10 @@ class ApiClient
public const TYPE_AVAILABILITY = 'VERFUEGBARKEIT'; public const TYPE_AVAILABILITY = 'VERFUEGBARKEIT';
public const TYPE_AVAILABILITY_HOTEL = 'VERFUEGBARKEITHOTEL'; public const TYPE_AVAILABILITY_HOTEL = 'VERFUEGBARKEITHOTEL';
public const TYPE_BOOKING_UPDATE = 'BUCHUNGAENDERUNG'; public const TYPE_BOOKING_UPDATE = 'BUCHUNGAENDERUNG';
public const TYPE_BOOKING = 'BUCHUNG';
public const TYPE_PRODUCTS = 'PRODUKTE'; public const TYPE_PRODUCTS = 'PRODUKTE';
public const TYPE_PRODUCT_DATA = 'PRODUKTDATEN'; public const TYPE_PRODUCT_DATA = 'PRODUKTDATEN';
public const TYPE_AGENCIES = 'AGENTUREN';
private array $config; private array $config;
@@ -203,6 +207,60 @@ class ApiClient
return $this->sendRequest(static::TYPE_BOOKING_UPDATE, $data, [], $debug); 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 * @throws ApiClientException
*/ */
@@ -328,6 +386,27 @@ class ApiClient
return $this->sendRequest(static::TYPE_PRODUCTS, $data); 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 * @throws ApiClientException
*/ */
+4
View File
@@ -51,4 +51,8 @@ final class Constants
// Payment methods // Payment methods
public const PAYMENT_METHOD_TRANSFER = 'transfer'; public const PAYMENT_METHOD_TRANSFER = 'transfer';
public const PAYMENT_METHOD_DEBIT = 'debit'; 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;
} }
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\BusProNet\DataProcessor; namespace App\BusProNet\DataProcessor;
use App\BusProNet\Constants;
use App\BusProNet\Model\Communication; use App\BusProNet\Model\Communication;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\BookingEditDto; 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<string, array<int>> 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<string, array<int>> 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<string, array<int>> 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<string, array<int>> 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<string, array<int>> 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;
}
} }
+4 -4
View File
@@ -15,18 +15,18 @@ use Symfony\Component\Validator\Constraints as Assert;
*/ */
class Address class Address
{ {
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])] #[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])]
public ?string $street = null; 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; 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 $city = null;
public ?string $district = 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; public ?string $country = null;
/** /**
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
final readonly class Agency
{
public function __construct(
public int $id,
public string $name,
public string $code,
public ?string $street = null,
public ?string $postCode = null,
public ?string $city = null,
public ?string $phone = null,
) {
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* Represents a successful API response from a booking request.
*
* Response structure for successful requests:
* - <buchung>möglich</buchung> = inquiry validation successful
* - <buchung>erfolgt</buchung> = 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<int, PriceItem> $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;
}
}
+1 -1
View File
@@ -175,4 +175,4 @@ class Insurance
return array_values(array_unique($urls)); return array_values(array_unique($urls));
} }
} }
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* Represents payment terms from the booking response.
*/
class PaymentTerms
{
public function __construct(
public readonly ?float $depositAmount = null,
public readonly ?string $depositDate = null,
public readonly ?float $finalPaymentAmount = null,
public readonly ?string $finalPaymentDate = null,
) {
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* Represents a single price item from the booking response.
*/
class PriceItem
{
public function __construct(
public readonly int $position,
public readonly string $type,
public readonly ?string $subType,
public readonly string $label,
public readonly ?string $dateFrom,
public readonly ?string $dateTo,
public readonly int $quantity,
public readonly ?string $assignment,
public readonly float $unitPrice,
public readonly float $totalPrice,
public readonly ?string $id,
) {
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlLoader;
use App\BusProNet\ApiClient;
use App\BusProNet\Model\Agency;
use App\BusProNet\Model\Notification;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class AgencyLoader
{
public const DEFAULT_AGENCY_CODE = '0001';
public function __construct(
private readonly CacheInterface $cache,
private readonly ApiClient $apiClient,
private readonly LoggerInterface $logger,
) {
}
/**
* @return Agency[]
*/
public function loadAll(): array
{
try {
return $this->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);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\Agency;
use Symfony\Component\DomCrawler\Crawler;
final class AgencyParser extends AbstractParser
{
/**
* @return Agency[]
*/
public function parse(Crawler $node): array
{
$agencies = [];
$node->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;
}
}
@@ -59,12 +59,16 @@ class ApiResponseParser extends AbstractParser
return (new AvailabilitiesParser())->parseRooms($resultNode); return (new AvailabilitiesParser())->parseRooms($resultNode);
case ApiClient::TYPE_BOOKING_UPDATE: case ApiClient::TYPE_BOOKING_UPDATE:
return (new BookingUpdateParser())->parse($resultNode); return (new BookingUpdateParser())->parse($resultNode);
case ApiClient::TYPE_BOOKING:
return (new BookingResponseParser())->parse($resultNode);
case ApiClient::TYPE_PRODUCTS: case ApiClient::TYPE_PRODUCTS:
return (new ProductsParser())->parse($resultNode); return (new ProductsParser())->parse($resultNode);
case ApiClient::TYPE_PRODUCT_DATA: case ApiClient::TYPE_PRODUCT_DATA:
$travelNode = $crawler->filterXPath('//reise/termin'); $travelNode = $crawler->filterXPath('//reise/termin');
return (new TravelParser())->parse($travelNode->first(), ...$additionalArgs); return (new TravelParser())->parse($travelNode->first(), ...$additionalArgs);
case ApiClient::TYPE_AGENCIES:
return (new AgencyParser())->parse($resultNode);
} }
throw new ResponseParserException('Unable to parse XML response'); throw new ResponseParserException('Unable to parse XML response');
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\PaymentTerms;
use App\BusProNet\Model\PriceItem;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parses XML responses from booking requests (inquiry and final booking).
*
* Expected XML structure:
* <ergebnis>
* <satz typ="BUCHUNG" />
* <buchung>möglich|erfolgt</buchung>
* <vorgang>321530</vorgang>
* <preise>
* <preis position="1" art="BEF" unterart="BUS" bezeichnung="..." ... />
* </preise>
* <gesamtpreis>671,78</gesamtpreis>
* <zahlungsbedingungen>
* <anzahlung betrag="128,00" termin="21.03.2017" />
* <restzahlung betrag="543,78" termin="12.11.2017" />
* </zahlungsbedingungen>
* </ergebnis>
*/
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<int, PriceItem>
*/
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
);
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ class InsuranceParser extends AbstractParser
// Second pass: Parse individual insurances with conditional filtering // Second pass: Parse individual insurances with conditional filtering
$individualInsurances = []; $individualInsurances = [];
$xmlContent->filterXPath('//versicherungen/versicherung') $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 $id = (int) $node->attr('idbuspro'); // Individual insurances have int IDs
$isComplementary = $this->getBoolAttributeValue($node->attr('zusatzversicherung')); $isComplementary = $this->getBoolAttributeValue($node->attr('zusatzversicherung'));
@@ -33,18 +33,23 @@ trait BookingExceptionHandlerTrait
return $bookingService->getOrCreateBookingCreateDto($request); return $bookingService->getOrCreateBookingCreateDto($request);
} catch (BookingSessionNotFoundException $e) { } catch (BookingSessionNotFoundException $e) {
$this->addFlash('error', 'Ihre Buchungssitzung ist abgelaufen. Bitte starten Sie eine neue Buchung.'); $this->addFlash('error', 'Ihre Buchungssitzung ist abgelaufen. Bitte starten Sie eine neue Buchung.');
return $this->redirectToRoute('app_booking_create_error'); return $this->redirectToRoute('app_booking_create_error');
} catch (TravelNotFoundException $e) { } catch (TravelNotFoundException $e) {
$this->addFlash('error', 'Die angeforderte Reise wurde nicht gefunden.'); $this->addFlash('error', 'Die angeforderte Reise wurde nicht gefunden.');
return $this->redirectToRoute('app_booking_create_error'); return $this->redirectToRoute('app_booking_create_error');
} catch (HotelNotFoundException $e) { } catch (HotelNotFoundException $e) {
$this->addFlash('error', 'Das angeforderte Hotel wurde nicht gefunden.'); $this->addFlash('error', 'Das angeforderte Hotel wurde nicht gefunden.');
return $this->redirectToRoute('app_booking_create_error'); return $this->redirectToRoute('app_booking_create_error');
} catch (HotelNotInTravelException $e) { } catch (HotelNotInTravelException $e) {
$this->addFlash('error', 'Das Hotel ist für diese Reise nicht verfügbar.'); $this->addFlash('error', 'Das Hotel ist für diese Reise nicht verfügbar.');
return $this->redirectToRoute('app_booking_create_error'); return $this->redirectToRoute('app_booking_create_error');
} catch (NoRoomsAvailableException $e) { } catch (NoRoomsAvailableException $e) {
$this->addFlash('error', 'Für diese Reise sind aktuell keine Zimmer verfügbar.'); $this->addFlash('error', 'Für diese Reise sind aktuell keine Zimmer verfügbar.');
return $this->redirectToRoute('app_booking_create_error'); return $this->redirectToRoute('app_booking_create_error');
} }
} }
@@ -59,8 +64,8 @@ trait BookingExceptionHandlerTrait
{ {
try { try {
return $bookingService->getOrCreateBookingCreateDto($request); return $bookingService->getOrCreateBookingCreateDto($request);
} catch (BookingSessionNotFoundException | TravelNotFoundException | HotelNotFoundException | HotelNotInTravelException | NoRoomsAvailableException $e) { } catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotFoundException|HotelNotInTravelException|NoRoomsAvailableException $e) {
return new Response('', 400); return new Response('', 400);
} }
} }
} }
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Controller\Booking;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class BookingSuccessController extends AbstractController
{
#[Route('/bookings/success', name: 'app_booking_success')]
public function success(Request $request): Response
{
$bookingNumber = $request->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,
]);
}
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Controller\Booking; namespace App\Controller\Booking;
use App\BusProNet\XmlLoader\AgencyLoader;
use App\Exception\HotelNotFoundException; use App\Exception\HotelNotFoundException;
use App\Exception\HotelNotInTravelException; use App\Exception\HotelNotInTravelException;
use App\Exception\NoRoomsAvailableException; use App\Exception\NoRoomsAvailableException;
@@ -25,6 +26,7 @@ class CreateInitController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly BookingService $bookingService, 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 * This endpoint provides a clean way to start the booking flow with just
* dateId and hotelId parameters. It clears any existing booking session * dateId and hotelId parameters. It clears any existing booking session
* and creates a fresh BookingCreateDto before redirecting to step 1. * 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+'])] #[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 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 // Clear any existing booking session to ensure fresh start
$this->bookingService->clearBookingSession($request); $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 // 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 // Redirect to step 1 of the booking flow
return $this->redirectToRoute('app_booking_create_step_1'); 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. * Displays user-friendly error messages for booking initialization failures.
* *
@@ -4,15 +4,17 @@ declare(strict_types=1);
namespace App\Controller\Booking; namespace App\Controller\Booking;
use App\BusProNet\Constants; use App\BusProNet\ApiClient;
use App\BusProNet\Model\Notification;
use App\Controller\Traits\HtmxControllerTrait; use App\Controller\Traits\HtmxControllerTrait;
use App\Form\BookingCreateStep3Type; use App\Form\BookingCreateStep3Type;
use App\Service\BookingPriceCalculatorService;
use App\Service\BookingService; use App\Service\BookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; 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). * Handles the third step of the booking creation process (payment method selection).
@@ -25,6 +27,9 @@ class CreateStep3Controller extends AbstractController
public function __construct( public function __construct(
private readonly BookingService $bookingService, 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); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$bookingCreateDto->currentStep = 4; try {
$this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); // 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', [ return $this->render('booking/create_step_3.html.twig', [
@@ -4,13 +4,17 @@ declare(strict_types=1);
namespace App\Controller\Booking; namespace App\Controller\Booking;
use App\BusProNet\ApiClient;
use App\BusProNet\Model\Notification;
use App\Controller\Traits\HtmxControllerTrait; use App\Controller\Traits\HtmxControllerTrait;
use App\Form\BookingCreateStep4Type; use App\Form\BookingCreateStep4Type;
use App\Service\BookingService; use App\Service\BookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
/** /**
* Handles the fourth step of the booking creation process (confirmation). * Handles the fourth step of the booking creation process (confirmation).
*/ */
@@ -22,6 +26,8 @@ class CreateStep4Controller extends AbstractController
public function __construct( public function __construct(
private readonly BookingService $bookingService, private readonly BookingService $bookingService,
private readonly ApiClient $apiClient,
private readonly LoggerInterface $logger,
) { ) {
} }
@@ -46,13 +52,49 @@ class CreateStep4Controller extends AbstractController
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
// TODO: Perform inquiry API call try {
// TODO: If inquiry successful, perform booking API call // Submit final booking (already validated in Step 3)
// TODO: Clear session and redirect to success page $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', [ return $this->render('booking/create_step_4.html.twig', [
@@ -61,4 +103,4 @@ class CreateStep4Controller extends AbstractController
...$this->getSummaryVariables($bookingCreateDto), ...$this->getSummaryVariables($bookingCreateDto),
]); ]);
} }
} }
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\BusProNet\Form\CountryType;
use App\BusProNet\Model\Address;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AddressType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->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,
]);
}
}
+7 -5
View File
@@ -148,14 +148,16 @@ class BookingParticipantType extends AbstractType
], $getFieldState('nationality'))) ], $getFieldState('nationality')))
->add('email', EmailType::class, $this->mergeFieldState([ ->add('email', EmailType::class, $this->mergeFieldState([
'label' => 'E-Mail', 'label' => 'E-Mail',
'required' => false,
'sanitize_html' => true,
], $getFieldState('email'))) ], $getFieldState('email')))
->add('mobile', TextType::class, $this->mergeFieldState([ ->add('mobile', TextType::class, $this->mergeFieldState([
'label' => 'Telefon (mobil)', 'label' => 'Telefon (mobil)',
'required' => false, 'required' => 0 === $participantIndex,
'sanitize_html' => true, '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 // Add body dimensions with state handling - use shouldIncludeField method
if ($this->fieldStateProvider->shouldIncludeField('bodyDimensions', $bookingDto, $participantIndex)) { 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 // Clear the form and rebuild from scratch with updated states
// Rebuild base fields 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) { foreach ($baseFields as $fieldName) {
if ($form->has($fieldName)) { if ($form->has($fieldName)) {
$form->remove($fieldName); $form->remove($fieldName);
+2
View File
@@ -31,6 +31,8 @@ class BookingCreateDto implements BookingDtoInterface
public ?BankAccountDto $bankAccount = null; public ?BankAccountDto $bankAccount = null;
public ?int $agencyId = null;
public function __construct(public Travel $travel, public int $hotelId) public function __construct(public Travel $travel, public int $hotelId)
{ {
} }
+11
View File
@@ -2,6 +2,7 @@
namespace App\Form\Model; namespace App\Form\Model;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\PersonalData; use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\Pickup; use App\BusProNet\Model\Pickup;
@@ -56,11 +57,16 @@ class ParticipantDto
#[Assert\NotNull(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create_step_2'])] #[Assert\NotNull(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create_step_2'])]
public ?\DateTimeImmutable $dateOfBirth = null; 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'])] #[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 $email = null;
public ?string $mobile = 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'])] #[Assert\NotNull(message: 'Bitte ein Zimmer auswählen', groups: ['booking_create_step_2'])]
public ?int $assignedRoomId = null; public ?int $assignedRoomId = null;
@@ -104,6 +110,11 @@ class ParticipantDto
*/ */
public array $notifications = []; public array $notifications = [];
public function __construct()
{
$this->address = new Address();
}
public static function fromPersonalData(PersonalData $personalData): static public static function fromPersonalData(PersonalData $personalData): static
{ {
$instance = new static(); $instance = new static();
@@ -85,8 +85,9 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl
$this->applyBulkInsuranceToAllParticipants($bookingDto, $participant->insurance); $this->applyBulkInsuranceToAllParticipants($bookingDto, $participant->insurance);
} }
// If bulk insurance is disabled, clear dependent participants' insurances // If bulk insurance was CHANGED from enabled to disabled, clear dependent participants' insurances
if (false === $isBulkEnabled) { // Don't clear if it was never enabled (to allow independent insurance selection)
if (false === $isBulkEnabled && $this->wasBulkInsurancePreviouslyEnabled($bookingDto)) {
$this->clearDependentParticipantsInsurance($bookingDto); $this->clearDependentParticipantsInsurance($bookingDto);
} }
} }
@@ -97,8 +98,8 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl
* Uses InsuranceMatchingService to find the appropriate price tier for each participant * Uses InsuranceMatchingService to find the appropriate price tier for each participant
* based on their individual travel price and eligibility criteria. * based on their individual travel price and eligibility criteria.
* *
* @param BookingCreateDto $bookingDto The booking DTO with all participants * @param BookingCreateDto $bookingDto The booking DTO with all participants
* @param object $applicantInsurance The insurance selected by the applicant * @param object $applicantInsurance The insurance selected by the applicant
*/ */
private function applyBulkInsuranceToAllParticipants(BookingCreateDto $bookingDto, object $applicantInsurance): void private function applyBulkInsuranceToAllParticipants(BookingCreateDto $bookingDto, object $applicantInsurance): void
{ {
@@ -139,4 +140,38 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl
$participant->insurance = null; $participant->insurance = null;
} }
} }
}
/**
* 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;
}
}
+13 -1
View File
@@ -95,6 +95,17 @@ class BookingService
$request->getSession()->set(self::BOOKING_CREATE_KEY, $bookingCreateDto); $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. * 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 * and saves it to the session. It's designed to be called from the clean
* booking entry point without requiring UID parameters. * 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); $travelData = $this->travelDataService->getTravelData($dateId, $hotelId);
if (null === $travelData) { if (null === $travelData) {
@@ -138,6 +149,7 @@ class BookingService
$bookingCreateDto = new BookingCreateDto($travelData, $hotelId); $bookingCreateDto = new BookingCreateDto($travelData, $hotelId);
$bookingCreateDto->roomSelections = $roomSelections; $bookingCreateDto->roomSelections = $roomSelections;
$bookingCreateDto->currentStep = 1; $bookingCreateDto->currentStep = 1;
$bookingCreateDto->agencyId = $agencyId;
$this->saveBookingCreateDto($request, $bookingCreateDto); $this->saveBookingCreateDto($request, $bookingCreateDto);
@@ -16,6 +16,7 @@ class ParticipantValidator extends ConstraintValidator
$this->assertBodyMeasurementsValid($participant); $this->assertBodyMeasurementsValid($participant);
$this->assertTransportationSelected($participant); $this->assertTransportationSelected($participant);
$this->assertPickupSelected($participant); $this->assertPickupSelected($participant);
$this->assertApplicantAddressValid($participant);
} }
public function assertBodyMeasurementsValid(ParticipantDto $participant): void 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()
;
}
}
} }
+11
View File
@@ -102,6 +102,17 @@
{{ form_row(participant.email) }} {{ form_row(participant.email) }}
{{ form_row(participant.mobile) }} {{ form_row(participant.mobile) }}
</div> </div>
{% if participant.address is defined %}
<fieldset class="border border-gray-300 rounded p-4 mb-4">
<legend class="font-semibold px-2">{{ participant.address.vars.label }}</legend>
<div class="grid grid-cols-2 gap-4">
{{ form_row(participant.address.street) }}
{{ form_row(participant.address.postCode) }}
{{ form_row(participant.address.city) }}
{{ form_row(participant.address.country) }}
</div>
</fieldset>
{% endif %}
{% if participant.bodyDimensions is defined %} {% if participant.bodyDimensions is defined %}
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
{{ form_row(participant.bodyDimensions.height) }} {{ form_row(participant.bodyDimensions.height) }}
+30
View File
@@ -0,0 +1,30 @@
{% extends 'layout.html.twig' %}
{% block title %}Buchung erfolgreich{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto text-center py-12">
<div class="mb-8">
<svg class="w-24 h-24 text-green-500 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<h1 class="text-3xl font-bold mb-4">Buchung erfolgreich abgeschlossen</h1>
<p class="text-xl mb-8">
Ihre Buchungsnummer: <strong class="font-mono">{{ bookingNumber }}</strong>
</p>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
<p class="text-gray-700">
Sie erhalten in Kürze eine Bestätigungs-E-Mail mit allen Details zu Ihrer Buchung.
</p>
</div>
<a href="https://www.ep-reisen.de" class="button bg-button bg-button--primary">
Zurück zur Startseite
</a>
</div>
{% endblock %}
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\XmlParser;
use App\BusProNet\XmlParser\AgencyParser;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DomCrawler\Crawler;
class AgencyParserTest extends TestCase
{
private AgencyParser $parser;
protected function setUp(): void
{
$this->parser = new AgencyParser();
}
public function testParseAgencies(): void
{
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
<ergebnis>
<satz typ="AGENTUREN" />
<agenturen>
<agentur id="124487">
<name>ADAC Hansa e.V.</name>
<code>295</code>
<strasse>Musterstr. 123</strasse>
<plz>99999</plz>
<ort>Musterort</ort>
<telefon>0361-442930</telefon>
</agentur>
<agentur id="75861">
<name>ADAC im Fördepark</name>
<code>103</code>
<strasse>Sebastianusstr. 24</strasse>
<plz>41468</plz>
<ort>Neuss</ort>
</agentur>
</agenturen>
</ergebnis>';
$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 = '<?xml version="1.0" encoding="utf-8"?>
<ergebnis>
<satz typ="AGENTUREN" />
<agenturen>
<agentur id="12345">
<name>Test Agency</name>
<code>001</code>
</agentur>
</agenturen>
</ergebnis>';
$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 = '<?xml version="1.0" encoding="utf-8"?>
<ergebnis>
<satz typ="AGENTUREN" />
<agenturen>
</agenturen>
</ergebnis>';
$crawler = new Crawler($xmlContent);
$resultNode = $crawler->filterXPath('//ergebnis');
$agencies = $this->parser->parse($resultNode);
$this->assertCount(0, $agencies);
}
}