Files
myep/docs/PROJECT_OVERVIEW.md
T

436 lines
22 KiB
Markdown

# MyEP Next Booking - Project Overview
**Symfony 6.4 travel booking application** integrating with Bus Pro Net (BPN) XML API.
## Core Architecture
### Multi-Step Booking Flow
1. **Step 1**: Room selection and dates (`Create\Step1Controller`)
2. **Step 2**: Participant details with card-based UI (`Create\Step2Controller`)
3. **Step 3**: Payment method selection (`Create\Step3Controller`)
4. **Step 4**: Final confirmation with comprehensive pricing breakdown and submission to BPN API (`Create\Step4Controller`)
- Displays complete pricing breakdown by category (rooms, services grouped by type)
- Shows per-participant total pricing in card headers
- Displays individual service prices inline with each booked service
- Three-level pricing transparency: aggregate, participant, and service-level
5. **Edit Flow**: Similar card-based UI for existing bookings (`Edit\IndexController`)
### Card-Based UI Pattern (Production)
- **Overview**: Grid of participant cards with lazy-loaded individual forms
- **Performance**: Handles 50+ participants efficiently via HTMX
- **Controllers**:
- `Create\Step2Controller` - create flow with validation before step 3
- `Edit\IndexController` - edit flow with validation before API submission
- **Shared Logic**:
- `ParticipantCardFlowTrait` - Card rendering and form handling
- `ParticipantValidationTrait` - Validation error extraction for card indicators
- **Validation Pattern**:
- Both controllers use validation-only forms (`BookingCreateStep2Type`, `BookingEditType`)
- Standard Symfony form flow: `handleRequest()``isSubmitted()``isValid()`
- On invalid: Extract error indices, display error banner, highlight cards, disable submit button
- On valid: Proceed to next step (create) or submit to API (edit)
## Key Architectural Layers
### BusProNet Integration (`src/BusProNet/`)
- `ApiClient` - XML API communication
- `XmlParser/` - Response parsers (travels, hotels, bookings)
- `XmlLoader/` - Data loaders with caching
- `DataProcessor/` - Transform API data to DTOs
### Form System (`src/Form/`)
- **DTOs**: `BookingCreateDto`, `ParticipantDto` (session-stored)
- **Field Handlers**: 15+ specialized handlers in `src/Form/Service/`
- Registered via service tags with dependency resolution
- Process in dependency order via `ParticipantFieldHandlerRegistry`
- **Conditional Fields**: Universal condition system (`FieldConditionInterface`)
- Age-based, field-dependent, service-specific conditions
- Applied via `CreateFieldStateProvider` / `EditFieldStateProvider`
- **HTMX Integration**: Real-time updates for dynamic fields
### Service Layer (`src/Service/`)
- `BookingService` - Core booking workflow
- `BookingPriceCalculatorService` - Comprehensive pricing calculations
- `getPricingBreakdown()` - Complete pricing breakdown with rooms and services grouped by type
- `calculateAllParticipantIndividualPrices()` - Per-participant total pricing
- `calculateIndividualParticipantPrice()` - Single participant total (room + all services)
- `calculateServicePricing()` - Service aggregation with grouping by subtype
- Powers sidebar summary and Step 4 confirmation pricing display
- `BookingFingerprintService` - Dirty state detection for edit mode
- `TravelDataService` - API integration and caching
- `ParticipantCardDataService` - Card display data
- `InsuranceService` - Consolidated insurance operations (eligibility, type filtering, reassignment) with request-scoped caching
- `RoomAssignmentService` - Automatic room assignment with intelligent conflict resolution
### Room Reassignment with Conflict Resolution
The room assignment system allows participants to freely select any room from Step 1 selections, with automatic conflict resolution when capacity is exceeded.
**Key Features:**
- **Flexible Selection**: All rooms from Step 1 always appear in participant dropdown, regardless of current capacity
- **Auto-Assignment**: When exactly ONE room type selected, all participants auto-assigned and dropdown disabled
- **Conflict Resolution**: When participant selects room at capacity, system automatically unassigns minimum participants needed
- **Unassignment Priority**: Highest index participants unassigned first (keeps applicant and early participants stable)
- **User Notifications**: Unassigned participants receive warning notifications via toast system
**Implementation:**
- `ParticipantRoomChoiceLoader`: Always includes all selected rooms (no capacity filtering)
- `ParticipantAssignedRoomFieldHandler`: Detects conflicts and resolves via auto-unassignment
- `detectRoomCapacityConflict()`: Calculates if assignment would exceed capacity
- `resolveRoomCapacityConflict()`: Unassigns minimum participants (highest index first)
- Generates notifications for unassigned participants only (not for user-initiated assignment)
- `RoomAssignmentService`: Unchanged - still auto-assigns when single room type selected
- `ParticipantFieldOptionsProvider`: Unchanged - disables dropdown when single room type (auto-assigned)
**Example Scenario:**
- User selects 1x "Doppelzimmer" (capacity 2), 3 participants
- Participants 0 and 1 auto-assigned to "Doppelzimmer"
- Participant 2 manually selects "Doppelzimmer" (at capacity)
- System automatically unassigns Participant 1 (highest index)
- Participant 2 gets assigned, Participant 1 receives warning notification
## Critical Patterns
### Field Handler Pattern
```php
// Handlers process in dependency order (topological sort)
// Example: insurance handler depends on ALL price-affecting fields
$this->fieldHandlerRegistry->processFieldsForParticipant(
$participantData,
$bookingDto,
$index
);
```
**Important**: Field handlers use "sync pattern" - only sync fields present in original submission to avoid validation errors.
### HTMX Block-Based Rendering
```php
// All HTMX swaps target #main-content with innerHTML
// OOB swaps for sidebar: #booking-summary
return $this->htmxOobResponse(
'booking/_participant_form.html.twig',
['participant_form', 'booking_summary'],
$templateData
);
```
### Service Enrichment Pattern
Services from BPN API may lack complete data (especially prices). Always enrich from travel data:
```php
// BookingDataProcessor::enrichParticipantServicesFromTravel()
// Looks up each service in travel data and replaces with full version
```
### Notification System
Field handlers generate notifications (auto-changes) → collected by controller → sent via HX-Trigger → displayed as toasts.
### Floating-Point Precision in Price Calculations
**Critical**: All monetary comparisons must account for floating-point arithmetic accumulation errors.
**The Problem:**
- Prices are parsed from XML as German-formatted strings (e.g., `"1.812,70"`)
- Multiple price additions (rooms + services + insurances) accumulate tiny precision errors (`~1e-15` per operation)
- With bulk insurance and deep calculation chains, errors compound to `~1e-13` or larger
- Example: API returns `1812.7`, calculation produces `1812.6999999999998`
**The Solution:**
- **Always round monetary values to 2 decimal places before comparison**
- Use `round($price, 2)` for cent precision (standard for EUR currency)
- Never use strict equality (`===` or `!==`) on unrounded float prices
**Implementation Example (Step3Controller:93-94):**
```php
// CORRECT: Round to cent precision before comparison
$apiTotal = round($inquiryResponse->totalPrice ?? 0.0, 2);
$calculatedTotal = round($this->priceCalculator->calculateGrandTotal($bookingCreateDto), 2);
if ($apiTotal !== $calculatedTotal) {
// Handle mismatch
}
// WRONG: Direct float comparison (will fail due to precision errors)
if ($inquiryResponse->totalPrice !== $this->priceCalculator->calculateGrandTotal($bookingCreateDto)) {
// This comparison is unreliable!
}
```
**Important Notes:**
- Rounding eliminates precision errors smaller than 1 cent (€0.01)
- The BPN API returns prices already rounded to cent precision
- This is the industry-standard approach for financial calculations
- Only affects comparison logic - does not alter actual price calculation flow
### Dirty State Detection (Edit Mode)
Fingerprint-based change detection to warn users about unsaved modifications:
- **BookingFingerprintService** generates SHA-256 hash of all mutable booking data
- Original fingerprint stored in `BookingDto::$originalFingerprint` on API load
- `isDirty()` compares current state with original to detect changes
- Yellow warning banner displays when changes detected
- Update button conditionally shown only when dirty
- Fingerprint persists in session, survives page refreshes
- Resets on submission, "Änderungen verwerfen", or "Zurück" actions
**Unavailable Services Handling:**
- Services with `available <= 0` included in edit mode (not filtered out)
- `ParticipantFieldOptionsProvider::shouldMakeServiceReadonly()` implements intelligent readonly logic:
- **Create mode**: Uses standard availability calculator (filters out unavailable services)
- **Edit mode**: Services participants already have remain editable even if now fully booked
- **Edit mode**: Unavailable services participant doesn't have are marked readonly
- Prevents fingerprint false positives when services become fully booked during editing session
- Ensures service IDs remain consistent in form submissions for accurate dirty detection
## Key Service Dependencies
### Field Handler Execution Order
Critical for correct pricing and auto-reassignment:
1. Age-dependent fields (dateOfBirth)
2. Price-affecting services (skiPass, rentals, courses, board, transportation, pickup, parking)
3. **Insurance handler LAST** (depends on all price-affecting fields)
4. Bulk insurance handler (applicant only)
### Insurance System
- **3-pass parsing**: Referenced IDs → Individual insurances → Packages with family detection
- **Auto-reassignment**: Maintains insurance type when price tier changes
- **Age constraints**: Absolute age (at travel date) vs birth year
- **Hydration**: `TravelDataService::hydrateInsurancePackageRelationships()` rebuilds package relationships after cache deserialization
- **ID Type**: Insurance IDs are strings (not integers) - ensure all test fixtures use string IDs
- **Mutability**: Insurances are always readonly in edit mode (API limitation) - see `InsuranceMutabilityCondition`
### Transportation Services
- **Unified pickup field**: Single field for both directions (BPN API limitation)
- **Conditional visibility**: Pickup vs parking fields mutually exclusive
- **Direction mapping**: `DirectionMapper` translates API ↔ internal codes
## Important Field Dependencies
### Ski Pass → Rentals → Insurance → Body Dimensions
- Rentals filtered by ski pass duration (exact date matching)
- Rental insurance only shown when rentals selected
- Body dimensions only shown when rentals selected
- All cleared automatically when dependencies removed
### Date of Birth → Age-Dependent Services
- Courses, additional services, board, insurance hidden until DOB provided
- Age evaluated at travel start date, not current date
- Dual constraint types: absolute_age, birth_year, mixed
### Bulk Insurance Booking
- Applicant enables bulk → applies to all participants
- `BulkInsuranceBookingCondition` hides dependent participant insurance fields
- Uses `InsuranceService::batchAssignInsuranceToParticipants()` for price tier matching
## Data Flow
### Create Flow
1. Load/create DTO from session
2. Enrich with fresh API availability data
3. Auto-assign rooms, preselect mandatory services
4. Render cards → user edits participant → field handlers process → save to session
5. Validation check before step 3
6. Final submission to BPN API
### Edit Flow
1. Load booking from BPN API on first visit
2. Generate fingerprint of initial state for dirty detection
3. Store in session with `MODE_EDIT`
4. Apply mutability constraints via `EditFieldStateProvider`
5. Same card-based UI as create flow
6. Handle canceled participants (status 'S')
7. Display warning banner when unsaved changes detected
8. **Validation on submission**: Form wraps cards, validates all participants before API call
9. Submit changes back to BPN API (only if validation passes)
10. Staleness warnings after 5 minutes
11. **Session cleanup**: "Zurück" button clears session via `app_booking_edit_cancel` action
## Common Development Tasks
### Adding a New Field Handler
1. Create handler class extending `AbstractParticipantFieldHandler`
2. Implement `shouldProcess()`, `process()`, `getDependencies()`
3. Register in `services.yaml` with `participant.field_handler` tag
4. Add field options to `ParticipantFieldOptionsProvider`
5. Add conditional logic to `CreateFieldStateProvider` if needed
6. Update template with HTMX refresh triggers
### Adding a New Conditional Field
1. Create condition class implementing `FieldConditionInterface`
2. Register in `CreateFieldStateProvider::registerFieldStateConditions()`
3. Use composite conditions for complex logic (AND/OR/NOT)
### Debugging Field Handler Issues
- Check execution order in `ParticipantFieldHandlerRegistry` (topological sort)
- Verify `shouldProcess()` logic for mode awareness
- Ensure dependencies declared correctly
- Check sync pattern: only sync fields in original submission
### Writing Tests
- **Insurance IDs**: Always use strings, not integers (e.g., `'100'` not `100`)
- **Room properties**: Use `$label` property, not `$name`
- **Participant names**: Index 0 expects "Anmelder:in", others expect "Teilnehmer:in N" (1-based)
- **Mock dependencies**: Ensure all constructor dependencies have mocks. Note: `InsuranceService` is stateless with no dependencies (no mocking required)
- **Insurance mutability**: In edit mode, insurances are always readonly (API limitation)
## File Locations
### Key Design Patterns
**Service Availability in Edit Mode:**
Edit mode requires special handling of unavailable services to prevent fingerprint false positives:
```php
// ParticipantFieldOptionsProvider - intelligent availability filtering
$bookingDto->travel->getAdditionalServicesBySubTypes(
Constants::TOKEN_COURSES,
BookingDto::MODE_CREATE === $bookingDto->getMode() // Only filter in create mode
)
// shouldMakeServiceReadonly() - context-aware readonly logic
// In edit mode: service readonly ONLY if unavailable AND participant doesn't have it
private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex, string $fieldName): bool
{
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex);
}
// Edit mode: allow keeping services participant already has
if (null !== $service->available && $service->available > 0) {
return false;
}
$participantHasService = match ($fieldName) {
'courses' => $this->hasServiceById($participant->courses, $service->id),
'additionalServices' => $this->hasServiceById($participant->additionalServices, $service->id),
// ... other field types
};
return !$participantHasService; // Readonly only if participant doesn't have it
}
```
**Session Lifecycle Management:**
Edit mode session requires proper cleanup to prevent dirty state persistence:
- **Entry**: `IndexController::loadFormData()` initializes from API with original fingerprint
- **Exit (save)**: `IndexController::index()` clears session on successful API update
- **Exit (discard)**: `IndexController::reloadFromApi()` clears session and reloads from API
- **Exit (cancel)**: `IndexController::cancelEdit()` clears session when user clicks "Zurück"
- **Validation**: Dirty state persists across page refreshes until explicit action taken
### Controllers
**Create Namespace** (`src/Controller/Booking/Create/`):
- `IndexController.php` - Booking session initialization and error handling
- `Step1Controller.php` - Room selection and dates
- `Step2Controller.php` - Participant details with card-based UI
- `Step3Controller.php` - Payment method selection
- `Step4Controller.php` - Final confirmation with comprehensive pricing display and API submission
- Integrates `BookingPriceCalculatorService` for complete pricing breakdown
- Calculates per-participant individual prices via `calculateAllParticipantIndividualPrices()`
- Template displays three-level pricing: aggregate breakdown, per-participant totals, and inline service prices
- All service prices shown in blue (text-blue-700) with German formatting
- Zero prices hidden per project standards
- `SuccessController.php` - Success page after booking completion
**Edit Namespace** (`src/Controller/Booking/Edit/`):
- `IndexController.php` - Edit flow with card-based UI, validation, and session management
- `index()` - Main edit view with dirty state detection
- `editParticipant()` - Individual participant form editing
- `refreshParticipantForm()` - HTMX refresh without validation
- `reloadFromApi()` - Discard changes and reload from API
- `cancelEdit()` - Clean session exit to bookings list
**Root Booking Namespace** (`src/Controller/Booking/`):
- `IndexController.php` - Bookings list
- `DownloadController.php` - Booking document downloads
**Shared Traits** (`src/Controller/Booking/Traits/`):
- `ParticipantCardFlowTrait` - Card rendering, form creation, summary calculation
- `ParticipantValidationTrait` - Validation error extraction for card indicators
- `BookingCreateTrait` - Create flow helpers
- `BookingDataTrait` - API data fetching
- `BookingExceptionHandlerTrait` - Error handling
### Templates
**Create Flow**:
- `templates/booking/create/step_1.html.twig` - Room selection
- `templates/booking/create/step_2.html.twig` - Participant cards
- `templates/booking/create/step_3.html.twig` - Payment method
- `templates/booking/create/step_4.html.twig` - Final confirmation with comprehensive pricing display
- **Aggregate pricing breakdown**: Rooms and services grouped by type with totals
- **Per-participant cards**: Individual total price in header, all personal details and service selections
- **Inline service pricing**: Each service shows price in blue (€X,XX format) next to label
- **Payment summary**: Selected payment method and bank details (if debit)
- **Confirmation checkbox**: Final acceptance before API submission
- `templates/booking/create/success.html.twig` - Success page
- `templates/booking/create/error.html.twig` - Error page
**Edit Flow**:
- `templates/booking/edit/index.html.twig` - Edit with participant cards
**Shared Components**:
- `templates/booking/_participant_card.html.twig` - Individual participant card
- `templates/booking/_participant_form.html.twig` - Participant edit form
- `templates/booking/_summary.html.twig` - Pricing summary sidebar
### Services
- `src/Service/BookingService.php` - Core workflow and session management
- `src/Service/BookingFingerprintService.php` - Dirty state detection via SHA-256 fingerprinting
- `src/Service/ParticipantCardDataService.php` - Card data generation
- `src/Form/Service/ParticipantFieldHandlerRegistry.php` - Handler orchestration
- `src/Form/Service/ParticipantFieldOptionsProvider.php` - Field configuration with mode-aware availability
- `src/Form/Service/CreateFieldStateProvider.php` - Conditional field states
- `src/Form/Service/EditFieldStateProvider.php` - Edit mode mutability constraints
### Field Handlers
- `src/Form/Service/Participant*FieldHandler.php` (15+ handlers)
- Transportation, pickup, parking, ski pass, rentals, insurance, bulk insurance, etc.
## Testing
```bash
./vendor/bin/phpunit # All tests (182 tests, 465 assertions)
./vendor/bin/phpunit tests/Service/ # Service layer
./vendor/bin/phpunit tests/BusProNet/ # API integration
./vendor/bin/phpunit tests/Form/ # Form processing and field handlers
/opt/homebrew/bin/php-cs-fixer fix --rules=@Symfony # Code style (Symfony ruleset)
```
**Test Coverage Areas:**
- BusProNet data loaders and processors
- XML parsers (travels, hotels, bookings, insurances)
- Form DTOs and field handlers
- Service layer (pricing, insurance matching, room assignment)
- Conditional field system
- Utility classes
## Development Environment
```bash
ddev start # Start DDEV
ddev composer install # Install dependencies
ddev exec bin/console cache:clear # Clear cache
ddev logs # Read PHP error logs
ddev exec "php -r 'opcache_reset()';" # Clear opcache after code changes
```
## Important Notes
- **Room prices are per person**
- **Room model uses `$label` property** (not `$name`) - ensure test fixtures use correct property
- **Zero prices display without suffix** (e.g., "Vollpension" not "Vollpension (€0,00)")
- **All services sorted by price** (cheapest first) via `SortByPriceTrait`
- **Field sync pattern critical**: Only sync fields in original submission to avoid "extra fields" errors
- **Insurance handler requires mode awareness**: Skips processing in edit mode (API doesn't return insurance data)
- **Insurance IDs are strings**: All insurance IDs must be strings, not integers (type safety)
- **Clear opcache after code changes** affecting hydration or serialization
- **HTMX targeting consistency**: All swaps target `#main-content` with `innerHTML`, sidebar via OOB swap
- **Validation pattern**: Both create and edit flows use validation-only forms that wrap card UI for standard Symfony form handling
- **Card error indicators**: `ParticipantValidationTrait::extractParticipantErrorIndices()` parses form errors to highlight invalid participant cards
- **Edit mode service availability**: Services with `available <= 0` remain visible and editable for participants who already have them (prevents fingerprint false positives)
- **Session cleanup on exit**: All exit paths from edit mode (save, discard, cancel) properly clear session to reset dirty state
- **Participant naming convention**: Index 0 is "Anmelder:in", others are "Teilnehmer:in N" (1-based, not 0-based)
- **Floating-point price comparisons**: ALWAYS use `round($price, 2)` before comparing monetary values to avoid precision errors (see "Floating-Point Precision in Price Calculations" section)
## References
- **Project conventions**: `../CLAUDE.md` (root level)
- **User preferences**: `~/.claude/CLAUDE.md`
- **Documentation index**: `README.md` (this directory)