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();