# 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.