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 * * @return list */ public function buildTimeline( array $prices, \DateTimeImmutable $rangeStart, \DateTimeImmutable $rangeEnd, string $currency, ): array { if (empty($prices)) { return []; } $rangeEndNext = $rangeEnd->modify('+1 day'); // Step 1: collect all boundary points, keyed by timestamp for deduplication. $boundaries = [ $rangeStart->getTimestamp() => $rangeStart, $rangeEndNext->getTimestamp() => $rangeEndNext, ]; 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 range are included to correctly detect // overlaps at the range edges, but the segments themselves are skipped. if ($segStart < $rangeStart || $segStart >= $rangeEndNext) { continue; } $candidates = array_filter( $prices, fn ($p) => $p->getDateFrom() <= $segStart && $p->getDateTo() >= $segStart, ); $winner = $this->resolveWinner($candidates); if (null === $winner) { // Gap: reset merge state so the next winner always opens a new row. $lastWinner = null; $lastDefault = null; continue; } $defaultWinner = null; if (PriceType::DISCOUNT === $winner->getType()) { $defaults = array_filter($candidates, fn ($p) => null === $p->getType()); $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) { $lastRow = $rows[count($rows) - 1]; $rows[count($rows) - 1] = new PriceTimelineItem( dateFrom: $lastRow->dateFrom, dateTo: $segDateTo->format('Y-m-d'), season: $lastRow->season, includedPax: $lastRow->includedPax, pricePerNight: $lastRow->pricePerNight, priceAdditionalPerson: $lastRow->priceAdditionalPerson, defaultPricePerNight: $lastRow->defaultPricePerNight, defaultPriceAdditionalPerson: $lastRow->defaultPriceAdditionalPerson, currency: $lastRow->currency, type: $lastRow->type, ); } else { $rows[] = new PriceTimelineItem( 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: null !== $defaultWinner ? round($defaultWinner->getPricePerNight() / 100, 2) : null, defaultPriceAdditionalPerson: null !== $defaultWinner ? 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 (null === $winner) { $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; } }