fix: consider parking space availability
This commit is contained in:
@@ -195,7 +195,6 @@ services:
|
|||||||
- 'App\Form\Service\ParticipantTransportationInboundFieldHandler'
|
- 'App\Form\Service\ParticipantTransportationInboundFieldHandler'
|
||||||
- 'App\Form\Service\ParticipantPickupFieldHandler'
|
- 'App\Form\Service\ParticipantPickupFieldHandler'
|
||||||
- 'App\Form\Service\ParticipantDropOffFieldHandler'
|
- 'App\Form\Service\ParticipantDropOffFieldHandler'
|
||||||
- 'App\Form\Service\ParticipantParkingFieldHandler'
|
|
||||||
- 'App\Form\Service\ParticipantRentalInsuranceFieldHandler'
|
- 'App\Form\Service\ParticipantRentalInsuranceFieldHandler'
|
||||||
- 'App\Form\Service\ParticipantLicensePlateFieldHandler'
|
- 'App\Form\Service\ParticipantLicensePlateFieldHandler'
|
||||||
# Complex handlers with dependencies - use service references
|
# Complex handlers with dependencies - use service references
|
||||||
@@ -205,6 +204,7 @@ services:
|
|||||||
- '@App\Form\Service\ParticipantInsuranceFieldHandler'
|
- '@App\Form\Service\ParticipantInsuranceFieldHandler'
|
||||||
- '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler'
|
- '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler'
|
||||||
- '@App\Form\Service\ParticipantPromoVoucherFieldHandler'
|
- '@App\Form\Service\ParticipantPromoVoucherFieldHandler'
|
||||||
|
- '@App\Form\Service\ParticipantParkingFieldHandler'
|
||||||
|
|
||||||
# Booking Status Rules
|
# Booking Status Rules
|
||||||
App\BusProNet\Service\StatusRule\Selection1473StatusRule: ~
|
App\BusProNet\Service\StatusRule\Selection1473StatusRule: ~
|
||||||
|
|||||||
@@ -647,13 +647,24 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
$fieldOptions = [
|
||||||
'label' => $this->serviceLabelFormatter->formatServiceLabelForServices(
|
'label' => $this->serviceLabelFormatter->formatServiceLabelForServices(
|
||||||
Constants::SERVICE_LABELS[Constants::TOKEN_PARKING],
|
Constants::SERVICE_LABELS[Constants::TOKEN_PARKING],
|
||||||
$parkingServices
|
$parkingServices
|
||||||
),
|
),
|
||||||
'required' => false,
|
'required' => false,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// There's only ever one parking type, so check readonly state against the single service
|
||||||
|
$parkingService = reset($parkingServices);
|
||||||
|
if ($this->shouldMakeServiceReadonly($parkingService, $bookingDto, $participantIndex, 'parking')) {
|
||||||
|
$fieldOptions['attr'] = [
|
||||||
|
'readonly' => true,
|
||||||
|
'data-tooltip' => 'ausgebucht',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fieldOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Bulk insurance booking checkbox (applicant only - controls insurance assignment for all participants)
|
// Bulk insurance booking checkbox (applicant only - controls insurance assignment for all participants)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\BusProNet\Utility\DirectionMapper;
|
|||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
|
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
|
||||||
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles parking service selection for self-organized transportation.
|
* Handles parking service selection for self-organized transportation.
|
||||||
@@ -20,6 +21,11 @@ use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
|
|||||||
*/
|
*/
|
||||||
class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler
|
class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
public function getFieldName(): string
|
public function getFieldName(): string
|
||||||
{
|
{
|
||||||
return 'parking';
|
return 'parking';
|
||||||
@@ -73,7 +79,22 @@ class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler
|
|||||||
|
|
||||||
// Store parking service object for pricing calculation
|
// Store parking service object for pricing calculation
|
||||||
if (true === $isParkingSelected) {
|
if (true === $isParkingSelected) {
|
||||||
$participant->parkingService = $this->findParkingService($bookingDto, $participantIndex);
|
$parkingService = $this->findParkingService($bookingDto, $participantIndex);
|
||||||
|
|
||||||
|
// Reject the selection if the service is sold out, in case the client
|
||||||
|
// bypassed the readonly UI state (e.g. a stale form or manual submission)
|
||||||
|
if (
|
||||||
|
null !== $parkingService
|
||||||
|
&& BookingDto::MODE_EDIT !== $bookingDto->getMode()
|
||||||
|
&& $this->serviceAvailabilityCalculator->isServiceUnavailable($parkingService->id, $bookingDto, $participantIndex)
|
||||||
|
) {
|
||||||
|
$participant->parking = false;
|
||||||
|
$participant->parkingService = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$participant->parkingService = $parkingService;
|
||||||
} else {
|
} else {
|
||||||
$participant->parkingService = null;
|
$participant->parkingService = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,11 @@ class ServiceAvailabilityCalculator
|
|||||||
$serviceUsage[$participant->skiPass->id] = ($serviceUsage[$participant->skiPass->id] ?? 0) + 1;
|
$serviceUsage[$participant->skiPass->id] = ($serviceUsage[$participant->skiPass->id] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parking service (single selection)
|
||||||
|
if (null !== $participant->parkingService && null !== $participant->parkingService->id) {
|
||||||
|
$serviceUsage[$participant->parkingService->id] = ($serviceUsage[$participant->parkingService->id] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
// Transportation services (single selection each)
|
// Transportation services (single selection each)
|
||||||
if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->id) {
|
if (null !== $participant->transportationOutbound && null !== $participant->transportationOutbound->id) {
|
||||||
$serviceUsage[$participant->transportationOutbound->id] = ($serviceUsage[$participant->transportationOutbound->id] ?? 0) + 1;
|
$serviceUsage[$participant->transportationOutbound->id] = ($serviceUsage[$participant->transportationOutbound->id] ?? 0) + 1;
|
||||||
@@ -231,6 +236,7 @@ class ServiceAvailabilityCalculator
|
|||||||
Constants::TOKEN_RENTALS,
|
Constants::TOKEN_RENTALS,
|
||||||
Constants::TOKEN_SKI_PASS,
|
Constants::TOKEN_SKI_PASS,
|
||||||
Constants::TOKEN_BOARD,
|
Constants::TOKEN_BOARD,
|
||||||
|
Constants::TOKEN_PARKING,
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($additionalServiceTokens as $token) {
|
foreach ($additionalServiceTokens as $token) {
|
||||||
|
|||||||
@@ -135,6 +135,64 @@ class ParticipantFieldOptionsProviderMandatoryServiceTest extends TestCase
|
|||||||
$this->assertSame('Parkplatz (€12,50)', $options['label']);
|
$this->assertSame('Parkplatz (€12,50)', $options['label']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testParkingIsReadonlyWhenSoldOut(): void
|
||||||
|
{
|
||||||
|
$parkingService = new Service();
|
||||||
|
$parkingService->id = 3;
|
||||||
|
$parkingService->label = 'Parkplatz';
|
||||||
|
$parkingService->subType = Constants::TOKEN_PARKING;
|
||||||
|
$parkingService->available = 0;
|
||||||
|
$parkingService->price = 12.5;
|
||||||
|
|
||||||
|
$travel = $this->createTravelWithAdditionalServices([$parkingService]);
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->index = 0;
|
||||||
|
$participant->dateOfBirth = $travel->dateFrom->modify('-25 years');
|
||||||
|
$bookingDto->participants[0] = $participant;
|
||||||
|
|
||||||
|
$serviceAvailabilityCalculator = $this->createMock(ServiceAvailabilityCalculator::class);
|
||||||
|
$serviceAvailabilityCalculator->method('isServiceUnavailable')
|
||||||
|
->with($parkingService->id, $bookingDto, 0)
|
||||||
|
->willReturn(true);
|
||||||
|
|
||||||
|
$provider = new ParticipantFieldOptionsProvider(
|
||||||
|
$serviceAvailabilityCalculator,
|
||||||
|
$this->createMock(InsuranceManager::class),
|
||||||
|
$this->createMock(BookingPriceCalculator::class),
|
||||||
|
new ServiceLabelFormatter(),
|
||||||
|
$this->createMock(TranslatorInterface::class)
|
||||||
|
);
|
||||||
|
|
||||||
|
$options = $provider->getFieldOptions('parking', $bookingDto, 0);
|
||||||
|
|
||||||
|
$this->assertTrue($options['attr']['readonly']);
|
||||||
|
$this->assertSame('ausgebucht', $options['attr']['data-tooltip']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParkingIsNotReadonlyWhenAvailable(): void
|
||||||
|
{
|
||||||
|
$parkingService = new Service();
|
||||||
|
$parkingService->id = 3;
|
||||||
|
$parkingService->label = 'Parkplatz';
|
||||||
|
$parkingService->subType = Constants::TOKEN_PARKING;
|
||||||
|
$parkingService->available = 10;
|
||||||
|
$parkingService->price = 12.5;
|
||||||
|
|
||||||
|
$travel = $this->createTravelWithAdditionalServices([$parkingService]);
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->index = 0;
|
||||||
|
$participant->dateOfBirth = $travel->dateFrom->modify('-25 years');
|
||||||
|
$bookingDto->participants[0] = $participant;
|
||||||
|
|
||||||
|
$options = $this->provider->getFieldOptions('parking', $bookingDto, 0);
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('attr', $options);
|
||||||
|
}
|
||||||
|
|
||||||
public function testNullParticipantReturnsEmptyOptions(): void
|
public function testNullParticipantReturnsEmptyOptions(): void
|
||||||
{
|
{
|
||||||
$mandatoryService = $this->createMandatoryService(1, 'Ortstaxe');
|
$mandatoryService = $this->createMandatoryService(1, 'Ortstaxe');
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ namespace App\Tests\Form\Service;
|
|||||||
use App\BusProNet\Model\Service;
|
use App\BusProNet\Model\Service;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\BusProNet\Utility\DirectionMapper;
|
use App\BusProNet\Utility\DirectionMapper;
|
||||||
|
use App\BusProNet\Constants;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Form\Service\ParticipantParkingFieldHandler;
|
use App\Form\Service\ParticipantParkingFieldHandler;
|
||||||
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class ParticipantParkingFieldHandlerTest extends TestCase
|
class ParticipantParkingFieldHandlerTest extends TestCase
|
||||||
@@ -18,7 +20,7 @@ class ParticipantParkingFieldHandlerTest extends TestCase
|
|||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->handler = new ParticipantParkingFieldHandler();
|
$this->handler = new ParticipantParkingFieldHandler(new ServiceAvailabilityCalculator());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGetFieldName(): void
|
public function testGetFieldName(): void
|
||||||
@@ -237,6 +239,50 @@ class ParticipantParkingFieldHandlerTest extends TestCase
|
|||||||
$this->assertFalse($participant->parking);
|
$this->assertFalse($participant->parking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testProcessFieldRejectsParkingWhenSoldOut(): void
|
||||||
|
{
|
||||||
|
$pkwService = $this->createTransportationService(DirectionMapper::SUBTYPE_CAR_API);
|
||||||
|
$parkingService = $this->createParkingService(available: 0);
|
||||||
|
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->dateOfBirth = new \DateTimeImmutable('-25 years');
|
||||||
|
$participant->transportationOutbound = $pkwService;
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->additionalServices = [$parkingService->id => $parkingService];
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
$bookingDto->participants = [$participant];
|
||||||
|
|
||||||
|
$submittedData = ['parking' => true];
|
||||||
|
|
||||||
|
$this->handler->processField($submittedData, $bookingDto, 0);
|
||||||
|
|
||||||
|
$this->assertFalse($participant->parking);
|
||||||
|
$this->assertNull($participant->parkingService);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testProcessFieldAllowsParkingWhenAvailable(): void
|
||||||
|
{
|
||||||
|
$pkwService = $this->createTransportationService(DirectionMapper::SUBTYPE_CAR_API);
|
||||||
|
$parkingService = $this->createParkingService(available: 5);
|
||||||
|
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->dateOfBirth = new \DateTimeImmutable('-25 years');
|
||||||
|
$participant->transportationOutbound = $pkwService;
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->additionalServices = [$parkingService->id => $parkingService];
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
$bookingDto->participants = [$participant];
|
||||||
|
|
||||||
|
$submittedData = ['parking' => true];
|
||||||
|
|
||||||
|
$this->handler->processField($submittedData, $bookingDto, 0);
|
||||||
|
|
||||||
|
$this->assertTrue($participant->parking);
|
||||||
|
$this->assertSame($parkingService, $participant->parkingService);
|
||||||
|
}
|
||||||
|
|
||||||
public function testProcessFieldSetsParkingToFalseWhenNotSelected(): void
|
public function testProcessFieldSetsParkingToFalseWhenNotSelected(): void
|
||||||
{
|
{
|
||||||
$pkwService = $this->createTransportationService(DirectionMapper::SUBTYPE_CAR_API);
|
$pkwService = $this->createTransportationService(DirectionMapper::SUBTYPE_CAR_API);
|
||||||
@@ -300,4 +346,15 @@ class ParticipantParkingFieldHandlerTest extends TestCase
|
|||||||
|
|
||||||
return $service;
|
return $service;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function createParkingService(int $available): Service
|
||||||
|
{
|
||||||
|
$service = new Service();
|
||||||
|
$service->id = 200;
|
||||||
|
$service->label = 'Parkplatz';
|
||||||
|
$service->subType = Constants::TOKEN_PARKING;
|
||||||
|
$service->available = $available;
|
||||||
|
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\Model\Service;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Model\ParticipantDto;
|
||||||
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ServiceAvailabilityCalculatorTest extends TestCase
|
||||||
|
{
|
||||||
|
private ServiceAvailabilityCalculator $calculator;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->calculator = new ServiceAvailabilityCalculator();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParkingServiceIsUnavailableWhenSoldOut(): void
|
||||||
|
{
|
||||||
|
$parkingService = $this->createParkingService(id: 1, available: 0);
|
||||||
|
$bookingDto = $this->createBookingDtoWithServices([$parkingService]);
|
||||||
|
|
||||||
|
$this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParkingServiceIsAvailableWhenQuotaRemains(): void
|
||||||
|
{
|
||||||
|
$parkingService = $this->createParkingService(id: 1, available: 5);
|
||||||
|
$bookingDto = $this->createBookingDtoWithServices([$parkingService]);
|
||||||
|
|
||||||
|
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParkingSelectedByOtherParticipantCountsTowardUsage(): void
|
||||||
|
{
|
||||||
|
$parkingService = $this->createParkingService(id: 1, available: 1);
|
||||||
|
$bookingDto = $this->createBookingDtoWithServices([$parkingService]);
|
||||||
|
|
||||||
|
$otherParticipant = new ParticipantDto();
|
||||||
|
$otherParticipant->parkingService = $parkingService;
|
||||||
|
$bookingDto->participants[1] = $otherParticipant;
|
||||||
|
|
||||||
|
// Only one parking slot remains, and another participant already claimed it,
|
||||||
|
// so it must show unavailable for the current participant (index 0)
|
||||||
|
$this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createParkingService(int $id, int $available): Service
|
||||||
|
{
|
||||||
|
$service = new Service();
|
||||||
|
$service->id = $id;
|
||||||
|
$service->label = 'Parkplatz';
|
||||||
|
$service->subType = Constants::TOKEN_PARKING;
|
||||||
|
$service->available = $available;
|
||||||
|
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createBookingDtoWithServices(array $services): BookingDto
|
||||||
|
{
|
||||||
|
$travel = new Travel();
|
||||||
|
|
||||||
|
$indexed = [];
|
||||||
|
foreach ($services as $service) {
|
||||||
|
$indexed[$service->id] = $service;
|
||||||
|
}
|
||||||
|
$travel->additionalServices = $indexed;
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
$bookingDto->participants[0] = new ParticipantDto();
|
||||||
|
|
||||||
|
return $bookingDto;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user