feat: groups price calculator admin crud, booking/offer flow and api

This commit is contained in:
Björn Fromme
2026-08-03 15:25:10 +02:00
parent eabed8295a
commit 9d2aa11fdb
258 changed files with 16231 additions and 582 deletions
@@ -0,0 +1,582 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationPrice;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Enum\Groups\AdditionalServiceType;
use App\Enum\Groups\Season;
use App\Repository\Groups\AccommodationRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:groups:import-legacy-data',
description: 'Imports Accommodation/AccommodationPrice/BoardService/AdditionalService data from the legacy TYPO3 CSV exports (hotels.csv, groupspriceconfig.csv, groupspriceboard.csv, groupspriceoption.csv, seasons.csv)',
)]
class ImportGroupsLegacyDataCommand extends Command
{
private const int DEFAULT_MIN_NIGHTS = 1;
private const Season DEFAULT_SEASON = Season::SECONDARY;
/** @var array<int, AdditionalServiceType> 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<string, Season> 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<int, Accommodation> $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<array<string, string>>
*/
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<array<string, string>> $configRows
*
* @return array<int, string>
*/
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<array<string, string>> $rows
*
* @return list<array{season: Season, dateFrom: \DateTimeImmutable, dateTo: \DateTimeImmutable}>
*/
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<array{season: Season, dateFrom: \DateTimeImmutable, dateTo: \DateTimeImmutable}> $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<array<string, string>> $rows
* @param array<int, Accommodation> $accommodationByUid
* @param list<int> $knownUids
* @param list<array{season: Season, dateFrom: \DateTimeImmutable, dateTo: \DateTimeImmutable}> $seasonRanges
*
* @return array{0: int, 1: list<int>, 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<array<string, string>> $rows
* @param array<int, Accommodation> $accommodationByUid
* @param list<int> $knownUids
*
* @return array{0: int, 1: list<int>}
*/
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<array<string, string>> $rows
* @param array<int, Accommodation> $accommodationByUid
* @param list<int> $knownUids
*
* @return array{0: int, 1: list<int>, 2: list<AdditionalService>}
*/
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<int, Accommodation> $accommodationByUid
* @param list<AdditionalService> $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);
}
}
}
}
}