diff --git a/api.http b/api.http index c678ddfe..2045a02d 100644 --- a/api.http +++ b/api.http @@ -127,6 +127,13 @@ GET {{base_url}}/api/contingents/calendar?hotelCode=DGS&dateFrom=2026-06-01&date Accept: application/json Authorization: Bearer {{$auth.token("oauth2_api")}} +### API contintent prices +# @no-cookie-jar +GET {{base_url}}/api/contingents/prices?hotelCode=DGS&year=2026 +Accept: application/json +Content-Type: application/json +Authorization: Bearer {{$auth.token("oauth2_api")}} + ### API pickups planning webhook # @no-cookie-jar POST {{base_url}}/api/pickups-planning diff --git a/assets/images/icons.svg b/assets/images/icons.svg index dd69085a..223de89f 100644 --- a/assets/images/icons.svg +++ b/assets/images/icons.svg @@ -181,7 +181,15 @@ - + + + + + + + + + diff --git a/migrations/Version20260702084714.php b/migrations/Version20260702084714.php new file mode 100644 index 00000000..dae94a9c --- /dev/null +++ b/migrations/Version20260702084714.php @@ -0,0 +1,43 @@ +addSql('ALTER TABLE accommodation_price ADD type VARCHAR(20) DEFAULT NULL'); + + $this->addSql(<<<'SQL' + INSERT INTO accommodation_price + (accommodation_id, date_from, date_to, season, included_pax, min_nights, + price_per_night, price_additional_person, accept_undersubscription, type, + created_at, updated_at, created_by_id, updated_by_id) + SELECT accommodation_id, date_from, date_to, season, included_pax, min_nights, + price_per_night_override, price_additional_person_override, accept_undersubscription, 'discount', + created_at, updated_at, created_by_id, updated_by_id + FROM accommodation_price + WHERE price_per_night_override IS NOT NULL + SQL); + + $this->addSql('ALTER TABLE accommodation_price DROP price_per_night_override, DROP price_additional_person_override'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE accommodation_price ADD price_per_night_override INT DEFAULT NULL, ADD price_additional_person_override INT DEFAULT NULL, DROP type'); + } +} diff --git a/src/Controller/Api/ContingentController.php b/src/Controller/Api/ContingentController.php index 9af6afc0..c42e2cd5 100644 --- a/src/Controller/Api/ContingentController.php +++ b/src/Controller/Api/ContingentController.php @@ -7,7 +7,10 @@ namespace App\Controller\Api; use App\BpnConnect\ContingentsClient; use App\BpnConnect\Exception\BpnConnectException; use App\Entity\Groups\AccommodationPrice; +use App\Enum\Groups\PriceType; use App\Repository\Groups\AccommodationPriceRepository; +use App\Repository\Groups\AccommodationRepository; +use App\Service\PriceTimelineBuilder; use Psr\Cache\InvalidArgumentException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -24,11 +27,44 @@ class ContingentController extends AbstractController { public function __construct( private readonly ContingentsClient $contingentsClient, + private readonly AccommodationRepository $accommodationRepository, private readonly AccommodationPriceRepository $priceRepository, private readonly CacheInterface $cache, + private readonly PriceTimelineBuilder $priceTimelineBuilder, ) { } + #[Route(path: '/contingents/prices', name: 'api_contingents_prices', methods: ['GET'])] + public function prices(Request $request): JsonResponse + { + $hotelCode = $request->query->get('hotelCode'); + $yearRaw = $request->query->get('year'); + + if (!$hotelCode || !$yearRaw) { + return $this->json(['error' => 'hotelCode and year are required.'], Response::HTTP_BAD_REQUEST); + } + + if (!preg_match('/^\d{4}$/', $yearRaw)) { + return $this->json(['error' => 'year must be a 4-digit integer.'], Response::HTTP_BAD_REQUEST); + } + + $accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $hotelCode]); + + if (null === $accommodation) { + return $this->json(['error' => 'hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST); + } + + $year = (int) $yearRaw; + $yearStart = new \DateTimeImmutable("{$year}-01-01"); + $yearEnd = new \DateTimeImmutable("{$year}-12-31"); + + $prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $yearStart, $yearEnd); + + $currency = $accommodation->getCurrency(); + + return $this->json($this->priceTimelineBuilder->buildTimeline($prices, $yearStart, $yearEnd, $currency)); + } + #[Route(path: '/contingents/calendar', name: 'api_contingents_calendar', methods: ['GET'])] public function calendar(Request $request): JsonResponse { @@ -47,6 +83,12 @@ class ContingentController extends AbstractController return $this->json(['error' => 'dateFrom and dateTo must be in Y-m-d format.'], Response::HTTP_BAD_REQUEST); } + $accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $hotelCode]); + + if (null === $accommodation) { + return $this->json(['error' => 'hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST); + } + try { $cacheKey = sprintf('contingents_calendar_%s_%s_%s', $hotelCode, $dateFromRaw, $dateToRaw); $calendar = $this->cache->get($cacheKey, function (ItemInterface $item) use ($hotelCode, $dateFromRaw, $dateToRaw) { @@ -60,8 +102,10 @@ class ContingentController extends AbstractController $prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo); + $currency = $accommodation->getCurrency(); + $data = array_map( - fn ($entry) => $this->enrichEntry($entry->date, $entry->status->value, $prices), + fn ($entry) => $this->enrichEntry($entry->date, $entry->status->value, $prices, $currency), $calendar->data, ); @@ -70,33 +114,39 @@ class ContingentController extends AbstractController /** * @param AccommodationPrice[] $prices - * @return array{date: string, status: string, pricePerNight: float|null, pricePerNightOverride: float|null, includedPax: int|null} + * @return array{date: string, status: string, type: string|null, pricePerNight: float|null, defaultPricePerNight: float|null, priceAdditionalPerson: float|null, defaultPriceAdditionalPerson: float|null, includedPax: int|null, minPax: int|null, minNights: int|null} */ - private function enrichEntry(string $date, string $status, array $prices): array + private function enrichEntry(string $date, string $status, array $prices, string $currency): array { $day = (new \DateTimeImmutable($date))->setTime(0, 0); - $price = null; - foreach ($prices as $candidate) { - if ($candidate->getDateFrom() > $day || $candidate->getDateTo() < $day) { - continue; - } + // dateFrom and dateTo are both inclusive (last night, not checkout day) + $candidates = array_filter( + $prices, + fn($p) => $p->getDateFrom() <= $day && $p->getDateTo() >= $day, + ); - if (null === $price || $candidate->getDateFrom() > $price->getDateFrom()) { - $price = $candidate; - } + $winner = $this->priceTimelineBuilder->resolveWinner($candidates); + + $defaultPrice = null; + if ($winner?->getType() === PriceType::DISCOUNT) { + $defaults = array_filter($candidates, fn($p) => $p->getType() === null); + $defaultPrice = $this->priceTimelineBuilder->resolveWinner($defaults); } return [ 'date' => $date, 'status' => $status, - 'pricePerNight' => null !== $price ? round($price->getPricePerNight() / 100, 2) : null, - 'pricePerNightOverride' => null !== $price?->getPricePerNightOverride() ? round($price->getPricePerNightOverride() / 100, 2) : null, - 'priceAdditionalPerson' => null !== $price ? round($price->getPriceAdditionalPerson() / 100, 2) : null, - 'priceAdditionalPersonOverride' => null !== $price?->getPriceAdditionalPersonOverride() ? round($price->getPriceAdditionalPersonOverride() / 100, 2) : null, - 'includedPax' => $price?->getIncludedPax(), - 'minPax' => $price?->getAccommodation()->getMinPaxSummer(), - 'minNights' => $price?->getMinNights(), + 'type' => $winner?->getType()?->value, + 'pricePerNight' => $winner !== null ? round($winner->getPricePerNight() / 100, 2) : null, + 'defaultPricePerNight' => $defaultPrice !== null ? round($defaultPrice->getPricePerNight() / 100, 2) : null, + 'priceAdditionalPerson' => $winner !== null ? round($winner->getPriceAdditionalPerson() / 100, 2) : null, + 'defaultPriceAdditionalPerson' => $defaultPrice !== null ? round($defaultPrice->getPriceAdditionalPerson() / 100, 2) : null, + 'currency' => $currency, + 'includedPax' => $winner?->getIncludedPax(), + 'minPax' => $winner?->getAccommodation()->getMinPaxSummer(), + 'minNights' => $winner?->getMinNights(), ]; } + } diff --git a/src/Entity/Groups/AccommodationPrice.php b/src/Entity/Groups/AccommodationPrice.php index 519f419b..49a042c7 100644 --- a/src/Entity/Groups/AccommodationPrice.php +++ b/src/Entity/Groups/AccommodationPrice.php @@ -6,6 +6,7 @@ use App\Entity\BlameableEntity; use App\Entity\BlameableEntityInterface; use App\Entity\TimestampableEntity; use App\Entity\TimestampableEntityInterface; +use App\Enum\Groups\PriceType; use App\Enum\Groups\Season; use App\Repository\Groups\AccommodationPriceRepository; use Doctrine\DBAL\Types\Types; @@ -31,6 +32,7 @@ class AccommodationPrice implements BlameableEntityInterface, TimestampableEntit #[Assert\NotNull(message: 'required')] private ?\DateTimeImmutable $dateFrom = null; + // Inclusive: the last night this price applies. A booking 01.01.–10.01. covers 9 nights (01.01.–09.01.); 10.01. is checkout. #[ORM\Column(type: Types::DATE_IMMUTABLE)] #[Assert\NotNull(message: 'required')] #[Assert\GreaterThan(propertyPath: 'dateFrom', message: 'Das Bis-Datum muss nach dem Von-Datum liegen')] @@ -55,11 +57,8 @@ class AccommodationPrice implements BlameableEntityInterface, TimestampableEntit #[Assert\NotNull(message: 'required')] private ?int $minNights = null; - #[ORM\Column(nullable: true)] - private ?int $pricePerNightOverride = null; - - #[ORM\Column(nullable: true)] - private ?int $priceAdditionalPersonOverride = null; + #[ORM\Column(length: 20, nullable: true, enumType: PriceType::class)] + private ?PriceType $type = null; #[ORM\Column] private bool $acceptUndersubscription = false; @@ -170,40 +169,18 @@ class AccommodationPrice implements BlameableEntityInterface, TimestampableEntit return $this; } - public function getPricePerNightOverride(): ?int + public function getType(): ?PriceType { - return $this->pricePerNightOverride; + return $this->type; } - public function setPricePerNightOverride(?int $pricePerNightOverride): self + public function setType(?PriceType $type): self { - $this->pricePerNightOverride = $pricePerNightOverride; + $this->type = $type; return $this; } - public function getEffectivePricePerNight(): int - { - return $this->pricePerNightOverride ?? $this->pricePerNight; - } - - public function getPriceAdditionalPersonOverride(): ?int - { - return $this->priceAdditionalPersonOverride; - } - - public function setPriceAdditionalPersonOverride(?int $priceAdditionalPersonOverride): self - { - $this->priceAdditionalPersonOverride = $priceAdditionalPersonOverride; - - return $this; - } - - public function getEffectivePriceAdditionalPerson(): int - { - return $this->priceAdditionalPersonOverride ?? $this->priceAdditionalPerson; - } - public function isAcceptUndersubscription(): bool { return $this->acceptUndersubscription; diff --git a/src/Enum/Groups/PriceType.php b/src/Enum/Groups/PriceType.php new file mode 100644 index 00000000..366bd595 --- /dev/null +++ b/src/Enum/Groups/PriceType.php @@ -0,0 +1,25 @@ + 1, + self::DISCOUNT => 2, + }; + } + + public function label(): string + { + return match($this) { + self::OVERRIDE => 'Override', + self::DISCOUNT => 'Rabatt', + }; + } +} diff --git a/src/Form/Admin/Groups/AccommodationPriceType.php b/src/Form/Admin/Groups/AccommodationPriceType.php index d75785be..4390b79e 100644 --- a/src/Form/Admin/Groups/AccommodationPriceType.php +++ b/src/Form/Admin/Groups/AccommodationPriceType.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Form\Admin\Groups; use App\Entity\Groups\AccommodationPrice; +use App\Enum\Groups\PriceType; use App\Enum\Groups\Season; use App\Form\DatepickerType; use Symfony\Component\Form\AbstractType; @@ -30,6 +31,13 @@ class AccommodationPriceType extends AbstractType 'html5' => true, 'widget' => 'single_text', ]) + ->add('type', EnumType::class, [ + 'label' => 'Typ', + 'class' => PriceType::class, + 'required' => false, + 'placeholder' => 'Standard', + 'choice_label' => fn($item) => $item->label(), + ]) ->add('season', EnumType::class, [ 'label' => 'Saison', 'class' => Season::class, @@ -46,23 +54,11 @@ class AccommodationPriceType extends AbstractType 'currency' => $options['currency'], 'divisor' => 100, ]) - ->add('pricePerNightOverride', MoneyType::class, [ - 'label' => 'Streich-Preis pro Nacht', - 'required' => false, - 'currency' => $options['currency'], - 'divisor' => 100, - ]) ->add('priceAdditionalPerson', MoneyType::class, [ 'label' => 'Preis weitere Person', 'currency' => $options['currency'], 'divisor' => 100, ]) - ->add('priceAdditionalPersonOverride', MoneyType::class, [ - 'label' => 'Streich-Preis weitere Person', - 'required' => false, - 'currency' => $options['currency'], - 'divisor' => 100, - ]) ->add('acceptUndersubscription', CheckboxType::class, [ 'label' => 'Unterbelegung akzeptieren', 'required' => false, diff --git a/src/Service/PriceTimelineBuilder.php b/src/Service/PriceTimelineBuilder.php new file mode 100644 index 00000000..958519c8 --- /dev/null +++ b/src/Service/PriceTimelineBuilder.php @@ -0,0 +1,159 @@ + OVERRIDE > base) and shorter/later periods + * on ties. When the winner is a DISCOUNT, resolveWinner() is called a second time + * on the null-type candidates to obtain the original base price, which is exposed + * as defaultPricePerNight / defaultPriceAdditionalPerson. + * 3. Consecutive segments whose winner AND default-price entity are both unchanged + * are merged into one row. Tracking the default-price entity separately prevents + * a DISCOUNT row from being incorrectly extended when the underlying base price + * changes mid-discount period. A gap (no winner) resets both trackers. + * + * @param AccommodationPrice[] $prices + */ + public function buildTimeline( + array $prices, + \DateTimeImmutable $yearStart, + \DateTimeImmutable $yearEnd, + string $currency, + ): array { + if (empty($prices)) { + return []; + } + + $yearEndNext = $yearEnd->modify('+1 day'); + + // Step 1: collect all boundary points, keyed by timestamp for deduplication. + $boundaries = [ + $yearStart->getTimestamp() => $yearStart, + $yearEndNext->getTimestamp() => $yearEndNext, + ]; + foreach ($prices as $price) { + $from = $price->getDateFrom(); + $toNext = $price->getDateTo()->modify('+1 day'); + $boundaries[$from->getTimestamp()] = $from; + $boundaries[$toNext->getTimestamp()] = $toNext; + } + + ksort($boundaries); + $boundaries = array_values($boundaries); + + $rows = []; + $lastWinner = null; + $lastDefault = null; + + // Step 2: walk each segment [start, end) and resolve the effective price. + for ($i = 0, $count = count($boundaries) - 1; $i < $count; $i++) { + $segStart = $boundaries[$i]; + $segEndNext = $boundaries[$i + 1]; + + // Boundaries from prices outside the year are included to correctly detect + // overlaps at the year edges, but the segments themselves are skipped. + if ($segStart < $yearStart || $segStart >= $yearEndNext) { + continue; + } + + $candidates = array_filter( + $prices, + fn($p) => $p->getDateFrom() <= $segStart && $p->getDateTo() >= $segStart, + ); + + $winner = $this->resolveWinner($candidates); + + if ($winner === null) { + // Gap: reset merge state so the next winner always opens a new row. + $lastWinner = null; + $lastDefault = null; + continue; + } + + $defaultWinner = null; + if ($winner->getType() === PriceType::DISCOUNT) { + $defaults = array_filter($candidates, fn($p) => $p->getType() === null); + $defaultWinner = $this->resolveWinner($defaults); + } + + $segDateTo = $segEndNext->modify('-1 day'); + + // Step 3: extend the previous row if both the winner and the default-price entity + // are unchanged; otherwise open a new row. + if ($lastWinner === $winner && $lastDefault === $defaultWinner) { + $rows[count($rows) - 1]['dateTo'] = $segDateTo->format('Y-m-d'); + } else { + $rows[] = [ + 'dateFrom' => $segStart->format('Y-m-d'), + 'dateTo' => $segDateTo->format('Y-m-d'), + 'season' => $winner->getSeason()?->value, + 'includedPax' => $winner->getIncludedPax(), + 'pricePerNight' => round($winner->getPricePerNight() / 100, 2), + 'priceAdditionalPerson' => round($winner->getPriceAdditionalPerson() / 100, 2), + 'defaultPricePerNight' => $defaultWinner !== null ? round($defaultWinner->getPricePerNight() / 100, 2) : null, + 'defaultPriceAdditionalPerson' => $defaultWinner !== null ? round($defaultWinner->getPriceAdditionalPerson() / 100, 2) : null, + 'currency' => $currency, + 'type' => $winner->getType()?->value, + ]; + $lastWinner = $winner; + $lastDefault = $defaultWinner; + } + } + + return $rows; + } + + /** + * Resolves which price entity wins for a set of candidates active at the same point in time. + * + * Higher-priority types win: DISCOUNT (2) > OVERRIDE (1) > base/null (0). + * Ties within the same type are broken by preferring the shorter period, then the later start date. + * + * @param AccommodationPrice[] $candidates + */ + public function resolveWinner(array $candidates): ?AccommodationPrice + { + $winner = null; + foreach ($candidates as $candidate) { + if ($winner === null) { + $winner = $candidate; + continue; + } + $cp = $candidate->getType()?->priority() ?? 0; + $wp = $winner->getType()?->priority() ?? 0; + if ($cp > $wp) { + $winner = $candidate; + continue; + } + if ($cp === $wp) { + $cs = $candidate->getDateFrom()->diff($candidate->getDateTo())->days; + $ws = $winner->getDateFrom()->diff($winner->getDateTo())->days; + if ($cs < $ws || ($cs === $ws && $candidate->getDateFrom() > $winner->getDateFrom())) { + $winner = $candidate; + } + } + } + + return $winner; + } +} diff --git a/src/Twig/Components/Calendar.php b/src/Twig/Components/Calendar.php index bcc52375..454b5db7 100644 --- a/src/Twig/Components/Calendar.php +++ b/src/Twig/Components/Calendar.php @@ -172,19 +172,25 @@ class Calendar continue; } - $priceFormatted = number_format($price->getEffectivePricePerNight() / 100, 2, ',', '.') . ' €'; + $priceFormatted = number_format($price->getPricePerNight() / 100, 2, ',', '.') . ' €'; $label = sprintf('Preis: %s/Nacht (min. %d Nächte)', $priceFormatted, $price->getMinNights()); $day = CarbonImmutable::instance($price->getDateFrom()); $end = CarbonImmutable::instance($price->getDateTo()); $span = $day->diffInDays($end); + $priority = $price->getType()?->priority() ?? 0; $limit = $day->addYears(3); + // $end is the last night (inclusive), so <= is correct while ($day <= $end && $day <= $limit) { $key = $day->format('Y-m-d'); $map[$key] ??= ['price' => null, 'board' => null, 'additional' => null]; - if ($map[$key]['price'] === null || $span < $map[$key]['price']['span']) { - $map[$key]['price'] = ['label' => $label, 'span' => $span]; + $existing = $map[$key]['price']; + $replace = $existing === null + || $priority > $existing['priority'] + || ($priority === $existing['priority'] && $span < $existing['span']); + if ($replace) { + $map[$key]['price'] = ['label' => $label, 'span' => $span, 'priority' => $priority]; } $day = $day->addDay(); } diff --git a/templates/admin/accommodation/_form.html.twig b/templates/admin/accommodation/_form.html.twig index fe7b5731..dc932de0 100644 --- a/templates/admin/accommodation/_form.html.twig +++ b/templates/admin/accommodation/_form.html.twig @@ -62,13 +62,15 @@ - + @@ -90,13 +92,21 @@ {% for price in accommodation.accommodationPrices %} - + @@ -104,16 +114,10 @@ {{ price.includedPax }}
von bis + Typ + Saison
{{ price.dateFrom | date('d.m.Y') }} {{ price.dateTo | date('d.m.Y') }} + {% if price.type is not null %} + {% if price.type.value == 'discount' %} + {{ icon('discount', 'w-5 h-5') }} + {% elseif price.type.value == 'override' %} + {{ icon('stack', 'w-5 h-5') }} + {% endif %} + {% endif %} + {{ price.season.token | trans }} - {{ (price.effectivePricePerNight / 100) | format_currency(accommodation.currency) }} - {% if price.pricePerNightOverride is not null %} - {{ (price.pricePerNight / 100) | format_currency(accommodation.currency) }} - {% endif %} + {{ (price.pricePerNight / 100) | format_currency(accommodation.currency) }} - {{ (price.effectivePriceAdditionalPerson / 100) | format_currency(accommodation.currency) }} - {% if price.priceAdditionalPersonOverride is not null %} - {{ (price.priceAdditionalPerson / 100) | format_currency(accommodation.currency) }} - {% endif %} + {{ (price.priceAdditionalPerson / 100) | format_currency(accommodation.currency) }} {% if price.acceptUndersubscription %} diff --git a/templates/admin/accommodation_price/_form.html.twig b/templates/admin/accommodation_price/_form.html.twig index 7868db88..46964b87 100644 --- a/templates/admin/accommodation_price/_form.html.twig +++ b/templates/admin/accommodation_price/_form.html.twig @@ -2,15 +2,14 @@ {{ form_start(form) }}
-
+
{{ form_row(form.dateFrom) }} {{ form_row(form.dateTo) }} {{ form_row(form.season) }} + {{ form_row(form.type) }}
{{ form_row(form.pricePerNight) }} - {{ form_row(form.pricePerNightOverride) }} {{ form_row(form.priceAdditionalPerson) }} - {{ form_row(form.priceAdditionalPersonOverride) }} {{ form_row(form.includedPax) }} {{ form_row(form.minNights) }} {{ form_row(form.acceptUndersubscription) }} diff --git a/templates/components/calendar/grid.html.twig b/templates/components/calendar/grid.html.twig index ccca0cc5..af8ddb24 100644 --- a/templates/components/calendar/grid.html.twig +++ b/templates/components/calendar/grid.html.twig @@ -1,56 +1,71 @@ -
+{% set weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %} +
{% for month in months %} + {% set totalCols = month.firstDow + (month.days | length) %}
-
{{ month.label }}
- -
- {% for header in ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %} -
{{ header }}
- {% endfor %} - - {% if month.firstDow > 0 %} - {% for i in 1..month.firstDow %} -
- {% endfor %} - {% endif %} - - {% for dayNum, day in month.days %} -
- {{ dayNum }} -
- {% if day.hasPrice %} - - {% endif %} - {% if day.hasBoard %} - - {% endif %} - {% if day.hasAdditional %} - - {% endif %} - {% if day.isBlocked %} - - {% endif %} -
+
{{ month.label }}
+
+
+
+
+ {% for col in 0..(totalCols - 1) %} + {% set dow = col % 7 %} +
+ {{ weekdays[dow] }} +
+ {% endfor %}
- {% endfor %} +
+
+ {% if month.firstDow > 0 %} + {% for i in 1..month.firstDow %} +
+ {% endfor %} + {% endif %} + {% for dayNum, day in month.days %} +
+ {{ dayNum }} +
+ {% endfor %} +
+ {% for row in [ + {label: 'Preis', key: 'hasPrice', color: 'bg-blue-500'}, + {label: 'Verpfl.', key: 'hasBoard', color: 'bg-green-500'}, + {label: 'Zusatzl.', key: 'hasAdditional', color: 'bg-amber-500'}, + {label: 'n. v.', key: 'isBlocked', color: 'bg-red-500'}, + ] %} +
+
{{ row.label }}
+ {% if month.firstDow > 0 %} + {% for i in 1..month.firstDow %} +
+ {% endfor %} + {% endif %} + {% for dayNum, day in month.days %} +
+
+
+ {% endfor %} +
+ {% endfor %} +
{% endfor %}
-
+
- Preiskonfigurationen + Preiskonfigurationen
- Verpflegungsleistungen + Verpflegungsleistungen
- optionale Zusatzleistungen + optionale Zusatzleistungen
- nicht verfügbar + nicht verfügbar
- diff --git a/tests/Service/PriceTimelineBuilderTest.php b/tests/Service/PriceTimelineBuilderTest.php new file mode 100644 index 00000000..74ea9e1c --- /dev/null +++ b/tests/Service/PriceTimelineBuilderTest.php @@ -0,0 +1,261 @@ +builder = new PriceTimelineBuilder(); + } + + // --- buildTimeline --- + + public function testReturnsEmptyArrayForNoPrices(): void + { + $result = $this->builder->buildTimeline([], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertSame([], $result); + } + + public function testSingleBasePriceWithinYear(): void + { + $price = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000, priceAdditionalPerson: 2000, includedPax: 2); + + $result = $this->builder->buildTimeline([$price], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertCount(1, $result); + $this->assertSame('2026-01-01', $result[0]['dateFrom']); + $this->assertSame('2026-03-31', $result[0]['dateTo']); + $this->assertSame(150.0, $result[0]['pricePerNight']); + $this->assertSame(20.0, $result[0]['priceAdditionalPerson']); + $this->assertSame(2, $result[0]['includedPax']); + $this->assertNull($result[0]['type']); + $this->assertNull($result[0]['defaultPricePerNight']); + $this->assertNull($result[0]['defaultPriceAdditionalPerson']); + $this->assertSame('EUR', $result[0]['currency']); + } + + public function testBasePriceStartingBeforeYearIsClamped(): void + { + $price = $this->makePrice('2025-12-01', '2026-03-31', pricePerNight: 10000); + + $result = $this->builder->buildTimeline([$price], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertCount(1, $result); + $this->assertSame('2026-01-01', $result[0]['dateFrom']); + $this->assertSame('2026-03-31', $result[0]['dateTo']); + } + + public function testBasePriceEndingAfterYearIsClamped(): void + { + $price = $this->makePrice('2026-10-01', '2027-01-31', pricePerNight: 10000); + + $result = $this->builder->buildTimeline([$price], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertCount(1, $result); + $this->assertSame('2026-10-01', $result[0]['dateFrom']); + $this->assertSame('2026-12-31', $result[0]['dateTo']); + } + + public function testDiscountSplitsBasePeriodIntoThreeRows(): void + { + $base = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000); + $discount = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 12000, type: PriceType::DISCOUNT); + + $result = $this->builder->buildTimeline([$base, $discount], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertCount(3, $result); + $this->assertSame('2026-01-01', $result[0]['dateFrom']); + $this->assertSame('2026-01-14', $result[0]['dateTo']); + $this->assertNull($result[0]['type']); + + $this->assertSame('2026-01-15', $result[1]['dateFrom']); + $this->assertSame('2026-01-31', $result[1]['dateTo']); + $this->assertSame('discount', $result[1]['type']); + + $this->assertSame('2026-02-01', $result[2]['dateFrom']); + $this->assertSame('2026-03-31', $result[2]['dateTo']); + $this->assertNull($result[2]['type']); + } + + public function testDiscountRowIncludesDefaultPrice(): void + { + $base = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000, priceAdditionalPerson: 3000); + $discount = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 12000, priceAdditionalPerson: 2000, type: PriceType::DISCOUNT); + + $result = $this->builder->buildTimeline([$base, $discount], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $discountRow = $result[1]; + $this->assertSame(120.0, $discountRow['pricePerNight']); + $this->assertSame(150.0, $discountRow['defaultPricePerNight']); + $this->assertSame(20.0, $discountRow['priceAdditionalPerson']); + $this->assertSame(30.0, $discountRow['defaultPriceAdditionalPerson']); + } + + public function testBaseRowsHaveNullDefaultPriceFields(): void + { + $base = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000); + $discount = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 12000, type: PriceType::DISCOUNT); + + $result = $this->builder->buildTimeline([$base, $discount], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertNull($result[0]['defaultPricePerNight']); + $this->assertNull($result[0]['defaultPriceAdditionalPerson']); + $this->assertNull($result[2]['defaultPricePerNight']); + $this->assertNull($result[2]['defaultPriceAdditionalPerson']); + } + + public function testDiscountWithNoBasePriceHasNullDefaultPrice(): void + { + $discount = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 12000, type: PriceType::DISCOUNT); + + $result = $this->builder->buildTimeline([$discount], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertCount(1, $result); + $this->assertSame('discount', $result[0]['type']); + $this->assertNull($result[0]['defaultPricePerNight']); + } + + public function testGapBetweenTwoPricesIsOmitted(): void + { + $first = $this->makePrice('2026-01-01', '2026-01-31', pricePerNight: 10000); + $second = $this->makePrice('2026-03-01', '2026-03-31', pricePerNight: 20000); + + $result = $this->builder->buildTimeline([$first, $second], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertCount(2, $result); + $this->assertSame('2026-01-01', $result[0]['dateFrom']); + $this->assertSame('2026-01-31', $result[0]['dateTo']); + $this->assertSame('2026-03-01', $result[1]['dateFrom']); + $this->assertSame('2026-03-31', $result[1]['dateTo']); + } + + public function testOverrideSplitsBasePeriod(): void + { + $base = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000); + $override = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 18000, type: PriceType::OVERRIDE); + + $result = $this->builder->buildTimeline([$base, $override], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertCount(3, $result); + $this->assertNull($result[0]['type']); + $this->assertSame('override', $result[1]['type']); + $this->assertSame(180.0, $result[1]['pricePerNight']); + $this->assertNull($result[2]['type']); + } + + public function testDiscountOverTwoDifferentBasePricesProducesTwoDiscountRows(): void + { + // The discount spans two different base price periods. Even though the discount entity + // is the same, the rows must NOT be merged because defaultPricePerNight differs. + $baseA = $this->makePrice('2026-01-01', '2026-01-20', pricePerNight: 10000); + $baseB = $this->makePrice('2026-01-21', '2026-03-31', pricePerNight: 12000); + $discount = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 8000, type: PriceType::DISCOUNT); + + $result = $this->builder->buildTimeline([$baseA, $baseB, $discount], new \DateTimeImmutable('2026-01-01'), new \DateTimeImmutable('2026-12-31'), 'EUR'); + + $this->assertCount(4, $result); + + $this->assertSame('2026-01-01', $result[0]['dateFrom']); + $this->assertSame('2026-01-14', $result[0]['dateTo']); + $this->assertNull($result[0]['type']); + + $this->assertSame('2026-01-15', $result[1]['dateFrom']); + $this->assertSame('2026-01-20', $result[1]['dateTo']); + $this->assertSame('discount', $result[1]['type']); + $this->assertSame(100.0, $result[1]['defaultPricePerNight']); // baseA + + $this->assertSame('2026-01-21', $result[2]['dateFrom']); + $this->assertSame('2026-01-31', $result[2]['dateTo']); + $this->assertSame('discount', $result[2]['type']); + $this->assertSame(120.0, $result[2]['defaultPricePerNight']); // baseB + + $this->assertSame('2026-02-01', $result[3]['dateFrom']); + $this->assertSame('2026-03-31', $result[3]['dateTo']); + $this->assertNull($result[3]['type']); + } + + // --- resolveWinner --- + + public function testResolveWinnerReturnsNullForEmptyArray(): void + { + $this->assertNull($this->builder->resolveWinner([])); + } + + public function testResolveWinnerReturnsSingleCandidate(): void + { + $price = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 10000); + + $this->assertSame($price, $this->builder->resolveWinner([$price])); + } + + public function testResolveWinnerPrefersDiscountOverBase(): void + { + $base = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000); + $discount = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 12000, type: PriceType::DISCOUNT); + + $this->assertSame($discount, $this->builder->resolveWinner([$base, $discount])); + } + + public function testResolveWinnerPrefersOverrideOverBase(): void + { + $base = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000); + $override = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 18000, type: PriceType::OVERRIDE); + + $this->assertSame($override, $this->builder->resolveWinner([$base, $override])); + } + + public function testResolveWinnerPrefersDiscountOverOverride(): void + { + $override = $this->makePrice('2026-01-01', '2026-01-31', pricePerNight: 18000, type: PriceType::OVERRIDE); + $discount = $this->makePrice('2026-01-01', '2026-01-31', pricePerNight: 12000, type: PriceType::DISCOUNT); + + $this->assertSame($discount, $this->builder->resolveWinner([$override, $discount])); + } + + public function testResolveWinnerPrefersShorterPeriodOnEqualTypeTie(): void + { + $wide = $this->makePrice('2026-01-01', '2026-03-31', pricePerNight: 15000); + $narrow = $this->makePrice('2026-01-15', '2026-01-31', pricePerNight: 12000); + + $this->assertSame($narrow, $this->builder->resolveWinner([$wide, $narrow])); + } + + public function testResolveWinnerPrefersLaterStartOnEqualLengthTie(): void + { + // Both cover 30 days; the one starting later should win. + $earlier = $this->makePrice('2026-01-01', '2026-01-30', pricePerNight: 15000); + $later = $this->makePrice('2026-02-01', '2026-03-02', pricePerNight: 12000); + + $this->assertSame($later, $this->builder->resolveWinner([$earlier, $later])); + } + + private function makePrice( + string $dateFrom, + string $dateTo, + int $pricePerNight, + int $priceAdditionalPerson = 0, + int $includedPax = 2, + ?PriceType $type = null, + ): AccommodationPrice { + $price = new AccommodationPrice(); + $price->setDateFrom(new \DateTimeImmutable($dateFrom)); + $price->setDateTo(new \DateTimeImmutable($dateTo)); + $price->setPricePerNight($pricePerNight); + $price->setPriceAdditionalPerson($priceAdditionalPerson); + $price->setIncludedPax($includedPax); + $price->setType($type); + + return $price; + } +}