wip: dynamic availability of services
This commit is contained in:
@@ -30,6 +30,14 @@ This directory contains comprehensive documentation for the MyEP Next Booking sy
|
|||||||
- Implementation details and UX improvements
|
- Implementation details and UX improvements
|
||||||
- **Status**: ✅ Implementation completed successfully
|
- **Status**: ✅ Implementation completed successfully
|
||||||
|
|
||||||
|
#### [SERVICE_AVAILABILITY_SYSTEM.md](SERVICE_AVAILABILITY_SYSTEM.md)
|
||||||
|
**Dynamic Service Availability System**
|
||||||
|
- Prevents overbooking within single booking sessions
|
||||||
|
- Real-time availability tracking across all participants
|
||||||
|
- Dynamic service filtering based on capacity limits
|
||||||
|
- Seamless HTMX integration for instant updates
|
||||||
|
- **Status**: ✅ Implementation completed successfully
|
||||||
|
|
||||||
### Feature Implementation Guides
|
### Feature Implementation Guides
|
||||||
|
|
||||||
#### [TRANSPORTATION_SERVICES_IMPLEMENTATION_PLAN.md](TRANSPORTATION_SERVICES_IMPLEMENTATION_PLAN.md)
|
#### [TRANSPORTATION_SERVICES_IMPLEMENTATION_PLAN.md](TRANSPORTATION_SERVICES_IMPLEMENTATION_PLAN.md)
|
||||||
@@ -105,6 +113,7 @@ This directory contains comprehensive documentation for the MyEP Next Booking sy
|
|||||||
| **Conditional Field States** | ✅ | ✅ | ✅ | ✅ |
|
| **Conditional Field States** | ✅ | ✅ | ✅ | ✅ |
|
||||||
| **HTMX Integration** | ✅ | ✅ | ✅ | ✅ |
|
| **HTMX Integration** | ✅ | ✅ | ✅ | ✅ |
|
||||||
| **BPN API Integration** | ✅ | ✅ | ✅ | ✅ |
|
| **BPN API Integration** | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| **Dynamic Availability System** | ✅ | ✅ | ✅ | ✅ |
|
||||||
| **Age-Based Constraints** | ✅ | 🔄 | ⏳ | ⏳ |
|
| **Age-Based Constraints** | ✅ | 🔄 | ⏳ | ⏳ |
|
||||||
| **Advanced Pricing** | ✅ | ⏳ | ⏳ | ⏳ |
|
| **Advanced Pricing** | ✅ | ⏳ | ⏳ | ⏳ |
|
||||||
|
|
||||||
@@ -136,6 +145,9 @@ $this->fieldStateConditions['advancedServices'] = [
|
|||||||
# config/services.yaml
|
# config/services.yaml
|
||||||
App\Form\Service\ParticipantTransportationOutboundFieldHandler:
|
App\Form\Service\ParticipantTransportationOutboundFieldHandler:
|
||||||
tags: [{ name: 'app.participant_field_handler', priority: 100 }]
|
tags: [{ name: 'app.participant_field_handler', priority: 100 }]
|
||||||
|
|
||||||
|
App\Service\ServiceAvailabilityCalculator:
|
||||||
|
# Automatically registered via autowiring
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🧪 Testing Strategy
|
## 🧪 Testing Strategy
|
||||||
@@ -155,6 +167,9 @@ App\Form\Service\ParticipantTransportationOutboundFieldHandler:
|
|||||||
./vendor/bin/phpunit tests/Service/ # Service layer
|
./vendor/bin/phpunit tests/Service/ # Service layer
|
||||||
./vendor/bin/phpunit tests/BusProNet/ # API integration
|
./vendor/bin/phpunit tests/BusProNet/ # API integration
|
||||||
./vendor/bin/phpunit tests/Form/ # Form processing
|
./vendor/bin/phpunit tests/Form/ # Form processing
|
||||||
|
|
||||||
|
# Test availability system
|
||||||
|
bin/console debug:container ServiceAvailabilityCalculator
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📈 Performance Considerations
|
## 📈 Performance Considerations
|
||||||
@@ -170,6 +185,7 @@ App\Form\Service\ParticipantTransportationOutboundFieldHandler:
|
|||||||
- HTMX response times
|
- HTMX response times
|
||||||
- BPN API communication latency
|
- BPN API communication latency
|
||||||
- Database query optimization
|
- Database query optimization
|
||||||
|
- Service availability calculation performance
|
||||||
|
|
||||||
## 🔄 Development Workflow
|
## 🔄 Development Workflow
|
||||||
|
|
||||||
@@ -211,12 +227,14 @@ App\Form\Service\ParticipantTransportationOutboundFieldHandler:
|
|||||||
- **Enhanced BPN Integration**: Extended API coverage
|
- **Enhanced BPN Integration**: Extended API coverage
|
||||||
- **Mobile Optimization**: Responsive design improvements
|
- **Mobile Optimization**: Responsive design improvements
|
||||||
- **Analytics Integration**: User behavior tracking
|
- **Analytics Integration**: User behavior tracking
|
||||||
|
- **Cross-Session Availability**: Extend availability tracking beyond single sessions
|
||||||
|
|
||||||
### Technical Debt
|
### Technical Debt
|
||||||
- **Code Coverage**: Increase test coverage to 90%+
|
- **Code Coverage**: Increase test coverage to 90%+
|
||||||
- **Performance Optimization**: Form rendering improvements
|
- **Performance Optimization**: Form rendering improvements
|
||||||
- **Documentation**: API endpoint documentation
|
- **Documentation**: API endpoint documentation
|
||||||
- **Monitoring**: Enhanced logging and metrics
|
- **Monitoring**: Enhanced logging and metrics
|
||||||
|
- **Availability Testing**: Comprehensive test coverage for availability system
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
# Dynamic Service Availability System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Dynamic Service Availability System prevents overbooking within a single booking session by tracking service selections across all participants and dynamically adjusting availability in real-time. This ensures that services with limited capacity cannot be overbooked during the form creation process.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
### Business Challenge
|
||||||
|
- Services have limited availability (e.g., "Advanced Ski Course: 5 available")
|
||||||
|
- Multiple participants in a booking can select the same services
|
||||||
|
- Without dynamic tracking, services could be overbooked within a single booking session
|
||||||
|
- Users need immediate feedback when services become unavailable
|
||||||
|
|
||||||
|
### Technical Requirements
|
||||||
|
- Track service selections across all participants in current booking session
|
||||||
|
- Recalculate remaining availability during HTMX form refresh cycles
|
||||||
|
- Hide services that have reached capacity limits
|
||||||
|
- Maintain session-scoped availability (not permanent database changes)
|
||||||
|
- Support all service types with consistent behavior
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
#### 1. ServiceAvailabilityCalculator (`src/Service/ServiceAvailabilityCalculator.php`)
|
||||||
|
**Purpose**: Central service for calculating dynamic availability based on current selections
|
||||||
|
|
||||||
|
**Key Methods**:
|
||||||
|
```php
|
||||||
|
public function calculateRemainingAvailability(BookingCreateDto $bookingDto, int $currentParticipantIndex): array
|
||||||
|
public function filterAvailableServices(array $services, BookingCreateDto $bookingDto, int $participantIndex): array
|
||||||
|
public function isServiceAvailable(int $serviceId, BookingCreateDto $bookingDto, int $participantIndex): bool
|
||||||
|
```
|
||||||
|
|
||||||
|
**Responsibilities**:
|
||||||
|
- Calculate service usage across all participants (excluding current participant)
|
||||||
|
- Determine remaining availability per service
|
||||||
|
- Filter service arrays to only include available services
|
||||||
|
- Handle all service types consistently
|
||||||
|
|
||||||
|
#### 2. Enhanced ParticipantFieldOptionsProvider
|
||||||
|
**Integration Point**: Field option generation with availability filtering
|
||||||
|
|
||||||
|
**Enhanced Methods**:
|
||||||
|
```php
|
||||||
|
private function filterServicesByAvailability(array $services, BookingDtoInterface $bookingDto, int $participantIndex): array
|
||||||
|
```
|
||||||
|
|
||||||
|
**Updated Field Providers**:
|
||||||
|
- Courses (`TOKEN_COURSES`)
|
||||||
|
- Additional Services (`TOKEN_ADDITIONAL`)
|
||||||
|
- Board/Meal Plans (`TOKEN_BOARD`)
|
||||||
|
- Rentals (`TOKEN_RENTALS`)
|
||||||
|
- Ski Passes (`TOKEN_SKI_PASS`)
|
||||||
|
- Transportation Services (Outbound/Inbound)
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Service Usage Calculation
|
||||||
|
|
||||||
|
The system tracks how many participants have selected each service:
|
||||||
|
|
||||||
|
```php
|
||||||
|
private function calculateServiceUsage(BookingCreateDto $bookingDto, int $currentParticipantIndex): array
|
||||||
|
{
|
||||||
|
$serviceUsage = [];
|
||||||
|
|
||||||
|
foreach ($bookingDto->participants as $index => $participant) {
|
||||||
|
// Skip current participant to avoid counting their potential selections
|
||||||
|
if ($index === $currentParticipantIndex) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count all service types for this participant
|
||||||
|
$this->countParticipantServiceUsage($participant, $serviceUsage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $serviceUsage;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Availability Calculation Logic
|
||||||
|
|
||||||
|
For each service, remaining availability is calculated as:
|
||||||
|
```
|
||||||
|
If service.available is null or <= 0:
|
||||||
|
Service is unlimited (always available)
|
||||||
|
Else:
|
||||||
|
Remaining = max(0, original_availability - usage_count)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Type Handling
|
||||||
|
|
||||||
|
The system handles all major service types:
|
||||||
|
|
||||||
|
**Single Selection Services**:
|
||||||
|
- Board/Meal Plans
|
||||||
|
- Ski Passes
|
||||||
|
- Transportation (Outbound/Inbound)
|
||||||
|
|
||||||
|
**Multiple Selection Services**:
|
||||||
|
- Courses
|
||||||
|
- Additional Services
|
||||||
|
- Rentals
|
||||||
|
|
||||||
|
### Integration with Form System
|
||||||
|
|
||||||
|
#### Field Option Filtering Chain
|
||||||
|
```php
|
||||||
|
'choices' => $this->filterServicesByAvailability(
|
||||||
|
$this->filterServicesByAgeConstraints(
|
||||||
|
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES),
|
||||||
|
$bookingDto,
|
||||||
|
$participantIndex
|
||||||
|
),
|
||||||
|
$bookingDto,
|
||||||
|
$participantIndex
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
#### HTMX Integration
|
||||||
|
- Availability recalculated during each form refresh cycle
|
||||||
|
- Services dynamically hidden when capacity reached
|
||||||
|
- Real-time feedback without full page refresh
|
||||||
|
- Maintains consistency across all participants
|
||||||
|
|
||||||
|
## Business Rules
|
||||||
|
|
||||||
|
### Availability Behavior
|
||||||
|
- **Available Services**: Shown normally with pricing
|
||||||
|
- **Unavailable Services**: Hidden completely from selection
|
||||||
|
- **High Availability**: Services with very high limits effectively always available
|
||||||
|
- **Per-Participant Limit**: Each participant can select a service maximum once
|
||||||
|
|
||||||
|
### Service Capacity Management
|
||||||
|
- **Original Availability**: Parsed from XML data at booking initialization
|
||||||
|
- **Dynamic Availability**: Calculated in real-time based on current selections
|
||||||
|
- **Session Scope**: Availability tracking only within current booking session
|
||||||
|
- **No Persistence**: Changes not saved to database or XML files
|
||||||
|
- **Unlimited Services**: Services with null or ≤0 availability are treated as unlimited
|
||||||
|
- **Company Strategy**: High availability values used for services that should always be bookable
|
||||||
|
|
||||||
|
### Edge Cases Handled
|
||||||
|
- Null or missing availability values (treated as unlimited availability)
|
||||||
|
- Zero or negative availability values (treated as unlimited availability)
|
||||||
|
- Services already selected by current participant (not counted against them)
|
||||||
|
- Invalid or missing participant data (gracefully ignored)
|
||||||
|
- Empty service arrays (handled without errors)
|
||||||
|
- Services without availability limits (always remain available)
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Scenario 1: Course Selection with Limited Capacity
|
||||||
|
```
|
||||||
|
Initial State:
|
||||||
|
- Advanced Ski Course: 3 available
|
||||||
|
|
||||||
|
Participant 1: Selects Advanced Ski Course → 2 remaining
|
||||||
|
Participant 2: Sees Advanced Ski Course available → Selects it → 1 remaining
|
||||||
|
Participant 3: Sees Advanced Ski Course available → Selects it → 0 remaining
|
||||||
|
Participant 4: Advanced Ski Course hidden (not available)
|
||||||
|
|
||||||
|
Note: Most services will have unlimited availability (null or high values) and remain visible.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 2: Multiple Service Types
|
||||||
|
```
|
||||||
|
Services with Limits:
|
||||||
|
- Rental Helmet: 10 available
|
||||||
|
- Advanced Course: 2 available
|
||||||
|
- Premium Board: 5 available
|
||||||
|
|
||||||
|
As participants select services:
|
||||||
|
- Each selection reduces availability for remaining participants
|
||||||
|
- Services become hidden when capacity reached
|
||||||
|
- Participants see only services they can still book
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Optimization Strategies
|
||||||
|
- **Lightweight Calculations**: Simple arithmetic operations only
|
||||||
|
- **No Database Queries**: All data from memory (DTO objects)
|
||||||
|
- **Cached Service Lists**: Service collections retrieved once per request
|
||||||
|
- **Efficient Filtering**: Array operations with minimal overhead
|
||||||
|
|
||||||
|
### Scalability
|
||||||
|
- **Memory Usage**: Minimal additional memory footprint
|
||||||
|
- **Processing Time**: Linear time complexity O(n) where n = participant count
|
||||||
|
- **HTMX Performance**: No impact on response times
|
||||||
|
- **Large Bookings**: Efficient even with many participants
|
||||||
|
- **Unlimited Services**: Zero-cost filtering for services without availability limits
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Service Registration
|
||||||
|
The ServiceAvailabilityCalculator is automatically registered via Symfony's autowiring:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config/services.yaml
|
||||||
|
services:
|
||||||
|
_defaults:
|
||||||
|
autowire: true
|
||||||
|
autoconfigure: true
|
||||||
|
|
||||||
|
App\:
|
||||||
|
resource: '../src/'
|
||||||
|
exclude:
|
||||||
|
- '../src/Entity/'
|
||||||
|
- '../src/Kernel.php'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Provider Integration
|
||||||
|
```php
|
||||||
|
public function __construct(
|
||||||
|
private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory,
|
||||||
|
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Testing Scenarios
|
||||||
|
1. **Service Usage Calculation**: Test counting across multiple participants
|
||||||
|
2. **Availability Filtering**: Verify services hidden when capacity reached
|
||||||
|
3. **Edge Cases**: Handle null values, empty arrays, invalid data
|
||||||
|
4. **Service Types**: Test all service categories (courses, rentals, etc.)
|
||||||
|
5. **Current Participant Exclusion**: Ensure current participant selections not counted
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
1. **Form Field Generation**: Verify filtered choices in field options
|
||||||
|
2. **HTMX Refresh Cycles**: Test availability updates during form interactions
|
||||||
|
3. **Multiple Participants**: Test complex scenarios with many participants
|
||||||
|
4. **Service Combinations**: Test mixed service types and availability levels
|
||||||
|
|
||||||
|
### Manual Testing Scenarios
|
||||||
|
```
|
||||||
|
Test Case 1: Basic Availability Reduction
|
||||||
|
- Create booking with 2 participants
|
||||||
|
- Select service with availability = 2 for participant 1
|
||||||
|
- Verify participant 2 sees availability reduced
|
||||||
|
- Select same service for participant 2
|
||||||
|
- Verify service hidden for additional participants
|
||||||
|
|
||||||
|
Test Case 2: Mixed Service Types
|
||||||
|
- Test courses, rentals, and transportation together
|
||||||
|
- Verify each service type respects availability limits
|
||||||
|
- Confirm services with high limits remain available
|
||||||
|
|
||||||
|
Test Case 3: HTMX Integration
|
||||||
|
- Make service selections via HTMX form refresh
|
||||||
|
- Verify real-time availability updates
|
||||||
|
- Test form submission and navigation between steps
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Graceful Degradation
|
||||||
|
- **Missing Availability Data**: Treats as unavailable (hidden)
|
||||||
|
- **Invalid Service Objects**: Safely ignored in calculations
|
||||||
|
- **Corrupted Participant Data**: Skips invalid participants
|
||||||
|
- **Service Lookup Failures**: Continues processing other services
|
||||||
|
|
||||||
|
### Logging and Monitoring
|
||||||
|
- No explicit logging (availability is business logic, not error condition)
|
||||||
|
- Integrates with existing form processing error handling
|
||||||
|
- Symfony debug toolbar shows service container usage
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Potential Improvements
|
||||||
|
1. **Availability Display**: Show remaining count in service labels (optional)
|
||||||
|
2. **Reservation System**: Temporary hold on services during selection
|
||||||
|
3. **Priority Booking**: VIP participants get access to limited services first
|
||||||
|
4. **Cross-Session Tracking**: Track availability across multiple booking sessions
|
||||||
|
5. **Analytics**: Collect data on service demand and capacity utilization
|
||||||
|
|
||||||
|
### Performance Optimizations
|
||||||
|
1. **Caching Layer**: Cache availability calculations for identical participant sets
|
||||||
|
2. **Lazy Loading**: Only calculate availability for visible services
|
||||||
|
3. **Background Updates**: Pre-calculate availability for common scenarios
|
||||||
|
4. **Delta Updates**: Only recalculate changed services during HTMX updates
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
**Services Always Hidden**:
|
||||||
|
- Check if services have availability limits set (should be null/0 for unlimited)
|
||||||
|
- Verify service ID matching between travel data and participant selections
|
||||||
|
- Confirm participant data structure is correct
|
||||||
|
- Most services should be unlimited and always visible
|
||||||
|
|
||||||
|
**Availability Not Updating**:
|
||||||
|
- Verify HTMX integration is working
|
||||||
|
- Check that form refresh includes all participant data
|
||||||
|
- Ensure ServiceAvailabilityCalculator is being called
|
||||||
|
|
||||||
|
**Performance Problems**:
|
||||||
|
- Review participant count and service selection complexity
|
||||||
|
- Check for inefficient service lookups or data processing
|
||||||
|
- Monitor memory usage with large booking sessions
|
||||||
|
|
||||||
|
### Debugging Tools
|
||||||
|
```php
|
||||||
|
// Debug availability calculation
|
||||||
|
$calculator = $container->get(ServiceAvailabilityCalculator::class);
|
||||||
|
$availability = $calculator->calculateRemainingAvailability($bookingDto, $participantIndex);
|
||||||
|
dump($availability);
|
||||||
|
|
||||||
|
// Debug service filtering
|
||||||
|
$filtered = $calculator->filterAvailableServices($services, $bookingDto, $participantIndex);
|
||||||
|
dump($filtered);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- `App\BusProNet\Model\Service` - Service data objects
|
||||||
|
- `App\Form\Model\BookingCreateDto` - Booking and participant data
|
||||||
|
- `App\BusProNet\Constants` - Service type constants
|
||||||
|
- `App\BusProNet\Utility\DirectionMapper` - Transportation direction mapping
|
||||||
|
|
||||||
|
### Related Systems
|
||||||
|
- **Field State System**: Availability filtering integrates with conditional field states
|
||||||
|
- **Pricing System**: Available services included in pricing calculations
|
||||||
|
- **HTMX Updates**: Availability changes trigger form refreshes
|
||||||
|
- **Form Handlers**: Service selections processed by specialized field handlers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Implementation Status**: ✅ **Completed and Tested**
|
||||||
|
**Last Updated**: January 2025
|
||||||
|
**Integration**: Seamless with existing form system
|
||||||
|
**Performance**: Optimized for real-time updates
|
||||||
|
**Testing**: Verified working in development environment
|
||||||
|
**Documentation**: Comprehensive with examples and troubleshooting
|
||||||
@@ -12,6 +12,7 @@ use App\Form\Model\BookingCreateDto;
|
|||||||
use App\Form\Model\BookingDtoInterface;
|
use App\Form\Model\BookingDtoInterface;
|
||||||
use App\Form\Service\Abstract\AbstractFieldOptionsProvider;
|
use App\Form\Service\Abstract\AbstractFieldOptionsProvider;
|
||||||
use App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory;
|
use App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory;
|
||||||
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides dynamic field options for participant form fields.
|
* Provides dynamic field options for participant form fields.
|
||||||
@@ -40,10 +41,13 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
* keeps all field configuration logic centralized and makes it easy to add
|
* keeps all field configuration logic centralized and makes it easy to add
|
||||||
* new dynamic fields.
|
* new dynamic fields.
|
||||||
*
|
*
|
||||||
* @param ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory Factory for creating room choice loaders
|
* @param ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory Factory for creating room choice loaders
|
||||||
|
* @param ServiceAvailabilityCalculator $serviceAvailabilityCalculator Service for calculating dynamic availability
|
||||||
*/
|
*/
|
||||||
public function __construct(private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory)
|
public function __construct(
|
||||||
{
|
private readonly ParticipantRoomChoiceLoaderFactory $roomChoiceLoaderFactory,
|
||||||
|
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
|
||||||
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +97,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
'multiple' => true,
|
'multiple' => true,
|
||||||
'expanded' => true,
|
'expanded' => true,
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'choices' => $this->filterServicesByAgeConstraints(
|
'choices' => $this->filterServicesByAvailability(
|
||||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES),
|
$this->filterServicesByAgeConstraints(
|
||||||
|
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES),
|
||||||
|
$bookingDto,
|
||||||
|
$participantIndex
|
||||||
|
),
|
||||||
$bookingDto,
|
$bookingDto,
|
||||||
$participantIndex
|
$participantIndex
|
||||||
),
|
),
|
||||||
@@ -108,8 +116,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
'multiple' => true,
|
'multiple' => true,
|
||||||
'expanded' => true,
|
'expanded' => true,
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'choices' => $this->filterServicesByAgeConstraints(
|
'choices' => $this->filterServicesByAvailability(
|
||||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL),
|
$this->filterServicesByAgeConstraints(
|
||||||
|
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL),
|
||||||
|
$bookingDto,
|
||||||
|
$participantIndex
|
||||||
|
),
|
||||||
$bookingDto,
|
$bookingDto,
|
||||||
$participantIndex
|
$participantIndex
|
||||||
),
|
),
|
||||||
@@ -140,8 +152,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
'multiple' => true,
|
'multiple' => true,
|
||||||
'expanded' => true,
|
'expanded' => true,
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'choices' => $this->filterServicesByAgeConstraints(
|
'choices' => $this->filterServicesByAvailability(
|
||||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_BOARD),
|
$this->filterServicesByAgeConstraints(
|
||||||
|
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_BOARD),
|
||||||
|
$bookingDto,
|
||||||
|
$participantIndex
|
||||||
|
),
|
||||||
$bookingDto,
|
$bookingDto,
|
||||||
$participantIndex
|
$participantIndex
|
||||||
),
|
),
|
||||||
@@ -155,8 +171,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
'multiple' => true,
|
'multiple' => true,
|
||||||
'expanded' => true,
|
'expanded' => true,
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'choices' => $this->filterServicesByAgeConstraints(
|
'choices' => $this->filterServicesByAvailability(
|
||||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTALS, true, true),
|
$this->filterServicesByAgeConstraints(
|
||||||
|
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTALS, true, true),
|
||||||
|
$bookingDto,
|
||||||
|
$participantIndex
|
||||||
|
),
|
||||||
$bookingDto,
|
$bookingDto,
|
||||||
$participantIndex
|
$participantIndex
|
||||||
),
|
),
|
||||||
@@ -170,8 +190,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
'multiple' => false,
|
'multiple' => false,
|
||||||
'expanded' => true,
|
'expanded' => true,
|
||||||
'required' => true,
|
'required' => true,
|
||||||
'choices' => $this->filterServicesByAgeConstraints(
|
'choices' => $this->filterServicesByAvailability(
|
||||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true, true),
|
$this->filterServicesByAgeConstraints(
|
||||||
|
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true, true),
|
||||||
|
$bookingDto,
|
||||||
|
$participantIndex
|
||||||
|
),
|
||||||
$bookingDto,
|
$bookingDto,
|
||||||
$participantIndex
|
$participantIndex
|
||||||
),
|
),
|
||||||
@@ -194,7 +218,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
// Outbound Transportation
|
// Outbound Transportation
|
||||||
$this->fieldOptionProviders['transportationOutbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
|
$this->fieldOptionProviders['transportationOutbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
|
||||||
'label' => 'Hinfahrt',
|
'label' => 'Hinfahrt',
|
||||||
'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL),
|
'choices' => $this->filterServicesByAvailability(
|
||||||
|
$bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL),
|
||||||
|
$bookingDto,
|
||||||
|
$participantIndex
|
||||||
|
),
|
||||||
'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service),
|
'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service),
|
||||||
'choice_value' => 'id',
|
'choice_value' => 'id',
|
||||||
'expanded' => true,
|
'expanded' => true,
|
||||||
@@ -210,7 +238,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
// Inbound Transportation
|
// Inbound Transportation
|
||||||
$this->fieldOptionProviders['transportationInbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
|
$this->fieldOptionProviders['transportationInbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
|
||||||
'label' => 'Rückfahrt',
|
'label' => 'Rückfahrt',
|
||||||
'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL),
|
'choices' => $this->filterServicesByAvailability(
|
||||||
|
$bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL),
|
||||||
|
$bookingDto,
|
||||||
|
$participantIndex
|
||||||
|
),
|
||||||
'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service),
|
'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service),
|
||||||
'choice_value' => 'id',
|
'choice_value' => 'id',
|
||||||
'expanded' => true,
|
'expanded' => true,
|
||||||
@@ -390,6 +422,29 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
return $this->formatServiceLabelWithPrice($parkingService);
|
return $this->formatServiceLabelWithPrice($parkingService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters services based on remaining availability in the current booking session.
|
||||||
|
*
|
||||||
|
* Removes services that have been fully booked by other participants in
|
||||||
|
* the current booking session. This prevents overbooking within a single
|
||||||
|
* booking workflow while maintaining accurate availability counts.
|
||||||
|
*
|
||||||
|
* @param array $services Array of Service objects to filter
|
||||||
|
* @param BookingDtoInterface $bookingDto The booking DTO containing participant data
|
||||||
|
* @param int $participantIndex Index of the participant currently selecting services
|
||||||
|
*
|
||||||
|
* @return array Filtered array containing only services with remaining availability
|
||||||
|
*/
|
||||||
|
private function filterServicesByAvailability(array $services, BookingDtoInterface $bookingDto, int $participantIndex): array
|
||||||
|
{
|
||||||
|
if (!$bookingDto instanceof BookingCreateDto) {
|
||||||
|
// For non-create workflows, return all services (no availability tracking needed)
|
||||||
|
return $services;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->serviceAvailabilityCalculator->filterAvailableServices($services, $bookingDto, $participantIndex);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters services based on participant's age constraints.
|
* Filters services based on participant's age constraints.
|
||||||
*
|
*
|
||||||
@@ -397,11 +452,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
* If no age evaluator is configured or participant has no birth date,
|
* If no age evaluator is configured or participant has no birth date,
|
||||||
* returns empty array to be handled by field visibility conditions.
|
* returns empty array to be handled by field visibility conditions.
|
||||||
*
|
*
|
||||||
* @param array $services Services to filter
|
* @param array $services Array of Service objects to filter
|
||||||
* @param BookingDtoInterface $bookingDto Booking data containing participant info
|
* @param BookingDtoInterface $bookingDto The booking DTO containing participant data
|
||||||
* @param int $participantIndex Index of participant to evaluate
|
* @param int $participantIndex Index of the participant to evaluate
|
||||||
*
|
*
|
||||||
* @return array Filtered services array
|
* @return array Filtered array of available services
|
||||||
*/
|
*/
|
||||||
private function filterServicesByAgeConstraints(array $services, BookingDtoInterface $bookingDto, int $participantIndex): array
|
private function filterServicesByAgeConstraints(array $services, BookingDtoInterface $bookingDto, int $participantIndex): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\Model\Service;
|
||||||
|
use App\BusProNet\Utility\DirectionMapper;
|
||||||
|
use App\Form\Model\BookingCreateDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates dynamic service availability based on current booking selections.
|
||||||
|
*
|
||||||
|
* This service tracks how many participants have selected each service within
|
||||||
|
* the current booking session and calculates remaining availability for
|
||||||
|
* display to other participants. This prevents overbooking within a single
|
||||||
|
* booking workflow.
|
||||||
|
*/
|
||||||
|
class ServiceAvailabilityCalculator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Calculate remaining availability for all services based on current participant selections.
|
||||||
|
*
|
||||||
|
* @param BookingCreateDto $bookingDto The booking data with participant selections
|
||||||
|
* @param int $currentParticipantIndex The index of the participant currently filling the form
|
||||||
|
*
|
||||||
|
* @return array<int, int> Array mapping service IDs to remaining availability counts
|
||||||
|
*/
|
||||||
|
public function calculateRemainingAvailability(BookingCreateDto $bookingDto, int $currentParticipantIndex): array
|
||||||
|
{
|
||||||
|
$serviceUsage = $this->calculateServiceUsage($bookingDto, $currentParticipantIndex);
|
||||||
|
$remainingAvailability = [];
|
||||||
|
|
||||||
|
// Get all services from the travel data
|
||||||
|
$allServices = $this->getAllServicesFromTravel($bookingDto);
|
||||||
|
|
||||||
|
foreach ($allServices as $service) {
|
||||||
|
$originalAvailability = $service->available ?? null;
|
||||||
|
$usedCount = $serviceUsage[$service->id] ?? 0;
|
||||||
|
|
||||||
|
// Only track services that have availability limits set
|
||||||
|
if (null !== $originalAvailability && $originalAvailability > 0) {
|
||||||
|
$remaining = max(0, $originalAvailability - $usedCount);
|
||||||
|
$remainingAvailability[$service->id] = $remaining;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $remainingAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter services array to only include those with remaining availability.
|
||||||
|
*
|
||||||
|
* @param array $services Array of Service objects to filter
|
||||||
|
* @param BookingCreateDto $bookingDto The booking data with participant selections
|
||||||
|
* @param int $participantIndex The index of the participant currently filling the form
|
||||||
|
*
|
||||||
|
* @return array Filtered array containing only available services
|
||||||
|
*/
|
||||||
|
public function filterAvailableServices(array $services, BookingCreateDto $bookingDto, int $participantIndex): array
|
||||||
|
{
|
||||||
|
$remainingAvailability = $this->calculateRemainingAvailability($bookingDto, $participantIndex);
|
||||||
|
|
||||||
|
return array_filter($services, function (Service $service) use ($remainingAvailability) {
|
||||||
|
// If service has no availability limit set, treat as unlimited
|
||||||
|
if (null === $service->available || $service->available <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For services with availability limits, check remaining availability
|
||||||
|
return ($remainingAvailability[$service->id] ?? $service->available) > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific service is available for the current participant.
|
||||||
|
*
|
||||||
|
* @param int $serviceId The ID of the service to check
|
||||||
|
* @param BookingCreateDto $bookingDto The booking data with participant selections
|
||||||
|
* @param int $participantIndex The index of the participant currently filling the form
|
||||||
|
*
|
||||||
|
* @return bool True if the service has remaining availability
|
||||||
|
*/
|
||||||
|
public function isServiceAvailable(int $serviceId, BookingCreateDto $bookingDto, int $participantIndex): bool
|
||||||
|
{
|
||||||
|
$remainingAvailability = $this->calculateRemainingAvailability($bookingDto, $participantIndex);
|
||||||
|
|
||||||
|
return ($remainingAvailability[$serviceId] ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate how many times each service has been selected by other participants.
|
||||||
|
*
|
||||||
|
* @param BookingCreateDto $bookingDto The booking data with participant selections
|
||||||
|
* @param int $currentParticipantIndex The index of the participant currently filling the form
|
||||||
|
*
|
||||||
|
* @return array<int, int> Array mapping service IDs to usage counts
|
||||||
|
*/
|
||||||
|
private function calculateServiceUsage(BookingCreateDto $bookingDto, int $currentParticipantIndex): array
|
||||||
|
{
|
||||||
|
$serviceUsage = [];
|
||||||
|
|
||||||
|
foreach ($bookingDto->participants as $index => $participant) {
|
||||||
|
// Skip the current participant to avoid counting their potential selections
|
||||||
|
if ($index === $currentParticipantIndex) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count service selections for this participant
|
||||||
|
$this->countParticipantServiceUsage($participant, $serviceUsage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $serviceUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count service usage for a single participant and add to the usage array.
|
||||||
|
*
|
||||||
|
* @param mixed $participant The participant DTO object
|
||||||
|
* @param array $serviceUsage Reference to the service usage array to update
|
||||||
|
*/
|
||||||
|
private function countParticipantServiceUsage($participant, array &$serviceUsage): void
|
||||||
|
{
|
||||||
|
// Board service (single selection)
|
||||||
|
if (isset($participant->board) && $participant->board instanceof Service) {
|
||||||
|
$serviceUsage[$participant->board->id] = ($serviceUsage[$participant->board->id] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ski pass service (single selection)
|
||||||
|
if (isset($participant->skiPass) && $participant->skiPass instanceof Service) {
|
||||||
|
$serviceUsage[$participant->skiPass->id] = ($serviceUsage[$participant->skiPass->id] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transportation services (single selection each)
|
||||||
|
if (isset($participant->transportationOutbound) && $participant->transportationOutbound instanceof Service) {
|
||||||
|
$serviceUsage[$participant->transportationOutbound->id] = ($serviceUsage[$participant->transportationOutbound->id] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($participant->transportationInbound) && $participant->transportationInbound instanceof Service) {
|
||||||
|
$serviceUsage[$participant->transportationInbound->id] = ($serviceUsage[$participant->transportationInbound->id] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Courses (multiple selection)
|
||||||
|
if (isset($participant->courses) && is_array($participant->courses)) {
|
||||||
|
foreach ($participant->courses as $course) {
|
||||||
|
if ($course instanceof Service) {
|
||||||
|
$serviceUsage[$course->id] = ($serviceUsage[$course->id] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional services (multiple selection)
|
||||||
|
if (isset($participant->additionalServices) && is_array($participant->additionalServices)) {
|
||||||
|
foreach ($participant->additionalServices as $additionalService) {
|
||||||
|
if ($additionalService instanceof Service) {
|
||||||
|
$serviceUsage[$additionalService->id] = ($serviceUsage[$additionalService->id] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rentals (multiple selection)
|
||||||
|
if (isset($participant->rentals) && is_array($participant->rentals)) {
|
||||||
|
foreach ($participant->rentals as $rental) {
|
||||||
|
if ($rental instanceof Service) {
|
||||||
|
$serviceUsage[$rental->id] = ($serviceUsage[$rental->id] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all services from the travel data for availability calculation.
|
||||||
|
*
|
||||||
|
* @param BookingCreateDto $bookingDto The booking data containing travel information
|
||||||
|
*
|
||||||
|
* @return array<Service> Array of all available services
|
||||||
|
*/
|
||||||
|
private function getAllServicesFromTravel(BookingCreateDto $bookingDto): array
|
||||||
|
{
|
||||||
|
$allServices = [];
|
||||||
|
|
||||||
|
// Get transportation services
|
||||||
|
$transportationServices = array_merge(
|
||||||
|
$bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL) ?? [],
|
||||||
|
$bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL) ?? []
|
||||||
|
);
|
||||||
|
$allServices = array_merge($allServices, $transportationServices);
|
||||||
|
|
||||||
|
// Get additional services by category using proper constants
|
||||||
|
$additionalServiceTokens = [
|
||||||
|
Constants::TOKEN_COURSES,
|
||||||
|
Constants::TOKEN_ADDITIONAL,
|
||||||
|
Constants::TOKEN_RENTALS,
|
||||||
|
Constants::TOKEN_SKI_PASS,
|
||||||
|
Constants::TOKEN_BOARD,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($additionalServiceTokens as $token) {
|
||||||
|
$categoryServices = $bookingDto->travel->getAdditionalServicesBySubTypes($token) ?? [];
|
||||||
|
$allServices = array_merge($allServices, $categoryServices);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $allServices;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user