Compare commits
4
Commits
97d98f7f12
...
8ff13614d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ff13614d2 | ||
|
|
331c88c38e | ||
|
|
c31d086356 | ||
|
|
dde36ccba2 |
No files matched your search
@@ -84,7 +84,7 @@ APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
|
||||
# Emails
|
||||
APP_DEFAULT_EMAIL_FROM=[email protected]
|
||||
APP_DEFAULT_EMAIL_TO=[email protected]
|
||||
ACCOMMODATION_INQUIRY_EMAIL=info@ep-reisen.de
|
||||
ACCOMMODATION_INQUIRY_EMAIL=gruppen@ep-reisen.de
|
||||
|
||||
# Global common defaults
|
||||
APP_SEASON_WINTER_FROM=2026-10-01
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
/**
|
||||
* Unchecks conflicting additional services as soon as one is selected, so the user sees the
|
||||
* result immediately instead of waiting for the debounced htmx refresh. The server resolves
|
||||
* the same conflicts again and stays the source of truth.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
|
||||
static targets = [ 'input' ]
|
||||
|
||||
select(event) {
|
||||
const input = event.target
|
||||
|
||||
if (!input.checked) {
|
||||
return
|
||||
}
|
||||
|
||||
const group = input.dataset.exclusionGroup
|
||||
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
|
||||
const exclusive = '1' === input.dataset.exclusive
|
||||
|
||||
this.inputTargets
|
||||
.filter(other => other !== input)
|
||||
.filter(other => other.dataset.exclusionGroup === group)
|
||||
.filter(other => exclusive || '1' === other.dataset.exclusive)
|
||||
.forEach(other => { other.checked = false })
|
||||
}
|
||||
}
|
||||
@@ -265,7 +265,7 @@ services:
|
||||
|
||||
App\Service\AccommodationBookingService:
|
||||
arguments:
|
||||
$officeEmail: '%accommodation_inquiry_email%'
|
||||
$accommodationEmail: '%accommodation_inquiry_email%'
|
||||
|
||||
App\Service\GroupsPriceCalculator:
|
||||
arguments:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260804120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add exclusion group and exclusive flag to additional services';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// The default only backfills existing rows; it is dropped again so the column matches the mapping.
|
||||
$this->addSql('ALTER TABLE additional_service ADD exclusion_group VARCHAR(128) DEFAULT NULL, ADD is_exclusive TINYINT(1) DEFAULT 0 NOT NULL');
|
||||
$this->addSql('ALTER TABLE additional_service ALTER COLUMN is_exclusive DROP DEFAULT');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE additional_service DROP exclusion_group, DROP is_exclusive');
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,14 @@ namespace App\Controller\Api;
|
||||
|
||||
use App\BpnConnect\ContingentsClient;
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\BpnConnect\Model\ContingentStatus;
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Enum\Groups\PriceType;
|
||||
use App\Model\ContingentCalendarQuery;
|
||||
use App\Model\ContingentPricesQuery;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use App\Service\PriceTimelineBuilder;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -34,6 +36,7 @@ class ContingentController extends AbstractController
|
||||
private readonly AccommodationPriceRepository $priceRepository,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly PriceTimelineBuilder $priceTimelineBuilder,
|
||||
private readonly AccommodationPriceCoverage $priceCoverage,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -96,8 +99,10 @@ class ContingentController extends AbstractController
|
||||
|
||||
$currency = $accommodation->getCurrency();
|
||||
|
||||
$covered = $this->priceCoverage->coveredDatesFor($prices, $dateFrom, $dateTo);
|
||||
|
||||
$data = array_map(
|
||||
fn ($entry) => $this->enrichEntry($entry->date, $entry->status->value, $prices, $currency),
|
||||
fn ($entry) => $this->enrichEntry($entry->date, $entry->status->value, $prices, $covered, $currency),
|
||||
$calendar->data,
|
||||
);
|
||||
|
||||
@@ -106,13 +111,19 @@ class ContingentController extends AbstractController
|
||||
|
||||
/**
|
||||
* @param AccommodationPrice[] $prices
|
||||
* @param array<string, true> $covered
|
||||
*
|
||||
* @return array{date: string, status: string, type: string|null, pricePerNight: float|null, defaultPricePerNight: float|null, priceAdditionalPerson: float|null, defaultPriceAdditionalPerson: float|null, includedPax: int|null, minNights: int|null}
|
||||
*/
|
||||
private function enrichEntry(string $date, string $status, array $prices, string $currency): array
|
||||
private function enrichEntry(string $date, string $status, array $prices, array $covered, string $currency): array
|
||||
{
|
||||
$day = (new \DateTimeImmutable($date))->setTime(0, 0);
|
||||
|
||||
// A day without a price is not sold, no matter what the contingent says.
|
||||
if (!isset($covered[$day->format('Y-m-d')])) {
|
||||
$status = ContingentStatus::Blocked->value;
|
||||
}
|
||||
|
||||
// dateFrom and dateTo are both inclusive (last night, not checkout day)
|
||||
$candidates = array_filter(
|
||||
$prices,
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Htmx\HxTrait;
|
||||
use App\Model\AccommodationBookingContext;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use App\Service\AccommodationSessionManager;
|
||||
use App\Service\CalendarGridBuilder;
|
||||
use App\Service\GroupsPriceCalculator;
|
||||
@@ -38,6 +39,7 @@ class Step1Controller extends AbstractAccommodationController
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly CalendarGridBuilder $calendarGridBuilder,
|
||||
private readonly GroupsPriceCalculator $priceCalculator,
|
||||
private readonly AccommodationPriceCoverage $priceCoverage,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -225,9 +227,12 @@ class Step1Controller extends AbstractAccommodationController
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches contingent + price data and returns a map of date → ['status', 'minNights'].
|
||||
* Fetches contingent + price data and returns a map of date → ['status', 'minNights']
|
||||
* covering every day of the requested range.
|
||||
*
|
||||
* Returns an empty array on API failure; the template treats missing dates as blocked.
|
||||
* A day is only available when the contingent allows it *and* an AccommodationPrice
|
||||
* covers it. On API failure the contingent imposes no restriction and price coverage
|
||||
* alone decides.
|
||||
*
|
||||
* @return array<string, array{status: string, minNights: int}>
|
||||
*/
|
||||
@@ -238,6 +243,7 @@ class Step1Controller extends AbstractAccommodationController
|
||||
string $dateFromStr,
|
||||
string $dateToStr,
|
||||
): array {
|
||||
$calendar = null;
|
||||
try {
|
||||
$cacheKey = sprintf('contingents_calendar_%s_%s_%s', $hotelCode, $dateFromStr, $dateToStr);
|
||||
$calendar = $this->cache->get(
|
||||
@@ -249,23 +255,38 @@ class Step1Controller extends AbstractAccommodationController
|
||||
},
|
||||
);
|
||||
} catch (BpnConnectException|\Psr\Cache\InvalidArgumentException) {
|
||||
return [];
|
||||
// Fall through with $calendar === null — price coverage still applies
|
||||
}
|
||||
|
||||
$contingentStatus = [];
|
||||
if (null !== $calendar) {
|
||||
foreach ($calendar->data as $entry) {
|
||||
$contingentStatus[(new \DateTimeImmutable($entry->date))->format('Y-m-d')] = $entry->status;
|
||||
}
|
||||
}
|
||||
|
||||
$prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
|
||||
$covered = $this->priceCoverage->coveredDatesFor($prices, $dateFrom, $dateTo);
|
||||
|
||||
$start = $dateFrom->setTime(0, 0);
|
||||
$end = $dateTo->setTime(0, 0);
|
||||
|
||||
$availableDates = [];
|
||||
foreach ($calendar->data as $entry) {
|
||||
if (ContingentStatus::Ok === $entry->status) {
|
||||
$availableDates[$entry->date] = true;
|
||||
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
|
||||
$date = $day->format('Y-m-d');
|
||||
// Days the contingent API does not mention are unrestricted
|
||||
$status = $contingentStatus[$date] ?? ContingentStatus::Ok;
|
||||
|
||||
if (ContingentStatus::Ok === $status && isset($covered[$date])) {
|
||||
$availableDates[$date] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$enriched = [];
|
||||
foreach ($calendar->data as $entry) {
|
||||
$isAvailable = ContingentStatus::Ok === $entry->status;
|
||||
$prevDate = (new \DateTimeImmutable($entry->date))->modify('-1 day')->format('Y-m-d');
|
||||
$prevAvailable = isset($availableDates[$prevDate]);
|
||||
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
|
||||
$date = $day->format('Y-m-d');
|
||||
$isAvailable = isset($availableDates[$date]);
|
||||
$prevAvailable = isset($availableDates[$day->modify('-1 day')->format('Y-m-d')]);
|
||||
|
||||
$status = match (true) {
|
||||
$isAvailable && $prevAvailable => 'ok',
|
||||
@@ -274,14 +295,13 @@ class Step1Controller extends AbstractAccommodationController
|
||||
default => 'blocked',
|
||||
};
|
||||
|
||||
$day = (new \DateTimeImmutable($entry->date))->setTime(0, 0);
|
||||
$candidates = array_values(array_filter(
|
||||
$prices,
|
||||
fn(AccommodationPrice $p): bool => $p->getDateFrom() <= $day && $p->getDateTo() >= $day,
|
||||
fn (AccommodationPrice $p): bool => $p->getDateFrom() <= $day && $p->getDateTo() >= $day,
|
||||
));
|
||||
$winner = $this->priceTimelineBuilder->resolveWinner($candidates);
|
||||
|
||||
$enriched[$entry->date] = [
|
||||
$enriched[$date] = [
|
||||
'status' => $status,
|
||||
'minNights' => $winner?->getMinNights() ?? 0,
|
||||
];
|
||||
|
||||
@@ -72,7 +72,7 @@ class Step2Controller extends AbstractAccommodationController
|
||||
|
||||
return $this->htmxOobResponse(
|
||||
'groups/booking/step_2.html.twig',
|
||||
['accommodation_form', 'accommodation_summary'],
|
||||
['booking_form', 'accommodation_summary'],
|
||||
['dto' => $dto, 'ctx' => $ctx, 'form' => $form->createView()],
|
||||
);
|
||||
}
|
||||
@@ -92,6 +92,7 @@ class Step2Controller extends AbstractAccommodationController
|
||||
$form = $this->createForm(AccommodationStep2Type::class, $dto, [
|
||||
'board_service_choices' => $this->buildServiceChoices($services['boardServices']),
|
||||
'additional_service_choices' => $this->buildServiceChoices($services['additionalServices']),
|
||||
'additional_services' => $services['additionalServices'],
|
||||
'max_adolescent_age' => (int) $accommodation->getMaxAdolescentAge(),
|
||||
'validation_groups' => $validate ? ['step_2'] : false,
|
||||
]);
|
||||
|
||||
@@ -53,6 +53,12 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity
|
||||
#[ORM\Column(length: 128, nullable: true)]
|
||||
private ?string $selectionGroup = null;
|
||||
|
||||
#[ORM\Column(length: 128, nullable: true)]
|
||||
private ?string $exclusionGroup = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $isExclusive = false;
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
$this->id = null;
|
||||
@@ -158,4 +164,28 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getExclusionGroup(): ?string
|
||||
{
|
||||
return $this->exclusionGroup;
|
||||
}
|
||||
|
||||
public function setExclusionGroup(?string $exclusionGroup): self
|
||||
{
|
||||
$this->exclusionGroup = $exclusionGroup;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isExclusive(): bool
|
||||
{
|
||||
return $this->isExclusive;
|
||||
}
|
||||
|
||||
public function setIsExclusive(bool $isExclusive): self
|
||||
{
|
||||
$this->isExclusive = $isExclusive;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use App\Service\AdditionalServiceExclusionResolver;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
@@ -18,6 +20,11 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
*/
|
||||
class AccommodationStep2Type extends AbstractType
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdditionalServiceExclusionResolver $exclusionResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
@@ -51,7 +58,9 @@ class AccommodationStep2Type extends AbstractType
|
||||
])
|
||||
;
|
||||
|
||||
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void {
|
||||
$availableServices = $options['additional_services'];
|
||||
|
||||
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($availableServices): void {
|
||||
$data = $event->getData();
|
||||
|
||||
if (!is_array($data)) {
|
||||
@@ -64,6 +73,14 @@ class AccommodationStep2Type extends AbstractType
|
||||
}
|
||||
}
|
||||
|
||||
$dto = $event->getForm()->getData();
|
||||
|
||||
$data['selectedAdditionalServiceIds'] = $this->exclusionResolver->resolve(
|
||||
$availableServices,
|
||||
array_map('intval', (array) ($data['selectedAdditionalServiceIds'] ?? [])),
|
||||
$dto instanceof AccommodationBookingDto ? $dto->selectedAdditionalServiceIds : [],
|
||||
);
|
||||
|
||||
$event->setData($data);
|
||||
});
|
||||
}
|
||||
@@ -75,8 +92,10 @@ class AccommodationStep2Type extends AbstractType
|
||||
'validation_groups' => ['step_2'],
|
||||
'board_service_choices' => [],
|
||||
'additional_service_choices' => [],
|
||||
'additional_services' => [],
|
||||
'max_adolescent_age' => 0,
|
||||
]);
|
||||
$resolver->setAllowedTypes('max_adolescent_age', 'int');
|
||||
$resolver->setAllowedTypes('additional_services', AdditionalService::class . '[]');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Form\Admin\Groups;
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Enum\Groups\AdditionalServiceType as AdditionalServiceTypeEnum;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
|
||||
@@ -46,6 +47,15 @@ class AdditionalServiceType extends AbstractType
|
||||
'required' => false,
|
||||
'disabled' => true,
|
||||
])
|
||||
->add('exclusionGroup', TextType::class, [
|
||||
'label' => 'Ausschlussgruppe',
|
||||
'required' => false,
|
||||
'help' => 'Leistungen mit derselben Ausschlussgruppe können sich gegenseitig abwählen.',
|
||||
])
|
||||
->add('isExclusive', CheckboxType::class, [
|
||||
'label' => 'Schließt andere Leistungen dieser Gruppe aus',
|
||||
'required' => false,
|
||||
])
|
||||
->add('dateFrom', DateType::class, [
|
||||
'label' => 'Datum von',
|
||||
'html5' => true,
|
||||
|
||||
@@ -37,7 +37,7 @@ class AccommodationBookingService
|
||||
private readonly CmsDataProvider $cmsDataProvider,
|
||||
private readonly AccommodationBookingLinkSigner $linkSigner,
|
||||
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
|
||||
private readonly string $officeEmail,
|
||||
private readonly string $accommodationEmail,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -369,6 +369,30 @@ class AccommodationBookingService
|
||||
|
||||
public function sendNotificationEmail(AccommodationBooking $booking): void
|
||||
{
|
||||
$this->sendBookingEmail(
|
||||
$booking,
|
||||
$this->accommodationEmail,
|
||||
sprintf(
|
||||
'Neue Unterkunfts%s: %s',
|
||||
$booking->isInquiry() ? 'anfrage' : 'buchung',
|
||||
$booking->getAccommodation()?->getName(),
|
||||
),
|
||||
'email/accommodation_booking.html.twig',
|
||||
'Failed to send accommodation booking notification email',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* All accommodation mail is sent from the group desk address so customer replies
|
||||
* land there rather than in the general inbox.
|
||||
*/
|
||||
private function sendBookingEmail(
|
||||
AccommodationBooking $booking,
|
||||
string $to,
|
||||
string $subject,
|
||||
string $template,
|
||||
string $errorMessage,
|
||||
): void {
|
||||
try {
|
||||
$this->mailer->createAndSendEmail(
|
||||
[
|
||||
@@ -376,18 +400,15 @@ class AccommodationBookingService
|
||||
'accessLink' => $this->accessLinkOrNull($booking),
|
||||
],
|
||||
[
|
||||
'to' => $this->officeEmail,
|
||||
'subject' => sprintf(
|
||||
'Neue Unterkunfts%s: %s',
|
||||
$booking->isInquiry() ? 'anfrage' : 'buchung',
|
||||
$booking->getAccommodation()?->getName(),
|
||||
),
|
||||
'template' => 'email/accommodation_booking.html.twig',
|
||||
'from' => $this->accommodationEmail,
|
||||
'to' => $to,
|
||||
'subject' => $subject,
|
||||
'template' => $template,
|
||||
'attachments' => [],
|
||||
],
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to send accommodation booking notification email', [
|
||||
$this->logger->error($errorMessage, [
|
||||
'booking_id' => $booking->getId(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
@@ -415,27 +436,15 @@ class AccommodationBookingService
|
||||
*/
|
||||
public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void
|
||||
{
|
||||
try {
|
||||
$this->mailer->createAndSendEmail(
|
||||
[
|
||||
'booking' => $booking,
|
||||
'accessLink' => $this->accessLinkOrNull($booking),
|
||||
],
|
||||
[
|
||||
'to' => $booking->getEmail(),
|
||||
'subject' => $booking->isInquiry()
|
||||
? 'Deine Anfrage ist bei uns eingegangen'
|
||||
: 'Deine Buchung ist bestätigt',
|
||||
'template' => 'email/accommodation_booking_customer.html.twig',
|
||||
'attachments' => [],
|
||||
],
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to send accommodation booking customer confirmation email', [
|
||||
'booking_id' => $booking->getId(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
$this->sendBookingEmail(
|
||||
$booking,
|
||||
$booking->getEmail(),
|
||||
$booking->isInquiry()
|
||||
? 'Deine Anfrage ist bei uns eingegangen'
|
||||
: 'Deine Buchung ist bestätigt',
|
||||
'email/accommodation_booking_customer.html.twig',
|
||||
'Failed to send accommodation booking customer confirmation email',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -469,48 +478,24 @@ class AccommodationBookingService
|
||||
|
||||
public function sendOfferAcceptedNotificationEmail(AccommodationBooking $booking): void
|
||||
{
|
||||
try {
|
||||
$this->mailer->createAndSendEmail(
|
||||
[
|
||||
'booking' => $booking,
|
||||
'accessLink' => $this->accessLinkOrNull($booking),
|
||||
],
|
||||
[
|
||||
'to' => $this->officeEmail,
|
||||
'subject' => sprintf('Angebot angenommen: %s', $booking->getAccommodation()?->getName()),
|
||||
'template' => 'email/offer_accepted.html.twig',
|
||||
'attachments' => [],
|
||||
],
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to send offer accepted notification email', [
|
||||
'booking_id' => $booking->getId(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
$this->sendBookingEmail(
|
||||
$booking,
|
||||
$this->accommodationEmail,
|
||||
sprintf('Angebot angenommen: %s', $booking->getAccommodation()?->getName()),
|
||||
'email/offer_accepted.html.twig',
|
||||
'Failed to send offer accepted notification email',
|
||||
);
|
||||
}
|
||||
|
||||
public function sendOfferAcceptedCustomerEmail(AccommodationBooking $booking): void
|
||||
{
|
||||
try {
|
||||
$this->mailer->createAndSendEmail(
|
||||
[
|
||||
'booking' => $booking,
|
||||
'accessLink' => $this->accessLinkOrNull($booking),
|
||||
],
|
||||
[
|
||||
'to' => $booking->getEmail(),
|
||||
'subject' => 'Deine Buchung ist bestätigt',
|
||||
'template' => 'email/offer_accepted_customer.html.twig',
|
||||
'attachments' => [],
|
||||
],
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to send offer accepted customer email', [
|
||||
'booking_id' => $booking->getId(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
$this->sendBookingEmail(
|
||||
$booking,
|
||||
$booking->getEmail(),
|
||||
'Deine Buchung ist bestätigt',
|
||||
'email/offer_accepted_customer.html.twig',
|
||||
'Failed to send offer accepted customer email',
|
||||
);
|
||||
}
|
||||
|
||||
private function accessLinkOrNull(AccommodationBooking $booking): ?string
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
|
||||
/**
|
||||
* Determines which days of a date range are covered by an AccommodationPrice.
|
||||
*
|
||||
* A day without a covering price is not sold and must not be offered as available,
|
||||
* regardless of what the external contingent calendar reports.
|
||||
*/
|
||||
final readonly class AccommodationPriceCoverage
|
||||
{
|
||||
public function __construct(
|
||||
private AccommodationPriceRepository $priceRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, true> keyed by Y-m-d, only covered days present
|
||||
*/
|
||||
public function coveredDates(string $hotelCode, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): array
|
||||
{
|
||||
return $this->coveredDatesFor(
|
||||
$this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo),
|
||||
$dateFrom,
|
||||
$dateTo,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AccommodationPrice[] $prices
|
||||
*
|
||||
* @return array<string, true> keyed by Y-m-d, only covered days present
|
||||
*/
|
||||
public function coveredDatesFor(array $prices, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): array
|
||||
{
|
||||
$start = $dateFrom->setTime(0, 0);
|
||||
$end = $dateTo->setTime(0, 0);
|
||||
|
||||
$covered = [];
|
||||
|
||||
// dateFrom and dateTo are both inclusive (last night, not checkout day)
|
||||
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
|
||||
foreach ($prices as $price) {
|
||||
if ($price->getDateFrom() <= $day && $price->getDateTo() >= $day) {
|
||||
$covered[$day->format('Y-m-d')] = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $covered;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
|
||||
/**
|
||||
* Resolves mutually exclusive additional services.
|
||||
*
|
||||
* Services sharing an exclusion group interact as soon as at least one of the two involved
|
||||
* services is flagged as exclusive: selecting the exclusive one clears all other members of
|
||||
* the group, selecting any other member clears the exclusive ones. Members that are not
|
||||
* exclusive can be combined freely.
|
||||
*
|
||||
* Conflicts are resolved in favour of the newest selection, determined by diffing the
|
||||
* submitted ids against the previously stored ones.
|
||||
*/
|
||||
class AdditionalServiceExclusionResolver
|
||||
{
|
||||
/**
|
||||
* @param AdditionalService[] $availableServices
|
||||
* @param array<array-key, int> $submittedIds keys are preserved, grouped radios submit under their group name
|
||||
* @param array<array-key, int> $previousIds
|
||||
*
|
||||
* @return array<array-key, int>
|
||||
*/
|
||||
public function resolve(array $availableServices, array $submittedIds, array $previousIds): array
|
||||
{
|
||||
$rules = $this->buildRules($availableServices);
|
||||
|
||||
if ([] === $rules) {
|
||||
return $submittedIds;
|
||||
}
|
||||
|
||||
$added = array_values(array_diff($submittedIds, $previousIds));
|
||||
|
||||
foreach ($added as $addedId) {
|
||||
if (!isset($rules[$addedId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$submittedIds = array_filter(
|
||||
$submittedIds,
|
||||
fn(int $id) => !$this->conflicts($rules, $addedId, $id),
|
||||
);
|
||||
}
|
||||
|
||||
return $submittedIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{group: string, exclusive: bool}> $rules
|
||||
*/
|
||||
private function conflicts(array $rules, int $a, int $b): bool
|
||||
{
|
||||
if ($a === $b || !isset($rules[$b])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($rules[$a]['group'] !== $rules[$b]['group']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $rules[$a]['exclusive'] || $rules[$b]['exclusive'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AdditionalService[] $availableServices
|
||||
*
|
||||
* @return array<int, array{group: string, exclusive: bool}>
|
||||
*/
|
||||
private function buildRules(array $availableServices): array
|
||||
{
|
||||
$rules = [];
|
||||
|
||||
foreach ($availableServices as $service) {
|
||||
$group = $service->getExclusionGroup();
|
||||
$id = $service->getId();
|
||||
|
||||
if (null === $id || null === $group || '' === $group) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rules[$id] = ['group' => $group, 'exclusive' => $service->isExclusive()];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\BpnConnect\Model\ContingentStatus;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Model\CalendarDay;
|
||||
use App\Model\CalendarMonth;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
|
||||
@@ -45,6 +46,7 @@ class Calendar
|
||||
public function __construct(
|
||||
private readonly ContingentsClient $contingentsClient,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly AccommodationPriceCoverage $priceCoverage,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -119,6 +121,18 @@ class Calendar
|
||||
}
|
||||
}
|
||||
|
||||
// A day without a price is not sold, no matter what the contingent says.
|
||||
$rangeStart = new \DateTimeImmutable($dateFrom);
|
||||
$rangeEnd = new \DateTimeImmutable($dateTo);
|
||||
$covered = $this->priceCoverage->coveredDates($hotelCode, $rangeStart, $rangeEnd);
|
||||
|
||||
for ($day = $rangeStart->setTime(0, 0); $day <= $rangeEnd->setTime(0, 0); $day = $day->modify('+1 day')) {
|
||||
$key = $day->format('Y-m-d');
|
||||
if (!isset($covered[$key])) {
|
||||
$blocked[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $blocked;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
</div>
|
||||
{{ form_row(form.price) }}
|
||||
{{ form_row(form.selectionGroup) }}
|
||||
{{ form_row(form.exclusionGroup) }}
|
||||
<div class="lg:col-span-2">
|
||||
{{ form_row(form.isExclusive) }}
|
||||
</div>
|
||||
{{ form_row(form.dateFrom) }}
|
||||
{{ form_row(form.dateTo) }}
|
||||
</div>
|
||||
|
||||
@@ -33,174 +33,22 @@
|
||||
{# Main form #}
|
||||
{% block accommodation_form %}
|
||||
<div id="accommodation-form"
|
||||
class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-white flex flex-col min-h-0"
|
||||
{% if htmx_oob_swap is defined and htmx_oob_swap %}hx-swap-oob="true"{% endif %}>
|
||||
|
||||
{% include '_partials/_flashes.html.twig' %}
|
||||
class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-white flex flex-col min-h-0">
|
||||
|
||||
{# Pagination - desktop only (fixed above scrollable content) #}
|
||||
<div class="hidden lg:block shrink-0">
|
||||
{% include 'groups/booking/_pagination.html.twig' with { 'current_step': 2 } %}
|
||||
</div>
|
||||
|
||||
<div id="participant-form"
|
||||
{# The scroll container itself must survive the htmx refresh — only its contents
|
||||
are swapped, so assets/loading.js can restore the scroll position. #}
|
||||
<div id="booking-form"
|
||||
class="flex-1 overflow-y-auto px-4 lg:px-8 py-8"
|
||||
hx-post="{{ path('app_groups_booking_step_2_refresh') }}"
|
||||
hx-trigger="change delay:300ms"
|
||||
hx-swap="none">
|
||||
|
||||
{# Validation errors #}
|
||||
{% from '_partials/_validation_errors.html.twig' import validation_alert %}
|
||||
{{ validation_alert(form) }}
|
||||
|
||||
{% if dto.isInquiry %}
|
||||
{% include '_partials/_alert.html.twig' with {
|
||||
level: 'warning',
|
||||
title: 'Diese Buchung wird als unverbindliche Anfrage behandelt',
|
||||
messages: dto.inquiryReasons
|
||||
} %}
|
||||
{% endif %}
|
||||
|
||||
{# Pax counts #}
|
||||
<h2 class="pb-4">
|
||||
Personenanzahl und gewünschte Leistungen
|
||||
</h2>
|
||||
|
||||
<div class="grid lg:grid-cols-3 lg:gap-x-8">
|
||||
{{ form_row(form.paxCount) }}
|
||||
{{ form_row(form.minorsCount) }}
|
||||
{{ form_row(form.childrenCount) }}
|
||||
</div>
|
||||
|
||||
{# Board services — rendered manually so price is formatted in Twig #}
|
||||
{% do form.selectedBoardServiceId.setRendered %}
|
||||
{% if ctx.boardServices is not empty %}
|
||||
<div class="pb-4">
|
||||
<fieldset class="border border-primary-bg">
|
||||
<legend class="w-full bg-primary-bg p-2 font-semibold uppercase">
|
||||
Verpflegungsleistungen
|
||||
</legend>
|
||||
<table class="w-full table-fixed border-collapse">
|
||||
{% for service in ctx.boardServices %}
|
||||
<tr>
|
||||
<td class="border border-primary-bg p-2 align-top">
|
||||
<label for="board_service_{{ service.id }}" class="cursor-pointer">{{ service.label }}</label>
|
||||
{% if service.description %}
|
||||
<div {{ stimulus_controller('tooltip') }}>
|
||||
<button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">Details</button>
|
||||
<template data-tooltip-target="template">
|
||||
<div class="text-sm">{{ service.description|nl2br }}</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="border border-primary-bg p-2 align-top w-32 text-right">
|
||||
<div>{% if service.price == 0 %}inkl.{% else %}{{ (service.price / 100)|format_currency(ctx.accommodation.currency) }}{% endif %}</div>
|
||||
{% if service.price != 0 %}
|
||||
<div class="text-xs text-gray-500">pro Person und Nacht</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="border border-primary-bg p-2 align-top w-12 text-center">
|
||||
<input type="radio"
|
||||
id="board_service_{{ service.id }}"
|
||||
name="{{ form.selectedBoardServiceId.vars.full_name }}"
|
||||
value="{{ service.id }}"
|
||||
{% if dto.selectedBoardServiceId == service.id %}checked{% endif %}
|
||||
class="form-radio">
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</fieldset>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Additional services — rendered manually to support checkbox/radio mix #}
|
||||
{% do form.selectedAdditionalServiceIds.setRendered %}
|
||||
{% set selectedAdditionalServiceBaseName = form.selectedAdditionalServiceIds.vars.full_name|replace({'[]': ''}) %}
|
||||
|
||||
{% if ctx.ungroupedAdditionalServices is not empty or ctx.groupedAdditionalServices is not empty %}
|
||||
|
||||
{# Ungrouped → checkboxes #}
|
||||
{% if ctx.ungroupedAdditionalServices is not empty %}
|
||||
<div class="pb-4">
|
||||
<fieldset class="border border-primary-bg">
|
||||
<legend class="w-full bg-primary-bg p-2 font-semibold uppercase">
|
||||
Optionale Zusatzleistungen
|
||||
</legend>
|
||||
<table class="w-full table-fixed border-collapse">
|
||||
{% for service in ctx.ungroupedAdditionalServices %}
|
||||
<tr>
|
||||
<td class="border border-primary-bg p-2 align-top">
|
||||
<label for="add_service_{{ service.id }}" class="cursor-pointer">{{ service.label }}</label>
|
||||
{% if service.description %}
|
||||
<div {{ stimulus_controller('tooltip') }}>
|
||||
<button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">Details</button>
|
||||
<template data-tooltip-target="template">
|
||||
<div class="text-sm">{{ service.description|nl2br }}</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="border border-primary-bg p-2 align-top w-32 text-right">
|
||||
<div>{% if service.price == 0 %}inkl.{% else %}{{ (service.price / 100)|format_currency(ctx.accommodation.currency) }}{% endif %}</div>
|
||||
<div class="text-xs text-gray-500">{{ service.type.label | trans }}</div>
|
||||
</td>
|
||||
<td class="border border-primary-bg p-2 align-top w-12 text-center">
|
||||
<input type="checkbox"
|
||||
id="add_service_{{ service.id }}"
|
||||
name="{{ form.selectedAdditionalServiceIds.vars.full_name }}"
|
||||
value="{{ service.id }}"
|
||||
{% if service.id in dto.selectedAdditionalServiceIds %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</fieldset>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Grouped → radio buttons per group, one fieldset per group #}
|
||||
{% for groupName, services in ctx.groupedAdditionalServices %}
|
||||
<div class="pb-4">
|
||||
<fieldset class="border border-primary-bg">
|
||||
<legend class="w-full bg-primary-bg p-2 font-semibold uppercase">{{ groupName }}</legend>
|
||||
<table class="w-full table-fixed border-collapse">
|
||||
{% for service in services %}
|
||||
<tr>
|
||||
<td class="border border-primary-bg p-2 align-top">
|
||||
<label for="add_service_{{ service.id }}" class="cursor-pointer">{{ service.label }}</label>
|
||||
{% if service.description %}
|
||||
<div {{ stimulus_controller('tooltip') }}>
|
||||
<button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">Details</button>
|
||||
<template data-tooltip-target="template">
|
||||
<div class="text-sm">{{ service.description|nl2br }}</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="border border-primary-bg p-2 align-top w-32 text-right">
|
||||
<div>{% if service.price == 0 %}inkl.{% else %}{{ (service.price / 100)|format_currency(ctx.accommodation.currency) }}{% endif %}</div>
|
||||
<div class="text-xs text-gray-500">{{ service.type.label | trans }}</div>
|
||||
</td>
|
||||
<td class="border border-primary-bg p-2 align-top w-12 text-center">
|
||||
<input type="radio"
|
||||
id="add_service_{{ service.id }}"
|
||||
name="{{ selectedAdditionalServiceBaseName }}[{{ groupName|e('html_attr') }}]"
|
||||
value="{{ service.id }}"
|
||||
{% if service.id in dto.selectedAdditionalServiceIds %}checked{% endif %}
|
||||
class="form-radio">
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</fieldset>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
hx-target="#booking-form"
|
||||
hx-swap="innerHTML">
|
||||
{{ block('booking_form') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -221,3 +69,179 @@
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
|
||||
{# Swapped into #booking-form on every htmx refresh #}
|
||||
{% block booking_form %}
|
||||
|
||||
{# Flash messages - must be inside the swapped region to be displayed #}
|
||||
{% include '_partials/_flashes.html.twig' %}
|
||||
|
||||
{# Validation errors #}
|
||||
{% from '_partials/_validation_errors.html.twig' import validation_alert %}
|
||||
{{ validation_alert(form) }}
|
||||
|
||||
{% if dto.isInquiry %}
|
||||
{% include '_partials/_alert.html.twig' with {
|
||||
level: 'warning',
|
||||
title: 'Diese Buchung wird als unverbindliche Anfrage behandelt',
|
||||
messages: dto.inquiryReasons
|
||||
} %}
|
||||
{% endif %}
|
||||
|
||||
{# Pax counts #}
|
||||
<h2 class="pb-4">
|
||||
Personenanzahl und gewünschte Leistungen
|
||||
</h2>
|
||||
|
||||
<div class="grid lg:grid-cols-3 lg:gap-x-8">
|
||||
{{ form_row(form.paxCount) }}
|
||||
{{ form_row(form.minorsCount) }}
|
||||
{{ form_row(form.childrenCount) }}
|
||||
</div>
|
||||
|
||||
{# Board services — rendered manually so price is formatted in Twig #}
|
||||
{% do form.selectedBoardServiceId.setRendered %}
|
||||
{% if ctx.boardServices is not empty %}
|
||||
<div class="pb-4">
|
||||
<fieldset class="border border-primary-bg">
|
||||
<legend class="w-full bg-primary-bg p-2 font-semibold uppercase">
|
||||
Verpflegungsleistungen
|
||||
</legend>
|
||||
<table class="w-full table-fixed border-collapse">
|
||||
{% for service in ctx.boardServices %}
|
||||
<tr>
|
||||
<td class="border border-primary-bg p-2 align-top">
|
||||
<label for="board_service_{{ service.id }}" class="cursor-pointer">{{ service.label }}</label>
|
||||
{% if service.description %}
|
||||
<div {{ stimulus_controller('tooltip') }}>
|
||||
<button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">Details</button>
|
||||
<template data-tooltip-target="template">
|
||||
<div class="text-sm">{{ service.description|nl2br }}</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="border border-primary-bg p-2 align-top w-32 text-right">
|
||||
<div>{% if service.price == 0 %}inkl.{% else %}{{ (service.price / 100)|format_currency(ctx.accommodation.currency) }}{% endif %}</div>
|
||||
{% if service.price != 0 %}
|
||||
<div class="text-xs text-gray-500">pro Person und Nacht</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="border border-primary-bg p-2 align-top w-12 text-center">
|
||||
<input type="radio"
|
||||
id="board_service_{{ service.id }}"
|
||||
name="{{ form.selectedBoardServiceId.vars.full_name }}"
|
||||
value="{{ service.id }}"
|
||||
{% if dto.selectedBoardServiceId == service.id %}checked{% endif %}
|
||||
class="form-radio">
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</fieldset>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Additional services — rendered manually to support checkbox/radio mix #}
|
||||
{% do form.selectedAdditionalServiceIds.setRendered %}
|
||||
{% set selectedAdditionalServiceBaseName = form.selectedAdditionalServiceIds.vars.full_name|replace({'[]': ''}) %}
|
||||
|
||||
{% if ctx.ungroupedAdditionalServices is not empty or ctx.groupedAdditionalServices is not empty %}
|
||||
|
||||
{# Wraps every fieldset so mutually exclusive services can be unchecked across fieldsets.
|
||||
Purely for instant feedback — the server resolves the same conflicts on submit. #}
|
||||
<div {{ stimulus_controller('service-exclusion') }} {{ stimulus_action('service-exclusion', 'select', 'change') }}>
|
||||
|
||||
{# Ungrouped → checkboxes #}
|
||||
{% if ctx.ungroupedAdditionalServices is not empty %}
|
||||
<div class="pb-4">
|
||||
<fieldset class="border border-primary-bg">
|
||||
<legend class="w-full bg-primary-bg p-2 font-semibold uppercase">
|
||||
Optionale Zusatzleistungen
|
||||
</legend>
|
||||
<table class="w-full table-fixed border-collapse">
|
||||
{% for service in ctx.ungroupedAdditionalServices %}
|
||||
<tr>
|
||||
<td class="border border-primary-bg p-2 align-top">
|
||||
<label for="add_service_{{ service.id }}" class="cursor-pointer">{{ service.label }}</label>
|
||||
{% if service.description %}
|
||||
<div {{ stimulus_controller('tooltip') }}>
|
||||
<button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">Details</button>
|
||||
<template data-tooltip-target="template">
|
||||
<div class="text-sm">{{ service.description|nl2br }}</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="border border-primary-bg p-2 align-top w-32 text-right">
|
||||
<div>{% if service.price == 0 %}inkl.{% else %}{{ (service.price / 100)|format_currency(ctx.accommodation.currency) }}{% endif %}</div>
|
||||
<div class="text-xs text-gray-500">{{ service.type.label | trans }}</div>
|
||||
</td>
|
||||
<td class="border border-primary-bg p-2 align-top w-12 text-center">
|
||||
<input type="checkbox"
|
||||
id="add_service_{{ service.id }}"
|
||||
name="{{ form.selectedAdditionalServiceIds.vars.full_name }}"
|
||||
value="{{ service.id }}"
|
||||
{% if service.id in dto.selectedAdditionalServiceIds %}checked{% endif %}
|
||||
{% if service.exclusionGroup %}
|
||||
{{ stimulus_target('service-exclusion', 'input') }}
|
||||
data-exclusion-group="{{ service.exclusionGroup }}"
|
||||
data-exclusive="{{ service.isExclusive ? '1' : '0' }}"
|
||||
{% endif %}
|
||||
class="form-checkbox">
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</fieldset>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Grouped → radio buttons per group, one fieldset per group #}
|
||||
{% for groupName, services in ctx.groupedAdditionalServices %}
|
||||
<div class="pb-4">
|
||||
<fieldset class="border border-primary-bg">
|
||||
<legend class="w-full bg-primary-bg p-2 font-semibold uppercase">{{ groupName }}</legend>
|
||||
<table class="w-full table-fixed border-collapse">
|
||||
{% for service in services %}
|
||||
<tr>
|
||||
<td class="border border-primary-bg p-2 align-top">
|
||||
<label for="add_service_{{ service.id }}" class="cursor-pointer">{{ service.label }}</label>
|
||||
{% if service.description %}
|
||||
<div {{ stimulus_controller('tooltip') }}>
|
||||
<button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">Details</button>
|
||||
<template data-tooltip-target="template">
|
||||
<div class="text-sm">{{ service.description|nl2br }}</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="border border-primary-bg p-2 align-top w-32 text-right">
|
||||
<div>{% if service.price == 0 %}inkl.{% else %}{{ (service.price / 100)|format_currency(ctx.accommodation.currency) }}{% endif %}</div>
|
||||
<div class="text-xs text-gray-500">{{ service.type.label | trans }}</div>
|
||||
</td>
|
||||
<td class="border border-primary-bg p-2 align-top w-12 text-center">
|
||||
<input type="radio"
|
||||
id="add_service_{{ service.id }}"
|
||||
name="{{ selectedAdditionalServiceBaseName }}[{{ groupName|e('html_attr') }}]"
|
||||
value="{{ service.id }}"
|
||||
{% if service.id in dto.selectedAdditionalServiceIds %}checked{% endif %}
|
||||
{% if service.exclusionGroup %}
|
||||
data-service-exclusion-target="input"
|
||||
data-exclusion-group="{{ service.exclusionGroup }}"
|
||||
data-exclusive="{{ service.isExclusive ? '1' : '0' }}"
|
||||
{% endif %}
|
||||
class="form-radio">
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</fieldset>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -36,7 +36,7 @@
|
||||
{% include 'groups/booking/_pagination.html.twig' with { 'current_step': 3 } %}
|
||||
</div>
|
||||
|
||||
<div id="participant-form" class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
|
||||
<div id="booking-form" class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
|
||||
<h2 class="pb-4">
|
||||
Persönliche Daten
|
||||
</h2>
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Entity\Groups\Accommodation;
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use App\Service\AccommodationSessionManager;
|
||||
use App\Service\CalendarGridBuilder;
|
||||
use App\Service\GroupsPriceCalculator;
|
||||
@@ -105,15 +106,18 @@ class Step1ControllerTest extends TestCase
|
||||
AccommodationBookingService $bookingService,
|
||||
AccommodationSessionManager $sessionManager,
|
||||
): TestableAccommodationStep1Controller {
|
||||
$priceRepository = $this->createMock(AccommodationPriceRepository::class);
|
||||
|
||||
return new TestableAccommodationStep1Controller(
|
||||
$bookingService,
|
||||
$sessionManager,
|
||||
$this->createMock(ContingentsClient::class),
|
||||
$this->createMock(AccommodationPriceRepository::class),
|
||||
$priceRepository,
|
||||
new PriceTimelineBuilder(),
|
||||
$this->createMock(CacheInterface::class),
|
||||
new CalendarGridBuilder(),
|
||||
new GroupsPriceCalculator(new PriceTimelineBuilder(), ['runningCostsEur' => 0, 'runningCostsChf' => 0, 'undersubscription30Eur' => 0, 'undersubscription30Chf' => 0, 'undersubscription40Eur' => 0, 'undersubscription40Chf' => 0]),
|
||||
new AccommodationPriceCoverage($priceRepository),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -129,6 +133,7 @@ final class TestableAccommodationStep1Controller extends Step1Controller
|
||||
CacheInterface $cache,
|
||||
CalendarGridBuilder $calendarGridBuilder,
|
||||
GroupsPriceCalculator $priceCalculator,
|
||||
AccommodationPriceCoverage $priceCoverage,
|
||||
) {
|
||||
parent::__construct(
|
||||
$bookingService,
|
||||
@@ -139,6 +144,7 @@ final class TestableAccommodationStep1Controller extends Step1Controller
|
||||
$cache,
|
||||
$calendarGridBuilder,
|
||||
$priceCalculator,
|
||||
$priceCoverage,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,22 +9,56 @@ use App\Controller\Api\ContingentController;
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use App\Service\PriceTimelineBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
class ContingentControllerTest extends TestCase
|
||||
{
|
||||
public function testEnrichEntryIncludesPricingMetadata(): void
|
||||
{
|
||||
$controller = new ContingentController(
|
||||
$controller = $this->createController();
|
||||
$price = $this->createPrice();
|
||||
|
||||
$result = $this->enrichEntry($controller, '2026-07-06', 'available', [$price], ['2026-07-06' => true]);
|
||||
|
||||
self::assertSame('available', $result['status']);
|
||||
self::assertSame(4, $result['includedPax']);
|
||||
self::assertSame(3, $result['minNights']);
|
||||
self::assertSame(123.45, $result['pricePerNight']);
|
||||
self::assertSame('EUR', $result['currency']);
|
||||
}
|
||||
|
||||
public function testEnrichEntryBlocksDayWithoutPrice(): void
|
||||
{
|
||||
$controller = $this->createController();
|
||||
$price = $this->createPrice();
|
||||
|
||||
$result = $this->enrichEntry($controller, '2026-08-01', 'OK', [$price], ['2026-07-06' => true]);
|
||||
|
||||
self::assertSame('BLOCKED', $result['status']);
|
||||
self::assertNull($result['pricePerNight']);
|
||||
self::assertNull($result['includedPax']);
|
||||
self::assertNull($result['minNights']);
|
||||
}
|
||||
|
||||
private function createController(): ContingentController
|
||||
{
|
||||
return new ContingentController(
|
||||
$this->createMock(ContingentsClient::class),
|
||||
$this->createMock(AccommodationRepository::class),
|
||||
$this->createMock(AccommodationPriceRepository::class),
|
||||
$this->createMock(CacheInterface::class),
|
||||
new PriceTimelineBuilder(),
|
||||
new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class)),
|
||||
$this->createMock(LoggerInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
private function createPrice(): AccommodationPrice
|
||||
{
|
||||
$price = new AccommodationPrice();
|
||||
$price->setDateFrom(new \DateTimeImmutable('2026-07-01'));
|
||||
$price->setDateTo(new \DateTimeImmutable('2026-07-31'));
|
||||
@@ -33,13 +67,24 @@ class ContingentControllerTest extends TestCase
|
||||
$price->setPriceAdditionalPerson(1500);
|
||||
$price->setMinNights(3);
|
||||
|
||||
return $price;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AccommodationPrice[] $prices
|
||||
* @param array<string, true> $covered
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function enrichEntry(
|
||||
ContingentController $controller,
|
||||
string $date,
|
||||
string $status,
|
||||
array $prices,
|
||||
array $covered,
|
||||
): array {
|
||||
$method = new \ReflectionMethod($controller, 'enrichEntry');
|
||||
|
||||
$result = $method->invoke($controller, '2026-07-06', 'available', [$price], 'EUR');
|
||||
|
||||
self::assertSame(4, $result['includedPax']);
|
||||
self::assertSame(3, $result['minNights']);
|
||||
self::assertSame(123.45, $result['pricePerNight']);
|
||||
self::assertSame('EUR', $result['currency']);
|
||||
return $method->invoke($controller, $date, $status, $prices, $covered, 'EUR');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Groups;
|
||||
|
||||
use App\BpnConnect\ContingentsClient;
|
||||
use App\BpnConnect\Model\ContingentCalendarEntry;
|
||||
use App\BpnConnect\Model\ContingentCalendarMeta;
|
||||
use App\BpnConnect\Model\ContingentCalendarResponse;
|
||||
use App\BpnConnect\Model\ContingentStatus;
|
||||
use App\Controller\Groups\Step1Controller;
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use App\Service\AccommodationSessionManager;
|
||||
use App\Service\CalendarGridBuilder;
|
||||
use App\Service\GroupsPriceCalculator;
|
||||
use App\Service\PriceTimelineBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
class Step1CalendarDataTest extends TestCase
|
||||
{
|
||||
public function testDaysWithoutPriceAreBlockedEvenWhenContingentIsOk(): void
|
||||
{
|
||||
// Contingent says OK for the whole week, prices only cover 03.–05.
|
||||
$price = new AccommodationPrice();
|
||||
$price->setDateFrom(new \DateTimeImmutable('2026-06-03'));
|
||||
$price->setDateTo(new \DateTimeImmutable('2026-06-05'));
|
||||
$price->setMinNights(2);
|
||||
|
||||
$enriched = $this->buildEnrichedDayData(
|
||||
$this->calendarResponse([
|
||||
'2026-06-01' => ContingentStatus::Ok,
|
||||
'2026-06-02' => ContingentStatus::Ok,
|
||||
'2026-06-03' => ContingentStatus::Ok,
|
||||
'2026-06-04' => ContingentStatus::Ok,
|
||||
'2026-06-05' => ContingentStatus::Ok,
|
||||
'2026-06-06' => ContingentStatus::Ok,
|
||||
'2026-06-07' => ContingentStatus::Ok,
|
||||
]),
|
||||
[$price],
|
||||
'2026-06-01',
|
||||
'2026-06-07',
|
||||
);
|
||||
|
||||
self::assertSame('blocked', $enriched['2026-06-01']['status']);
|
||||
self::assertSame('blocked', $enriched['2026-06-02']['status']);
|
||||
// First priced night — arrival only
|
||||
self::assertSame('blocked-to-ok', $enriched['2026-06-03']['status']);
|
||||
self::assertSame('ok', $enriched['2026-06-04']['status']);
|
||||
self::assertSame('ok', $enriched['2026-06-05']['status']);
|
||||
// Day after the last priced night — still valid as checkout
|
||||
self::assertSame('checkout-only', $enriched['2026-06-06']['status']);
|
||||
self::assertSame('blocked', $enriched['2026-06-07']['status']);
|
||||
|
||||
self::assertSame(2, $enriched['2026-06-04']['minNights']);
|
||||
self::assertSame(0, $enriched['2026-06-07']['minNights']);
|
||||
}
|
||||
|
||||
public function testBlockedContingentWinsOverExistingPrice(): void
|
||||
{
|
||||
$price = new AccommodationPrice();
|
||||
$price->setDateFrom(new \DateTimeImmutable('2026-06-01'));
|
||||
$price->setDateTo(new \DateTimeImmutable('2026-06-03'));
|
||||
$price->setMinNights(1);
|
||||
|
||||
$enriched = $this->buildEnrichedDayData(
|
||||
$this->calendarResponse([
|
||||
'2026-06-01' => ContingentStatus::Ok,
|
||||
'2026-06-02' => ContingentStatus::Blocked,
|
||||
'2026-06-03' => ContingentStatus::Ok,
|
||||
]),
|
||||
[$price],
|
||||
'2026-06-01',
|
||||
'2026-06-03',
|
||||
);
|
||||
|
||||
self::assertSame('blocked-to-ok', $enriched['2026-06-01']['status']);
|
||||
self::assertSame('checkout-only', $enriched['2026-06-02']['status']);
|
||||
self::assertSame('blocked-to-ok', $enriched['2026-06-03']['status']);
|
||||
}
|
||||
|
||||
public function testPriceCoverageStillAppliesWhenContingentApiIsUnavailable(): void
|
||||
{
|
||||
$price = new AccommodationPrice();
|
||||
$price->setDateFrom(new \DateTimeImmutable('2026-06-01'));
|
||||
$price->setDateTo(new \DateTimeImmutable('2026-06-02'));
|
||||
$price->setMinNights(1);
|
||||
|
||||
$enriched = $this->buildEnrichedDayData(null, [$price], '2026-06-01', '2026-06-03');
|
||||
|
||||
self::assertSame('blocked-to-ok', $enriched['2026-06-01']['status']);
|
||||
self::assertSame('ok', $enriched['2026-06-02']['status']);
|
||||
self::assertSame('checkout-only', $enriched['2026-06-03']['status']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, ContingentStatus> $statuses
|
||||
*/
|
||||
private function calendarResponse(array $statuses): ContingentCalendarResponse
|
||||
{
|
||||
$entries = [];
|
||||
foreach ($statuses as $date => $status) {
|
||||
$entries[] = new ContingentCalendarEntry(date: $date, status: $status);
|
||||
}
|
||||
|
||||
return new ContingentCalendarResponse(
|
||||
new ContingentCalendarMeta('', '', 'days', 'HOTEL', 1, count($entries)),
|
||||
$entries,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AccommodationPrice[] $prices
|
||||
*
|
||||
* @return array<string, array{status: string, minNights: int}>
|
||||
*/
|
||||
private function buildEnrichedDayData(
|
||||
?ContingentCalendarResponse $calendar,
|
||||
array $prices,
|
||||
string $dateFrom,
|
||||
string $dateTo,
|
||||
): array {
|
||||
$cache = $this->createMock(CacheInterface::class);
|
||||
if (null === $calendar) {
|
||||
$cache->method('get')->willThrowException(new \App\BpnConnect\Exception\BpnConnectException('down'));
|
||||
} else {
|
||||
$cache->method('get')->willReturn($calendar);
|
||||
}
|
||||
|
||||
$priceRepository = $this->createMock(AccommodationPriceRepository::class);
|
||||
$priceRepository->method('findByHotelCodeAndDateRange')->willReturn($prices);
|
||||
|
||||
$controller = new Step1Controller(
|
||||
$this->createMock(AccommodationBookingService::class),
|
||||
$this->createMock(AccommodationSessionManager::class),
|
||||
$this->createMock(ContingentsClient::class),
|
||||
$priceRepository,
|
||||
new PriceTimelineBuilder(),
|
||||
$cache,
|
||||
$this->createMock(CalendarGridBuilder::class),
|
||||
$this->createMock(GroupsPriceCalculator::class),
|
||||
new AccommodationPriceCoverage($priceRepository),
|
||||
);
|
||||
|
||||
$method = new \ReflectionMethod($controller, 'buildEnrichedDayData');
|
||||
|
||||
return $method->invoke(
|
||||
$controller,
|
||||
'HOTEL',
|
||||
new \DateTimeImmutable($dateFrom),
|
||||
new \DateTimeImmutable($dateTo),
|
||||
$dateFrom,
|
||||
$dateTo,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,21 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form;
|
||||
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Form\AccommodationStep2Type;
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use App\Service\AdditionalServiceExclusionResolver;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\Forms;
|
||||
|
||||
class AccommodationStep2TypeTest extends TestCase
|
||||
{
|
||||
public function testChildrenLabelIsBuiltFromMaxAdolescentAgeOption(): void
|
||||
{
|
||||
$form = Forms::createFormFactoryBuilder()
|
||||
->getFormFactory()
|
||||
->create(AccommodationStep2Type::class, new AccommodationBookingDto(), [
|
||||
'max_adolescent_age' => 15,
|
||||
]);
|
||||
$form = $this->createForm(new AccommodationBookingDto(), [
|
||||
'max_adolescent_age' => 15,
|
||||
]);
|
||||
|
||||
self::assertSame(
|
||||
'davon Kinder (4–15 Jahre)',
|
||||
@@ -28,9 +29,7 @@ class AccommodationStep2TypeTest extends TestCase
|
||||
public function testBlankOptionalChildCountersAreNormalizedToZero(): void
|
||||
{
|
||||
$dto = new AccommodationBookingDto();
|
||||
$form = Forms::createFormFactoryBuilder()
|
||||
->getFormFactory()
|
||||
->create(AccommodationStep2Type::class, $dto);
|
||||
$form = $this->createForm($dto);
|
||||
|
||||
$form->submit([
|
||||
'paxCount' => '2',
|
||||
@@ -48,14 +47,12 @@ class AccommodationStep2TypeTest extends TestCase
|
||||
public function testGroupedAdditionalServiceValuesCanSubmitUnderSelectionGroupKeys(): void
|
||||
{
|
||||
$dto = new AccommodationBookingDto();
|
||||
$form = Forms::createFormFactoryBuilder()
|
||||
->getFormFactory()
|
||||
->create(AccommodationStep2Type::class, $dto, [
|
||||
'additional_service_choices' => [
|
||||
'Ski service' => 101,
|
||||
'Board service' => 202,
|
||||
],
|
||||
]);
|
||||
$form = $this->createForm($dto, [
|
||||
'additional_service_choices' => [
|
||||
'Ski service' => 101,
|
||||
'Board service' => 202,
|
||||
],
|
||||
]);
|
||||
|
||||
$form->submit([
|
||||
'paxCount' => '2',
|
||||
@@ -71,4 +68,87 @@ class AccommodationStep2TypeTest extends TestCase
|
||||
self::assertTrue($form->isSynchronized());
|
||||
self::assertSame([101, 202], array_values($dto->selectedAdditionalServiceIds));
|
||||
}
|
||||
|
||||
public function testNewlySelectedExclusiveServiceClearsItsConflicts(): void
|
||||
{
|
||||
$dto = new AccommodationBookingDto();
|
||||
$dto->selectedAdditionalServiceIds = [11, 12];
|
||||
|
||||
$form = $this->createForm($dto, [
|
||||
'additional_service_choices' => [
|
||||
'Keller' => 11,
|
||||
'1. OG' => 12,
|
||||
'Komplett' => 13,
|
||||
],
|
||||
'additional_services' => [
|
||||
$this->service(11, 'Reinigung', false),
|
||||
$this->service(12, 'Reinigung', false),
|
||||
$this->service(13, 'Reinigung', true),
|
||||
],
|
||||
]);
|
||||
|
||||
$form->submit([
|
||||
'paxCount' => '2',
|
||||
'minorsCount' => '0',
|
||||
'childrenCount' => '0',
|
||||
'selectedBoardServiceId' => '',
|
||||
'selectedAdditionalServiceIds' => ['11', '12', '13'],
|
||||
]);
|
||||
|
||||
self::assertTrue($form->isSynchronized());
|
||||
self::assertSame([13], array_values($dto->selectedAdditionalServiceIds));
|
||||
}
|
||||
|
||||
public function testNewlySelectedRegularServiceClearsTheExclusiveOne(): void
|
||||
{
|
||||
$dto = new AccommodationBookingDto();
|
||||
$dto->selectedAdditionalServiceIds = [13];
|
||||
|
||||
$form = $this->createForm($dto, [
|
||||
'additional_service_choices' => [
|
||||
'Keller' => 11,
|
||||
'Komplett' => 13,
|
||||
],
|
||||
'additional_services' => [
|
||||
$this->service(11, 'Reinigung', false),
|
||||
$this->service(13, 'Reinigung', true),
|
||||
],
|
||||
]);
|
||||
|
||||
$form->submit([
|
||||
'paxCount' => '2',
|
||||
'minorsCount' => '0',
|
||||
'childrenCount' => '0',
|
||||
'selectedBoardServiceId' => '',
|
||||
'selectedAdditionalServiceIds' => ['13', '11'],
|
||||
]);
|
||||
|
||||
self::assertTrue($form->isSynchronized());
|
||||
self::assertSame([11], array_values($dto->selectedAdditionalServiceIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*
|
||||
* @return FormInterface<AccommodationBookingDto>
|
||||
*/
|
||||
private function createForm(AccommodationBookingDto $dto, array $options = []): FormInterface
|
||||
{
|
||||
return Forms::createFormFactoryBuilder()
|
||||
->addType(new AccommodationStep2Type(new AdditionalServiceExclusionResolver()))
|
||||
->getFormFactory()
|
||||
->create(AccommodationStep2Type::class, $dto, $options);
|
||||
}
|
||||
|
||||
private function service(int $id, ?string $exclusionGroup, bool $isExclusive): AdditionalService
|
||||
{
|
||||
$service = new AdditionalService();
|
||||
$service->setExclusionGroup($exclusionGroup);
|
||||
$service->setIsExclusive($isExclusive);
|
||||
|
||||
$property = new \ReflectionProperty(AdditionalService::class, 'id');
|
||||
$property->setValue($service, $id);
|
||||
|
||||
return $service;
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,7 @@ class AccommodationBookingServiceTest extends TestCase
|
||||
->with(
|
||||
self::callback(static fn (array $context) => 'https://example.com/offer/signed-link' === $context['accessLink']),
|
||||
self::callback(static fn (array $options) => '[email protected]' === $options['to']
|
||||
&& '[email protected]' === $options['from']
|
||||
&& 'email/accommodation_booking_customer.html.twig' === $options['template']),
|
||||
);
|
||||
|
||||
@@ -219,6 +220,7 @@ class AccommodationBookingServiceTest extends TestCase
|
||||
->with(
|
||||
self::anything(),
|
||||
self::callback(static fn (array $options) => in_array($options['to'], ['[email protected]', '[email protected]'], true)
|
||||
&& '[email protected]' === $options['from']
|
||||
&& in_array($options['template'], ['email/offer_accepted.html.twig', 'email/offer_accepted_customer.html.twig'], true)),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AccommodationPriceCoverageTest extends TestCase
|
||||
{
|
||||
public function testCoversOnlyDaysWithinPricePeriod(): void
|
||||
{
|
||||
$coverage = new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class));
|
||||
|
||||
$covered = $coverage->coveredDatesFor(
|
||||
[$this->price('2026-06-01', '2026-06-09')],
|
||||
new \DateTimeImmutable('2026-06-01'),
|
||||
new \DateTimeImmutable('2026-06-12'),
|
||||
);
|
||||
|
||||
// dateTo is the last night, so 2026-06-09 is still covered
|
||||
self::assertSame([
|
||||
'2026-06-01', '2026-06-02', '2026-06-03', '2026-06-04', '2026-06-05',
|
||||
'2026-06-06', '2026-06-07', '2026-06-08', '2026-06-09',
|
||||
], array_keys($covered));
|
||||
}
|
||||
|
||||
public function testGapBetweenPricePeriodsStaysUncovered(): void
|
||||
{
|
||||
$coverage = new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class));
|
||||
|
||||
$covered = $coverage->coveredDatesFor(
|
||||
[$this->price('2026-06-01', '2026-06-02'), $this->price('2026-06-05', '2026-06-06')],
|
||||
new \DateTimeImmutable('2026-06-01'),
|
||||
new \DateTimeImmutable('2026-06-06'),
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
['2026-06-01', '2026-06-02', '2026-06-05', '2026-06-06'],
|
||||
array_keys($covered),
|
||||
);
|
||||
}
|
||||
|
||||
public function testNoPricesMeansNoCoverage(): void
|
||||
{
|
||||
$coverage = new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class));
|
||||
|
||||
$covered = $coverage->coveredDatesFor(
|
||||
[],
|
||||
new \DateTimeImmutable('2026-06-01'),
|
||||
new \DateTimeImmutable('2026-06-03'),
|
||||
);
|
||||
|
||||
self::assertSame([], $covered);
|
||||
}
|
||||
|
||||
private function price(string $from, string $to): AccommodationPrice
|
||||
{
|
||||
$price = new AccommodationPrice();
|
||||
$price->setDateFrom(new \DateTimeImmutable($from));
|
||||
$price->setDateTo(new \DateTimeImmutable($to));
|
||||
|
||||
return $price;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Service\AdditionalServiceExclusionResolver;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AdditionalServiceExclusionResolverTest extends TestCase
|
||||
{
|
||||
private const int BASEMENT = 1;
|
||||
private const int FIRST_FLOOR = 2;
|
||||
private const int SECOND_FLOOR = 3;
|
||||
private const int FULL = 4;
|
||||
private const int BREAKFAST = 5;
|
||||
|
||||
public function testSelectingTheExclusiveServiceClearsTheOtherGroupMembers(): void
|
||||
{
|
||||
$resolved = $this->resolve(
|
||||
[self::BASEMENT, self::FIRST_FLOOR, self::FULL],
|
||||
[self::BASEMENT, self::FIRST_FLOOR],
|
||||
);
|
||||
|
||||
self::assertSame([self::FULL], array_values($resolved));
|
||||
}
|
||||
|
||||
public function testSelectingANonExclusiveServiceClearsOnlyTheExclusiveOne(): void
|
||||
{
|
||||
$resolved = $this->resolve(
|
||||
[self::FULL, self::BASEMENT],
|
||||
[self::FULL],
|
||||
);
|
||||
|
||||
self::assertSame([self::BASEMENT], array_values($resolved));
|
||||
}
|
||||
|
||||
public function testNonExclusiveGroupMembersCanBeCombined(): void
|
||||
{
|
||||
$resolved = $this->resolve(
|
||||
[self::BASEMENT, self::FIRST_FLOOR, self::SECOND_FLOOR],
|
||||
[self::BASEMENT],
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
[self::BASEMENT, self::FIRST_FLOOR, self::SECOND_FLOOR],
|
||||
array_values($resolved),
|
||||
);
|
||||
}
|
||||
|
||||
public function testServicesWithoutAnExclusionGroupAreNeverCleared(): void
|
||||
{
|
||||
$resolved = $this->resolve(
|
||||
[self::BREAKFAST, self::BASEMENT, self::FULL],
|
||||
[self::BREAKFAST, self::BASEMENT],
|
||||
);
|
||||
|
||||
self::assertSame([self::BREAKFAST, self::FULL], array_values($resolved));
|
||||
}
|
||||
|
||||
public function testUnchangedSelectionIsLeftAlone(): void
|
||||
{
|
||||
$resolved = $this->resolve(
|
||||
[self::BASEMENT, self::FULL],
|
||||
[self::BASEMENT, self::FULL],
|
||||
);
|
||||
|
||||
self::assertSame([self::BASEMENT, self::FULL], array_values($resolved));
|
||||
}
|
||||
|
||||
public function testSubmittedArrayKeysArePreserved(): void
|
||||
{
|
||||
$resolver = new AdditionalServiceExclusionResolver();
|
||||
|
||||
$resolved = $resolver->resolve(
|
||||
$this->services(),
|
||||
['Reinigung' => self::FULL, 'Verpflegung' => self::BREAKFAST, 0 => self::BASEMENT],
|
||||
[self::BASEMENT],
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
['Reinigung' => self::FULL, 'Verpflegung' => self::BREAKFAST],
|
||||
$resolved,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $submittedIds
|
||||
* @param list<int> $previousIds
|
||||
*
|
||||
* @return array<array-key, int>
|
||||
*/
|
||||
private function resolve(array $submittedIds, array $previousIds): array
|
||||
{
|
||||
return (new AdditionalServiceExclusionResolver())->resolve(
|
||||
$this->services(),
|
||||
$submittedIds,
|
||||
$previousIds,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return AdditionalService[]
|
||||
*/
|
||||
private function services(): array
|
||||
{
|
||||
return [
|
||||
$this->service(self::BASEMENT, 'Reinigung', false),
|
||||
$this->service(self::FIRST_FLOOR, 'Reinigung', false),
|
||||
$this->service(self::SECOND_FLOOR, 'Reinigung', false),
|
||||
$this->service(self::FULL, 'Reinigung', true),
|
||||
$this->service(self::BREAKFAST, null, false),
|
||||
];
|
||||
}
|
||||
|
||||
private function service(int $id, ?string $exclusionGroup, bool $isExclusive): AdditionalService
|
||||
{
|
||||
$service = new AdditionalService();
|
||||
$service->setExclusionGroup($exclusionGroup);
|
||||
$service->setIsExclusive($isExclusive);
|
||||
|
||||
$property = new \ReflectionProperty(AdditionalService::class, 'id');
|
||||
$property->setValue($service, $id);
|
||||
|
||||
return $service;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user