wip: refactored price overrides
This commit is contained in:
1 parent
4d0aa0b82d
commit
6a6051e92b
13 files changed
+668
-118
No files matched your search
@@ -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
|
||||
|
||||
@@ -175,7 +175,15 @@
|
||||
<symbol id="icon-no-symbol" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</symbol>
|
||||
<symbol id="icon-draggable" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8.5 7C9.32843 7 10 6.32843 10 5.5C10 4.67157 9.32843 4 8.5 4C7.67157 4 7 4.67157 7 5.5C7 6.32843 7.67157 7 8.5 7ZM8.5 13.5C9.32843 13.5 10 12.8284 10 12C10 11.1716 9.32843 10.5 8.5 10.5C7.67157 10.5 7 11.1716 7 12C7 12.8284 7.67157 13.5 8.5 13.5ZM10 18.5C10 19.3284 9.32843 20 8.5 20C7.67157 20 7 19.3284 7 18.5C7 17.6716 7.67157 17 8.5 17C9.32843 17 10 17.6716 10 18.5ZM15.5 7C16.3284 7 17 6.32843 17 5.5C17 4.67157 16.3284 4 15.5 4C14.6716 4 14 4.67157 14 5.5C14 6.32843 14.6716 7 15.5 7ZM17 12C17 12.8284 16.3284 13.5 15.5 13.5C14.6716 13.5 14 12.8284 14 12C14 11.1716 14.6716 10.5 15.5 10.5C16.3284 10.5 17 11.1716 17 12ZM15.5 20C16.3284 20 17 19.3284 17 18.5C17 17.6716 16.3284 17 15.5 17C14.6716 17 14 17.6716 14 18.5C14 19.3284 14.6716 20 15.5 20Z"/></symbol>
|
||||
<symbol id="icon-draggable" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8.5 7C9.32843 7 10 6.32843 10 5.5C10 4.67157 9.32843 4 8.5 4C7.67157 4 7 4.67157 7 5.5C7 6.32843 7.67157 7 8.5 7ZM8.5 13.5C9.32843 13.5 10 12.8284 10 12C10 11.1716 9.32843 10.5 8.5 10.5C7.67157 10.5 7 11.1716 7 12C7 12.8284 7.67157 13.5 8.5 13.5ZM10 18.5C10 19.3284 9.32843 20 8.5 20C7.67157 20 7 19.3284 7 18.5C7 17.6716 7.67157 17 8.5 17C9.32843 17 10 17.6716 10 18.5ZM15.5 7C16.3284 7 17 6.32843 17 5.5C17 4.67157 16.3284 4 15.5 4C14.6716 4 14 4.67157 14 5.5C14 6.32843 14.6716 7 15.5 7ZM17 12C17 12.8284 16.3284 13.5 15.5 13.5C14.6716 13.5 14 12.8284 14 12C14 11.1716 14.6716 10.5 15.5 10.5C16.3284 10.5 17 11.1716 17 12ZM15.5 20C16.3284 20 17 19.3284 17 18.5C17 17.6716 16.3284 17 15.5 17C14.6716 17 14 17.6716 14 18.5C14 19.3284 14.6716 20 15.5 20Z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-discount" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10.0544 2.0941C11.1756 1.13856 12.8248 1.13855 13.9461 2.09411L15.2941 3.24286C15.4542 3.37935 15.6533 3.46182 15.8631 3.47856L17.6286 3.61945C19.0971 3.73663 20.2633 4.9028 20.3805 6.37131L20.5214 8.13679C20.5381 8.34654 20.6205 8.54568 20.757 8.70585L21.9058 10.0539C22.8614 11.1751 22.8614 12.8243 21.9058 13.9456L20.757 15.2935C20.6206 15.4537 20.538 15.6529 20.5213 15.8627L20.3805 17.6281C20.2633 19.0967 19.0971 20.2628 17.6286 20.3799L15.8631 20.5208C15.6533 20.5376 15.4542 20.6201 15.2941 20.7566L13.9461 21.9053C12.8248 22.8609 11.1756 22.8608 10.0543 21.9053L8.70631 20.7566C8.54615 20.6201 8.34705 20.5376 8.1373 20.5209L6.37184 20.3799C4.9033 20.2627 3.73716 19.0966 3.61997 17.6281L3.47906 15.8627C3.46232 15.6529 3.37983 15.4538 3.24336 15.2936L2.0946 13.9455C1.13905 12.8243 1.13904 11.1752 2.09458 10.0539L3.24334 8.70589C3.37983 8.54573 3.46234 8.34654 3.47907 8.13678L3.61996 6.3713C3.73714 4.90278 4.90327 3.73665 6.3718 3.61946L8.13729 3.47857C8.34705 3.46183 8.54619 3.37935 8.70636 3.24286L10.0544 2.0941ZM12.6488 3.61632C12.2751 3.29782 11.7253 3.29781 11.3516 3.61632L10.0036 4.76509C9.5231 5.17456 8.92568 5.42201 8.29637 5.47223L6.5309 5.61312C6.04139 5.65219 5.65268 6.04089 5.61362 6.53041L5.47272 8.29593C5.4225 8.92521 5.17505 9.52259 4.76559 10.0031L3.61683 11.3511C3.29832 11.7248 3.29831 12.2746 3.61683 12.6483L4.76559 13.9963C5.17506 14.4768 5.4225 15.0743 5.47275 15.7035L5.61363 17.469C5.65268 17.9585 6.04139 18.3473 6.53092 18.3863L8.29636 18.5272C8.92563 18.5774 9.5231 18.8249 10.0036 19.2344L11.3516 20.3831C11.7254 20.7016 12.2751 20.7016 12.6488 20.3831L13.9969 19.2343C14.4773 18.8249 15.0747 18.5774 15.704 18.5272L17.4695 18.3863C17.959 18.3472 18.3478 17.9585 18.3868 17.469L18.5277 15.7035C18.5779 15.0742 18.8253 14.4768 19.2349 13.9964L20.3836 12.6483C20.7022 12.2746 20.7021 11.7249 20.3836 11.3511L19.2348 10.0031C18.8253 9.52259 18.5779 8.92519 18.5277 8.2959L18.3868 6.53041C18.3478 6.0409 17.959 5.65219 17.4695 5.61312L15.704 5.47224C15.0748 5.42203 14.4773 5.17455 13.9968 4.76508L12.6488 3.61632ZM14.8284 7.75718L16.2426 9.1714L9.17154 16.2425L7.75733 14.8282L14.8284 7.75718ZM10.2322 10.232C9.64641 10.8178 8.69667 10.8178 8.11088 10.232C7.52509 9.6463 7.52509 8.69652 8.11088 8.11073C8.69667 7.52494 9.64641 7.52494 10.2322 8.11073C10.818 8.69652 10.818 9.6463 10.2322 10.232ZM13.7677 15.8889C14.3535 16.4747 15.3032 16.4747 15.889 15.8889C16.4748 15.3031 16.4748 14.3534 15.889 13.7676C15.3032 13.1818 14.3535 13.1818 13.7677 13.7676C13.1819 14.3534 13.1819 15.3031 13.7677 15.8889Z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-stack" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.0833 15.1999L21.2854 15.9212C21.5221 16.0633 21.5989 16.3704 21.4569 16.6072C21.4146 16.6776 21.3557 16.7365 21.2854 16.7787L12.5144 22.0412C12.1977 22.2313 11.8021 22.2313 11.4854 22.0412L2.71451 16.7787C2.47772 16.6366 2.40093 16.3295 2.54301 16.0927C2.58523 16.0223 2.64413 15.9634 2.71451 15.9212L3.9166 15.1999L11.9999 20.0499L20.0833 15.1999ZM20.0833 10.4999L21.2854 11.2212C21.5221 11.3633 21.5989 11.6704 21.4569 11.9072C21.4146 11.9776 21.3557 12.0365 21.2854 12.0787L11.9999 17.6499L2.71451 12.0787C2.47772 11.9366 2.40093 11.6295 2.54301 11.3927C2.58523 11.3223 2.64413 11.2634 2.71451 11.2212L3.9166 10.4999L11.9999 15.3499L20.0833 10.4999ZM12.5144 1.30864L21.2854 6.5712C21.5221 6.71327 21.5989 7.0204 21.4569 7.25719C21.4146 7.32757 21.3557 7.38647 21.2854 7.42869L11.9999 12.9999L2.71451 7.42869C2.47772 7.28662 2.40093 6.97949 2.54301 6.7427C2.58523 6.67232 2.64413 6.61343 2.71451 6.5712L11.4854 1.30864C11.8021 1.11864 12.1977 1.11864 12.5144 1.30864ZM11.9999 3.33233L5.88723 6.99995L11.9999 10.6676L18.1126 6.99995L11.9999 3.33233Z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-bus" xmlns="http://www.w3.org/2000/svg" stroke-width="1.5" viewBox="0 0 24 24" stroke="currentColor" fill="none">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M6 17m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260702084714 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Replace override price fields with a PriceType column; migrate existing override rows to sibling DISCOUNT records';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum\Groups;
|
||||
|
||||
enum PriceType: string
|
||||
{
|
||||
case OVERRIDE = 'override';
|
||||
case DISCOUNT = 'discount';
|
||||
|
||||
public function priority(): int
|
||||
{
|
||||
return match($this) {
|
||||
self::OVERRIDE => 1,
|
||||
self::DISCOUNT => 2,
|
||||
};
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::OVERRIDE => 'Override',
|
||||
self::DISCOUNT => 'Rabatt',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Enum\Groups\PriceType;
|
||||
|
||||
class PriceTimelineBuilder
|
||||
{
|
||||
/**
|
||||
* Builds a flat, non-overlapping timeline of effective price rows for the given year.
|
||||
*
|
||||
* Base price periods are split wherever an override or discount applies, so each
|
||||
* returned row represents a contiguous date range with a single effective price.
|
||||
* Periods with no price configured are omitted. Prices that start before or end after
|
||||
* the queried year are clamped to its boundaries.
|
||||
*
|
||||
* The algorithm is an interval sweep:
|
||||
* 1. Collect every dateFrom and (dateTo + 1 day) from all prices as boundary points,
|
||||
* plus the year start and (yearEnd + 1 day). This ensures the sweep slices at
|
||||
* every point where the set of active prices changes.
|
||||
* 2. Walk adjacent boundary pairs [start, end). For each segment, find all prices
|
||||
* active at `start` and resolve the winner via resolveWinner(), which prefers
|
||||
* higher-priority types (DISCOUNT > 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -62,13 +62,15 @@
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>
|
||||
von
|
||||
</th>
|
||||
<th>
|
||||
bis
|
||||
</th>
|
||||
<th>
|
||||
Typ
|
||||
</th>
|
||||
<th>
|
||||
Saison
|
||||
</th>
|
||||
@@ -90,13 +92,21 @@
|
||||
<tbody>
|
||||
{% for price in accommodation.accommodationPrices %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>
|
||||
{{ price.dateFrom | date('d.m.Y') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ price.dateTo | date('d.m.Y') }}
|
||||
</td>
|
||||
<td>
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td>
|
||||
{{ price.season.token | trans }}
|
||||
</td>
|
||||
@@ -104,16 +114,10 @@
|
||||
{{ price.includedPax }}
|
||||
</td>
|
||||
<td>
|
||||
{{ (price.effectivePricePerNight / 100) | format_currency(accommodation.currency) }}
|
||||
{% if price.pricePerNightOverride is not null %}
|
||||
<span class="line-through text-xs">{{ (price.pricePerNight / 100) | format_currency(accommodation.currency) }}</span>
|
||||
{% endif %}
|
||||
{{ (price.pricePerNight / 100) | format_currency(accommodation.currency) }}
|
||||
</td>
|
||||
<td>
|
||||
{{ (price.effectivePriceAdditionalPerson / 100) | format_currency(accommodation.currency) }}
|
||||
{% if price.priceAdditionalPersonOverride is not null %}
|
||||
<span class="line-through text-xs">{{ (price.priceAdditionalPerson / 100) | format_currency(accommodation.currency) }}</span>
|
||||
{% endif %}
|
||||
{{ (price.priceAdditionalPerson / 100) | format_currency(accommodation.currency) }}
|
||||
</td>
|
||||
<td>
|
||||
{% if price.acceptUndersubscription %}
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
|
||||
{{ form_start(form) }}
|
||||
<div class="grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
|
||||
<div class="lg:col-span-2 grid lg:grid-cols-3 gap-y-4 lg:gap-x-8">
|
||||
<div class="lg:col-span-2 grid lg:grid-cols-4 gap-y-4 lg:gap-x-8">
|
||||
{{ form_row(form.dateFrom) }}
|
||||
{{ form_row(form.dateTo) }}
|
||||
{{ form_row(form.season) }}
|
||||
{{ form_row(form.type) }}
|
||||
</div>
|
||||
{{ 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) }}
|
||||
|
||||
@@ -1,56 +1,71 @@
|
||||
<div class="grid grid-cols-3 gap-8 mb-4">
|
||||
{% set weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %}
|
||||
<div class="space-y-4 mb-4">
|
||||
{% for month in months %}
|
||||
{% set totalCols = month.firstDow + (month.days | length) %}
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-center mb-2">{{ month.label }}</div>
|
||||
|
||||
<div class="grid grid-cols-7 rounded-md overflow-hidden ring-1 ring-gray-300">
|
||||
{% for header in ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %}
|
||||
<div class="text-center text-xs text-gray-400 font-medium py-1 bg-gray-50 border-r border-b border-gray-200">{{ header }}</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if month.firstDow > 0 %}
|
||||
{% for i in 1..month.firstDow %}
|
||||
<div class="h-8 border-r border-b border-gray-200 bg-gray-50/40"></div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% for dayNum, day in month.days %}
|
||||
<div class="{{ html_classes('h-8 flex flex-col items-center justify-between py-1 border-r border-b border-gray-200 cursor-default', { 'bg-secondary/25': day.isToday }) }}"
|
||||
{% if day.tooltip %}{{ stimulus_controller('tooltip', {'content': day.tooltip}) }}{% endif %}
|
||||
>
|
||||
<span class="text-xs text-gray-700 leading-none">{{ dayNum }}</span>
|
||||
<div class="flex gap-px">
|
||||
{% if day.hasPrice %}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span>
|
||||
{% endif %}
|
||||
{% if day.hasBoard %}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||
{% endif %}
|
||||
{% if day.hasAdditional %}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-500"></span>
|
||||
{% endif %}
|
||||
{% if day.isBlocked %}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-sm font-semibold mb-1">{{ month.label }}</div>
|
||||
<div class="overflow-x-auto">
|
||||
<div class="w-max">
|
||||
<div class="flex">
|
||||
<div class="w-20 shrink-0"></div>
|
||||
{% for col in 0..(totalCols - 1) %}
|
||||
{% set dow = col % 7 %}
|
||||
<div class="w-6 text-center">
|
||||
<span class="block text-xs leading-none {{ dow >= 5 ? 'text-red-400' : 'text-gray-300' }}">{{ weekdays[dow] }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="flex mb-0.5">
|
||||
<div class="w-20 shrink-0"></div>
|
||||
{% if month.firstDow > 0 %}
|
||||
{% for i in 1..month.firstDow %}
|
||||
<div class="w-6"></div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% for dayNum, day in month.days %}
|
||||
<div class="w-6 text-center">
|
||||
<span class="{{ html_classes('block text-xs leading-none cursor-default', {'font-bold text-secondary': day.isToday, 'text-gray-500': not day.isToday}) }}"
|
||||
{% if day.tooltip %}{{ stimulus_controller('tooltip', {'content': day.tooltip}) }}{% endif %}
|
||||
>{{ dayNum }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% 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'},
|
||||
] %}
|
||||
<div class="flex items-center mt-0.5">
|
||||
<div class="w-20 shrink-0 text-xs text-gray-500">{{ row.label }}</div>
|
||||
{% if month.firstDow > 0 %}
|
||||
{% for i in 1..month.firstDow %}
|
||||
<div class="w-6 px-px"><div class="h-3"></div></div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% for dayNum, day in month.days %}
|
||||
<div class="w-6 px-px">
|
||||
<div class="h-3 {{ day[row.key] ? row.color : 'bg-gray-100' }}"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="flex justify-around items-center gap-6 mb-4 text-sm text-gray-600">
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2 mb-4 text-sm text-gray-600">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-blue-500"></span> Preiskonfigurationen
|
||||
<span class="inline-block w-4 h-3 bg-blue-500"></span> Preiskonfigurationen
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-green-500"></span> Verpflegungsleistungen
|
||||
<span class="inline-block w-4 h-3 bg-green-500"></span> Verpflegungsleistungen
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-amber-500"></span> optionale Zusatzleistungen
|
||||
<span class="inline-block w-4 h-3 bg-amber-500"></span> optionale Zusatzleistungen
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-red-500"></span> nicht verfügbar
|
||||
<span class="inline-block w-4 h-3 bg-red-500"></span> nicht verfügbar
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Enum\Groups\PriceType;
|
||||
use App\Service\PriceTimelineBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class PriceTimelineBuilderTest extends TestCase
|
||||
{
|
||||
private PriceTimelineBuilder $builder;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user