maps groupspriceoption "type" column to AdditionalServiceType */ private const array OPTION_TYPE_MAP = [ 1 => AdditionalServiceType::Flat, 2 => AdditionalServiceType::PerPerson, 3 => AdditionalServiceType::PerNight, 4 => AdditionalServiceType::PerPersonPerNight, ]; /** @var array maps seasons.csv "token" column to Season, per translations/messages.de.yaml */ private const array SEASON_TOKEN_MAP = [ 'HS' => Season::PEAK, 'NS' => Season::SECONDARY, 'VNS' => Season::ADV_SECONDARY, ]; public function __construct( private readonly AccommodationRepository $accommodationRepository, private readonly EntityManagerInterface $entityManager, ) { parent::__construct(); } protected function configure(): void { $this ->addArgument('path', InputArgument::OPTIONAL, 'Directory containing hotels.csv, groupspriceconfig.csv, groupspriceboard.csv, groupspriceoption.csv, seasons.csv', 'temp') ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Parse and report without persisting anything') ; } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $dryRun = (bool) $input->getOption('dry-run'); $path = rtrim((string) $input->getArgument('path'), '/'); foreach (['hotels.csv', 'groupspriceconfig.csv', 'groupspriceboard.csv', 'groupspriceoption.csv', 'seasons.csv'] as $file) { if (!is_readable($path.'/'.$file)) { $io->error(sprintf('Cannot read %s/%s', $path, $file)); return Command::FAILURE; } } $hotels = $this->readCsv($path.'/hotels.csv'); $configRows = $this->readCsv($path.'/groupspriceconfig.csv'); $boardRows = $this->readCsv($path.'/groupspriceboard.csv'); $optionRows = $this->readCsv($path.'/groupspriceoption.csv'); $seasonRows = $this->readCsv($path.'/seasons.csv', ','); $currencyByHotelUid = $this->deriveCurrencies($configRows); $seasonRanges = $this->parseSeasonRanges($seasonRows); $created = []; $updated = []; /** @var array $accommodationByUid */ $accommodationByUid = []; foreach ($hotels as $row) { $uid = (int) $row['uid']; $code = trim($row['code']); $name = trim($row['name']); $currency = $currencyByHotelUid[$uid] ?? 'EUR'; $existing = $this->accommodationRepository->findOneByCmsCode($code) ?? $this->accommodationRepository->findOneByCalendarCode($code); if (null !== $existing) { $existing->setName($name); $existing->setCurrency($currency); foreach ($existing->getAccommodationPrices()->toArray() as $price) { $existing->removeAccommodationPrice($price); if (!$dryRun) { $this->entityManager->remove($price); } } foreach ($existing->getBoardServices()->toArray() as $board) { $existing->removeBoardService($board); if (!$dryRun) { $this->entityManager->remove($board); } } foreach ($existing->getAdditionalServices()->toArray() as $service) { $existing->removeAdditionalService($service); if (!$dryRun) { $this->entityManager->remove($service); } } $accommodationByUid[$uid] = $existing; $updated[] = sprintf('%s (%s) — replacing existing prices/board/services', $name, $code); continue; } $existingByCalendarCode = null; foreach ($accommodationByUid as $a) { // guard against double-import: a prior run may already have created this one if ($a->getCalendarCode() === $code) { $existingByCalendarCode = $a; break; } } if (null !== $existingByCalendarCode) { $accommodationByUid[$uid] = $existingByCalendarCode; continue; } $accommodation = new Accommodation(); $accommodation->setCalendarCode($code); $accommodation->setName($name); $accommodation->setCurrency($currency); $accommodation->setMaxAdolescentAge(0); if (!$dryRun) { $this->entityManager->persist($accommodation); } $accommodationByUid[$uid] = $accommodation; $created[] = sprintf('%s (%s, %s)', $name, $code, $currency); } $knownUids = array_keys($accommodationByUid); [$priceCount, $skippedPriceUids, $unmatchedSeasonCount] = $this->importPrices($configRows, $accommodationByUid, $knownUids, $seasonRanges, $dryRun); [$boardCount, $skippedBoardUids] = $this->importBoardServices($boardRows, $accommodationByUid, $knownUids, $dryRun); [$optionCount, $skippedOptionUids, $optionsNeedingDefaultRange] = $this->importAdditionalServices($optionRows, $accommodationByUid, $knownUids, $dryRun); // BoardService/AdditionalService need a date range; derive it from each // accommodation's freshly-imported AccommodationPrice rows now that they exist. // AdditionalServices whose title carried its own year keep the scoped range set above. $this->applyDateRangeToServices($accommodationByUid, $optionsNeedingDefaultRange); if (!$dryRun) { $this->entityManager->flush(); } $io->section($dryRun ? 'Dry run summary' : 'Import summary'); $io->writeln(sprintf('Accommodations created: %d', \count($created))); foreach ($created as $line) { $io->writeln(' + '.$line); } $io->writeln(sprintf('Accommodations updated: %d', \count($updated))); foreach ($updated as $line) { $io->writeln(' ~ '.$line); } $io->writeln(sprintf('AccommodationPrice rows imported: %d', $priceCount)); if ($unmatchedSeasonCount > 0) { $io->writeln(sprintf(' of which %d fell outside all seasons.csv ranges and defaulted to %s', $unmatchedSeasonCount, self::DEFAULT_SEASON->value)); } $io->writeln(sprintf('BoardService rows imported: %d', $boardCount)); $io->writeln(sprintf('AdditionalService rows imported: %d', $optionCount)); $skippedUids = array_unique(array_merge($skippedPriceUids, $skippedBoardUids, $skippedOptionUids)); sort($skippedUids); if ([] !== $skippedUids) { $io->warning(sprintf( 'Skipped rows referencing hotel uid(s) not present in hotels.csv: %s', implode(', ', $skippedUids), )); } $io->note('Dropped legacy columns with no entity equivalent (verified all-zero or unused): bonus_card_included, price_additional_person_group_1/2/3[_chf], ignore_with_board.'); if ($dryRun) { $io->note('Dry run — nothing was persisted.'); } $io->success('Done.'); return Command::SUCCESS; } /** * @return list> */ private function readCsv(string $file, string $separator = ';'): array { $handle = fopen($file, 'r'); if (false === $handle) { throw new \RuntimeException(sprintf('Unable to open %s', $file)); } $header = fgetcsv($handle, escape: '\\', separator: $separator, enclosure: '"'); if (false === $header) { fclose($handle); return []; } $rows = []; while (false !== ($row = fgetcsv($handle, escape: '\\', separator: $separator, enclosure: '"'))) { $rows[] = array_combine($header, $row); } fclose($handle); return $rows; } /** * Derives EUR/CHF per hotel uid from groupspriceconfig.csv: if any row for that hotel * has a non-zero price_chf, the hotel is priced in CHF, otherwise EUR. * * @param list> $configRows * * @return array */ private function deriveCurrencies(array $configRows): array { $currencies = []; foreach ($configRows as $row) { $uid = (int) $row['hotel']; if ($this->parseDecimal($row['price_chf']) > 0.0) { $currencies[$uid] = 'CHF'; } elseif (!isset($currencies[$uid])) { $currencies[$uid] = 'EUR'; } } return $currencies; } private function parseDecimal(string $value): float { $value = trim($value); if ('' === $value) { return 0.0; } return (float) str_replace(',', '.', $value); } private function toCents(string $value): int { return (int) round($this->parseDecimal($value) * 100); } private function priceColumn(string $currency): string { return 'CHF' === $currency ? 'price_chf' : 'price'; } /** * Parses seasons.csv (token,date_from,date_to; date_to exclusive, same convention as * groupspriceconfig.csv) into Season-tagged ranges, skipping unrecognized tokens. * * @param list> $rows * * @return list */ private function parseSeasonRanges(array $rows): array { $ranges = []; foreach ($rows as $row) { $season = self::SEASON_TOKEN_MAP[trim($row['token'])] ?? null; if (null === $season) { continue; } $ranges[] = [ 'season' => $season, 'dateFrom' => new \DateTimeImmutable(trim($row['date_from'])), 'dateTo' => new \DateTimeImmutable(trim($row['date_to'])), ]; } return $ranges; } /** * Resolves the season with the largest night-count overlap with [$dateFrom, $dateToExclusive) * among $seasonRanges. Falls back to DEFAULT_SEASON (with matched=false) when the price * period doesn't overlap any season range at all (e.g. a gap in seasons.csv). * * @param list $seasonRanges * * @return array{0: Season, 1: bool} */ private function resolveSeason(\DateTimeImmutable $dateFrom, \DateTimeImmutable $dateToExclusive, array $seasonRanges): array { $bestSeason = null; $bestOverlapDays = 0; foreach ($seasonRanges as $range) { $overlapStart = max($dateFrom, $range['dateFrom']); $overlapEnd = min($dateToExclusive, $range['dateTo']); if ($overlapStart >= $overlapEnd) { continue; } $overlapDays = $overlapStart->diff($overlapEnd)->days; if ($overlapDays > $bestOverlapDays) { $bestOverlapDays = $overlapDays; $bestSeason = $range['season']; } } return [$bestSeason ?? self::DEFAULT_SEASON, null !== $bestSeason]; } /** * @param list> $rows * @param array $accommodationByUid * @param list $knownUids * @param list $seasonRanges * * @return array{0: int, 1: list, 2: int} */ private function importPrices(array $rows, array $accommodationByUid, array $knownUids, array $seasonRanges, bool $dryRun): array { $count = 0; $skipped = []; $unmatchedSeasonCount = 0; foreach ($rows as $row) { $uid = (int) $row['hotel']; if (!\in_array($uid, $knownUids, true)) { $skipped[] = $uid; continue; } $accommodation = $accommodationByUid[$uid]; $column = $this->priceColumn($accommodation->getCurrency()); $additionalColumn = 'CHF' === $accommodation->getCurrency() ? 'price_additional_person_chf' : 'price_additional_person'; $rawDateFrom = (new \DateTimeImmutable('@'.$row['date_from']))->setTime(0, 0); // Source date_to is exclusive; AccommodationPrice::dateTo is the last included night. $rawDateToExclusive = (new \DateTimeImmutable('@'.$row['date_to']))->setTime(0, 0); [$season, $matched] = $this->resolveSeason($rawDateFrom, $rawDateToExclusive, $seasonRanges); if (!$matched) { ++$unmatchedSeasonCount; } $price = new AccommodationPrice(); $price->setDateFrom($rawDateFrom); $price->setDateTo($rawDateToExclusive->modify('-1 day')); $price->setSeason($season); $price->setIncludedPax((int) $row['persons_included']); $price->setPricePerNight($this->toCents($row[$column])); $price->setPriceAdditionalPerson($this->toCents($row[$additionalColumn])); $price->setMinNights(self::DEFAULT_MIN_NIGHTS); $price->setType(null); $price->setAcceptUndersubscription(false); $price->setAcceptShortTerm(false); $accommodation->addAccommodationPrice($price); if (!$dryRun) { $this->entityManager->persist($price); } ++$count; } return [$count, $skipped, $unmatchedSeasonCount]; } /** * @param list> $rows * @param array $accommodationByUid * @param list $knownUids * * @return array{0: int, 1: list} */ private function importBoardServices(array $rows, array $accommodationByUid, array $knownUids, bool $dryRun): array { $count = 0; $skipped = []; foreach ($rows as $row) { $uid = (int) $row['hotel']; if (!\in_array($uid, $knownUids, true)) { $skipped[] = $uid; continue; } $accommodation = $accommodationByUid[$uid]; $column = $this->priceColumn($accommodation->getCurrency()); $board = new BoardService(); $board->setLabel(trim($row['title'])); $board->setPrice($this->toCents($row[$column])); // dateFrom/dateTo filled in applyDateRangeToServices() once prices are known. $board->setDateFrom(new \DateTimeImmutable('today')); $board->setDateTo(new \DateTimeImmutable('today')); $accommodation->addBoardService($board); if (!$dryRun) { $this->entityManager->persist($board); } ++$count; } return [$count, $skipped]; } /** * @param list> $rows * @param array $accommodationByUid * @param list $knownUids * * @return array{0: int, 1: list, 2: list} */ private function importAdditionalServices(array $rows, array $accommodationByUid, array $knownUids, bool $dryRun): array { $count = 0; $skipped = []; $needsDefaultRange = []; foreach ($rows as $row) { $uid = (int) $row['hotel']; if (!\in_array($uid, $knownUids, true)) { $skipped[] = $uid; continue; } $accommodation = $accommodationByUid[$uid]; $column = $this->priceColumn($accommodation->getCurrency()); $type = self::OPTION_TYPE_MAP[(int) $row['type']] ?? AdditionalServiceType::Flat; [$year, $label] = $this->stripYearPrefix(trim($row['title'])); $service = new AdditionalService(); $service->setLabel($label); $service->setPrice($this->toCents($row[$column])); $service->setType($type); if (null !== $year) { // Title carried its own year (e.g. "2026 Endreinigung ..."); scope validity to // that accommodation's own price rows within that year, not the calendar year. [$yearDateFrom, $yearDateTo] = $this->findYearPriceRange($accommodation, $year); $service->setDateFrom($yearDateFrom ?? new \DateTimeImmutable($year.'-01-01')); $service->setDateTo($yearDateTo ?? new \DateTimeImmutable($year.'-12-31')); } else { // dateFrom/dateTo filled in applyDateRangeToServices() once prices are known. $service->setDateFrom(new \DateTimeImmutable('today')); $service->setDateTo(new \DateTimeImmutable('today')); $needsDefaultRange[] = $service; } $accommodation->addAdditionalService($service); if (!$dryRun) { $this->entityManager->persist($service); } ++$count; } return [$count, $skipped, $needsDefaultRange]; } /** * Splits a leading "YYYY " prefix off a title, e.g. "2026 Endreinigung Küche" → * [2026, "Endreinigung Küche"]. Returns [null, $label unchanged] when no such prefix * is present. * * @return array{0: ?int, 1: string} */ private function stripYearPrefix(string $label): array { if (preg_match('/^(\d{4})\s+(.+)$/', $label, $matches)) { return [(int) $matches[1], $matches[2]]; } return [null, $label]; } /** * Finds the earliest dateFrom / latest dateTo among an accommodation's already-imported * AccommodationPrice rows that fall within the given calendar year. * * @return array{0: ?\DateTimeImmutable, 1: ?\DateTimeImmutable} */ private function findYearPriceRange(Accommodation $accommodation, int $year): array { $dateFrom = null; $dateTo = null; foreach ($accommodation->getAccommodationPrices() as $price) { if ((int) $price->getDateFrom()->format('Y') !== $year) { continue; } if (null === $dateFrom || $price->getDateFrom() < $dateFrom) { $dateFrom = $price->getDateFrom(); } if (null === $dateTo || $price->getDateTo() > $dateTo) { $dateTo = $price->getDateTo(); } } return [$dateFrom, $dateTo]; } /** * @param array $accommodationByUid * @param list $additionalServicesNeedingDefaultRange only these * (titles without a leading year) get the accommodation- * wide fallback range; year-prefixed ones already have * their scoped range set in importAdditionalServices() */ private function applyDateRangeToServices(array $accommodationByUid, array $additionalServicesNeedingDefaultRange): void { $needsDefaultRange = new \SplObjectStorage(); foreach ($additionalServicesNeedingDefaultRange as $service) { $needsDefaultRange->attach($service); } $seen = []; foreach ($accommodationByUid as $accommodation) { $id = spl_object_id($accommodation); if (isset($seen[$id])) { continue; } $seen[$id] = true; $prices = $accommodation->getAccommodationPrices(); if ($prices->isEmpty()) { continue; } $dateFrom = null; $dateTo = null; foreach ($prices as $price) { if (null === $dateFrom || $price->getDateFrom() < $dateFrom) { $dateFrom = $price->getDateFrom(); } if (null === $dateTo || $price->getDateTo() > $dateTo) { $dateTo = $price->getDateTo(); } } if (null === $dateFrom || null === $dateTo) { continue; } foreach ($accommodation->getBoardServices() as $board) { $board->setDateFrom($dateFrom); $board->setDateTo($dateTo); } foreach ($accommodation->getAdditionalServices() as $service) { if ($needsDefaultRange->contains($service)) { $service->setDateFrom($dateFrom); $service->setDateTo($dateTo); } } } } }