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; } }