feat: api endpoints and xml sync for contingents data

This commit is contained in:
Björn Fromme
2026-04-22 11:23:52 +02:00
parent a1f68ec11d
commit 9b84893d88
50 changed files with 2567 additions and 221 deletions
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
use Symfony\Component\Serializer\Attribute\Groups;
class ContingentCalendarEvent
{
public function __construct(
#[Groups(['api:contingent'])]
public string $date,
#[Groups(['api:contingent'])]
public string $status,
#[Groups(['api:contingent'])]
public int $pax,
#[Groups(['api:contingent'])]
public int $available,
) {
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
use Symfony\Component\Serializer\Attribute\Groups;
class ContingentDateSummary
{
public function __construct(
#[Groups(['api:contingent'])]
public string $date,
#[Groups(['api:contingent'])]
public ?int $minNights,
#[Groups(['api:contingent'])]
public int $total,
#[Groups(['api:contingent'])]
public int $capacity,
) {
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
use Symfony\Component\Serializer\Attribute\Groups;
class ContingentRoomAvailability
{
public function __construct(
#[Groups(['api:contingent'])]
public string $date,
#[Groups(['api:contingent'])]
public string $roomCode,
#[Groups(['api:contingent'])]
public string $roomLabel,
#[Groups(['api:contingent'])]
public string $bookingUrl,
#[Groups(['api:contingent'])]
public int $pax,
#[Groups(['api:contingent'])]
public int $available,
#[Groups(['api:contingent'])]
public string $status,
#[Groups(['api:contingent'])]
public ?float $minPrice,
#[Groups(['api:contingent'])]
public ?int $minNights,
#[Groups(['api:contingent'])]
public ?float $additionalNightMinPrice,
#[Groups(['api:contingent'])]
public ?int $additionalNightMinNights,
#[Groups(['api:contingent'])]
public float $priceForSelection,
) {
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ trait TypeConversionTrait
protected function stringToFloat(?string $string): ?float
{
if (true === empty($string)) {
if (null === $string || '' === $string) {
return null;
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Utility;
class BookingUrlUtility
{
public function __construct(
private readonly string $defaultBaseUrl,
) {
}
public function build(int $hotelId, int $dateId, ?string $myEpUrl = null): string
{
$query = http_build_query([
'date_id' => $dateId,
'hotel_id' => $hotelId,
], '', '&', PHP_QUERY_RFC3986);
$baseUrlInput = null !== $myEpUrl && '' !== trim($myEpUrl)
? $myEpUrl
: $this->defaultBaseUrl;
$baseUrl = $this->normalizeBaseUrl($baseUrlInput);
if ('' === $baseUrl) {
return sprintf('/bookings/create?%s', $query);
}
return sprintf('%s/bookings/create?%s', $baseUrl, $query);
}
private function normalizeBaseUrl(string $url): string
{
$trimmedUrl = trim($url);
return rtrim($trimmedUrl, '/');
}
}
@@ -0,0 +1,127 @@
<?php
namespace App\BusProNet\XmlLoader;
use App\BusProNet\XmlParser\ContingentParser;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use Psr\Cache\InvalidArgumentException;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
class ContingentLoader extends AbstractLoader
{
public function __construct(
private readonly ContingentParser $parser,
TagAwareCacheInterface $cache,
FilesystemOperator $xmlExportContingents,
) {
parent::__construct($cache, $xmlExportContingents);
}
/**
* @return array<int, string>
*/
public function generateFilesMap(): array
{
try {
return $this->cache->get('bpn_contingent_files', function (ItemInterface $item) {
$item->expiresAfter(3 * 60 * 60);
$item->tag(['xml-sync']);
$xmlFiles = $this->xmlExport
->listContents('.')
->filter(fn (StorageAttributes $attributes) => $attributes->isFile() && str_starts_with($attributes->path(), 'HotelZimmer_'));
$mapping = [];
foreach ($xmlFiles as $file) {
if (1 !== preg_match('/HotelZimmer_(\d+)\.xml$/', $file->path(), $matches)) {
continue;
}
$mapping[(int) $matches[1]] = $file->path();
}
return $mapping;
});
} catch (InvalidArgumentException) {
return [];
}
}
/**
* @return array{roomTypes: array<int, array<string, mixed>>, rows: array<int, array<string, mixed>>}
*/
public function loadByHotelId(int $hotelId): array
{
$cacheKey = sprintf('contingent_hotel_%d', $hotelId);
try {
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($hotelId) {
$item->expiresAfter(3600);
$item->tag(['xml-sync']);
return $this->loadByHotelIdUncached($hotelId);
});
} catch (InvalidArgumentException) {
return $this->loadByHotelIdUncached($hotelId);
}
}
/**
* @return array{roomTypes: array<int, array<string, mixed>>, rows: array<int, array<string, mixed>>}
*/
private function loadByHotelIdUncached(int $hotelId): array
{
$mapping = $this->generateFilesMap();
if (false === isset($mapping[$hotelId])) {
return ['roomTypes' => [], 'rows' => []];
}
$filename = $mapping[$hotelId];
try {
$xml = $this->xmlExport->read($filename);
} catch (FilesystemException) {
return ['roomTypes' => [], 'rows' => []];
}
$linkedHotelId = $this->getRootLinkedHotelId($xml);
if (null !== $linkedHotelId) {
if (false === isset($mapping[$linkedHotelId])) {
return ['roomTypes' => [], 'rows' => []];
}
try {
$xml = $this->xmlExport->read($mapping[$linkedHotelId]);
} catch (FilesystemException) {
return ['roomTypes' => [], 'rows' => []];
}
}
return $this->parser->parse($xml);
}
private function getRootLinkedHotelId(string $xml): ?int
{
$document = new \DOMDocument();
if (false === @$document->loadXML($xml)) {
return null;
}
$root = $document->documentElement;
if (null === $root) {
return null;
}
$link = $root->getAttribute('idbuspro_kontingent_aus');
if ('' === $link) {
return null;
}
return (int) $link;
}
}
+1
View File
@@ -17,6 +17,7 @@ class HotelLoader extends AbstractLoader
try {
return $this->cache->get('bpn_hotels', function (ItemInterface $item) use ($filename) {
$item->expiresAfter(3 * 60 * 60);
$item->tag(['xml-sync']);
$hotels = [];
+2 -1
View File
@@ -27,7 +27,8 @@ class InsuranceLoader extends AbstractLoader
{
try {
return $this->cache->get('bpn_insurances', function (ItemInterface $item) use ($filename) {
$item->expiresAfter(24 * 60 * 60);
$item->expiresAfter(3 * 60 * 60);
$item->tag(['xml-sync']);
$crawler = $this->loadXml($filename);
+1
View File
@@ -16,6 +16,7 @@ class PickupLoader extends AbstractLoader
try {
return $this->cache->get('bpn_pickups', function (ItemInterface $item) use ($filename) {
$item->expiresAfter(3 * 60 * 60);
$item->tag(['xml-sync']);
$crawler = $this->loadXml($filename);
$pickupNodes = $crawler->filterXPath('//zustiege/zustieg');
+1
View File
@@ -58,6 +58,7 @@ class TravelLoader extends AbstractLoader
try {
return $this->cache->get('bpn_travels_mapping', function (ItemInterface $item) {
$item->expiresAfter(3 * 60 * 60);
$item->tag(['xml-sync']);
$xmlFiles = $this
->xmlExport
@@ -9,6 +9,15 @@ abstract class AbstractParser
{
use TypeConversionTrait;
protected function getIntOrNullAttribute(?string $value): ?int
{
if (null === $value || '' === $value) {
return null;
}
return (int) $value;
}
protected function getStringOrNullValue(Crawler $node): ?string
{
return 0 < $node->count() ? $node->text() : null;
@@ -0,0 +1,151 @@
<?php
namespace App\BusProNet\XmlParser;
use Symfony\Component\DomCrawler\Crawler;
class ContingentParser extends AbstractParser
{
private const CONTROL_ROOM_CODE = 'BelKal';
/**
* @var array<string>
*/
private array $ignoredRoomCodes = [
'PDGS',
];
/**
* @return array{roomTypes: array<int, array<string, mixed>>, rows: array<int, array<string, mixed>>}
*/
public function parse(string $xml): array
{
$crawler = new Crawler($xml);
$roomTypes = $this->parseRoomTypes($crawler);
$rows = $this->parseRows($crawler, $roomTypes);
return [
'roomTypes' => $roomTypes,
'rows' => $rows,
];
}
/**
* @return array<int, array<string, mixed>>
*/
private function parseRoomTypes(Crawler $crawler): array
{
$roomTypes = [];
$crawler
->filterXPath('//unterbringungen/unterbringung')
->each(function (Crawler $node) use (&$roomTypes) {
$roomId = $this->getIntOrNullAttribute($node->attr('idbuspro'));
$code = $node->attr('code');
if (null === $roomId || null === $code) {
return;
}
if (true === in_array($code, $this->ignoredRoomCodes, true)) {
return;
}
$label = $node->attr('zimmerbezeichnung')
?? $this->getStringOrNullValue($node->filterXPath('.//text'))
?? $code;
$pax = $this->getIntOrNullAttribute($node->attr('pax_max')) ?? 0;
$link = $this->getIntOrNullAttribute($node->attr('idbuspro_kontingent_aus'));
$roomTypes[$roomId] = [
'idBusPro' => $roomId,
'code' => $code,
'pax' => $pax,
'label' => $label,
'isControlRoom' => self::CONTROL_ROOM_CODE === $code,
'link' => $link,
];
});
return $roomTypes;
}
/**
* @param array<int, array<string, mixed>> $roomTypes
*
* @return array<int, array<string, mixed>>
*/
private function parseRows(Crawler $crawler, array $roomTypes): array
{
$rows = [];
$crawler
->filterXPath('//kapazitaeten/kapazitaet')
->each(function (Crawler $dateNode) use (&$rows, $roomTypes) {
$date = $this->stringToDate($dateNode->attr('termin'));
if (null === $date) {
return;
}
foreach ($roomTypes as $roomType) {
$roomId = $roomType['link'] ?? $roomType['idBusPro'];
$roomNode = $this->findRoomNode($dateNode, $roomId);
if (null === $roomNode) {
continue;
}
$capacity = $this->getIntOrNullAttribute($roomNode->attr('kontingent')) ?? 0;
$free = $this->getIntOrNullAttribute($roomNode->attr('frei')) ?? 0;
$statusRaw = $roomNode->attr('status') ?? '';
$rows[] = [
'date' => $date,
'roomCode' => $roomType['code'],
'roomLabel' => $roomType['label'],
'pax' => $roomType['isControlRoom'] ? 0 : $capacity * (int) ($roomType['pax'] ?? 0),
'available' => $roomType['isControlRoom'] ? 0 : $free * (int) ($roomType['pax'] ?? 0),
'status' => $this->mapStatus($statusRaw),
'minPrice' => $this->stringToFloat($roomNode->attr('abpreis')),
'minNights' => $this->getIntOrNullAttribute($roomNode->attr('abpreis_naechte')),
'additionalNightMinPrice' => $this->stringToFloat($roomNode->attr('abpreis_verlaengerung')),
'additionalNightMinNights' => $this->getIntOrNullAttribute($roomNode->attr('abpreis_verlaengerung_naechte')),
'isControlRoom' => $roomType['isControlRoom'],
];
}
});
return $rows;
}
private function findRoomNode(Crawler $dateNode, int $roomId): ?Crawler
{
$roomNode = $dateNode->filterXPath(sprintf('.//zimmerliste/zimmer[@idbuspro="%d"]', $roomId));
if (0 < $roomNode->count()) {
return $roomNode->first();
}
$roomNode = $dateNode->filterXPath(sprintf('.//zimmerliste/zimmer[@id="%d"]', $roomId));
if (0 < $roomNode->count()) {
return $roomNode->first();
}
$roomNode = $dateNode->filterXPath(sprintf('.//zimmerliste/zimmer[@id_zimmer="%d"]', $roomId));
if (0 < $roomNode->count()) {
return $roomNode->first();
}
return null;
}
private function mapStatus(?string $status): string
{
return match (strtoupper((string) $status)) {
'A' => 'ON_REQUEST',
'S' => 'BLOCKED',
default => 'OK',
};
}
}
@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\BusProNet\Model\XmlExportInfo;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\LockableTrait;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
#[AsCommand(
name: 'app:bpn:xml-cache-invalidate',
description: 'Invalidates BPN XML caches when local uebertragung.info changes'
)]
class BpnXmlCacheInvalidateCommand extends Command
{
use LockableTrait;
private const CACHE_TAGS_TO_INVALIDATE = ['xml-sync'];
private const STATE_CACHE_KEY = 'bpn_xml_info_state';
public function __construct(
private readonly FilesystemOperator $xmlExport,
private readonly FilesystemOperator $xmlExportContingents,
private readonly TagAwareCacheInterface $bpnCache,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('force', 'f', InputOption::VALUE_NONE, 'Invalidate cache tags without state comparison');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if (false === $this->lock()) {
$io->warning('Cache invalidation is already running in another process');
return Command::SUCCESS;
}
if (true === $input->getOption('force')) {
$this->invalidateCaches();
$io->success('Cache tags invalidated (forced)');
return Command::SUCCESS;
}
$state = $this->loadState();
$newState = $state;
$changedDatasets = [];
foreach ($this->getInfoTargets() as $target) {
$dataset = $target['name'];
$info = $this->readInfo($target['storage'], $dataset);
if (null === $info) {
continue;
}
$signature = $this->buildSignature($info);
$newState[$dataset] = $signature;
$io->text(sprintf(
'[%s] %s (%d files)',
$dataset,
$info->lastTransfer->format('d.m.Y H:i:s'),
$info->fileCount
));
if (false === array_key_exists($dataset, $state) || $state[$dataset] !== $signature) {
$changedDatasets[] = $dataset;
}
}
if ([] === $changedDatasets) {
$io->success('No uebertragung.info changes detected');
return Command::SUCCESS;
}
$this->invalidateCaches();
$this->saveState($newState);
$io->success(sprintf(
'Invalidated cache tags due to changes in: %s',
implode(', ', $changedDatasets)
));
$this->logger->info('BPN cache invalidated after uebertragung.info change', [
'datasets' => $changedDatasets,
]);
return Command::SUCCESS;
}
private function readInfo(FilesystemOperator $storage, string $dataset): ?XmlExportInfo
{
try {
if (false === $storage->fileExists(XmlExportInfo::getFilename())) {
$this->logger->warning('uebertragung.info missing', ['dataset' => $dataset]);
return null;
}
$content = $storage->read(XmlExportInfo::getFilename());
return XmlExportInfo::fromString($content);
} catch (FilesystemException|\InvalidArgumentException $e) {
$this->logger->warning('Could not parse uebertragung.info', [
'dataset' => $dataset,
'exception' => $e->getMessage(),
]);
return null;
}
}
private function buildSignature(XmlExportInfo $info): string
{
return sprintf('%s|%d', $info->lastTransfer->format('c'), $info->fileCount);
}
private function invalidateCaches(): void
{
$this->bpnCache->invalidateTags(self::CACHE_TAGS_TO_INVALIDATE);
}
/**
* @return array<string, string>
*/
private function loadState(): array
{
$stateJson = $this->cache->get(self::STATE_CACHE_KEY, fn (): string => '{}');
return $this->decodeState($stateJson);
}
/**
* @return array<string, string>
*/
private function decodeState(string $stateJson): array
{
try {
$state = json_decode($stateJson, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return [];
}
if (false === is_array($state)) {
return [];
}
$normalizedState = [];
foreach ($state as $dataset => $signature) {
if (true === is_string($dataset) && true === is_string($signature)) {
$normalizedState[$dataset] = $signature;
}
}
return $normalizedState;
}
/**
* @param array<string, string> $state
*/
private function saveState(array $state): void
{
$this->cache->delete(self::STATE_CACHE_KEY);
$this->cache->get(self::STATE_CACHE_KEY, fn (): string => json_encode($state, JSON_THROW_ON_ERROR));
}
/**
* @return array<int, array{name: string, storage: FilesystemOperator}>
*/
private function getInfoTargets(): array
{
return [
[
'name' => 'travel',
'storage' => $this->xmlExport,
],
[
'name' => 'contingents',
'storage' => $this->xmlExportContingents,
],
];
}
}
+221 -149
View File
@@ -5,12 +5,11 @@ declare(strict_types=1);
namespace App\Command;
use App\BusProNet\Model\XmlExportInfo;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Service\TravelDataProvider;
use App\Service\TravelSnapshotManager;
use App\Model\BpnXmlSnapshotRefreshResult;
use App\Model\BpnXmlSyncTarget;
use App\Service\BpnXmlSyncManager;
use App\Service\BpnXmlSnapshotRefreshManager;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
@@ -19,7 +18,6 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\Cache\CacheInterface;
#[AsCommand(
name: 'app:bpn:xml-sync',
@@ -29,20 +27,10 @@ class BpnXmlSyncCommand extends Command
{
use LockableTrait;
private const CACHE_KEYS_TO_INVALIDATE = [
'bpn_travels_mapping',
'bpn_hotels',
'bpn_pickups',
];
public function __construct(
private readonly FilesystemOperator $xmlSource,
private readonly FilesystemOperator $xmlExport,
private readonly CacheInterface $cache,
private readonly BpnXmlSyncManager $syncManager,
private readonly BpnXmlSnapshotRefreshManager $snapshotRefreshManager,
private readonly LoggerInterface $logger,
private readonly TravelLoader $travelLoader,
private readonly TravelDataProvider $travelDataService,
private readonly TravelSnapshotManager $snapshotService,
) {
parent::__construct();
}
@@ -65,177 +53,261 @@ class BpnXmlSyncCommand extends Command
return Command::SUCCESS;
}
$force = $input->getOption('force');
$dryRun = $input->getOption('dry-run');
return $this->sync(
(bool) $input->getOption('force'),
(bool) $input->getOption('dry-run'),
$io,
$output,
);
}
$remoteInfo = $this->readRemoteInfo();
private function sync(bool $force, bool $dryRun, SymfonyStyle $io, OutputInterface $output): int
{
$totalSyncedCount = 0;
$totalDeletedCount = 0;
$hasSyncedFiles = false;
$hasFailures = false;
$travelSynced = false;
$travelRemoteTimestamp = null;
$snapshotResult = null;
$orphanedDeleted = null;
if (null === $remoteInfo) {
$io->warning('Remote info unavailable - will retry on next scheduled run');
foreach ($this->syncManager->getSyncTargets() as $target) {
$targetResult = $this->syncTarget($target, $force, $dryRun, $io, $output);
return Command::SUCCESS;
if ($targetResult['failed']) {
$hasFailures = true;
}
if (false === $targetResult['synced']) {
continue;
}
$hasSyncedFiles = true;
$totalSyncedCount += $targetResult['updated'];
$totalDeletedCount += $targetResult['deleted'];
if ($targetResult['travelSynced']) {
$travelSynced = true;
$travelRemoteTimestamp = $targetResult['remoteTimestamp'];
}
}
$io->text($this->formatInfoLine('Remote', $remoteInfo, $output));
$localInfo = $this->readLocalInfo();
if (null !== $localInfo) {
$io->text($this->formatInfoLine('Local', $localInfo, $output));
} else {
$io->text('Local: no data');
if (false === $dryRun && $hasSyncedFiles) {
$invalidatedTags = $this->syncManager->invalidateCaches();
$io->text(sprintf(
'Invalidated %d cache tags',
$invalidatedTags,
));
}
$needsSync = $force || null === $localInfo || $remoteInfo->isNewerThan($localInfo);
if (false === $needsSync) {
$io->success('Local data is up to date, no sync required');
$this->logger->info('XML sync skipped: local data is up to date');
return Command::SUCCESS;
if (false === $dryRun && $travelSynced) {
$io->section('Refreshing snapshots');
$snapshotResult = $this->refreshSnapshots($io);
if (null !== $snapshotResult) {
$orphanedDeleted = $snapshotResult->orphanedDeleted;
}
}
if ($dryRun) {
$io->note('Sync required (dry-run mode, no files downloaded)');
return Command::SUCCESS;
}
$io->section('Syncing files');
try {
$syncResult = $this->syncFiles($io);
} catch (FilesystemException $e) {
$io->error(sprintf('Sync failed: %s', $e->getMessage()));
$this->logger->error('XML sync failed during file transfer', ['exception' => $e]);
if ($hasFailures) {
return Command::FAILURE;
}
$this->invalidateCaches();
$io->info(sprintf('Invalidated %d cache keys', count(self::CACHE_KEYS_TO_INVALIDATE)));
$io->section('Refreshing snapshots');
$xmlFileMap = null;
$snapshotResult = null;
$orphanedDeleted = null;
try {
$xmlFileMap = $this->travelLoader->generateFilesMap();
} catch (\Throwable $e) {
$this->logger->warning('Failed to generate XML file map after sync; snapshot refresh skipped', [
'error' => $e->getMessage(),
]);
if ($dryRun) {
return Command::SUCCESS;
}
if (null !== $xmlFileMap) {
$total = array_sum(array_map(fn ($entry) => count($entry['hotels']), $xmlFileMap));
$io->progressStart($total);
$snapshotResult = $this->travelDataService->syncSnapshotsFromXml(
$xmlFileMap,
function () use ($io): void { $io->progressAdvance(); },
if ($hasSyncedFiles) {
$successMessage = sprintf(
'Sync complete: %d files updated, %d files deleted',
$totalSyncedCount,
$totalDeletedCount
);
$io->progressFinish();
if (null !== $snapshotResult) {
$successMessage .= sprintf(
', snapshots: %d processed, %d failed',
$snapshotResult->processed,
$snapshotResult->failed
);
}
$orphanedDeleted = $this->snapshotService->purgeOrphanedFutureSnapshots(array_keys($xmlFileMap));
if (null !== $orphanedDeleted) {
$successMessage .= sprintf(', orphaned deleted: %d', $orphanedDeleted);
}
$io->success($successMessage);
$this->logger->info('XML sync completed', [
'files_updated' => $totalSyncedCount,
'files_deleted' => $totalDeletedCount,
'snapshots_processed' => $snapshotResult?->processed,
'snapshots_failed' => $snapshotResult?->failed,
'orphaned_deleted' => $orphanedDeleted,
'remote_timestamp' => $travelRemoteTimestamp,
]);
return Command::SUCCESS;
}
$successMessage = sprintf('Sync complete: %d files updated, %d files deleted', $syncResult['updated'], $syncResult['deleted']);
if (null !== $snapshotResult) {
$successMessage .= sprintf(', snapshots: %d processed, %d failed', $snapshotResult['processed'], $snapshotResult['failed']);
}
if (null !== $orphanedDeleted) {
$successMessage .= sprintf(', orphaned deleted: %d', $orphanedDeleted);
}
$io->success($successMessage);
$this->logger->info('XML sync completed', [
'files_updated' => $syncResult['updated'],
'files_deleted' => $syncResult['deleted'],
'snapshots_processed' => $snapshotResult['processed'] ?? null,
'snapshots_failed' => $snapshotResult['failed'] ?? null,
'orphaned_deleted' => $orphanedDeleted,
'remote_timestamp' => $remoteInfo->lastTransfer->format('c'),
]);
$io->success('No sync required');
return Command::SUCCESS;
}
private function readRemoteInfo(): ?XmlExportInfo
/**
* @return array{synced: bool, failed: bool, updated: int, deleted: int, travelSynced: bool, remoteTimestamp: ?string}
*/
private function syncTarget(
BpnXmlSyncTarget $target,
bool $force,
bool $dryRun,
SymfonyStyle $io,
OutputInterface $output,
): array
{
try {
$content = $this->xmlSource->read(XmlExportInfo::getFilename());
$datasetName = $target->name;
return XmlExportInfo::fromString($content);
} catch (FilesystemException|\InvalidArgumentException $e) {
$this->logger->warning('Could not read remote info file, likely being updated', [
'exception' => $e->getMessage(),
$remoteInfo = $this->syncManager->readRemoteInfo($target->source, $datasetName);
if (null === $remoteInfo) {
$io->warning(sprintf(
'[%s] Remote info unavailable - will retry on next scheduled run',
$datasetName
));
return [
'synced' => false,
'failed' => false,
'updated' => 0,
'deleted' => 0,
'travelSynced' => false,
'remoteTimestamp' => null,
];
}
$io->text($this->formatInfoLine(sprintf('[%s] Remote', $datasetName), $remoteInfo, $output));
$localInfo = $this->syncManager->readLocalInfo($target->destination);
if (null !== $localInfo) {
$io->text($this->formatInfoLine(sprintf('[%s] Local', $datasetName), $localInfo, $output));
} else {
$io->text(sprintf('[%s] Local: no data', $datasetName));
}
$needsSync = $force || null === $localInfo || $remoteInfo->isNewerThan($localInfo);
if (false === $needsSync) {
$io->text(sprintf('[%s] Local data is up to date, no sync required', $datasetName));
$this->logger->info('XML sync skipped: local data is up to date', [
'dataset' => $datasetName,
]);
return null;
return [
'synced' => false,
'failed' => false,
'updated' => 0,
'deleted' => 0,
'travelSynced' => false,
'remoteTimestamp' => null,
];
}
}
private function readLocalInfo(): ?XmlExportInfo
{
if ($dryRun) {
$io->note(sprintf('[%s] Sync required (dry-run mode, no files downloaded)', $datasetName));
return [
'synced' => false,
'failed' => false,
'updated' => 0,
'deleted' => 0,
'travelSynced' => false,
'remoteTimestamp' => null,
];
}
$io->section(sprintf('Syncing %s files', $datasetName));
$progressStarted = false;
try {
if (false === $this->xmlExport->fileExists(XmlExportInfo::getFilename())) {
return null;
$syncResult = $this->syncManager->syncFiles(
$target->source,
$target->destination,
function (int $total) use ($io, &$progressStarted): void {
$progressStarted = true;
$io->progressStart($total);
},
fn () => $io->progressAdvance(),
);
} catch (FilesystemException $e) {
$io->error(sprintf('[%s] Sync failed: %s', $datasetName, $e->getMessage()));
$this->logger->error('XML sync failed during file transfer', [
'dataset' => $datasetName,
'exception' => $e,
]);
return [
'synced' => false,
'failed' => true,
'updated' => 0,
'deleted' => 0,
'travelSynced' => false,
'remoteTimestamp' => null,
];
} finally {
if ($progressStarted) {
$io->progressFinish();
}
$content = $this->xmlExport->read(XmlExportInfo::getFilename());
return XmlExportInfo::fromString($content);
} catch (FilesystemException|\InvalidArgumentException) {
return null;
}
}
/**
* @return array<string, int>
*
* @throws FilesystemException
*/
private function syncFiles(SymfonyStyle $io): array
{
$remoteFiles = $this->xmlSource
->listContents('.')
->filter(fn (StorageAttributes $attributes) => $attributes->isFile())
->map(fn (StorageAttributes $attributes) => $attributes->path())
->toArray();
$localFiles = $this->xmlExport
->listContents('.')
->filter(fn (StorageAttributes $attributes) => $attributes->isFile())
->map(fn (StorageAttributes $attributes) => $attributes->path())
->toArray();
$filesToDelete = array_diff($localFiles, $remoteFiles);
$io->progressStart(count($remoteFiles));
foreach ($remoteFiles as $path) {
$content = $this->xmlSource->read($path);
$this->xmlExport->write($path, $content);
$io->progressAdvance();
}
$io->progressFinish();
foreach ($filesToDelete as $path) {
$this->xmlExport->delete($path);
if ('travel' === $datasetName) {
$travelSynced = true;
$remoteTimestamp = $remoteInfo->lastTransfer->format('c');
} else {
$travelSynced = false;
$remoteTimestamp = null;
}
$io->text(sprintf(
'[%s] Synced %d files, deleted %d',
$datasetName,
$syncResult['updated'],
$syncResult['deleted']
));
$this->logger->info('XML sync completed', [
'dataset' => $datasetName,
'files_updated' => $syncResult['updated'],
'files_deleted' => $syncResult['deleted'],
'remote_timestamp' => $remoteInfo->lastTransfer->format('c'),
]);
return [
'updated' => count($remoteFiles),
'deleted' => count($filesToDelete),
'synced' => true,
'failed' => false,
'updated' => $syncResult['updated'],
'deleted' => $syncResult['deleted'],
'travelSynced' => $travelSynced,
'remoteTimestamp' => $remoteTimestamp,
];
}
private function invalidateCaches(): void
private function refreshSnapshots(SymfonyStyle $io): ?BpnXmlSnapshotRefreshResult
{
foreach (self::CACHE_KEYS_TO_INVALIDATE as $key) {
$this->cache->delete($key);
$plan = $this->snapshotRefreshManager->buildPlan();
if (null === $plan) {
return null;
}
$progressStarted = false;
try {
$io->progressStart($plan->total);
$progressStarted = true;
return $this->snapshotRefreshManager->refresh(
$plan,
fn () => $io->progressAdvance(),
);
} finally {
if ($progressStarted) {
$io->progressFinish();
}
}
}
+242
View File
@@ -0,0 +1,242 @@
<?php
namespace App\Controller\Api;
use App\BusProNet\Utility\DateCodeUtility;
use App\Exception\HotelNotInTravelException;
use App\Exception\TravelNotFoundException;
use App\Service\ContingentDataService;
use App\Service\TravelDataProvider;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* API endpoints for contingent availability data.
*
* References are accepted as either numeric IDs or business codes:
* - `hotelRef`: hotel ID (`idbuspro`) or hotel code
* - `dateRef`: date ID or date code (sanitized before mapping)
*/
#[Route('/api')]
#[IsGranted('ROLE_OAUTH2_API')]
class ContingentController extends AbstractController
{
public function __construct(
private readonly ContingentDataService $contingentDataService,
private readonly TravelDataProvider $travelDataProvider,
) {
}
#[Route(
'/contingents/calendar',
name: 'api_contingents_calendar',
methods: ['GET'],
)]
public function calendar(Request $request): JsonResponse
{
$hotelReference = $request->query->get('hotelRef');
if (null === $hotelReference || '' === trim($hotelReference)) {
return $this->json(['error' => 'hotelRef is required'], Response::HTTP_BAD_REQUEST);
}
return $this->handleCalendar($request, $hotelReference);
}
#[Route(
'/contingents',
name: 'api_contingents_single',
methods: ['GET'],
)]
public function byDate(Request $request): JsonResponse
{
$hotelReference = $request->query->get('hotelRef');
$dateReference = $request->query->get('dateRef');
if (null === $hotelReference || '' === trim($hotelReference)) {
return $this->json(['error' => 'hotelRef is required'], Response::HTTP_BAD_REQUEST);
}
if (null === $dateReference || '' === trim($dateReference)) {
return $this->json(['error' => 'dateRef is required'], Response::HTTP_BAD_REQUEST);
}
return $this->handleByDate($hotelReference, $dateReference);
}
#[Route(
'/contingents/rooms',
name: 'api_contingents_rooms',
methods: ['GET'],
)]
public function rooms(Request $request): JsonResponse
{
$hotelReference = $request->query->get('hotelRef');
$dateReference = $request->query->get('dateRef');
$dateFrom = $request->query->get('dateFrom');
$dateTo = $request->query->get('dateTo');
$myEpUrl = $request->query->get('my_ep_url');
if (null === $hotelReference || '' === trim($hotelReference)) {
return $this->json(['error' => 'hotelRef is required'], Response::HTTP_BAD_REQUEST);
}
if (null === $dateReference || '' === trim($dateReference)) {
return $this->json(['error' => 'dateRef is required'], Response::HTTP_BAD_REQUEST);
}
if (null === $dateFrom || null === $dateTo) {
return $this->json(['error' => 'dateFrom and dateTo are required'], Response::HTTP_BAD_REQUEST);
}
return $this->handleRooms(
$hotelReference,
$dateReference,
$dateFrom,
$dateTo,
is_string($myEpUrl) ? $myEpUrl : null,
);
}
/**
* Shared execution path for calendar data after hotel reference validation.
*/
private function handleCalendar(Request $request, string $hotelReference): JsonResponse
{
$hotelId = $this->resolveHotelId($hotelReference);
if (null === $hotelId) {
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$dateFrom = $request->query->get('dateFrom');
$dateTo = $request->query->get('dateTo');
if (null === $dateFrom || null === $dateTo) {
return $this->json(['error' => 'dateFrom and dateTo are required'], Response::HTTP_BAD_REQUEST);
}
try {
$events = $this->contingentDataService->getCalendarEvents($hotelId, $dateFrom, $dateTo);
} catch (\InvalidArgumentException $e) {
return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
}
return $this->json($events, Response::HTTP_OK, [], ['groups' => ['api:contingent']]);
}
/**
* Shared execution path for daily contingent summary after reference validation.
*/
private function handleByDate(string $hotelReference, string $dateReference): JsonResponse
{
$hotelId = $this->resolveHotelId($hotelReference);
$dateId = $this->resolveDateId($dateReference);
if (null === $hotelId || null === $dateId) {
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
}
try {
$contingents = $this->contingentDataService->getAvailableContingents($hotelId, $dateId);
} catch (TravelNotFoundException|HotelNotInTravelException) {
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
}
return $this->json($contingents, Response::HTTP_OK, [], ['groups' => ['api:contingent']]);
}
/**
* Shared execution path for room-level availability after reference validation.
*/
private function handleRooms(
string $hotelReference,
string $dateReference,
string $dateFrom,
string $dateTo,
?string $myEpUrl = null,
): JsonResponse {
$hotelId = $this->resolveHotelId($hotelReference);
$dateId = $this->resolveDateId($dateReference);
if (null === $hotelId || null === $dateId) {
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
}
try {
$rooms = $this->contingentDataService->getAvailableRooms($dateFrom, $dateTo, $hotelId, $dateId, $myEpUrl);
} catch (TravelNotFoundException|HotelNotInTravelException) {
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
} catch (\InvalidArgumentException $e) {
return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
}
return $this->json($rooms, Response::HTTP_OK, [], ['groups' => ['api:contingent']]);
}
/**
* Resolves a hotel reference to hotel ID.
*
* Numeric values are treated as IDs, otherwise mapped as hotel code.
*/
private function resolveHotelId(string $hotelReference): ?int
{
return $this->resolveReferenceId(
$hotelReference,
fn (string $reference): ?int => $this->travelDataProvider->mapHotelCodeToId($reference),
);
}
/**
* Resolves a date reference to date ID.
*
* Numeric values are treated as IDs, otherwise mapped as date code.
* Date codes are sanitized first (uppercased and separators removed).
*/
private function resolveDateId(string $dateReference): ?int
{
return $this->resolveReferenceId(
$dateReference,
fn (string $reference): ?int => $this->travelDataProvider->mapDateCodeToId($reference),
fn (string $reference): string => (new DateCodeUtility())->sanitize($reference),
);
}
/**
* Generic resolver for mixed ID/code references.
*
* Flow:
* 1. Trim and reject empty values.
* 2. If numeric, return as integer ID.
* 3. Optionally normalize the value.
* 4. Map normalized code to ID.
*
* @param callable $mapper maps code input to ID
* @param callable|null $normalizer optional code normalizer before mapping
*/
private function resolveReferenceId(
string $reference,
callable $mapper,
?callable $normalizer = null,
): ?int {
$trimmedReference = trim($reference);
if ('' === $trimmedReference) {
return null;
}
if (true === ctype_digit($trimmedReference)) {
return (int) $trimmedReference;
}
if (null !== $normalizer) {
$trimmedReference = $normalizer($trimmedReference);
}
return $mapper($trimmedReference);
}
}
@@ -63,9 +63,6 @@ class RoomSelectionToIdTransformer implements DataTransformerInterface
return $value->id;
}
throw new TransformationFailedException(sprintf(
'Invalid room id value: %s',
get_debug_type($value)
));
throw new TransformationFailedException(sprintf('Invalid room id value: %s', get_debug_type($value)));
}
}
+6 -6
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Form\Model;
use App\Model\BookingSummaryCmsHotelDto;
use App\Model\BookingSummaryCmsHotelData;
/**
* DTO containing all booking summary data for sidebar display.
@@ -15,11 +15,11 @@ class BookingSummaryDto
* @param array<int, RoomSelectionDto> $selectedRooms Selected room DTOs from booking
*/
public function __construct(
public readonly array $selectedRooms,
public readonly int $participantCount,
public readonly BookingSummaryPricingDto $pricing,
public readonly BookingSummaryVoucherDto $vouchers,
public readonly ?BookingSummaryCmsHotelDto $cmsData,
public readonly array $selectedRooms,
public readonly int $participantCount,
public readonly BookingSummaryPricingDto $pricing,
public readonly BookingSummaryVoucherDto $vouchers,
public readonly ?BookingSummaryCmsHotelData $cmsData,
) {
}
}
@@ -7,7 +7,7 @@ namespace App\Model;
/**
* Typed hotel CMS payload for the booking summary.
*/
final readonly class BookingSummaryCmsHotelDto
final readonly class BookingSummaryCmsHotelData
{
/**
* @param array<string, mixed>|null $images
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Model;
final readonly class BpnXmlSnapshotRefreshPlan
{
/**
* @param array<int, array{hotels: array<int|string, mixed>}> $xmlFileMap
*/
public function __construct(
public array $xmlFileMap,
public int $total,
) {
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Model;
final readonly class BpnXmlSnapshotRefreshResult
{
public function __construct(
public int $processed,
public int $failed,
public int $orphanedDeleted,
) {
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Model;
use League\Flysystem\FilesystemOperator;
final readonly class BpnXmlSyncTarget
{
public function __construct(
public string $name,
public FilesystemOperator $source,
public FilesystemOperator $destination,
) {
}
}
+3 -3
View File
@@ -12,7 +12,7 @@ use App\Form\Model\BookingDto;
use App\Form\Model\BookingSummaryDto;
use App\Form\Model\BookingSummaryPricingDto;
use App\Form\Model\BookingSummaryVoucherDto;
use App\Model\BookingSummaryCmsHotelDto;
use App\Model\BookingSummaryCmsHotelData;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@@ -126,7 +126,7 @@ class BookingSummaryAssembler
* Data is cached for 1 hour. This method can be called early in the booking
* flow to warm the cache.
*/
public function getCmsDataForProduct(?string $productCode, ?string $hotelCode): ?BookingSummaryCmsHotelDto
public function getCmsDataForProduct(?string $productCode, ?string $hotelCode): ?BookingSummaryCmsHotelData
{
if (null === $hotelCode) {
return null;
@@ -144,7 +144,7 @@ class BookingSummaryAssembler
// Fetch CMS images (nice to have)
$images = $this->cmsDataService->getProductImages($productCode, $hotelCode);
return new BookingSummaryCmsHotelDto(
return new BookingSummaryCmsHotelData(
name: $baseHotel?->name,
address: $this->formatHotelAddress($baseHotel),
images: $images,
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Model\BpnXmlSnapshotRefreshPlan;
use App\Model\BpnXmlSnapshotRefreshResult;
use App\BusProNet\XmlLoader\TravelLoader;
use Psr\Log\LoggerInterface;
final class BpnXmlSnapshotRefreshManager
{
public function __construct(
private readonly TravelLoader $travelLoader,
private readonly TravelDataProvider $travelDataService,
private readonly TravelSnapshotManager $snapshotService,
private readonly LoggerInterface $logger,
) {
}
public function buildPlan(): ?BpnXmlSnapshotRefreshPlan
{
try {
$xmlFileMap = $this->travelLoader->generateFilesMap();
} catch (\Throwable $e) {
$this->logger->warning('Failed to generate XML file map after sync; snapshot refresh skipped', [
'error' => $e->getMessage(),
]);
return null;
}
$total = array_sum(array_map(fn (array $entry): int => count($entry['hotels']), $xmlFileMap));
return new BpnXmlSnapshotRefreshPlan($xmlFileMap, $total);
}
public function refresh(
BpnXmlSnapshotRefreshPlan $plan,
?callable $onProgress = null,
): BpnXmlSnapshotRefreshResult {
$snapshotResult = $this->travelDataService->syncSnapshotsFromXml(
$plan->xmlFileMap,
function () use ($onProgress): void {
if (null !== $onProgress) {
$onProgress();
}
},
);
$orphanedDeleted = $this->snapshotService->purgeOrphanedFutureSnapshots(array_keys($plan->xmlFileMap));
return new BpnXmlSnapshotRefreshResult(
$snapshotResult['processed'],
$snapshotResult['failed'],
$orphanedDeleted,
);
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\XmlExportInfo;
use App\Model\BpnXmlSyncTarget;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
final class BpnXmlSyncManager
{
private const CACHE_TAGS_TO_INVALIDATE = [
'xml-sync',
];
public function __construct(
private readonly FilesystemOperator $xmlSource,
private readonly FilesystemOperator $xmlExport,
private readonly FilesystemOperator $xmlSourceContingents,
private readonly FilesystemOperator $xmlExportContingents,
private readonly TagAwareCacheInterface $bpnCache,
private readonly LoggerInterface $logger,
) {
}
/**
* @return list<BpnXmlSyncTarget>
*/
public function getSyncTargets(): array
{
return [
new BpnXmlSyncTarget('travel', $this->xmlSource, $this->xmlExport),
new BpnXmlSyncTarget('contingents', $this->xmlSourceContingents, $this->xmlExportContingents),
];
}
public function readRemoteInfo(FilesystemOperator $source, string $datasetName): ?XmlExportInfo
{
try {
$content = $source->read(XmlExportInfo::getFilename());
return XmlExportInfo::fromString($content);
} catch (FilesystemException|\InvalidArgumentException $e) {
$this->logger->warning('Could not read remote info file, likely being updated', [
'dataset' => $datasetName,
'exception' => $e->getMessage(),
]);
return null;
}
}
public function readLocalInfo(FilesystemOperator $destination): ?XmlExportInfo
{
try {
if (false === $destination->fileExists(XmlExportInfo::getFilename())) {
return null;
}
$content = $destination->read(XmlExportInfo::getFilename());
return XmlExportInfo::fromString($content);
} catch (FilesystemException|\InvalidArgumentException) {
return null;
}
}
/**
* @return array{updated:int,deleted:int}
*
* @throws FilesystemException
*/
public function syncFiles(
FilesystemOperator $source,
FilesystemOperator $destination,
?callable $onStart = null,
?callable $onProgress = null,
): array {
$remoteFiles = $source
->listContents('.')
->filter(fn (StorageAttributes $attributes) => $attributes->isFile())
->map(fn (StorageAttributes $attributes) => $attributes->path())
->toArray();
$localFiles = $destination
->listContents('.')
->filter(fn (StorageAttributes $attributes) => $attributes->isFile())
->map(fn (StorageAttributes $attributes) => $attributes->path())
->toArray();
$filesToDelete = array_diff($localFiles, $remoteFiles);
if (null !== $onStart) {
$onStart(count($remoteFiles));
}
foreach ($remoteFiles as $path) {
$content = $source->read($path);
$destination->write($path, $content);
if (null !== $onProgress) {
$onProgress();
}
}
foreach ($filesToDelete as $path) {
$destination->delete($path);
}
return [
'updated' => count($remoteFiles),
'deleted' => count($filesToDelete),
];
}
public function invalidateCaches(): int
{
$this->bpnCache->invalidateTags(self::CACHE_TAGS_TO_INVALIDATE);
return count(self::CACHE_TAGS_TO_INVALIDATE);
}
}
+258
View File
@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\ContingentCalendarEvent;
use App\BusProNet\Model\ContingentDateSummary;
use App\BusProNet\Model\ContingentRoomAvailability;
use App\BusProNet\Utility\BookingUrlUtility;
use App\BusProNet\XmlLoader\ContingentLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Exception\HotelNotInTravelException;
use App\Exception\TravelNotFoundException;
class ContingentDataService
{
private const STATUS_ORDER = [
'OK' => 0,
'ON_REQUEST' => 1,
'BLOCKED' => 2,
];
public function __construct(
private readonly ContingentLoader $contingentLoader,
private readonly TravelLoader $travelLoader,
private readonly BookingUrlUtility $bookingUrlUtility,
) {
}
/**
* @return array<string, ContingentDateSummary>
*
* @throws TravelNotFoundException
* @throws HotelNotInTravelException
*/
public function getAvailableContingents(int $hotelId, int $dateId): array
{
$travel = $this->travelLoader->loadById($dateId, $hotelId);
if (null === $travel->dateFrom || null === $travel->dateTo) {
return [];
}
$data = $this->contingentLoader->loadByHotelId($hotelId);
$rows = $this->filterRowsByRange($data['rows'], $travel->dateFrom, $travel->dateTo);
$belKalStatusByDate = $this->getBelKalStatusByDate($rows);
$result = [];
foreach ($rows as $row) {
if (true === ($row['isControlRoom'] ?? false)) {
continue;
}
$dateKey = $row['date']->format('Y-m-d');
if (false === isset($result[$dateKey])) {
$result[$dateKey] = new ContingentDateSummary($dateKey, null, 0, 0);
}
if (null !== $row['minNights']) {
$result[$dateKey]->minNights = null === $result[$dateKey]->minNights
? $row['minNights']
: min($result[$dateKey]->minNights, $row['minNights']);
}
$result[$dateKey]->total += (int) ($row['available'] ?? 0);
$result[$dateKey]->capacity += (int) ($row['pax'] ?? 0);
}
foreach ($belKalStatusByDate as $dateKey => $status) {
if (false === isset($result[$dateKey])) {
continue;
}
if ('OK' !== $status) {
$result[$dateKey]->total = 0;
}
}
return $result;
}
/**
* @return array<int, ContingentRoomAvailability>
*
* @throws TravelNotFoundException
* @throws HotelNotInTravelException
* @throws \InvalidArgumentException
*/
public function getAvailableRooms(
string $dateFrom,
string $dateTo,
int $hotelId,
int $dateId,
?string $myEpUrl = null,
): array {
$range = $this->parseDateRange($dateFrom, $dateTo);
$travel = $this->travelLoader->loadById($dateId, $hotelId);
if (null !== $travel->dateFrom && null !== $travel->dateTo) {
if ($range['from'] < $travel->dateFrom || $range['to'] > $travel->dateTo) {
throw new \InvalidArgumentException('Requested range must be within the travel date range');
}
}
$data = $this->contingentLoader->loadByHotelId($hotelId);
$rows = $this->filterRowsByRange($data['rows'], $range['from'], $range['to']);
$belKalStatusByDate = $this->getBelKalStatusByDate($rows);
$nights = $range['from']->diff($range['to'])->days;
$bookingUrl = $this->bookingUrlUtility->build($hotelId, $dateId, $myEpUrl);
$result = [];
foreach ($rows as $row) {
if (true === ($row['isControlRoom'] ?? false)) {
continue;
}
$dateKey = $row['date']->format('Y-m-d');
$status = $row['status'] ?? 'OK';
if (isset($belKalStatusByDate[$dateKey]) && 'OK' !== $belKalStatusByDate[$dateKey]) {
$status = $belKalStatusByDate[$dateKey];
}
$minNights = $row['minNights'] ?? 0;
$additionalNightMinPrice = $row['additionalNightMinPrice'] ?? 0.0;
$minPrice = $row['minPrice'] ?? 0.0;
$extraNights = max(0, $nights - (int) $minNights);
$priceForSelection = $minPrice + ($extraNights * $additionalNightMinPrice);
$result[] = new ContingentRoomAvailability(
date: $dateKey,
roomCode: (string) $row['roomCode'],
roomLabel: (string) $row['roomLabel'],
bookingUrl: $bookingUrl,
pax: (int) ($row['pax'] ?? 0),
available: 'OK' === $status ? (int) ($row['available'] ?? 0) : 0,
status: $status,
minPrice: $row['minPrice'],
minNights: $row['minNights'],
additionalNightMinPrice: $row['additionalNightMinPrice'],
additionalNightMinNights: $row['additionalNightMinNights'],
priceForSelection: (float) $priceForSelection,
);
}
return $result;
}
/**
* @return array<int, ContingentCalendarEvent>
*/
public function getCalendarEvents(int $hotelId, string $dateFrom, string $dateTo): array
{
$range = $this->parseDateRange($dateFrom, $dateTo);
$data = $this->contingentLoader->loadByHotelId($hotelId);
$rows = $this->filterRowsByRange($data['rows'], $range['from'], $range['to']);
$events = [];
foreach ($rows as $row) {
$dateKey = $row['date']->format('Y-m-d');
if (false === isset($events[$dateKey])) {
$events[$dateKey] = new ContingentCalendarEvent($dateKey, 'OK', 0, 0);
}
$events[$dateKey]->status = $this->mergeStatus(
$events[$dateKey]->status,
$row['status'] ?? 'OK'
);
if (false === ($row['isControlRoom'] ?? false)) {
$events[$dateKey]->pax += (int) ($row['pax'] ?? 0);
$events[$dateKey]->available += (int) ($row['available'] ?? 0);
}
}
ksort($events);
return array_values($events);
}
/**
* @param array<int, array<string, mixed>> $rows
*
* @return array<int, array<string, mixed>>
*/
private function filterRowsByRange(array $rows, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): array
{
return array_values(array_filter($rows, function (array $row) use ($dateFrom, $dateTo) {
if (false === isset($row['date']) || !$row['date'] instanceof \DateTimeImmutable) {
return false;
}
return $row['date'] >= $dateFrom && $row['date'] <= $dateTo;
}));
}
/**
* @param array<int, array<string, mixed>> $rows
*
* @return array<string, string>
*/
private function getBelKalStatusByDate(array $rows): array
{
$statuses = [];
foreach ($rows as $row) {
if (false === ($row['isControlRoom'] ?? false)) {
continue;
}
$dateKey = $row['date']->format('Y-m-d');
$statuses[$dateKey] = $this->mergeStatus($statuses[$dateKey] ?? 'OK', $row['status'] ?? 'OK');
}
return $statuses;
}
private function mergeStatus(string $current, string $candidate): string
{
$currentOrder = self::STATUS_ORDER[$current] ?? 0;
$candidateOrder = self::STATUS_ORDER[$candidate] ?? 0;
return $candidateOrder > $currentOrder ? $candidate : $current;
}
/**
* @return array{from: \DateTimeImmutable, to: \DateTimeImmutable}
*/
private function parseDateRange(string $dateFrom, string $dateTo): array
{
$from = $this->parseDate($dateFrom);
$to = $this->parseDate($dateTo);
if ($to < $from) {
throw new \InvalidArgumentException('dateTo must be on or after dateFrom');
}
return ['from' => $from, 'to' => $to];
}
private function parseDate(string $date): \DateTimeImmutable
{
$parsed = \DateTimeImmutable::createFromFormat('Y-m-d', $date);
if (false === $parsed || $parsed->format('Y-m-d') !== $date) {
throw new \InvalidArgumentException('Invalid date format, expected Y-m-d');
}
return $parsed->setTime(0, 0, 0);
}
}