diff --git a/.env b/.env
index d97fe06..4d89b40 100644
--- a/.env
+++ b/.env
@@ -61,6 +61,7 @@ APP_TERMS_AND_CONDITIONS_URL="https://www.ep-reisen.de/skireisen/infos/allgemein
APP_PRIVACY_POLICY_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/datenschutz-winterreisen/"
APP_CMS_API_BASE_URL="https://www.ep-reisen.de/"
APP_WEBSITE_BASE_URL="https://www.ep-reisen.de/"
+APP_BASE_URL="https://my.ep-reisen.de"
APP_CUSTOMER_SERVICE_EMAIL="kundenservice@ep-reisen.de"
# Travel Data Service Configuration
@@ -83,6 +84,7 @@ DEFAULT_BOOKING_STATUS=F
API_KEYS=
XML_EXPORT_PATH="%kernel.project_dir%/var/xmlexport"
+XML_EXPORT_CONTINGENTS_PATH="%kernel.project_dir%/var/xmlexportzimmer"
JSON_EXPORT_PATH="%kernel.project_dir%/var/jsonexport"
SFTP_XML_EXPORT_HOST=
@@ -90,6 +92,11 @@ SFTP_XML_EXPORT_PORT=
SFTP_XML_EXPORT_USER=
SFTP_XML_EXPORT_PASSWORD=
+SFTP_XML_EXPORT_CONTINGENTS_HOST=
+SFTP_XML_EXPORT_CONTINGENTS_PORT=
+SFTP_XML_EXPORT_CONTINGENTS_USER=
+SFTP_XML_EXPORT_CONTINGENTS_PASSWORD=
+
###> league/oauth2-server-bundle ###
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/secret/private.key
OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/secret/public.key
diff --git a/api.http b/api.http
index 77610f5..9b8e4f2 100644
--- a/api.http
+++ b/api.http
@@ -115,3 +115,24 @@ 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")}}
diff --git a/config/packages/flysystem.yaml b/config/packages/flysystem.yaml
index c82e644..7580bc5 100644
--- a/config/packages/flysystem.yaml
+++ b/config/packages/flysystem.yaml
@@ -4,6 +4,10 @@ flysystem:
adapter: 'local'
options:
directory: '%env(resolve:XML_EXPORT_PATH)%'
+ xml_export_contingents.storage:
+ adapter: 'local'
+ options:
+ directory: '%env(resolve:XML_EXPORT_CONTINGENTS_PATH)%'
xml_source.storage:
adapter: 'sftp'
options:
@@ -11,6 +15,13 @@ flysystem:
port: '%env(int:SFTP_XML_EXPORT_PORT)%'
username: '%env(SFTP_XML_EXPORT_USER)%'
password: '%env(SFTP_XML_EXPORT_PASSWORD)%'
+ xml_source_contingents.storage:
+ adapter: 'sftp'
+ options:
+ 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:
adapter: 'local'
options:
diff --git a/config/services.yaml b/config/services.yaml
index 449dc9d..f030e06 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -99,6 +99,21 @@ services:
arguments:
$path: '%path_to_keys%'
+ App\Service\BpnXmlSyncManager:
+ 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'
+
App\Security\Crypt:
arguments:
$path: '%path_to_keys%'
@@ -117,6 +132,31 @@ services:
arguments:
$bpnCache: '@bpn.cache'
+ App\BusProNet\XmlLoader\TravelLoader:
+ arguments:
+ $cache: '@bpn.cache'
+
+ App\BusProNet\XmlLoader\HotelLoader:
+ arguments:
+ $cache: '@bpn.cache'
+
+ App\BusProNet\XmlLoader\PickupLoader:
+ arguments:
+ $cache: '@bpn.cache'
+
+ App\BusProNet\XmlLoader\InsuranceLoader:
+ 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'
diff --git a/docs/contingent-api-plan.md b/docs/contingent-api-plan.md
new file mode 100644
index 0000000..346795d
--- /dev/null
+++ b/docs/contingent-api-plan.md
@@ -0,0 +1,162 @@
+# Contingent API with XML Caching (Crawler-Based) — Plan (Updated)
+
+## Summary
+Implement a cache-only contingent API backed by XML files in `var/xmlexportzimmer`, using Symfony cache (tag-aware) and Flysystem. Parsing uses DOM/Crawler and caches DTO arrays only. Add loader/parser/services/controllers and integrate cache invalidation into the existing XML sync flow. Provide endpoints for contingents, rooms, and calendar events.
+
+## Current Status (feature/contingents-api)
+- Implemented: parser, loader, data service, controller endpoints, XML sync for contingent dataset, and tests for parser/loader/service/controller.
+- Implemented: hotel/date reference resolution (`hotelRef`/`dateRef`) with numeric passthrough and code mapping.
+- Implemented: root-link one-hop behavior and BelKal control-room override semantics.
+- Implemented: contingent caching via `bpn.cache` with `xml-sync` tagging strategy.
+- In progress: enrich rooms payload with booking URL and additional travel metadata fields.
+
+## Public API / Interface Changes
+- **New endpoints (OAuth-protected)**:
+ 1. `GET /api/contingents?hotelRef={hotelRef}&dateRef={dateRef}`
+ - Returns: object keyed by date with `{ date, minNights, total, capacity }`.
+ 2. `GET /api/contingents/rooms?hotelRef={hotelRef}&dateRef={dateRef}&dateFrom={Y-m-d}&dateTo={Y-m-d}`
+ - Returns: **per date + room** rows with pricing and travel metadata.
+ 3. `GET /api/contingents/calendar?hotelRef={hotelRef}&dateFrom={Y-m-d}&dateTo={Y-m-d}`
+ - Returns per date `{ date, status, pax, available }` (status is worst-case across rooms).
+- **New config**:
+ - `.env`: `XML_EXPORT_CONTINGENTS_PATH="%kernel.project_dir%/var/xmlexportzimmer"`
+ - `config/packages/flysystem.yaml`: `xml_export_contingents.storage` and `xml_source_contingents.storage`.
+- **New cache pool usage**:
+ - `bpn.cache` used for contingent file map + hotel cache entries, tagged with `xml-sync`.
+
+## Key Decisions Locked
+- **Parsing approach**: DOM/Crawler (SimpleXML/Crawler), not XMLReader.
+- **Cache contents**: DTO arrays (scalars + `\DateTimeImmutable`) only. No SimpleXML/Crawler objects in cache.
+- **Rooms response shape**: **per date + room** (not aggregated).
+- **BelKal override**:
+ - BelKal is a control room that can override availability.
+ - If BelKal is A/S on a date, **all rooms** on that date are treated as on-request/blocked.
+- **Root-level link**: one hop only; do not chain if linked file also has link attribute.
+- **Room-level link**: link only affects which `zimmer[@idbuspro]` node is used for capacity.
+
+## Detailed Implementation Plan
+
+### 1) Configuration
+1. Add `XML_EXPORT_CONTINGENTS_PATH` to `.env`.
+2. Add Flysystem storage in `config/packages/flysystem.yaml`:
+ - `xml_export_contingents.storage` with `directory: '%env(resolve:XML_EXPORT_CONTINGENTS_PATH)%'`.
+ - `xml_source_contingents.storage` for SFTP contingent source.
+3. Wire service injection in `config/services.yaml`:
+ - Bind `xml_export_contingents.storage` into `ContingentLoader`.
+ - Inject `@bpn.cache` explicitly into `ContingentLoader` and `BpnXmlSyncCommand`.
+
+### 2) Parser (Crawler)
+**New** `src/BusProNet/XmlParser/ContingentParser.php`
+Responsibilities:
+- Parse a single `HotelZimmer_*.xml` (string or Crawler root) and return:
+ - Room types: `{ idBusPro, code, pax, label, controlRoom, link }`
+ - Contingent rows (DTO arrays) with:
+ - `date` (`\DateTimeImmutable`)
+ - `roomCode`, `roomLabel`
+ - `pax`, `available`, `status` (`OK|ON_REQUEST|BLOCKED`)
+ - `minPrice`, `minNights`
+ - `additionalNightMinPrice`, `additionalNightMinNights`
+- Logic:
+ - Room types from `unterbringungen/unterbringung`.
+ - Skip `code=PDGS`.
+ - Control room is `code=BelKal`.
+ - For each date node (`kapazitaeten/kapazitaet @termin d.m.Y`) and room type, resolve `zimmerliste/zimmer` by id:
+ - Use `roomType.link ?? roomType.idBusPro`.
+ - BelKal rows:
+ - status `A` => ON_REQUEST
+ - status `S` => BLOCKED
+ - Non-BelKal rows:
+ - `pax = kontingent * pax_max`
+ - `available = frei * pax_max`
+- Return arrays (no XML objects) to be cache-safe.
+
+ ### 3) Loader (Cache + Flysystem)
+**New** `src/BusProNet/XmlLoader/ContingentLoader.php`
+Responsibilities:
+- Extend `AbstractLoader`.
+- `generateFilesMap()`:
+ - List `HotelZimmer_*.xml` in `xml_export_zimmer.storage`.
+ - Build mapping `hotelId => filename`.
+ - Cache as `bpn_contingent_files` for 3h on `bpn.cache`.
+- `loadByHotelId(int $hotelId): array`:
+ - Use file map to find filename; return `[]` if missing.
+ - Read XML content.
+ - **Root-level link**: if root has `idbuspro_kontingent_aus`, load that file **instead** (one hop only).
+ - Parse using `ContingentParser`.
+ - Cache result in `bpn.cache` as `contingent_hotel_{hotelId}` TTL 3600, tagged `xml-sync`.
+
+### 4) Data Service (No DB)
+**New** `src/Service/ContingentDataService.php`
+Dependencies: `ContingentLoader`, `TravelLoader`, optional booking URL base.
+
+Functions:
+- `getAvailableContingents(int $hotelId, int $dateId): array`
+ - Load travel via `TravelLoader->loadById($dateId)` to get `dateFrom/dateTo`.
+ - Load contingents by hotel.
+ - Filter within range (inclusive).
+ - Aggregate per date: `minNights` (min), `total` (sum of available), `capacity` (sum pax).
+ - **BelKal override**: if BelKal is A/S on a date, mark all rooms on that date as on-request/blocked for outputs.
+- `getAvailableRooms(string $dateFrom, string $dateTo, int $hotelId, int $dateId, string $myEpUrl = ''): array`
+ - Filter contingents by range; build **per date + room** rows.
+ - Compute `priceForSelection = minPrice + max(0, nights - minNights) * additionalNightMinPrice`.
+ - Add bookingUrl (see below).
+ - Add `summer/servicesIncluded/servicesOptional` if travel metadata available; otherwise empty.
+ - **BelKal override**: if BelKal is A/S on a date, force each row’s `status` to ON_REQUEST/BLOCKED (and optionally available to 0 if desired by frontend).
+- `getCalendarEvents(int $hotelId, string $dateFrom, string $dateTo): array`
+ - Aggregate per date:
+ - Exclude BelKal rows from `pax` and `available`.
+ - Status is worst-case across **all** rows (BelKal included).
+ - Return `{ date, status, pax, available }`.
+
+### 5) Booking URL Utility
+- Add `src/BusProNet/Utility/BookingUrlUtility.php`.
+- URL format: `/bookings/create?date_id={dateId}&hotel_id={hotelId}`.
+- Base resolution: use optional `my_ep_url` when provided, otherwise `APP_BASE_URL` (default `https://my.ep-reisen.de`).
+
+### 6) Controller + Routes
+**New** `src/Controller/Api/ContingentController.php`
+- Base: `#[Route('/api')]` and `#[IsGranted('ROLE_OAUTH2_API')]`.
+- Canonical query routes:
+ 1. `/api/contingents/calendar` with query `hotelRef`, `dateFrom`, `dateTo`
+ 2. `/api/contingents` with query `hotelRef`, `dateRef`
+ 3. `/api/contingents/rooms` with query `hotelRef`, `dateRef`, `dateFrom`, `dateTo`
+- Reference resolution:
+ - Numeric `hotelRef` / `dateRef` values are treated as IDs.
+ - Non-numeric `hotelRef` is mapped as hotel code.
+ - Non-numeric `dateRef` is sanitized (`-` and `/` removed, uppercase) and mapped as date code.
+
+### 7) Cache Invalidation on Sync
+Update `src/Command/BpnXmlSyncCommand.php`:
+- Inject `TagAwareCacheInterface $bpnCache` (bind to `@bpn.cache`).
+- In `invalidateCaches()`:
+ - Invalidate `xml-sync` tag after successful sync.
+ - Sync now covers two datasets (`travel` and `contingents`) in one command run.
+
+## Edge Cases & Failure Modes
+- Missing `HotelZimmer_{hotelId}.xml`: loader returns empty array; API returns empty result or 404 (match existing pattern).
+- Root-level link missing target file: return empty array (optionally log), no fallback to original.
+- Travel not found for `dateId`: return 404.
+- Hotel not in travel: optionally validate using TravelLoader mapping and return 404.
+- Date parsing: `d.m.Y` -> `\DateTimeImmutable` with server timezone.
+
+## Tests / Scenarios
+1. **Parser – happy path**: room types, capacities, prices parsed correctly.
+2. **Parser – room link**: `idbuspro_kontingent_aus` on room type uses linked id for `zimmerliste/zimmer`.
+3. **Parser – root link**: root `idbuspro_kontingent_aus` loads alternate file exactly once.
+4. **Parser – BelKal override**:
+ - BelKal A/S forces all rooms on that date to on-request/blocked.
+ - Calendar status uses BelKal and sums exclude BelKal.
+5. **Service – getAvailableContingents**: correct per-date aggregation.
+6. **Service – getAvailableRooms**: per date + room rows with correct pricing.
+7. **Calendar**: worst-case status across rooms, with correct sums excluding BelKal.
+8. **Cache**: entries tagged and invalidated after `app:bpn:xml-sync`.
+
+## Performance Notes
+- DOM/Crawler parsing should be acceptable for ~7MB XMLs, especially with cached DTOs.
+- If XML size grows materially (>20-30MB) or cold-cache concurrency spikes, consider adding an XMLReader streaming parser as a future enhancement.
+
+## Assumptions / Defaults
+- `hotelRef` accepts either numeric `hotelId` (`idbuspro`) or hotel code.
+- `dateRef` accepts either numeric `dateId` or date code.
+- Rooms endpoint returns per date + room rows.
+- Return structure follows existing API patterns (`TravelController`, `PickupController`) for HTTP status and JSON formatting.
diff --git a/src/BusProNet/Model/ContingentCalendarEvent.php b/src/BusProNet/Model/ContingentCalendarEvent.php
new file mode 100644
index 0000000..277cb20
--- /dev/null
+++ b/src/BusProNet/Model/ContingentCalendarEvent.php
@@ -0,0 +1,22 @@
+ $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, '/');
+ }
+}
diff --git a/src/BusProNet/XmlLoader/ContingentLoader.php b/src/BusProNet/XmlLoader/ContingentLoader.php
new file mode 100644
index 0000000..f5e3e36
--- /dev/null
+++ b/src/BusProNet/XmlLoader/ContingentLoader.php
@@ -0,0 +1,127 @@
+
+ */
+ 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>, rows: array>}
+ */
+ 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>, rows: array>}
+ */
+ 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;
+ }
+}
diff --git a/src/BusProNet/XmlLoader/HotelLoader.php b/src/BusProNet/XmlLoader/HotelLoader.php
index 051f343..11bebff 100644
--- a/src/BusProNet/XmlLoader/HotelLoader.php
+++ b/src/BusProNet/XmlLoader/HotelLoader.php
@@ -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 = [];
diff --git a/src/BusProNet/XmlLoader/InsuranceLoader.php b/src/BusProNet/XmlLoader/InsuranceLoader.php
index 6573d49..f9a8bdd 100644
--- a/src/BusProNet/XmlLoader/InsuranceLoader.php
+++ b/src/BusProNet/XmlLoader/InsuranceLoader.php
@@ -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);
diff --git a/src/BusProNet/XmlLoader/PickupLoader.php b/src/BusProNet/XmlLoader/PickupLoader.php
index f8e5084..a4899ad 100644
--- a/src/BusProNet/XmlLoader/PickupLoader.php
+++ b/src/BusProNet/XmlLoader/PickupLoader.php
@@ -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');
diff --git a/src/BusProNet/XmlLoader/TravelLoader.php b/src/BusProNet/XmlLoader/TravelLoader.php
index 04e00dd..dd5cd44 100644
--- a/src/BusProNet/XmlLoader/TravelLoader.php
+++ b/src/BusProNet/XmlLoader/TravelLoader.php
@@ -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
diff --git a/src/BusProNet/XmlParser/AbstractParser.php b/src/BusProNet/XmlParser/AbstractParser.php
index 41f1ed8..56a6e9d 100644
--- a/src/BusProNet/XmlParser/AbstractParser.php
+++ b/src/BusProNet/XmlParser/AbstractParser.php
@@ -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;
diff --git a/src/BusProNet/XmlParser/ContingentParser.php b/src/BusProNet/XmlParser/ContingentParser.php
new file mode 100644
index 0000000..b36473b
--- /dev/null
+++ b/src/BusProNet/XmlParser/ContingentParser.php
@@ -0,0 +1,151 @@
+
+ */
+ private array $ignoredRoomCodes = [
+ 'PDGS',
+ ];
+
+ /**
+ * @return array{roomTypes: array>, rows: array>}
+ */
+ 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>
+ */
+ 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> $roomTypes
+ *
+ * @return array>
+ */
+ 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',
+ };
+ }
+}
diff --git a/src/Command/BpnXmlCacheInvalidateCommand.php b/src/Command/BpnXmlCacheInvalidateCommand.php
new file mode 100644
index 0000000..61e36eb
--- /dev/null
+++ b/src/Command/BpnXmlCacheInvalidateCommand.php
@@ -0,0 +1,203 @@
+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
+ */
+ private function loadState(): array
+ {
+ $stateJson = $this->cache->get(self::STATE_CACHE_KEY, fn (): string => '{}');
+
+ return $this->decodeState($stateJson);
+ }
+
+ /**
+ * @return array
+ */
+ 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 $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
+ */
+ private function getInfoTargets(): array
+ {
+ return [
+ [
+ 'name' => 'travel',
+ 'storage' => $this->xmlExport,
+ ],
+ [
+ 'name' => 'contingents',
+ 'storage' => $this->xmlExportContingents,
+ ],
+ ];
+ }
+}
diff --git a/src/Command/BpnXmlSyncCommand.php b/src/Command/BpnXmlSyncCommand.php
index 001c506..7a9ec54 100644
--- a/src/Command/BpnXmlSyncCommand.php
+++ b/src/Command/BpnXmlSyncCommand.php
@@ -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
- *
- * @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();
+ }
}
}
diff --git a/src/Controller/Api/ContingentController.php b/src/Controller/Api/ContingentController.php
new file mode 100644
index 0000000..a20009c
--- /dev/null
+++ b/src/Controller/Api/ContingentController.php
@@ -0,0 +1,242 @@
+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);
+ }
+}
diff --git a/src/Form/DataTransformer/RoomSelectionToIdTransformer.php b/src/Form/DataTransformer/RoomSelectionToIdTransformer.php
index d1775fc..f725e19 100644
--- a/src/Form/DataTransformer/RoomSelectionToIdTransformer.php
+++ b/src/Form/DataTransformer/RoomSelectionToIdTransformer.php
@@ -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)));
}
}
diff --git a/src/Form/Model/BookingSummaryDto.php b/src/Form/Model/BookingSummaryDto.php
index ae31697..ba8b581 100644
--- a/src/Form/Model/BookingSummaryDto.php
+++ b/src/Form/Model/BookingSummaryDto.php
@@ -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 $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,
) {
}
}
diff --git a/src/Model/BookingSummaryCmsHotelDto.php b/src/Model/BookingSummaryCmsHotelData.php
similarity index 87%
rename from src/Model/BookingSummaryCmsHotelDto.php
rename to src/Model/BookingSummaryCmsHotelData.php
index 68495ed..54cf106 100644
--- a/src/Model/BookingSummaryCmsHotelDto.php
+++ b/src/Model/BookingSummaryCmsHotelData.php
@@ -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|null $images
diff --git a/src/Model/BpnXmlSnapshotRefreshPlan.php b/src/Model/BpnXmlSnapshotRefreshPlan.php
new file mode 100644
index 0000000..1051a12
--- /dev/null
+++ b/src/Model/BpnXmlSnapshotRefreshPlan.php
@@ -0,0 +1,17 @@
+}> $xmlFileMap
+ */
+ public function __construct(
+ public array $xmlFileMap,
+ public int $total,
+ ) {
+ }
+}
diff --git a/src/Model/BpnXmlSnapshotRefreshResult.php b/src/Model/BpnXmlSnapshotRefreshResult.php
new file mode 100644
index 0000000..526ede5
--- /dev/null
+++ b/src/Model/BpnXmlSnapshotRefreshResult.php
@@ -0,0 +1,15 @@
+cmsDataService->getProductImages($productCode, $hotelCode);
- return new BookingSummaryCmsHotelDto(
+ return new BookingSummaryCmsHotelData(
name: $baseHotel?->name,
address: $this->formatHotelAddress($baseHotel),
images: $images,
diff --git a/src/Service/BpnXmlSnapshotRefreshManager.php b/src/Service/BpnXmlSnapshotRefreshManager.php
new file mode 100644
index 0000000..39acec7
--- /dev/null
+++ b/src/Service/BpnXmlSnapshotRefreshManager.php
@@ -0,0 +1,60 @@
+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,
+ );
+ }
+}
diff --git a/src/Service/BpnXmlSyncManager.php b/src/Service/BpnXmlSyncManager.php
new file mode 100644
index 0000000..5c10322
--- /dev/null
+++ b/src/Service/BpnXmlSyncManager.php
@@ -0,0 +1,126 @@
+
+ */
+ 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);
+ }
+}
diff --git a/src/Service/ContingentDataService.php b/src/Service/ContingentDataService.php
new file mode 100644
index 0000000..dbcd507
--- /dev/null
+++ b/src/Service/ContingentDataService.php
@@ -0,0 +1,258 @@
+ 0,
+ 'ON_REQUEST' => 1,
+ 'BLOCKED' => 2,
+ ];
+
+ public function __construct(
+ private readonly ContingentLoader $contingentLoader,
+ private readonly TravelLoader $travelLoader,
+ private readonly BookingUrlUtility $bookingUrlUtility,
+ ) {
+ }
+
+ /**
+ * @return array
+ *
+ * @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
+ *
+ * @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
+ */
+ 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> $rows
+ *
+ * @return array>
+ */
+ 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> $rows
+ *
+ * @return array
+ */
+ 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);
+ }
+}
diff --git a/tests/BusProNet/DataLoader/HotelDataLoaderTest.php b/tests/BusProNet/DataLoader/HotelDataLoaderTest.php
index 694eed2..1a4b473 100644
--- a/tests/BusProNet/DataLoader/HotelDataLoaderTest.php
+++ b/tests/BusProNet/DataLoader/HotelDataLoaderTest.php
@@ -7,6 +7,7 @@ use App\BusProNet\XmlLoader\HotelLoader;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
+use Symfony\Component\Cache\Adapter\TagAwareAdapter;
class HotelDataLoaderTest extends TestCase
{
@@ -32,7 +33,7 @@ class HotelDataLoaderTest extends TestCase
->with('hotel.xml')
->willReturn($xmlContent);
- $cache = new ArrayAdapter();
+ $cache = new TagAwareAdapter(new ArrayAdapter());
$loader = new HotelLoader($cache, $filesystem);
$hotels = $loader->loadAll();
diff --git a/tests/BusProNet/DataLoader/PickupDataLoaderTest.php b/tests/BusProNet/DataLoader/PickupDataLoaderTest.php
index 450afef..7a2a169 100644
--- a/tests/BusProNet/DataLoader/PickupDataLoaderTest.php
+++ b/tests/BusProNet/DataLoader/PickupDataLoaderTest.php
@@ -7,6 +7,7 @@ use App\BusProNet\XmlLoader\PickupLoader;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
+use Symfony\Component\Cache\Adapter\TagAwareAdapter;
class PickupDataLoaderTest extends TestCase
{
@@ -32,7 +33,7 @@ class PickupDataLoaderTest extends TestCase
->with('zustiege.xml')
->willReturn($xmlContent);
- $cache = new ArrayAdapter();
+ $cache = new TagAwareAdapter(new ArrayAdapter());
$loader = new PickupLoader($cache, $filesystem);
$pickup = $loader->loadById(1);
diff --git a/tests/BusProNet/Utility/BookingUrlUtilityTest.php b/tests/BusProNet/Utility/BookingUrlUtilityTest.php
new file mode 100644
index 0000000..9fcc367
--- /dev/null
+++ b/tests/BusProNet/Utility/BookingUrlUtilityTest.php
@@ -0,0 +1,56 @@
+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);
+ }
+}
diff --git a/tests/BusProNet/XmlLoader/ContingentLoaderTest.php b/tests/BusProNet/XmlLoader/ContingentLoaderTest.php
new file mode 100644
index 0000000..cccdd15
--- /dev/null
+++ b/tests/BusProNet/XmlLoader/ContingentLoaderTest.php
@@ -0,0 +1,137 @@
+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 = '';
+ $linkedXml = '';
+ $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 = '';
+
+ $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);
+ }
+}
diff --git a/tests/BusProNet/XmlParser/ContingentParserTest.php b/tests/BusProNet/XmlParser/ContingentParserTest.php
new file mode 100644
index 0000000..8fb1e78
--- /dev/null
+++ b/tests/BusProNet/XmlParser/ContingentParserTest.php
@@ -0,0 +1,102 @@
+parser = new ContingentParser();
+ }
+
+ public function testParseBuildsRoomTypesAndRowsWithLinksAndControlRoom(): void
+ {
+ $xml = <<<'XML'
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+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;
+
+ $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']);
+ }
+}
diff --git a/tests/Command/BpnXmlSyncCommandTest.php b/tests/Command/BpnXmlSyncCommandTest.php
index 1c40529..a8e9bda 100644
--- a/tests/Command/BpnXmlSyncCommandTest.php
+++ b/tests/Command/BpnXmlSyncCommandTest.php
@@ -6,6 +6,8 @@ namespace App\Tests\Command;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Command\BpnXmlSyncCommand;
+use App\Service\BpnXmlSyncManager;
+use App\Service\BpnXmlSnapshotRefreshManager;
use App\Service\TravelDataProvider;
use App\Service\TravelSnapshotManager;
use League\Flysystem\DirectoryListing;
@@ -14,17 +16,21 @@ use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
-use Symfony\Contracts\Cache\CacheInterface;
+use Symfony\Contracts\Cache\TagAwareCacheInterface;
class BpnXmlSyncCommandTest extends TestCase
{
private FilesystemOperator $xmlSource;
private FilesystemOperator $xmlExport;
- private CacheInterface $cache;
+ private FilesystemOperator $xmlSourceContingents;
+ private FilesystemOperator $xmlExportContingents;
+ private TagAwareCacheInterface $cache;
private LoggerInterface $logger;
private TravelLoader $travelLoader;
private TravelDataProvider $travelDataService;
private TravelSnapshotManager $travelSnapshotService;
+ private BpnXmlSnapshotRefreshManager $snapshotRefreshManager;
+ private BpnXmlSyncManager $syncManager;
private BpnXmlSyncCommand $command;
protected function tearDown(): void
@@ -39,40 +45,55 @@ class BpnXmlSyncCommandTest extends TestCase
{
$this->xmlSource = $this->createMock(FilesystemOperator::class);
$this->xmlExport = $this->createMock(FilesystemOperator::class);
- $this->cache = $this->createMock(CacheInterface::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);
$this->travelDataService = $this->createMock(TravelDataProvider::class);
$this->travelSnapshotService = $this->createMock(TravelSnapshotManager::class);
-
- $this->command = new BpnXmlSyncCommand(
- $this->xmlSource,
- $this->xmlExport,
- $this->cache,
- $this->logger,
+ $this->snapshotRefreshManager = new BpnXmlSnapshotRefreshManager(
$this->travelLoader,
$this->travelDataService,
$this->travelSnapshotService,
+ $this->logger,
+ );
+
+ $this->syncManager = new BpnXmlSyncManager(
+ $this->xmlSource,
+ $this->xmlExport,
+ $this->xmlSourceContingents,
+ $this->xmlExportContingents,
+ $this->cache,
+ $this->logger,
+ );
+
+ $this->command = new BpnXmlSyncCommand(
+ $this->syncManager,
+ $this->snapshotRefreshManager,
+ $this->logger,
);
}
/**
- * Remote newer than local → files downloaded → syncSnapshotsFromXml() called once.
+ * Remote newer than local -> files downloaded -> syncSnapshotsFromXml() called once.
*/
public function testSnapshotSyncIsTriggeredAfterSuccessfulFileDownload(): void
{
- $this->xmlSource->method('read')
- ->willReturn("24.03.2026 12:00:00\nExport\n3 Dateien\n");
+ $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("24.03.2026 10:00:00\nExport\n3 Dateien\n");
+ $this->xmlExport->method('read')->willReturn($olderTimestamp);
+ $this->xmlSource->method('listContents')->willReturn(new DirectoryListing([]));
+ $this->xmlExport->method('listContents')->willReturn(new DirectoryListing([]));
- // No remote files to copy (keeps syncFiles() trivial while still completing).
- $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]],
@@ -87,6 +108,15 @@ class BpnXmlSyncCommandTest extends TestCase
->with($fileMap, $this->isInstanceOf(\Closure::class))
->willReturn(['processed' => 2, 'failed' => 0]);
+ $this->travelSnapshotService->expects($this->once())
+ ->method('purgeOrphanedFutureSnapshots')
+ ->with([101, 102])
+ ->willReturn(0);
+
+ $this->cache->expects($this->once())
+ ->method('invalidateTags')
+ ->with(['xml-sync']);
+
$tester = new CommandTester($this->command);
$tester->execute([]);
@@ -94,7 +124,7 @@ class BpnXmlSyncCommandTest extends TestCase
}
/**
- * Local already up to date → early return before snapshot code is reached.
+ * Local already up to date -> early return before snapshot code is reached.
*/
public function testSnapshotSyncIsSkippedWhenLocalDataIsUpToDate(): void
{
@@ -102,9 +132,19 @@ class BpnXmlSyncCommandTest extends TestCase
$this->xmlSource->method('read')->willReturn($sameTimestamp);
$this->xmlExport->method('fileExists')->willReturn(true);
$this->xmlExport->method('read')->willReturn($sameTimestamp);
+ $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');
+ $this->cache->expects($this->never())->method('invalidateTags');
$tester = new CommandTester($this->command);
$tester->execute([]);
@@ -113,22 +153,25 @@ class BpnXmlSyncCommandTest extends TestCase
}
/**
- * generateFilesMap() throws → warning logged, syncSnapshotsFromXml() never called,
+ * generateFilesMap() throws -> warning logged, syncSnapshotsFromXml() never called,
* command still returns SUCCESS.
*/
public function testSnapshotSyncIsSkippedAndWarningLoggedWhenFileMapGenerationFails(): void
{
- $this->xmlSource->method('read')
- ->willReturn("24.03.2026 12:00:00\nExport\n3 Dateien\n");
+ $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("24.03.2026 10:00:00\nExport\n3 Dateien\n");
+ $this->xmlExport->method('read')->willReturn($olderTimestamp);
+ $this->xmlSource->method('listContents')->willReturn(new DirectoryListing([]));
+ $this->xmlExport->method('listContents')->willReturn(new DirectoryListing([]));
- $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')
@@ -142,6 +185,8 @@ class BpnXmlSyncCommandTest extends TestCase
);
$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([]);
diff --git a/tests/Controller/Api/ContingentControllerTest.php b/tests/Controller/Api/ContingentControllerTest.php
new file mode 100644
index 0000000..91e866e
--- /dev/null
+++ b/tests/Controller/Api/ContingentControllerTest.php
@@ -0,0 +1,188 @@
+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;
+ }
+}
diff --git a/tests/Form/Model/ParticipantEditDtoTest.php b/tests/Form/Model/ParticipantEditDtoTest.php
index c9f58c6..059333d 100644
--- a/tests/Form/Model/ParticipantEditDtoTest.php
+++ b/tests/Form/Model/ParticipantEditDtoTest.php
@@ -45,12 +45,7 @@ class ParticipantEditDtoTest extends TestCase
$mockServiceAgeEvaluator = $this->createMock(ServiceAgeEvaluator::class);
// Create custom validator factory that can inject dependencies
- $validatorFactory = new class(
- $mockVoucherService,
- $mockPriceCalculatorService,
- $mockParticipantEligibilityChecker,
- $mockServiceAgeEvaluator
- ) implements ConstraintValidatorFactoryInterface {
+ $validatorFactory = new class($mockVoucherService, $mockPriceCalculatorService, $mockParticipantEligibilityChecker, $mockServiceAgeEvaluator) implements ConstraintValidatorFactoryInterface {
public function __construct(
private readonly VoucherValidator $voucherService,
private readonly BookingPriceCalculator $priceCalculatorService,
diff --git a/tests/Form/Service/ParticipantFieldOptionsProviderBabyTest.php b/tests/Form/Service/ParticipantFieldOptionsProviderBabyTest.php
index be2538f..a984c17 100644
--- a/tests/Form/Service/ParticipantFieldOptionsProviderBabyTest.php
+++ b/tests/Form/Service/ParticipantFieldOptionsProviderBabyTest.php
@@ -13,8 +13,8 @@ use App\Form\Model\ParticipantDto;
use App\Form\Service\ParticipantFieldOptionsProvider;
use App\Service\BookingPriceCalculator;
use App\Service\InsuranceManager;
-use App\Service\ServiceLabelFormatter;
use App\Service\ServiceAvailabilityCalculator;
+use App\Service\ServiceLabelFormatter;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
diff --git a/tests/Form/Service/ParticipantFieldOptionsProviderMandatoryServiceTest.php b/tests/Form/Service/ParticipantFieldOptionsProviderMandatoryServiceTest.php
index ee2a8e9..6b7948e 100644
--- a/tests/Form/Service/ParticipantFieldOptionsProviderMandatoryServiceTest.php
+++ b/tests/Form/Service/ParticipantFieldOptionsProviderMandatoryServiceTest.php
@@ -12,8 +12,8 @@ use App\Form\Model\ParticipantDto;
use App\Form\Service\ParticipantFieldOptionsProvider;
use App\Service\BookingPriceCalculator;
use App\Service\InsuranceManager;
-use App\Service\ServiceLabelFormatter;
use App\Service\ServiceAvailabilityCalculator;
+use App\Service\ServiceLabelFormatter;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
diff --git a/tests/Service/BookingConfiguratorStatusTest.php b/tests/Service/BookingConfiguratorStatusTest.php
index 3fb0820..f7b18cf 100644
--- a/tests/Service/BookingConfiguratorStatusTest.php
+++ b/tests/Service/BookingConfiguratorStatusTest.php
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Constants;
-use App\BusProNet\Model\Service;
use App\BusProNet\Model\Room;
+use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use App\BusProNet\Service\BookingStatusRuleRegistry;
use App\BusProNet\XmlLoader\AgencyLoader;
diff --git a/tests/Service/BookingCreateContextFactoryTest.php b/tests/Service/BookingCreateContextFactoryTest.php
index baf4b97..8e151ec 100644
--- a/tests/Service/BookingCreateContextFactoryTest.php
+++ b/tests/Service/BookingCreateContextFactoryTest.php
@@ -8,13 +8,13 @@ use App\BusProNet\Model\Room;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingCreateContext;
use App\Form\Model\BookingDto;
+use App\Form\Model\BookingSummaryDto;
use App\Form\Model\ParticipantCardDataDto;
use App\Form\Model\ParticipantCardPriceDto;
-use App\Form\Model\BookingSummaryDto;
use App\Service\BookingCreateContextFactory;
-use App\Service\ParticipantCardAssembler;
-use App\Service\BookingSummaryAssembler;
use App\Service\BookingPriceCalculator;
+use App\Service\BookingSummaryAssembler;
+use App\Service\ParticipantCardAssembler;
use App\Service\RoomPricingCalculator;
use PHPUnit\Framework\TestCase;
diff --git a/tests/Service/BookingEditContextFactoryTest.php b/tests/Service/BookingEditContextFactoryTest.php
index 8c005d6..8cbc543 100644
--- a/tests/Service/BookingEditContextFactoryTest.php
+++ b/tests/Service/BookingEditContextFactoryTest.php
@@ -9,8 +9,8 @@ use App\BusProNet\Model\Booking;
use App\BusProNet\Model\MutableData;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
-use App\Form\Model\BookingMutabilityDto;
use App\Form\Model\BookingEditContext;
+use App\Form\Model\BookingMutabilityDto;
use App\Form\Model\BookingSummaryDto;
use App\Service\BookingEditContextFactory;
use App\Service\BookingSummaryAssembler;
diff --git a/tests/Service/BookingEditDraftManagerMutabilityTest.php b/tests/Service/BookingEditDraftManagerMutabilityTest.php
index 3fcb60e..d2f9660 100644
--- a/tests/Service/BookingEditDraftManagerMutabilityTest.php
+++ b/tests/Service/BookingEditDraftManagerMutabilityTest.php
@@ -13,9 +13,9 @@ use App\Entity\User;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Repository\BookingEditDraftRepository;
+use App\Service\BookingChangeTracker;
use App\Service\BookingEditDraftManager;
use App\Service\BookingEditDraftMerger;
-use App\Service\BookingChangeTracker;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
diff --git a/tests/Service/BookingEditSubmitterTest.php b/tests/Service/BookingEditSubmitterTest.php
index 29cb02e..b6c8bb3 100644
--- a/tests/Service/BookingEditSubmitterTest.php
+++ b/tests/Service/BookingEditSubmitterTest.php
@@ -422,11 +422,11 @@ class BookingEditSubmitterTest extends TestCase
private function createService(
?\App\BusProNet\ApiClient $apiClient = null,
- ?BookingEditDataLoader $dataLoader = null,
- ?BookingEditDraftManager $draftService = null,
- ?TravelDataProvider $travelDataService = null,
- ?BookingEditSubmitGuard $submitGuard = null,
- ?BookingSessionManager $bookingSessionService = null,
+ ?BookingEditDataLoader $dataLoader = null,
+ ?BookingEditDraftManager $draftService = null,
+ ?TravelDataProvider $travelDataService = null,
+ ?BookingEditSubmitGuard $submitGuard = null,
+ ?BookingSessionManager $bookingSessionService = null,
): BookingEditSubmitter {
return new BookingEditSubmitter(
$apiClient ?? $this->createMock(\App\BusProNet\ApiClient::class),
diff --git a/tests/Service/BookingPriceMismatchAnalyzerTest.php b/tests/Service/BookingPriceMismatchAnalyzerTest.php
index ab76948..6b5af95 100644
--- a/tests/Service/BookingPriceMismatchAnalyzerTest.php
+++ b/tests/Service/BookingPriceMismatchAnalyzerTest.php
@@ -11,8 +11,8 @@ use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto;
-use App\Service\BookingPricingAssembler;
use App\Service\BookingPriceMismatchAnalyzer;
+use App\Service\BookingPricingAssembler;
use PHPUnit\Framework\TestCase;
class BookingPriceMismatchAnalyzerTest extends TestCase
diff --git a/tests/Service/BookingSummaryAssemblerTest.php b/tests/Service/BookingSummaryAssemblerTest.php
index 477c538..79fbf80 100644
--- a/tests/Service/BookingSummaryAssemblerTest.php
+++ b/tests/Service/BookingSummaryAssemblerTest.php
@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Tests\Service;
+use App\BusProNet\DataProvider\CountryDataProvider;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Travel;
+use App\BusProNet\XmlLoader\HotelLoader;
use App\Form\Model\BookingDto;
use App\Form\Model\BookingSummaryDto;
use App\Form\Model\ParticipantDto;
@@ -15,8 +17,6 @@ use App\Service\BookingPriceCalculator;
use App\Service\BookingPricingAssembler;
use App\Service\BookingSummaryAssembler;
use App\Service\CmsDataProvider;
-use App\BusProNet\DataProvider\CountryDataProvider;
-use App\BusProNet\XmlLoader\HotelLoader;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Contracts\Cache\CacheInterface;
diff --git a/tests/Service/ContingentDataServiceTest.php b/tests/Service/ContingentDataServiceTest.php
new file mode 100644
index 0000000..2d201c2
--- /dev/null
+++ b/tests/Service/ContingentDataServiceTest.php
@@ -0,0 +1,163 @@
+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);
+ }
+}
diff --git a/tests/Validator/Constraints/MandatoryAdditionalServicesSelectedValidatorTest.php b/tests/Validator/Constraints/MandatoryAdditionalServicesSelectedValidatorTest.php
index e1d5c70..bd11e4c 100644
--- a/tests/Validator/Constraints/MandatoryAdditionalServicesSelectedValidatorTest.php
+++ b/tests/Validator/Constraints/MandatoryAdditionalServicesSelectedValidatorTest.php
@@ -176,4 +176,3 @@ class MandatoryAdditionalServicesSelectedValidatorTest extends ConstraintValidat
return $service;
}
}
-
diff --git a/tests/doctrine-object-manager.php b/tests/doctrine-object-manager.php
index f757fcc..aea1f33 100644
--- a/tests/doctrine-object-manager.php
+++ b/tests/doctrine-object-manager.php
@@ -3,9 +3,9 @@
use App\Kernel;
use Symfony\Component\Dotenv\Dotenv;
-require dirname(__DIR__) . '/vendor/autoload.php';
+require dirname(__DIR__).'/vendor/autoload.php';
-(new Dotenv())->bootEnv(dirname(__DIR__) . '/.env');
+(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$kernel->boot();