chore: remote obsolete contingents-related plumbing

This commit is contained in:
Björn Fromme
2026-06-11 16:19:14 +02:00
parent 8c99fba6fa
commit b2fccae431
19 changed files with 1 additions and 1711 deletions
-21
View File
@@ -136,24 +136,3 @@ GET {{base_url}}/api/pickups-planning/DPWWTS191125
Accept: application/json
Content-Type: application/json
Authorization: Bearer {{$auth.token("oauth2_api")}}
### API contingents calendar (query refs)
# @no-cookie-jar
GET {{base_url}}/api/contingents/calendar?hotelRef=MVKGR2&dateFrom=2026-03-01&dateTo=2026-03-31
Accept: application/json
Content-Type: application/json
Authorization: Bearer {{$auth.token("oauth2_api")}}
### API contingents by date (query refs)
# @no-cookie-jar
GET {{base_url}}/api/contingents?hotelRef=L2ABDSFDOR&dateRef=SBW131225
Accept: application/json
Content-Type: application/json
Authorization: Bearer {{$auth.token("oauth2_api")}}
### API contingents rooms (query refs)
# @no-cookie-jar
GET {{base_url}}/api/contingents/rooms?hotelRef=L2ABDSFDOR&dateRef=SBW131225&dateFrom=2026-03-01&dateTo=2026-03-08
Accept: application/json
Content-Type: application/json
Authorization: Bearer {{$auth.token("oauth2_api")}}
-9
View File
@@ -3,21 +3,12 @@ flysystem:
xml_export.storage:
local:
directory: '%env(resolve:XML_EXPORT_PATH)%'
xml_export_contingents.storage:
local:
directory: '%env(resolve:XML_EXPORT_CONTINGENTS_PATH)%'
xml_source.storage:
sftp:
host: '%env(SFTP_XML_EXPORT_HOST)%'
port: '%env(int:SFTP_XML_EXPORT_PORT)%'
username: '%env(SFTP_XML_EXPORT_USER)%'
password: '%env(SFTP_XML_EXPORT_PASSWORD)%'
xml_source_contingents.storage:
sftp:
host: '%env(SFTP_XML_EXPORT_CONTINGENTS_HOST)%'
port: '%env(int:SFTP_XML_EXPORT_CONTINGENTS_PORT)%'
username: '%env(SFTP_XML_EXPORT_CONTINGENTS_USER)%'
password: '%env(SFTP_XML_EXPORT_CONTINGENTS_PASSWORD)%'
xml_dump.storage:
local:
directory: '%kernel.project_dir%/var/bpn'
-12
View File
@@ -112,14 +112,11 @@ services:
arguments:
$xmlSource: '@xml_source.storage'
$xmlExport: '@xml_export.storage'
$xmlSourceContingents: '@xml_source_contingents.storage'
$xmlExportContingents: '@xml_export_contingents.storage'
$bpnCache: '@bpn.cache'
App\Command\BpnXmlCacheInvalidateCommand:
arguments:
$xmlExport: '@xml_export.storage'
$xmlExportContingents: '@xml_export_contingents.storage'
$bpnCache: '@bpn.cache'
$cache: '@cache.app'
@@ -161,15 +158,6 @@ services:
arguments:
$cache: '@bpn.cache'
App\BusProNet\XmlLoader\ContingentLoader:
arguments:
$cache: '@bpn.cache'
$xmlExportContingents: '@xml_export_contingents.storage'
App\BusProNet\Utility\BookingUrlUtility:
arguments:
$defaultBaseUrl: '%env(APP_BASE_URL)%'
App\Service\VoucherValidator:
arguments:
$cache: '@cache.app'
@@ -1,22 +0,0 @@
<?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,
) {
}
}
@@ -1,22 +0,0 @@
<?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,
) {
}
}
@@ -1,38 +0,0 @@
<?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,38 +0,0 @@
<?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, '/');
}
}
@@ -1,127 +0,0 @@
<?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,151 +0,0 @@
<?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',
};
}
}
@@ -31,7 +31,6 @@ class BpnXmlCacheInvalidateCommand extends Command
public function __construct(
private readonly FilesystemOperator $xmlExport,
private readonly FilesystemOperator $xmlExportContingents,
private readonly TagAwareCacheInterface $bpnCache,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
@@ -194,10 +193,6 @@ class BpnXmlCacheInvalidateCommand extends Command
'name' => 'travel',
'storage' => $this->xmlExport,
],
[
'name' => 'contingents',
'storage' => $this->xmlExportContingents,
],
];
}
}
-242
View File
@@ -1,242 +0,0 @@
<?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);
}
}
-3
View File
@@ -21,8 +21,6 @@ final class BpnXmlSyncManager
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,
) {
@@ -35,7 +33,6 @@ final class BpnXmlSyncManager
{
return [
new BpnXmlSyncTarget('travel', $this->xmlSource, $this->xmlExport),
new BpnXmlSyncTarget('contingents', $this->xmlSourceContingents, $this->xmlExportContingents),
];
}
-258
View File
@@ -1,258 +0,0 @@
<?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);
}
}
@@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace BusProNet\Utility;
use App\BusProNet\Utility\BookingUrlUtility;
use PHPUnit\Framework\TestCase;
class BookingUrlUtilityTest extends TestCase
{
public function testBuildUsesConfiguredDefaultBaseUrlAsAbsoluteFallback(): void
{
$utility = new BookingUrlUtility('https://my.ep-reisen.de');
$url = $utility->build(157047, 11606);
$this->assertSame(
'https://my.ep-reisen.de/bookings/create?date_id=11606&hotel_id=157047',
$url
);
}
public function testBuildUsesCustomBaseUrlWhenProvided(): void
{
$utility = new BookingUrlUtility('https://my.ep-reisen.de');
$url = $utility->build(157047, 11606, 'https://my.example.test/');
$this->assertSame(
'https://my.example.test/bookings/create?date_id=11606&hotel_id=157047',
$url
);
}
public function testBuildFallsBackToConfiguredDefaultWhenCustomBaseIsBlank(): void
{
$utility = new BookingUrlUtility('https://fallback.test');
$url = $utility->build(157047, 11606, ' ');
$this->assertSame(
'https://fallback.test/bookings/create?date_id=11606&hotel_id=157047',
$url
);
}
public function testBuildReturnsRelativeUrlWhenNoBaseUrlAvailable(): void
{
$utility = new BookingUrlUtility('');
$url = $utility->build(157047, 11606);
$this->assertSame('/bookings/create?date_id=11606&hotel_id=157047', $url);
}
}
@@ -1,137 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\XmlLoader;
use App\BusProNet\XmlLoader\ContingentLoader;
use App\BusProNet\XmlParser\ContingentParser;
use League\Flysystem\DirectoryListing;
use League\Flysystem\FileAttributes;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
class ContingentLoaderTest extends TestCase
{
public function testGenerateFilesMapBuildsMappingAndTagsCache(): void
{
$cache = $this->createMock(TagAwareCacheInterface::class);
$filesystem = $this->createMock(FilesystemOperator::class);
$parser = $this->createMock(ContingentParser::class);
$filesystem->method('listContents')->willReturn(new DirectoryListing([
new FileAttributes('HotelZimmer_100.xml', 10),
new FileAttributes('HotelZimmer_200.xml', 10),
new FileAttributes('other.xml', 10),
]));
$item = $this->createMock(ItemInterface::class);
$item->expects($this->once())->method('expiresAfter')->with(3 * 60 * 60);
$item->expects($this->once())->method('tag')->with(['xml-sync']);
$cache->expects($this->once())
->method('get')
->with('bpn_contingent_files', $this->isType('callable'))
->willReturnCallback(fn (string $key, callable $callback) => $callback($item));
$loader = new ContingentLoader($parser, $cache, $filesystem);
$map = $loader->generateFilesMap();
$this->assertSame([
100 => 'HotelZimmer_100.xml',
200 => 'HotelZimmer_200.xml',
], $map);
}
public function testLoadByHotelIdUsesRootLinkOneHopAndTagsCache(): void
{
$cache = $this->createMock(TagAwareCacheInterface::class);
$filesystem = $this->createMock(FilesystemOperator::class);
$parser = $this->createMock(ContingentParser::class);
$originalXml = '<hotelzimmer idbuspro_kontingent_aus="200"><unterbringungen /></hotelzimmer>';
$linkedXml = '<hotelzimmer><unterbringungen /></hotelzimmer>';
$parsed = ['roomTypes' => [], 'rows' => [['roomCode' => 'DZ']]];
$filesystem->method('listContents')->willReturn(new DirectoryListing([
new FileAttributes('HotelZimmer_100.xml', 10),
new FileAttributes('HotelZimmer_200.xml', 10),
]));
$filesystem->method('read')->willReturnMap([
['HotelZimmer_100.xml', $originalXml],
['HotelZimmer_200.xml', $linkedXml],
]);
$mapItem = $this->createMock(ItemInterface::class);
$mapItem->method('expiresAfter')->with(3 * 60 * 60);
$mapItem->method('tag')->with(['xml-sync']);
$hotelItem = $this->createMock(ItemInterface::class);
$hotelItem->method('expiresAfter')->with(3600);
$hotelItem->method('tag')->with(['xml-sync']);
$cache->method('get')->willReturnCallback(
function (string $key, callable $callback) use ($mapItem, $hotelItem) {
if ('bpn_contingent_files' === $key) {
return $callback($mapItem);
}
if ('contingent_hotel_100' === $key) {
return $callback($hotelItem);
}
return null;
}
);
$parser->expects($this->once())->method('parse')->with($linkedXml)->willReturn($parsed);
$loader = new ContingentLoader($parser, $cache, $filesystem);
$result = $loader->loadByHotelId(100);
$this->assertSame($parsed, $result);
}
public function testLoadByHotelIdReturnsEmptyWhenLinkedTargetIsMissing(): void
{
$cache = $this->createMock(TagAwareCacheInterface::class);
$filesystem = $this->createMock(FilesystemOperator::class);
$parser = $this->createMock(ContingentParser::class);
$xml = '<hotelzimmer idbuspro_kontingent_aus="999"><unterbringungen /></hotelzimmer>';
$filesystem->method('listContents')->willReturn(new DirectoryListing([
new FileAttributes('HotelZimmer_100.xml', 10),
]));
$filesystem->method('read')->willReturn($xml);
$mapItem = $this->createMock(ItemInterface::class);
$mapItem->method('expiresAfter')->with(3 * 60 * 60);
$mapItem->method('tag')->with(['xml-sync']);
$hotelItem = $this->createMock(ItemInterface::class);
$hotelItem->method('expiresAfter')->with(3600);
$hotelItem->method('tag')->with(['xml-sync']);
$cache->method('get')->willReturnCallback(
function (string $key, callable $callback) use ($mapItem, $hotelItem) {
if ('bpn_contingent_files' === $key) {
return $callback($mapItem);
}
if ('contingent_hotel_100' === $key) {
return $callback($hotelItem);
}
return null;
}
);
$parser->expects($this->never())->method('parse');
$loader = new ContingentLoader($parser, $cache, $filesystem);
$result = $loader->loadByHotelId(100);
$this->assertSame(['roomTypes' => [], 'rows' => []], $result);
}
}
@@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\XmlParser;
use App\BusProNet\XmlParser\ContingentParser;
use PHPUnit\Framework\TestCase;
class ContingentParserTest extends TestCase
{
private ContingentParser $parser;
protected function setUp(): void
{
$this->parser = new ContingentParser();
}
public function testParseBuildsRoomTypesAndRowsWithLinksAndControlRoom(): void
{
$xml = <<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<hotelzimmer>
<unterbringungen>
<unterbringung idbuspro="10" code="DZ" zimmerbezeichnung="Double Room" pax_max="2" />
<unterbringung idbuspro="11" code="BelKal" zimmerbezeichnung="Control" pax_max="1" />
<unterbringung idbuspro="12" code="PDGS" zimmerbezeichnung="Pseudo" pax_max="1" />
<unterbringung idbuspro="13" code="EZ" zimmerbezeichnung="Single Room" pax_max="1" idbuspro_kontingent_aus="10" />
</unterbringungen>
<kapazitaeten>
<kapazitaet termin="24.02.2026">
<zimmerliste>
<zimmer idbuspro="10" kontingent="3" frei="1" status="" abpreis="100,00" abpreis_naechte="2" abpreis_verlaengerung="15,00" abpreis_verlaengerung_naechte="1" />
<zimmer idbuspro="11" kontingent="1" frei="1" status="A" abpreis="0" abpreis_naechte="0" abpreis_verlaengerung="0" abpreis_verlaengerung_naechte="0" />
</zimmerliste>
</kapazitaet>
</kapazitaeten>
</hotelzimmer>
XML;
$result = $this->parser->parse($xml);
$this->assertArrayHasKey('roomTypes', $result);
$this->assertArrayHasKey('rows', $result);
$this->assertCount(3, $result['roomTypes']); // PDGS skipped
$this->assertCount(3, $result['rows']);
$rowsByCode = [];
foreach ($result['rows'] as $row) {
$rowsByCode[$row['roomCode']] = $row;
}
$this->assertSame('OK', $rowsByCode['DZ']['status']);
$this->assertSame(6, $rowsByCode['DZ']['pax']);
$this->assertSame(2, $rowsByCode['DZ']['available']);
$this->assertSame(100.0, $rowsByCode['DZ']['minPrice']);
$this->assertSame(2, $rowsByCode['DZ']['minNights']);
$this->assertSame(15.0, $rowsByCode['DZ']['additionalNightMinPrice']);
$this->assertSame(1, $rowsByCode['DZ']['additionalNightMinNights']);
$this->assertFalse($rowsByCode['DZ']['isControlRoom']);
// Linked room uses DZ contingent node but its own pax
$this->assertSame('Single Room', $rowsByCode['EZ']['roomLabel']);
$this->assertSame(3, $rowsByCode['EZ']['pax']);
$this->assertSame(1, $rowsByCode['EZ']['available']);
$this->assertSame('ON_REQUEST', $rowsByCode['BelKal']['status']);
$this->assertSame(0, $rowsByCode['BelKal']['pax']);
$this->assertSame(0, $rowsByCode['BelKal']['available']);
$this->assertTrue($rowsByCode['BelKal']['isControlRoom']);
}
public function testParseHandlesZeroValuesAndBlockedStatus(): void
{
$xml = <<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<hotelzimmer>
<unterbringungen>
<unterbringung idbuspro="20" code="DZ" zimmerbezeichnung="Double Room" pax_max="2" />
</unterbringungen>
<kapazitaeten>
<kapazitaet termin="25.02.2026">
<zimmerliste>
<zimmer idbuspro="20" kontingent="0" frei="0" status="S" abpreis="0" abpreis_naechte="0" abpreis_verlaengerung="0" abpreis_verlaengerung_naechte="0" />
</zimmerliste>
</kapazitaet>
</kapazitaeten>
</hotelzimmer>
XML;
$result = $this->parser->parse($xml);
$row = $result['rows'][0];
$this->assertSame('BLOCKED', $row['status']);
$this->assertSame(0, $row['pax']);
$this->assertSame(0, $row['available']);
$this->assertSame(0.0, $row['minPrice']);
$this->assertSame(0, $row['minNights']);
$this->assertSame(0.0, $row['additionalNightMinPrice']);
$this->assertSame(0, $row['additionalNightMinNights']);
}
}
+1 -117
View File
@@ -6,13 +6,12 @@ namespace App\Tests\Command;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Command\BpnXmlSyncCommand;
use App\Service\BpnXmlSyncManager;
use App\Service\BpnXmlSnapshotRefreshManager;
use App\Service\BpnXmlSyncManager;
use App\Service\TravelDataProvider;
use App\Service\TravelSnapshotManager;
use League\Flysystem\DirectoryListing;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\UnableToListContents;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
@@ -23,8 +22,6 @@ class BpnXmlSyncCommandTest extends TestCase
{
private FilesystemOperator $xmlSource;
private FilesystemOperator $xmlExport;
private FilesystemOperator $xmlSourceContingents;
private FilesystemOperator $xmlExportContingents;
private TagAwareCacheInterface $cache;
private LoggerInterface $logger;
private TravelLoader $travelLoader;
@@ -36,7 +33,6 @@ class BpnXmlSyncCommandTest extends TestCase
protected function tearDown(): void
{
// Release the filesystem lock held by LockableTrait so subsequent tests can acquire it.
$release = new \ReflectionMethod($this->command, 'release');
$release->setAccessible(true);
$release->invoke($this->command);
@@ -46,8 +42,6 @@ class BpnXmlSyncCommandTest extends TestCase
{
$this->xmlSource = $this->createMock(FilesystemOperator::class);
$this->xmlExport = $this->createMock(FilesystemOperator::class);
$this->xmlSourceContingents = $this->createMock(FilesystemOperator::class);
$this->xmlExportContingents = $this->createMock(FilesystemOperator::class);
$this->cache = $this->createMock(TagAwareCacheInterface::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->travelLoader = $this->createMock(TravelLoader::class);
@@ -63,8 +57,6 @@ class BpnXmlSyncCommandTest extends TestCase
$this->syncManager = new BpnXmlSyncManager(
$this->xmlSource,
$this->xmlExport,
$this->xmlSourceContingents,
$this->xmlExportContingents,
$this->cache,
$this->logger,
);
@@ -76,9 +68,6 @@ class BpnXmlSyncCommandTest extends TestCase
);
}
/**
* Remote newer than local -> files downloaded -> syncSnapshotsFromXml() called once.
*/
public function testSnapshotSyncIsTriggeredAfterSuccessfulFileDownload(): void
{
$newerTimestamp = "24.03.2026 12:00:00\nExport\n3 Dateien\n";
@@ -90,12 +79,6 @@ class BpnXmlSyncCommandTest extends TestCase
$this->xmlSource->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlExport->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlSourceContingents->method('read')->willReturn($olderTimestamp);
$this->xmlExportContingents->method('fileExists')->willReturn(true);
$this->xmlExportContingents->method('read')->willReturn($olderTimestamp);
$this->xmlSourceContingents->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlExportContingents->method('listContents')->willReturn(new DirectoryListing([]));
$fileMap = [
101 => ['hotels' => ['H1' => null, 'H2' => null]],
102 => ['hotels' => ['H3' => null]],
@@ -124,9 +107,6 @@ class BpnXmlSyncCommandTest extends TestCase
$this->assertSame(Command::SUCCESS, $tester->getStatusCode());
}
/**
* Local already up to date -> early return before snapshot code is reached.
*/
public function testSnapshotSyncIsSkippedWhenLocalDataIsUpToDate(): void
{
$sameTimestamp = "24.03.2026 10:00:00\nExport\n3 Dateien\n";
@@ -136,12 +116,6 @@ class BpnXmlSyncCommandTest extends TestCase
$this->xmlSource->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlExport->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlSourceContingents->method('read')->willReturn($sameTimestamp);
$this->xmlExportContingents->method('fileExists')->willReturn(true);
$this->xmlExportContingents->method('read')->willReturn($sameTimestamp);
$this->xmlSourceContingents->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlExportContingents->method('listContents')->willReturn(new DirectoryListing([]));
$this->travelLoader->expects($this->never())->method('generateFilesMap');
$this->travelDataService->expects($this->never())->method('syncSnapshotsFromXml');
$this->travelSnapshotService->expects($this->never())->method('purgeOrphanedFutureSnapshots');
@@ -153,10 +127,6 @@ class BpnXmlSyncCommandTest extends TestCase
$this->assertSame(Command::SUCCESS, $tester->getStatusCode());
}
/**
* generateFilesMap() throws -> warning logged, syncSnapshotsFromXml() never called,
* command still returns SUCCESS.
*/
public function testSnapshotSyncIsSkippedAndWarningLoggedWhenFileMapGenerationFails(): void
{
$newerTimestamp = "24.03.2026 12:00:00\nExport\n3 Dateien\n";
@@ -168,12 +138,6 @@ class BpnXmlSyncCommandTest extends TestCase
$this->xmlSource->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlExport->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlSourceContingents->method('read')->willReturn($olderTimestamp);
$this->xmlExportContingents->method('fileExists')->willReturn(true);
$this->xmlExportContingents->method('read')->willReturn($olderTimestamp);
$this->xmlSourceContingents->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlExportContingents->method('listContents')->willReturn(new DirectoryListing([]));
$this->travelLoader->expects($this->once())
->method('generateFilesMap')
->willThrowException(new \RuntimeException('Storage unavailable'));
@@ -194,84 +158,4 @@ class BpnXmlSyncCommandTest extends TestCase
$this->assertSame(Command::SUCCESS, $tester->getStatusCode());
}
public function testContingentTransferFailureDoesNotFailSuccessfulTravelSync(): void
{
$newerTimestamp = "24.03.2026 12:00:00\nExport\n3 Dateien\n";
$olderTimestamp = "24.03.2026 10:00:00\nExport\n3 Dateien\n";
$this->xmlSource->method('read')->willReturn($newerTimestamp);
$this->xmlExport->method('fileExists')->willReturn(true);
$this->xmlExport->method('read')->willReturn($olderTimestamp);
$this->xmlSource->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlExport->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlSourceContingents->method('read')->willReturn($newerTimestamp);
$this->xmlExportContingents->method('fileExists')->willReturn(true);
$this->xmlExportContingents->method('read')->willReturn($olderTimestamp);
$this->xmlSourceContingents->method('listContents')
->willThrowException(UnableToListContents::atLocation('.', false, new \RuntimeException('SFTP unavailable')));
$fileMap = [
101 => ['hotels' => ['H1' => null]],
];
$this->travelLoader->expects($this->once())
->method('generateFilesMap')
->willReturn($fileMap);
$this->travelDataService->expects($this->once())
->method('syncSnapshotsFromXml')
->with($fileMap, $this->isInstanceOf(\Closure::class))
->willReturn(['processed' => 1, 'failed' => 0]);
$this->travelSnapshotService->expects($this->once())
->method('purgeOrphanedFutureSnapshots')
->with([101])
->willReturn(0);
$this->cache->expects($this->once())
->method('invalidateTags')
->with(['xml-sync']);
$tester = new CommandTester($this->command);
$tester->execute([]);
$this->assertSame(Command::SUCCESS, $tester->getStatusCode());
$this->assertStringContainsString('[contingents] Sync failed', $tester->getDisplay());
$this->assertMatchesRegularExpression('/failed datasets:\s+contingents/', $tester->getDisplay());
}
public function testTravelTransferFailureDoesNotPreventContingentsSync(): void
{
$newerTimestamp = "24.03.2026 12:00:00\nExport\n3 Dateien\n";
$olderTimestamp = "24.03.2026 10:00:00\nExport\n3 Dateien\n";
$this->xmlSource->method('read')->willReturn($newerTimestamp);
$this->xmlExport->method('fileExists')->willReturn(true);
$this->xmlExport->method('read')->willReturn($olderTimestamp);
$this->xmlSource->method('listContents')
->willThrowException(UnableToListContents::atLocation('.', false, new \RuntimeException('SFTP unavailable')));
$this->xmlSourceContingents->method('read')->willReturn($newerTimestamp);
$this->xmlExportContingents->method('fileExists')->willReturn(true);
$this->xmlExportContingents->method('read')->willReturn($olderTimestamp);
$this->xmlSourceContingents->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlExportContingents->method('listContents')->willReturn(new DirectoryListing([]));
$this->travelLoader->expects($this->never())->method('generateFilesMap');
$this->travelDataService->expects($this->never())->method('syncSnapshotsFromXml');
$this->travelSnapshotService->expects($this->never())->method('purgeOrphanedFutureSnapshots');
$this->cache->expects($this->once())
->method('invalidateTags')
->with(['xml-sync']);
$tester = new CommandTester($this->command);
$tester->execute([]);
$this->assertSame(Command::SUCCESS, $tester->getStatusCode());
$this->assertStringContainsString('[travel] Sync failed', $tester->getDisplay());
$this->assertStringContainsString('[contingents] Synced 0 files, deleted 0', $tester->getDisplay());
$this->assertStringContainsString('datasets: travel', $tester->getDisplay());
}
}
@@ -1,188 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\Controller\Api\ContingentController;
use App\Service\ContingentDataService;
use App\Service\TravelDataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
class ContingentControllerTest extends TestCase
{
public function testByDateQueryResolvesCodeReferences(): void
{
$contingentDataService = $this->createMock(ContingentDataService::class);
$travelDataProvider = $this->createMock(TravelDataProvider::class);
$travelDataProvider
->expects($this->once())
->method('mapHotelCodeToId')
->with('SBW-HOTEL')
->willReturn(157047);
$travelDataProvider
->expects($this->once())
->method('mapDateCodeToId')
->with('SBW131225')
->willReturn(11606);
$contingentDataService
->expects($this->once())
->method('getAvailableContingents')
->with(157047, 11606)
->willReturn([]);
$controller = $this->createController($contingentDataService, $travelDataProvider);
$request = Request::create('/api/contingents', 'GET', [
'hotelRef' => 'SBW-HOTEL',
'dateRef' => 'sbw-13/12/25',
]);
$response = $controller->byDate($request);
$this->assertSame(200, $response->getStatusCode());
}
public function testByDateQueryRequiresDateReference(): void
{
$contingentDataService = $this->createMock(ContingentDataService::class);
$travelDataProvider = $this->createMock(TravelDataProvider::class);
$controller = $this->createController($contingentDataService, $travelDataProvider);
$request = Request::create('/api/contingents', 'GET', [
'hotelRef' => '157047',
]);
$response = $controller->byDate($request);
$payload = json_decode((string) $response->getContent(), true);
$this->assertSame(400, $response->getStatusCode());
$this->assertSame('dateRef is required', $payload['error']);
}
public function testByDateQueryReturnsNotFoundForUnknownDateReference(): void
{
$contingentDataService = $this->createMock(ContingentDataService::class);
$travelDataProvider = $this->createMock(TravelDataProvider::class);
$travelDataProvider
->expects($this->once())
->method('mapHotelCodeToId')
->with('SBW-HOTEL')
->willReturn(157047);
$travelDataProvider
->expects($this->once())
->method('mapDateCodeToId')
->with('UNKNOWN')
->willReturn(null);
$contingentDataService
->expects($this->never())
->method('getAvailableContingents');
$controller = $this->createController($contingentDataService, $travelDataProvider);
$request = Request::create('/api/contingents', 'GET', [
'hotelRef' => 'SBW-HOTEL',
'dateRef' => 'unknown',
]);
$response = $controller->byDate($request);
$payload = json_decode((string) $response->getContent(), true);
$this->assertSame(404, $response->getStatusCode());
$this->assertSame('Not found', $payload['message']);
}
public function testRoomsQueryRequiresDateRange(): void
{
$contingentDataService = $this->createMock(ContingentDataService::class);
$travelDataProvider = $this->createMock(TravelDataProvider::class);
$controller = $this->createController($contingentDataService, $travelDataProvider);
$request = Request::create('/api/contingents/rooms', 'GET', [
'hotelRef' => '157047',
'dateRef' => '11606',
]);
$response = $controller->rooms($request);
$payload = json_decode((string) $response->getContent(), true);
$this->assertSame(400, $response->getStatusCode());
$this->assertSame('dateFrom and dateTo are required', $payload['error']);
}
public function testByDateQueryUsesNumericReferencesAsIdentifiers(): void
{
$contingentDataService = $this->createMock(ContingentDataService::class);
$travelDataProvider = $this->createMock(TravelDataProvider::class);
$travelDataProvider
->expects($this->never())
->method('mapHotelCodeToId');
$travelDataProvider
->expects($this->never())
->method('mapDateCodeToId');
$contingentDataService
->expects($this->once())
->method('getAvailableContingents')
->with(157047, 11606)
->willReturn([]);
$controller = $this->createController($contingentDataService, $travelDataProvider);
$request = Request::create('/api/contingents', 'GET', [
'hotelRef' => '157047',
'dateRef' => '11606',
]);
$response = $controller->byDate($request);
$this->assertSame(200, $response->getStatusCode());
}
public function testRoomsQueryForwardsOptionalMyEpUrl(): void
{
$contingentDataService = $this->createMock(ContingentDataService::class);
$travelDataProvider = $this->createMock(TravelDataProvider::class);
$contingentDataService
->expects($this->once())
->method('getAvailableRooms')
->with('2026-03-01', '2026-03-08', 157047, 11606, 'https://my.ep-reisen.de')
->willReturn([]);
$controller = $this->createController($contingentDataService, $travelDataProvider);
$request = Request::create('/api/contingents/rooms', 'GET', [
'hotelRef' => '157047',
'dateRef' => '11606',
'dateFrom' => '2026-03-01',
'dateTo' => '2026-03-08',
'my_ep_url' => 'https://my.ep-reisen.de',
]);
$response = $controller->rooms($request);
$this->assertSame(200, $response->getStatusCode());
}
private function createController(
ContingentDataService $contingentDataService,
TravelDataProvider $travelDataProvider,
): ContingentController {
$controller = new ContingentController($contingentDataService, $travelDataProvider);
$container = new Container();
$container->set('serializer', new Serializer([new ObjectNormalizer()], [new JsonEncoder()]));
$controller->setContainer($container);
return $controller;
}
}
-163
View File
@@ -1,163 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\BookingUrlUtility;
use App\BusProNet\XmlLoader\ContingentLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Service\ContingentDataService;
use PHPUnit\Framework\TestCase;
class ContingentDataServiceTest extends TestCase
{
private ContingentLoader $contingentLoader;
private TravelLoader $travelLoader;
private ContingentDataService $service;
protected function setUp(): void
{
$this->contingentLoader = $this->createMock(ContingentLoader::class);
$this->travelLoader = $this->createMock(TravelLoader::class);
$this->service = new ContingentDataService(
$this->contingentLoader,
$this->travelLoader,
new BookingUrlUtility('https://my.ep-reisen.de'),
);
}
public function testGetAvailableContingentsAggregatesAndAppliesBelKalOverride(): void
{
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2026-02-01');
$travel->dateTo = new \DateTimeImmutable('2026-02-03');
$this->travelLoader->method('loadById')->with(123, 10)->willReturn($travel);
$this->contingentLoader->method('loadByHotelId')->with(10)->willReturn([
'rows' => [
[
'date' => new \DateTimeImmutable('2026-02-01'),
'isControlRoom' => false,
'available' => 4,
'pax' => 8,
'minNights' => 3,
'status' => 'OK',
],
[
'date' => new \DateTimeImmutable('2026-02-01'),
'isControlRoom' => false,
'available' => 2,
'pax' => 4,
'minNights' => 2,
'status' => 'OK',
],
[
'date' => new \DateTimeImmutable('2026-02-01'),
'isControlRoom' => true,
'available' => 0,
'pax' => 0,
'minNights' => null,
'status' => 'BLOCKED',
],
],
]);
$result = $this->service->getAvailableContingents(10, 123);
$this->assertArrayHasKey('2026-02-01', $result);
$summary = $result['2026-02-01'];
$this->assertSame('2026-02-01', $summary->date);
$this->assertSame(2, $summary->minNights);
$this->assertSame(12, $summary->capacity);
$this->assertSame(0, $summary->total); // BelKal override
}
public function testGetAvailableRoomsRejectsRangeOutsideTravelPeriod(): void
{
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2026-02-10');
$travel->dateTo = new \DateTimeImmutable('2026-02-20');
$this->travelLoader->method('loadById')->with(123, 10)->willReturn($travel);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Requested range must be within the travel date range');
$this->service->getAvailableRooms('2026-02-01', '2026-02-05', 10, 123);
}
public function testGetAvailableRoomsCalculatesPriceAndAppliesBelKalStatusOverride(): void
{
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2026-02-01');
$travel->dateTo = new \DateTimeImmutable('2026-02-10');
$this->travelLoader->method('loadById')->with(123, 10)->willReturn($travel);
$this->contingentLoader->method('loadByHotelId')->with(10)->willReturn([
'rows' => [
[
'date' => new \DateTimeImmutable('2026-02-03'),
'isControlRoom' => true,
'status' => 'ON_REQUEST',
],
[
'date' => new \DateTimeImmutable('2026-02-03'),
'isControlRoom' => false,
'roomCode' => 'DZ',
'roomLabel' => 'Double',
'pax' => 4,
'available' => 2,
'status' => 'OK',
'minPrice' => 100.0,
'minNights' => 2,
'additionalNightMinPrice' => 30.0,
'additionalNightMinNights' => 1,
],
],
]);
$result = $this->service->getAvailableRooms('2026-02-03', '2026-02-06', 10, 123);
$this->assertCount(1, $result);
$room = $result[0];
$this->assertSame('2026-02-03', $room->date);
$this->assertSame('ON_REQUEST', $room->status);
$this->assertSame(0, $room->available); // overridden
$this->assertSame(130.0, $room->priceForSelection); // 100 + (3-2)*30
$this->assertSame('https://my.ep-reisen.de/bookings/create?date_id=123&hotel_id=10', $room->bookingUrl);
}
public function testGetCalendarEventsUsesWorstStatusAndExcludesControlRoomFromSums(): void
{
$this->contingentLoader->method('loadByHotelId')->with(10)->willReturn([
'rows' => [
[
'date' => new \DateTimeImmutable('2026-02-03'),
'isControlRoom' => false,
'status' => 'OK',
'pax' => 6,
'available' => 2,
],
[
'date' => new \DateTimeImmutable('2026-02-03'),
'isControlRoom' => true,
'status' => 'BLOCKED',
'pax' => 0,
'available' => 0,
],
],
]);
$events = $this->service->getCalendarEvents(10, '2026-02-01', '2026-02-10');
$this->assertCount(1, $events);
$event = $events[0];
$this->assertSame('2026-02-03', $event->date);
$this->assertSame('BLOCKED', $event->status);
$this->assertSame(6, $event->pax);
$this->assertSame(2, $event->available);
}
}