feat: api endpoints and xml sync for contingents data
This commit is contained in:
@@ -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_PRIVACY_POLICY_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/datenschutz-winterreisen/"
|
||||||
APP_CMS_API_BASE_URL="https://www.ep-reisen.de/"
|
APP_CMS_API_BASE_URL="https://www.ep-reisen.de/"
|
||||||
APP_WEBSITE_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="[email protected]"
|
APP_CUSTOMER_SERVICE_EMAIL="[email protected]"
|
||||||
|
|
||||||
# Travel Data Service Configuration
|
# Travel Data Service Configuration
|
||||||
@@ -83,6 +84,7 @@ DEFAULT_BOOKING_STATUS=F
|
|||||||
API_KEYS=
|
API_KEYS=
|
||||||
|
|
||||||
XML_EXPORT_PATH="%kernel.project_dir%/var/xmlexport"
|
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"
|
JSON_EXPORT_PATH="%kernel.project_dir%/var/jsonexport"
|
||||||
|
|
||||||
SFTP_XML_EXPORT_HOST=
|
SFTP_XML_EXPORT_HOST=
|
||||||
@@ -90,6 +92,11 @@ SFTP_XML_EXPORT_PORT=
|
|||||||
SFTP_XML_EXPORT_USER=
|
SFTP_XML_EXPORT_USER=
|
||||||
SFTP_XML_EXPORT_PASSWORD=
|
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 ###
|
###> league/oauth2-server-bundle ###
|
||||||
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/secret/private.key
|
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/secret/private.key
|
||||||
OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/secret/public.key
|
OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/secret/public.key
|
||||||
|
|||||||
@@ -115,3 +115,24 @@ GET {{base_url}}/api/pickups-planning/DPWWTS191125
|
|||||||
Accept: application/json
|
Accept: application/json
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
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")}}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ flysystem:
|
|||||||
adapter: 'local'
|
adapter: 'local'
|
||||||
options:
|
options:
|
||||||
directory: '%env(resolve:XML_EXPORT_PATH)%'
|
directory: '%env(resolve:XML_EXPORT_PATH)%'
|
||||||
|
xml_export_contingents.storage:
|
||||||
|
adapter: 'local'
|
||||||
|
options:
|
||||||
|
directory: '%env(resolve:XML_EXPORT_CONTINGENTS_PATH)%'
|
||||||
xml_source.storage:
|
xml_source.storage:
|
||||||
adapter: 'sftp'
|
adapter: 'sftp'
|
||||||
options:
|
options:
|
||||||
@@ -11,6 +15,13 @@ flysystem:
|
|||||||
port: '%env(int:SFTP_XML_EXPORT_PORT)%'
|
port: '%env(int:SFTP_XML_EXPORT_PORT)%'
|
||||||
username: '%env(SFTP_XML_EXPORT_USER)%'
|
username: '%env(SFTP_XML_EXPORT_USER)%'
|
||||||
password: '%env(SFTP_XML_EXPORT_PASSWORD)%'
|
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:
|
xml_dump.storage:
|
||||||
adapter: 'local'
|
adapter: 'local'
|
||||||
options:
|
options:
|
||||||
|
|||||||
@@ -99,6 +99,21 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$path: '%path_to_keys%'
|
$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:
|
App\Security\Crypt:
|
||||||
arguments:
|
arguments:
|
||||||
$path: '%path_to_keys%'
|
$path: '%path_to_keys%'
|
||||||
@@ -117,6 +132,31 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$bpnCache: '@bpn.cache'
|
$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:
|
App\Service\VoucherValidator:
|
||||||
arguments:
|
arguments:
|
||||||
$cache: '@cache.app'
|
$cache: '@cache.app'
|
||||||
|
|||||||
@@ -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: `<base>/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.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\Model;
|
||||||
|
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
class ContingentCalendarEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public string $date,
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public string $status,
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public int $pax,
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public int $available,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\Model;
|
||||||
|
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
class ContingentDateSummary
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public string $date,
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public ?int $minNights,
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public int $total,
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public int $capacity,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\Model;
|
||||||
|
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
class ContingentRoomAvailability
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public string $date,
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public string $roomCode,
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public string $roomLabel,
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public string $bookingUrl,
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public int $pax,
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public int $available,
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public string $status,
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public ?float $minPrice,
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public ?int $minNights,
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public ?float $additionalNightMinPrice,
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public ?int $additionalNightMinNights,
|
||||||
|
#[Groups(['api:contingent'])]
|
||||||
|
public float $priceForSelection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ trait TypeConversionTrait
|
|||||||
|
|
||||||
protected function stringToFloat(?string $string): ?float
|
protected function stringToFloat(?string $string): ?float
|
||||||
{
|
{
|
||||||
if (true === empty($string)) {
|
if (null === $string || '' === $string) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\Utility;
|
||||||
|
|
||||||
|
class BookingUrlUtility
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $defaultBaseUrl,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(int $hotelId, int $dateId, ?string $myEpUrl = null): string
|
||||||
|
{
|
||||||
|
$query = http_build_query([
|
||||||
|
'date_id' => $dateId,
|
||||||
|
'hotel_id' => $hotelId,
|
||||||
|
], '', '&', PHP_QUERY_RFC3986);
|
||||||
|
|
||||||
|
$baseUrlInput = null !== $myEpUrl && '' !== trim($myEpUrl)
|
||||||
|
? $myEpUrl
|
||||||
|
: $this->defaultBaseUrl;
|
||||||
|
$baseUrl = $this->normalizeBaseUrl($baseUrlInput);
|
||||||
|
if ('' === $baseUrl) {
|
||||||
|
return sprintf('/bookings/create?%s', $query);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%s/bookings/create?%s', $baseUrl, $query);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeBaseUrl(string $url): string
|
||||||
|
{
|
||||||
|
$trimmedUrl = trim($url);
|
||||||
|
|
||||||
|
return rtrim($trimmedUrl, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\BusProNet\XmlLoader;
|
||||||
|
|
||||||
|
use App\BusProNet\XmlParser\ContingentParser;
|
||||||
|
use League\Flysystem\FilesystemException;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use League\Flysystem\StorageAttributes;
|
||||||
|
use Psr\Cache\InvalidArgumentException;
|
||||||
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
|
use Symfony\Contracts\Cache\TagAwareCacheInterface;
|
||||||
|
|
||||||
|
class ContingentLoader extends AbstractLoader
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContingentParser $parser,
|
||||||
|
TagAwareCacheInterface $cache,
|
||||||
|
FilesystemOperator $xmlExportContingents,
|
||||||
|
) {
|
||||||
|
parent::__construct($cache, $xmlExportContingents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function generateFilesMap(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->cache->get('bpn_contingent_files', function (ItemInterface $item) {
|
||||||
|
$item->expiresAfter(3 * 60 * 60);
|
||||||
|
$item->tag(['xml-sync']);
|
||||||
|
|
||||||
|
$xmlFiles = $this->xmlExport
|
||||||
|
->listContents('.')
|
||||||
|
->filter(fn (StorageAttributes $attributes) => $attributes->isFile() && str_starts_with($attributes->path(), 'HotelZimmer_'));
|
||||||
|
|
||||||
|
$mapping = [];
|
||||||
|
|
||||||
|
foreach ($xmlFiles as $file) {
|
||||||
|
if (1 !== preg_match('/HotelZimmer_(\d+)\.xml$/', $file->path(), $matches)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mapping[(int) $matches[1]] = $file->path();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mapping;
|
||||||
|
});
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{roomTypes: array<int, array<string, mixed>>, rows: array<int, array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
public function loadByHotelId(int $hotelId): array
|
||||||
|
{
|
||||||
|
$cacheKey = sprintf('contingent_hotel_%d', $hotelId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($hotelId) {
|
||||||
|
$item->expiresAfter(3600);
|
||||||
|
$item->tag(['xml-sync']);
|
||||||
|
|
||||||
|
return $this->loadByHotelIdUncached($hotelId);
|
||||||
|
});
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return $this->loadByHotelIdUncached($hotelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{roomTypes: array<int, array<string, mixed>>, rows: array<int, array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
private function loadByHotelIdUncached(int $hotelId): array
|
||||||
|
{
|
||||||
|
$mapping = $this->generateFilesMap();
|
||||||
|
|
||||||
|
if (false === isset($mapping[$hotelId])) {
|
||||||
|
return ['roomTypes' => [], 'rows' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = $mapping[$hotelId];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$xml = $this->xmlExport->read($filename);
|
||||||
|
} catch (FilesystemException) {
|
||||||
|
return ['roomTypes' => [], 'rows' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$linkedHotelId = $this->getRootLinkedHotelId($xml);
|
||||||
|
if (null !== $linkedHotelId) {
|
||||||
|
if (false === isset($mapping[$linkedHotelId])) {
|
||||||
|
return ['roomTypes' => [], 'rows' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$xml = $this->xmlExport->read($mapping[$linkedHotelId]);
|
||||||
|
} catch (FilesystemException) {
|
||||||
|
return ['roomTypes' => [], 'rows' => []];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->parser->parse($xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getRootLinkedHotelId(string $xml): ?int
|
||||||
|
{
|
||||||
|
$document = new \DOMDocument();
|
||||||
|
if (false === @$document->loadXML($xml)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = $document->documentElement;
|
||||||
|
if (null === $root) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$link = $root->getAttribute('idbuspro_kontingent_aus');
|
||||||
|
if ('' === $link) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $link;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ class HotelLoader extends AbstractLoader
|
|||||||
try {
|
try {
|
||||||
return $this->cache->get('bpn_hotels', function (ItemInterface $item) use ($filename) {
|
return $this->cache->get('bpn_hotels', function (ItemInterface $item) use ($filename) {
|
||||||
$item->expiresAfter(3 * 60 * 60);
|
$item->expiresAfter(3 * 60 * 60);
|
||||||
|
$item->tag(['xml-sync']);
|
||||||
|
|
||||||
$hotels = [];
|
$hotels = [];
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ class InsuranceLoader extends AbstractLoader
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
return $this->cache->get('bpn_insurances', function (ItemInterface $item) use ($filename) {
|
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);
|
$crawler = $this->loadXml($filename);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class PickupLoader extends AbstractLoader
|
|||||||
try {
|
try {
|
||||||
return $this->cache->get('bpn_pickups', function (ItemInterface $item) use ($filename) {
|
return $this->cache->get('bpn_pickups', function (ItemInterface $item) use ($filename) {
|
||||||
$item->expiresAfter(3 * 60 * 60);
|
$item->expiresAfter(3 * 60 * 60);
|
||||||
|
$item->tag(['xml-sync']);
|
||||||
|
|
||||||
$crawler = $this->loadXml($filename);
|
$crawler = $this->loadXml($filename);
|
||||||
$pickupNodes = $crawler->filterXPath('//zustiege/zustieg');
|
$pickupNodes = $crawler->filterXPath('//zustiege/zustieg');
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class TravelLoader extends AbstractLoader
|
|||||||
try {
|
try {
|
||||||
return $this->cache->get('bpn_travels_mapping', function (ItemInterface $item) {
|
return $this->cache->get('bpn_travels_mapping', function (ItemInterface $item) {
|
||||||
$item->expiresAfter(3 * 60 * 60);
|
$item->expiresAfter(3 * 60 * 60);
|
||||||
|
$item->tag(['xml-sync']);
|
||||||
|
|
||||||
$xmlFiles = $this
|
$xmlFiles = $this
|
||||||
->xmlExport
|
->xmlExport
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ abstract class AbstractParser
|
|||||||
{
|
{
|
||||||
use TypeConversionTrait;
|
use TypeConversionTrait;
|
||||||
|
|
||||||
|
protected function getIntOrNullAttribute(?string $value): ?int
|
||||||
|
{
|
||||||
|
if (null === $value || '' === $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
protected function getStringOrNullValue(Crawler $node): ?string
|
protected function getStringOrNullValue(Crawler $node): ?string
|
||||||
{
|
{
|
||||||
return 0 < $node->count() ? $node->text() : null;
|
return 0 < $node->count() ? $node->text() : null;
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\BusProNet\XmlParser;
|
||||||
|
|
||||||
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
|
||||||
|
class ContingentParser extends AbstractParser
|
||||||
|
{
|
||||||
|
private const CONTROL_ROOM_CODE = 'BelKal';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
private array $ignoredRoomCodes = [
|
||||||
|
'PDGS',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{roomTypes: array<int, array<string, mixed>>, rows: array<int, array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
public function parse(string $xml): array
|
||||||
|
{
|
||||||
|
$crawler = new Crawler($xml);
|
||||||
|
$roomTypes = $this->parseRoomTypes($crawler);
|
||||||
|
$rows = $this->parseRows($crawler, $roomTypes);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'roomTypes' => $roomTypes,
|
||||||
|
'rows' => $rows,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function parseRoomTypes(Crawler $crawler): array
|
||||||
|
{
|
||||||
|
$roomTypes = [];
|
||||||
|
|
||||||
|
$crawler
|
||||||
|
->filterXPath('//unterbringungen/unterbringung')
|
||||||
|
->each(function (Crawler $node) use (&$roomTypes) {
|
||||||
|
$roomId = $this->getIntOrNullAttribute($node->attr('idbuspro'));
|
||||||
|
$code = $node->attr('code');
|
||||||
|
|
||||||
|
if (null === $roomId || null === $code) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true === in_array($code, $this->ignoredRoomCodes, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = $node->attr('zimmerbezeichnung')
|
||||||
|
?? $this->getStringOrNullValue($node->filterXPath('.//text'))
|
||||||
|
?? $code;
|
||||||
|
|
||||||
|
$pax = $this->getIntOrNullAttribute($node->attr('pax_max')) ?? 0;
|
||||||
|
$link = $this->getIntOrNullAttribute($node->attr('idbuspro_kontingent_aus'));
|
||||||
|
|
||||||
|
$roomTypes[$roomId] = [
|
||||||
|
'idBusPro' => $roomId,
|
||||||
|
'code' => $code,
|
||||||
|
'pax' => $pax,
|
||||||
|
'label' => $label,
|
||||||
|
'isControlRoom' => self::CONTROL_ROOM_CODE === $code,
|
||||||
|
'link' => $link,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return $roomTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $roomTypes
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function parseRows(Crawler $crawler, array $roomTypes): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
$crawler
|
||||||
|
->filterXPath('//kapazitaeten/kapazitaet')
|
||||||
|
->each(function (Crawler $dateNode) use (&$rows, $roomTypes) {
|
||||||
|
$date = $this->stringToDate($dateNode->attr('termin'));
|
||||||
|
|
||||||
|
if (null === $date) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($roomTypes as $roomType) {
|
||||||
|
$roomId = $roomType['link'] ?? $roomType['idBusPro'];
|
||||||
|
|
||||||
|
$roomNode = $this->findRoomNode($dateNode, $roomId);
|
||||||
|
if (null === $roomNode) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$capacity = $this->getIntOrNullAttribute($roomNode->attr('kontingent')) ?? 0;
|
||||||
|
$free = $this->getIntOrNullAttribute($roomNode->attr('frei')) ?? 0;
|
||||||
|
$statusRaw = $roomNode->attr('status') ?? '';
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'date' => $date,
|
||||||
|
'roomCode' => $roomType['code'],
|
||||||
|
'roomLabel' => $roomType['label'],
|
||||||
|
'pax' => $roomType['isControlRoom'] ? 0 : $capacity * (int) ($roomType['pax'] ?? 0),
|
||||||
|
'available' => $roomType['isControlRoom'] ? 0 : $free * (int) ($roomType['pax'] ?? 0),
|
||||||
|
'status' => $this->mapStatus($statusRaw),
|
||||||
|
'minPrice' => $this->stringToFloat($roomNode->attr('abpreis')),
|
||||||
|
'minNights' => $this->getIntOrNullAttribute($roomNode->attr('abpreis_naechte')),
|
||||||
|
'additionalNightMinPrice' => $this->stringToFloat($roomNode->attr('abpreis_verlaengerung')),
|
||||||
|
'additionalNightMinNights' => $this->getIntOrNullAttribute($roomNode->attr('abpreis_verlaengerung_naechte')),
|
||||||
|
'isControlRoom' => $roomType['isControlRoom'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findRoomNode(Crawler $dateNode, int $roomId): ?Crawler
|
||||||
|
{
|
||||||
|
$roomNode = $dateNode->filterXPath(sprintf('.//zimmerliste/zimmer[@idbuspro="%d"]', $roomId));
|
||||||
|
if (0 < $roomNode->count()) {
|
||||||
|
return $roomNode->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
$roomNode = $dateNode->filterXPath(sprintf('.//zimmerliste/zimmer[@id="%d"]', $roomId));
|
||||||
|
if (0 < $roomNode->count()) {
|
||||||
|
return $roomNode->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
$roomNode = $dateNode->filterXPath(sprintf('.//zimmerliste/zimmer[@id_zimmer="%d"]', $roomId));
|
||||||
|
if (0 < $roomNode->count()) {
|
||||||
|
return $roomNode->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapStatus(?string $status): string
|
||||||
|
{
|
||||||
|
return match (strtoupper((string) $status)) {
|
||||||
|
'A' => 'ON_REQUEST',
|
||||||
|
'S' => 'BLOCKED',
|
||||||
|
default => 'OK',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\XmlExportInfo;
|
||||||
|
use League\Flysystem\FilesystemException;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Command\LockableTrait;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
use Symfony\Contracts\Cache\TagAwareCacheInterface;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:bpn:xml-cache-invalidate',
|
||||||
|
description: 'Invalidates BPN XML caches when local uebertragung.info changes'
|
||||||
|
)]
|
||||||
|
class BpnXmlCacheInvalidateCommand extends Command
|
||||||
|
{
|
||||||
|
use LockableTrait;
|
||||||
|
|
||||||
|
private const CACHE_TAGS_TO_INVALIDATE = ['xml-sync'];
|
||||||
|
private const STATE_CACHE_KEY = 'bpn_xml_info_state';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly FilesystemOperator $xmlExport,
|
||||||
|
private readonly FilesystemOperator $xmlExportContingents,
|
||||||
|
private readonly TagAwareCacheInterface $bpnCache,
|
||||||
|
private readonly CacheInterface $cache,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('force', 'f', InputOption::VALUE_NONE, 'Invalidate cache tags without state comparison');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
if (false === $this->lock()) {
|
||||||
|
$io->warning('Cache invalidation is already running in another process');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true === $input->getOption('force')) {
|
||||||
|
$this->invalidateCaches();
|
||||||
|
$io->success('Cache tags invalidated (forced)');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = $this->loadState();
|
||||||
|
$newState = $state;
|
||||||
|
$changedDatasets = [];
|
||||||
|
|
||||||
|
foreach ($this->getInfoTargets() as $target) {
|
||||||
|
$dataset = $target['name'];
|
||||||
|
$info = $this->readInfo($target['storage'], $dataset);
|
||||||
|
if (null === $info) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$signature = $this->buildSignature($info);
|
||||||
|
$newState[$dataset] = $signature;
|
||||||
|
|
||||||
|
$io->text(sprintf(
|
||||||
|
'[%s] %s (%d files)',
|
||||||
|
$dataset,
|
||||||
|
$info->lastTransfer->format('d.m.Y H:i:s'),
|
||||||
|
$info->fileCount
|
||||||
|
));
|
||||||
|
|
||||||
|
if (false === array_key_exists($dataset, $state) || $state[$dataset] !== $signature) {
|
||||||
|
$changedDatasets[] = $dataset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] === $changedDatasets) {
|
||||||
|
$io->success('No uebertragung.info changes detected');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->invalidateCaches();
|
||||||
|
$this->saveState($newState);
|
||||||
|
|
||||||
|
$io->success(sprintf(
|
||||||
|
'Invalidated cache tags due to changes in: %s',
|
||||||
|
implode(', ', $changedDatasets)
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->logger->info('BPN cache invalidated after uebertragung.info change', [
|
||||||
|
'datasets' => $changedDatasets,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readInfo(FilesystemOperator $storage, string $dataset): ?XmlExportInfo
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (false === $storage->fileExists(XmlExportInfo::getFilename())) {
|
||||||
|
$this->logger->warning('uebertragung.info missing', ['dataset' => $dataset]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = $storage->read(XmlExportInfo::getFilename());
|
||||||
|
|
||||||
|
return XmlExportInfo::fromString($content);
|
||||||
|
} catch (FilesystemException|\InvalidArgumentException $e) {
|
||||||
|
$this->logger->warning('Could not parse uebertragung.info', [
|
||||||
|
'dataset' => $dataset,
|
||||||
|
'exception' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildSignature(XmlExportInfo $info): string
|
||||||
|
{
|
||||||
|
return sprintf('%s|%d', $info->lastTransfer->format('c'), $info->fileCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invalidateCaches(): void
|
||||||
|
{
|
||||||
|
$this->bpnCache->invalidateTags(self::CACHE_TAGS_TO_INVALIDATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function loadState(): array
|
||||||
|
{
|
||||||
|
$stateJson = $this->cache->get(self::STATE_CACHE_KEY, fn (): string => '{}');
|
||||||
|
|
||||||
|
return $this->decodeState($stateJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function decodeState(string $stateJson): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$state = json_decode($stateJson, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === is_array($state)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedState = [];
|
||||||
|
foreach ($state as $dataset => $signature) {
|
||||||
|
if (true === is_string($dataset) && true === is_string($signature)) {
|
||||||
|
$normalizedState[$dataset] = $signature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalizedState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $state
|
||||||
|
*/
|
||||||
|
private function saveState(array $state): void
|
||||||
|
{
|
||||||
|
$this->cache->delete(self::STATE_CACHE_KEY);
|
||||||
|
$this->cache->get(self::STATE_CACHE_KEY, fn (): string => json_encode($state, JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{name: string, storage: FilesystemOperator}>
|
||||||
|
*/
|
||||||
|
private function getInfoTargets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'name' => 'travel',
|
||||||
|
'storage' => $this->xmlExport,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'contingents',
|
||||||
|
'storage' => $this->xmlExportContingents,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+216
-144
@@ -5,12 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace App\Command;
|
namespace App\Command;
|
||||||
|
|
||||||
use App\BusProNet\Model\XmlExportInfo;
|
use App\BusProNet\Model\XmlExportInfo;
|
||||||
use App\BusProNet\XmlLoader\TravelLoader;
|
use App\Model\BpnXmlSnapshotRefreshResult;
|
||||||
use App\Service\TravelDataProvider;
|
use App\Model\BpnXmlSyncTarget;
|
||||||
use App\Service\TravelSnapshotManager;
|
use App\Service\BpnXmlSyncManager;
|
||||||
|
use App\Service\BpnXmlSnapshotRefreshManager;
|
||||||
use League\Flysystem\FilesystemException;
|
use League\Flysystem\FilesystemException;
|
||||||
use League\Flysystem\FilesystemOperator;
|
|
||||||
use League\Flysystem\StorageAttributes;
|
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
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\Input\InputOption;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
use Symfony\Contracts\Cache\CacheInterface;
|
|
||||||
|
|
||||||
#[AsCommand(
|
#[AsCommand(
|
||||||
name: 'app:bpn:xml-sync',
|
name: 'app:bpn:xml-sync',
|
||||||
@@ -29,20 +27,10 @@ class BpnXmlSyncCommand extends Command
|
|||||||
{
|
{
|
||||||
use LockableTrait;
|
use LockableTrait;
|
||||||
|
|
||||||
private const CACHE_KEYS_TO_INVALIDATE = [
|
|
||||||
'bpn_travels_mapping',
|
|
||||||
'bpn_hotels',
|
|
||||||
'bpn_pickups',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly FilesystemOperator $xmlSource,
|
private readonly BpnXmlSyncManager $syncManager,
|
||||||
private readonly FilesystemOperator $xmlExport,
|
private readonly BpnXmlSnapshotRefreshManager $snapshotRefreshManager,
|
||||||
private readonly CacheInterface $cache,
|
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
private readonly TravelLoader $travelLoader,
|
|
||||||
private readonly TravelDataProvider $travelDataService,
|
|
||||||
private readonly TravelSnapshotManager $snapshotService,
|
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -65,177 +53,261 @@ class BpnXmlSyncCommand extends Command
|
|||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
$force = $input->getOption('force');
|
return $this->sync(
|
||||||
$dryRun = $input->getOption('dry-run');
|
(bool) $input->getOption('force'),
|
||||||
|
(bool) $input->getOption('dry-run'),
|
||||||
$remoteInfo = $this->readRemoteInfo();
|
$io,
|
||||||
|
$output,
|
||||||
if (null === $remoteInfo) {
|
);
|
||||||
$io->warning('Remote info unavailable - will retry on next scheduled run');
|
|
||||||
|
|
||||||
return Command::SUCCESS;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$io->text($this->formatInfoLine('Remote', $remoteInfo, $output));
|
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;
|
||||||
|
|
||||||
$localInfo = $this->readLocalInfo();
|
foreach ($this->syncManager->getSyncTargets() as $target) {
|
||||||
if (null !== $localInfo) {
|
$targetResult = $this->syncTarget($target, $force, $dryRun, $io, $output);
|
||||||
$io->text($this->formatInfoLine('Local', $localInfo, $output));
|
|
||||||
} else {
|
if ($targetResult['failed']) {
|
||||||
$io->text('Local: no data');
|
$hasFailures = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$needsSync = $force || null === $localInfo || $remoteInfo->isNewerThan($localInfo);
|
if (false === $targetResult['synced']) {
|
||||||
|
continue;
|
||||||
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 ($dryRun) {
|
$hasSyncedFiles = true;
|
||||||
$io->note('Sync required (dry-run mode, no files downloaded)');
|
$totalSyncedCount += $targetResult['updated'];
|
||||||
|
$totalDeletedCount += $targetResult['deleted'];
|
||||||
|
|
||||||
return Command::SUCCESS;
|
if ($targetResult['travelSynced']) {
|
||||||
|
$travelSynced = true;
|
||||||
|
$travelRemoteTimestamp = $targetResult['remoteTimestamp'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$io->section('Syncing files');
|
if (false === $dryRun && $hasSyncedFiles) {
|
||||||
|
$invalidatedTags = $this->syncManager->invalidateCaches();
|
||||||
|
$io->text(sprintf(
|
||||||
|
'Invalidated %d cache tags',
|
||||||
|
$invalidatedTags,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
if (false === $dryRun && $travelSynced) {
|
||||||
$syncResult = $this->syncFiles($io);
|
$io->section('Refreshing snapshots');
|
||||||
} catch (FilesystemException $e) {
|
$snapshotResult = $this->refreshSnapshots($io);
|
||||||
$io->error(sprintf('Sync failed: %s', $e->getMessage()));
|
if (null !== $snapshotResult) {
|
||||||
$this->logger->error('XML sync failed during file transfer', ['exception' => $e]);
|
$orphanedDeleted = $snapshotResult->orphanedDeleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasFailures) {
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->invalidateCaches();
|
if ($dryRun) {
|
||||||
$io->info(sprintf('Invalidated %d cache keys', count(self::CACHE_KEYS_TO_INVALIDATE)));
|
return Command::SUCCESS;
|
||||||
|
|
||||||
$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 (null !== $xmlFileMap) {
|
if ($hasSyncedFiles) {
|
||||||
$total = array_sum(array_map(fn ($entry) => count($entry['hotels']), $xmlFileMap));
|
$successMessage = sprintf(
|
||||||
$io->progressStart($total);
|
'Sync complete: %d files updated, %d files deleted',
|
||||||
|
$totalSyncedCount,
|
||||||
$snapshotResult = $this->travelDataService->syncSnapshotsFromXml(
|
$totalDeletedCount
|
||||||
$xmlFileMap,
|
|
||||||
function () use ($io): void { $io->progressAdvance(); },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$io->progressFinish();
|
|
||||||
|
|
||||||
$orphanedDeleted = $this->snapshotService->purgeOrphanedFutureSnapshots(array_keys($xmlFileMap));
|
|
||||||
}
|
|
||||||
|
|
||||||
$successMessage = sprintf('Sync complete: %d files updated, %d files deleted', $syncResult['updated'], $syncResult['deleted']);
|
|
||||||
if (null !== $snapshotResult) {
|
if (null !== $snapshotResult) {
|
||||||
$successMessage .= sprintf(', snapshots: %d processed, %d failed', $snapshotResult['processed'], $snapshotResult['failed']);
|
$successMessage .= sprintf(
|
||||||
|
', snapshots: %d processed, %d failed',
|
||||||
|
$snapshotResult->processed,
|
||||||
|
$snapshotResult->failed
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (null !== $orphanedDeleted) {
|
if (null !== $orphanedDeleted) {
|
||||||
$successMessage .= sprintf(', orphaned deleted: %d', $orphanedDeleted);
|
$successMessage .= sprintf(', orphaned deleted: %d', $orphanedDeleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
$io->success($successMessage);
|
$io->success($successMessage);
|
||||||
$this->logger->info('XML sync completed', [
|
$this->logger->info('XML sync completed', [
|
||||||
'files_updated' => $syncResult['updated'],
|
'files_updated' => $totalSyncedCount,
|
||||||
'files_deleted' => $syncResult['deleted'],
|
'files_deleted' => $totalDeletedCount,
|
||||||
'snapshots_processed' => $snapshotResult['processed'] ?? null,
|
'snapshots_processed' => $snapshotResult?->processed,
|
||||||
'snapshots_failed' => $snapshotResult['failed'] ?? null,
|
'snapshots_failed' => $snapshotResult?->failed,
|
||||||
'orphaned_deleted' => $orphanedDeleted,
|
'orphaned_deleted' => $orphanedDeleted,
|
||||||
'remote_timestamp' => $remoteInfo->lastTransfer->format('c'),
|
'remote_timestamp' => $travelRemoteTimestamp,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function readRemoteInfo(): ?XmlExportInfo
|
$io->success('No sync required');
|
||||||
{
|
|
||||||
try {
|
|
||||||
$content = $this->xmlSource->read(XmlExportInfo::getFilename());
|
|
||||||
|
|
||||||
return XmlExportInfo::fromString($content);
|
return Command::SUCCESS;
|
||||||
} catch (FilesystemException|\InvalidArgumentException $e) {
|
|
||||||
$this->logger->warning('Could not read remote info file, likely being updated', [
|
|
||||||
'exception' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function readLocalInfo(): ?XmlExportInfo
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
if (false === $this->xmlExport->fileExists(XmlExportInfo::getFilename())) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$content = $this->xmlExport->read(XmlExportInfo::getFilename());
|
|
||||||
|
|
||||||
return XmlExportInfo::fromString($content);
|
|
||||||
} catch (FilesystemException|\InvalidArgumentException) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, int>
|
* @return array{synced: bool, failed: bool, updated: int, deleted: int, travelSynced: bool, remoteTimestamp: ?string}
|
||||||
*
|
|
||||||
* @throws FilesystemException
|
|
||||||
*/
|
*/
|
||||||
private function syncFiles(SymfonyStyle $io): array
|
private function syncTarget(
|
||||||
|
BpnXmlSyncTarget $target,
|
||||||
|
bool $force,
|
||||||
|
bool $dryRun,
|
||||||
|
SymfonyStyle $io,
|
||||||
|
OutputInterface $output,
|
||||||
|
): array
|
||||||
{
|
{
|
||||||
$remoteFiles = $this->xmlSource
|
$datasetName = $target->name;
|
||||||
->listContents('.')
|
|
||||||
->filter(fn (StorageAttributes $attributes) => $attributes->isFile())
|
|
||||||
->map(fn (StorageAttributes $attributes) => $attributes->path())
|
|
||||||
->toArray();
|
|
||||||
|
|
||||||
$localFiles = $this->xmlExport
|
$remoteInfo = $this->syncManager->readRemoteInfo($target->source, $datasetName);
|
||||||
->listContents('.')
|
if (null === $remoteInfo) {
|
||||||
->filter(fn (StorageAttributes $attributes) => $attributes->isFile())
|
$io->warning(sprintf(
|
||||||
->map(fn (StorageAttributes $attributes) => $attributes->path())
|
'[%s] Remote info unavailable - will retry on next scheduled run',
|
||||||
->toArray();
|
$datasetName
|
||||||
|
));
|
||||||
$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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'updated' => count($remoteFiles),
|
'synced' => false,
|
||||||
'deleted' => count($filesToDelete),
|
'failed' => false,
|
||||||
|
'updated' => 0,
|
||||||
|
'deleted' => 0,
|
||||||
|
'travelSynced' => false,
|
||||||
|
'remoteTimestamp' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function invalidateCaches(): void
|
$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 [
|
||||||
|
'synced' => false,
|
||||||
|
'failed' => false,
|
||||||
|
'updated' => 0,
|
||||||
|
'deleted' => 0,
|
||||||
|
'travelSynced' => false,
|
||||||
|
'remoteTimestamp' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
$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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [
|
||||||
|
'synced' => true,
|
||||||
|
'failed' => false,
|
||||||
|
'updated' => $syncResult['updated'],
|
||||||
|
'deleted' => $syncResult['deleted'],
|
||||||
|
'travelSynced' => $travelSynced,
|
||||||
|
'remoteTimestamp' => $remoteTimestamp,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshSnapshots(SymfonyStyle $io): ?BpnXmlSnapshotRefreshResult
|
||||||
{
|
{
|
||||||
foreach (self::CACHE_KEYS_TO_INVALIDATE as $key) {
|
$plan = $this->snapshotRefreshManager->buildPlan();
|
||||||
$this->cache->delete($key);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
|
use App\BusProNet\Utility\DateCodeUtility;
|
||||||
|
use App\Exception\HotelNotInTravelException;
|
||||||
|
use App\Exception\TravelNotFoundException;
|
||||||
|
use App\Service\ContingentDataService;
|
||||||
|
use App\Service\TravelDataProvider;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API endpoints for contingent availability data.
|
||||||
|
*
|
||||||
|
* References are accepted as either numeric IDs or business codes:
|
||||||
|
* - `hotelRef`: hotel ID (`idbuspro`) or hotel code
|
||||||
|
* - `dateRef`: date ID or date code (sanitized before mapping)
|
||||||
|
*/
|
||||||
|
#[Route('/api')]
|
||||||
|
#[IsGranted('ROLE_OAUTH2_API')]
|
||||||
|
class ContingentController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContingentDataService $contingentDataService,
|
||||||
|
private readonly TravelDataProvider $travelDataProvider,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(
|
||||||
|
'/contingents/calendar',
|
||||||
|
name: 'api_contingents_calendar',
|
||||||
|
methods: ['GET'],
|
||||||
|
)]
|
||||||
|
public function calendar(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$hotelReference = $request->query->get('hotelRef');
|
||||||
|
|
||||||
|
if (null === $hotelReference || '' === trim($hotelReference)) {
|
||||||
|
return $this->json(['error' => 'hotelRef is required'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->handleCalendar($request, $hotelReference);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(
|
||||||
|
'/contingents',
|
||||||
|
name: 'api_contingents_single',
|
||||||
|
methods: ['GET'],
|
||||||
|
)]
|
||||||
|
public function byDate(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$hotelReference = $request->query->get('hotelRef');
|
||||||
|
$dateReference = $request->query->get('dateRef');
|
||||||
|
|
||||||
|
if (null === $hotelReference || '' === trim($hotelReference)) {
|
||||||
|
return $this->json(['error' => 'hotelRef is required'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $dateReference || '' === trim($dateReference)) {
|
||||||
|
return $this->json(['error' => 'dateRef is required'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->handleByDate($hotelReference, $dateReference);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(
|
||||||
|
'/contingents/rooms',
|
||||||
|
name: 'api_contingents_rooms',
|
||||||
|
methods: ['GET'],
|
||||||
|
)]
|
||||||
|
public function rooms(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$hotelReference = $request->query->get('hotelRef');
|
||||||
|
$dateReference = $request->query->get('dateRef');
|
||||||
|
$dateFrom = $request->query->get('dateFrom');
|
||||||
|
$dateTo = $request->query->get('dateTo');
|
||||||
|
$myEpUrl = $request->query->get('my_ep_url');
|
||||||
|
|
||||||
|
if (null === $hotelReference || '' === trim($hotelReference)) {
|
||||||
|
return $this->json(['error' => 'hotelRef is required'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $dateReference || '' === trim($dateReference)) {
|
||||||
|
return $this->json(['error' => 'dateRef is required'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $dateFrom || null === $dateTo) {
|
||||||
|
return $this->json(['error' => 'dateFrom and dateTo are required'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->handleRooms(
|
||||||
|
$hotelReference,
|
||||||
|
$dateReference,
|
||||||
|
$dateFrom,
|
||||||
|
$dateTo,
|
||||||
|
is_string($myEpUrl) ? $myEpUrl : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared execution path for calendar data after hotel reference validation.
|
||||||
|
*/
|
||||||
|
private function handleCalendar(Request $request, string $hotelReference): JsonResponse
|
||||||
|
{
|
||||||
|
$hotelId = $this->resolveHotelId($hotelReference);
|
||||||
|
|
||||||
|
if (null === $hotelId) {
|
||||||
|
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateFrom = $request->query->get('dateFrom');
|
||||||
|
$dateTo = $request->query->get('dateTo');
|
||||||
|
|
||||||
|
if (null === $dateFrom || null === $dateTo) {
|
||||||
|
return $this->json(['error' => 'dateFrom and dateTo are required'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$events = $this->contingentDataService->getCalendarEvents($hotelId, $dateFrom, $dateTo);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json($events, Response::HTTP_OK, [], ['groups' => ['api:contingent']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared execution path for daily contingent summary after reference validation.
|
||||||
|
*/
|
||||||
|
private function handleByDate(string $hotelReference, string $dateReference): JsonResponse
|
||||||
|
{
|
||||||
|
$hotelId = $this->resolveHotelId($hotelReference);
|
||||||
|
$dateId = $this->resolveDateId($dateReference);
|
||||||
|
|
||||||
|
if (null === $hotelId || null === $dateId) {
|
||||||
|
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$contingents = $this->contingentDataService->getAvailableContingents($hotelId, $dateId);
|
||||||
|
} catch (TravelNotFoundException|HotelNotInTravelException) {
|
||||||
|
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json($contingents, Response::HTTP_OK, [], ['groups' => ['api:contingent']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared execution path for room-level availability after reference validation.
|
||||||
|
*/
|
||||||
|
private function handleRooms(
|
||||||
|
string $hotelReference,
|
||||||
|
string $dateReference,
|
||||||
|
string $dateFrom,
|
||||||
|
string $dateTo,
|
||||||
|
?string $myEpUrl = null,
|
||||||
|
): JsonResponse {
|
||||||
|
$hotelId = $this->resolveHotelId($hotelReference);
|
||||||
|
$dateId = $this->resolveDateId($dateReference);
|
||||||
|
|
||||||
|
if (null === $hotelId || null === $dateId) {
|
||||||
|
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$rooms = $this->contingentDataService->getAvailableRooms($dateFrom, $dateTo, $hotelId, $dateId, $myEpUrl);
|
||||||
|
} catch (TravelNotFoundException|HotelNotInTravelException) {
|
||||||
|
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json($rooms, Response::HTTP_OK, [], ['groups' => ['api:contingent']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a hotel reference to hotel ID.
|
||||||
|
*
|
||||||
|
* Numeric values are treated as IDs, otherwise mapped as hotel code.
|
||||||
|
*/
|
||||||
|
private function resolveHotelId(string $hotelReference): ?int
|
||||||
|
{
|
||||||
|
return $this->resolveReferenceId(
|
||||||
|
$hotelReference,
|
||||||
|
fn (string $reference): ?int => $this->travelDataProvider->mapHotelCodeToId($reference),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a date reference to date ID.
|
||||||
|
*
|
||||||
|
* Numeric values are treated as IDs, otherwise mapped as date code.
|
||||||
|
* Date codes are sanitized first (uppercased and separators removed).
|
||||||
|
*/
|
||||||
|
private function resolveDateId(string $dateReference): ?int
|
||||||
|
{
|
||||||
|
return $this->resolveReferenceId(
|
||||||
|
$dateReference,
|
||||||
|
fn (string $reference): ?int => $this->travelDataProvider->mapDateCodeToId($reference),
|
||||||
|
fn (string $reference): string => (new DateCodeUtility())->sanitize($reference),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic resolver for mixed ID/code references.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Trim and reject empty values.
|
||||||
|
* 2. If numeric, return as integer ID.
|
||||||
|
* 3. Optionally normalize the value.
|
||||||
|
* 4. Map normalized code to ID.
|
||||||
|
*
|
||||||
|
* @param callable $mapper maps code input to ID
|
||||||
|
* @param callable|null $normalizer optional code normalizer before mapping
|
||||||
|
*/
|
||||||
|
private function resolveReferenceId(
|
||||||
|
string $reference,
|
||||||
|
callable $mapper,
|
||||||
|
?callable $normalizer = null,
|
||||||
|
): ?int {
|
||||||
|
$trimmedReference = trim($reference);
|
||||||
|
|
||||||
|
if ('' === $trimmedReference) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true === ctype_digit($trimmedReference)) {
|
||||||
|
return (int) $trimmedReference;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $normalizer) {
|
||||||
|
$trimmedReference = $normalizer($trimmedReference);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mapper($trimmedReference);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,9 +63,6 @@ class RoomSelectionToIdTransformer implements DataTransformerInterface
|
|||||||
return $value->id;
|
return $value->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new TransformationFailedException(sprintf(
|
throw new TransformationFailedException(sprintf('Invalid room id value: %s', get_debug_type($value)));
|
||||||
'Invalid room id value: %s',
|
|
||||||
get_debug_type($value)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Form\Model;
|
namespace App\Form\Model;
|
||||||
|
|
||||||
use App\Model\BookingSummaryCmsHotelDto;
|
use App\Model\BookingSummaryCmsHotelData;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO containing all booking summary data for sidebar display.
|
* DTO containing all booking summary data for sidebar display.
|
||||||
@@ -19,7 +19,7 @@ class BookingSummaryDto
|
|||||||
public readonly int $participantCount,
|
public readonly int $participantCount,
|
||||||
public readonly BookingSummaryPricingDto $pricing,
|
public readonly BookingSummaryPricingDto $pricing,
|
||||||
public readonly BookingSummaryVoucherDto $vouchers,
|
public readonly BookingSummaryVoucherDto $vouchers,
|
||||||
public readonly ?BookingSummaryCmsHotelDto $cmsData,
|
public readonly ?BookingSummaryCmsHotelData $cmsData,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace App\Model;
|
|||||||
/**
|
/**
|
||||||
* Typed hotel CMS payload for the booking summary.
|
* Typed hotel CMS payload for the booking summary.
|
||||||
*/
|
*/
|
||||||
final readonly class BookingSummaryCmsHotelDto
|
final readonly class BookingSummaryCmsHotelData
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed>|null $images
|
* @param array<string, mixed>|null $images
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Model;
|
||||||
|
|
||||||
|
final readonly class BpnXmlSnapshotRefreshPlan
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, array{hotels: array<int|string, mixed>}> $xmlFileMap
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public array $xmlFileMap,
|
||||||
|
public int $total,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Model;
|
||||||
|
|
||||||
|
final readonly class BpnXmlSnapshotRefreshResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $processed,
|
||||||
|
public int $failed,
|
||||||
|
public int $orphanedDeleted,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Model;
|
||||||
|
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
|
||||||
|
final readonly class BpnXmlSyncTarget
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $name,
|
||||||
|
public FilesystemOperator $source,
|
||||||
|
public FilesystemOperator $destination,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ use App\Form\Model\BookingDto;
|
|||||||
use App\Form\Model\BookingSummaryDto;
|
use App\Form\Model\BookingSummaryDto;
|
||||||
use App\Form\Model\BookingSummaryPricingDto;
|
use App\Form\Model\BookingSummaryPricingDto;
|
||||||
use App\Form\Model\BookingSummaryVoucherDto;
|
use App\Form\Model\BookingSummaryVoucherDto;
|
||||||
use App\Model\BookingSummaryCmsHotelDto;
|
use App\Model\BookingSummaryCmsHotelData;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Contracts\Cache\CacheInterface;
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
use Symfony\Contracts\Cache\ItemInterface;
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
@@ -126,7 +126,7 @@ class BookingSummaryAssembler
|
|||||||
* Data is cached for 1 hour. This method can be called early in the booking
|
* Data is cached for 1 hour. This method can be called early in the booking
|
||||||
* flow to warm the cache.
|
* flow to warm the cache.
|
||||||
*/
|
*/
|
||||||
public function getCmsDataForProduct(?string $productCode, ?string $hotelCode): ?BookingSummaryCmsHotelDto
|
public function getCmsDataForProduct(?string $productCode, ?string $hotelCode): ?BookingSummaryCmsHotelData
|
||||||
{
|
{
|
||||||
if (null === $hotelCode) {
|
if (null === $hotelCode) {
|
||||||
return null;
|
return null;
|
||||||
@@ -144,7 +144,7 @@ class BookingSummaryAssembler
|
|||||||
// Fetch CMS images (nice to have)
|
// Fetch CMS images (nice to have)
|
||||||
$images = $this->cmsDataService->getProductImages($productCode, $hotelCode);
|
$images = $this->cmsDataService->getProductImages($productCode, $hotelCode);
|
||||||
|
|
||||||
return new BookingSummaryCmsHotelDto(
|
return new BookingSummaryCmsHotelData(
|
||||||
name: $baseHotel?->name,
|
name: $baseHotel?->name,
|
||||||
address: $this->formatHotelAddress($baseHotel),
|
address: $this->formatHotelAddress($baseHotel),
|
||||||
images: $images,
|
images: $images,
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Model\BpnXmlSnapshotRefreshPlan;
|
||||||
|
use App\Model\BpnXmlSnapshotRefreshResult;
|
||||||
|
use App\BusProNet\XmlLoader\TravelLoader;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
final class BpnXmlSnapshotRefreshManager
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TravelLoader $travelLoader,
|
||||||
|
private readonly TravelDataProvider $travelDataService,
|
||||||
|
private readonly TravelSnapshotManager $snapshotService,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildPlan(): ?BpnXmlSnapshotRefreshPlan
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$xmlFileMap = $this->travelLoader->generateFilesMap();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->warning('Failed to generate XML file map after sync; snapshot refresh skipped', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = array_sum(array_map(fn (array $entry): int => count($entry['hotels']), $xmlFileMap));
|
||||||
|
|
||||||
|
return new BpnXmlSnapshotRefreshPlan($xmlFileMap, $total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refresh(
|
||||||
|
BpnXmlSnapshotRefreshPlan $plan,
|
||||||
|
?callable $onProgress = null,
|
||||||
|
): BpnXmlSnapshotRefreshResult {
|
||||||
|
$snapshotResult = $this->travelDataService->syncSnapshotsFromXml(
|
||||||
|
$plan->xmlFileMap,
|
||||||
|
function () use ($onProgress): void {
|
||||||
|
if (null !== $onProgress) {
|
||||||
|
$onProgress();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
$orphanedDeleted = $this->snapshotService->purgeOrphanedFutureSnapshots(array_keys($plan->xmlFileMap));
|
||||||
|
|
||||||
|
return new BpnXmlSnapshotRefreshResult(
|
||||||
|
$snapshotResult['processed'],
|
||||||
|
$snapshotResult['failed'],
|
||||||
|
$orphanedDeleted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\XmlExportInfo;
|
||||||
|
use App\Model\BpnXmlSyncTarget;
|
||||||
|
use League\Flysystem\FilesystemException;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use League\Flysystem\StorageAttributes;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Contracts\Cache\TagAwareCacheInterface;
|
||||||
|
|
||||||
|
final class BpnXmlSyncManager
|
||||||
|
{
|
||||||
|
private const CACHE_TAGS_TO_INVALIDATE = [
|
||||||
|
'xml-sync',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly FilesystemOperator $xmlSource,
|
||||||
|
private readonly FilesystemOperator $xmlExport,
|
||||||
|
private readonly FilesystemOperator $xmlSourceContingents,
|
||||||
|
private readonly FilesystemOperator $xmlExportContingents,
|
||||||
|
private readonly TagAwareCacheInterface $bpnCache,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<BpnXmlSyncTarget>
|
||||||
|
*/
|
||||||
|
public function getSyncTargets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new BpnXmlSyncTarget('travel', $this->xmlSource, $this->xmlExport),
|
||||||
|
new BpnXmlSyncTarget('contingents', $this->xmlSourceContingents, $this->xmlExportContingents),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function readRemoteInfo(FilesystemOperator $source, string $datasetName): ?XmlExportInfo
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$content = $source->read(XmlExportInfo::getFilename());
|
||||||
|
|
||||||
|
return XmlExportInfo::fromString($content);
|
||||||
|
} catch (FilesystemException|\InvalidArgumentException $e) {
|
||||||
|
$this->logger->warning('Could not read remote info file, likely being updated', [
|
||||||
|
'dataset' => $datasetName,
|
||||||
|
'exception' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function readLocalInfo(FilesystemOperator $destination): ?XmlExportInfo
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (false === $destination->fileExists(XmlExportInfo::getFilename())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = $destination->read(XmlExportInfo::getFilename());
|
||||||
|
|
||||||
|
return XmlExportInfo::fromString($content);
|
||||||
|
} catch (FilesystemException|\InvalidArgumentException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{updated:int,deleted:int}
|
||||||
|
*
|
||||||
|
* @throws FilesystemException
|
||||||
|
*/
|
||||||
|
public function syncFiles(
|
||||||
|
FilesystemOperator $source,
|
||||||
|
FilesystemOperator $destination,
|
||||||
|
?callable $onStart = null,
|
||||||
|
?callable $onProgress = null,
|
||||||
|
): array {
|
||||||
|
$remoteFiles = $source
|
||||||
|
->listContents('.')
|
||||||
|
->filter(fn (StorageAttributes $attributes) => $attributes->isFile())
|
||||||
|
->map(fn (StorageAttributes $attributes) => $attributes->path())
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$localFiles = $destination
|
||||||
|
->listContents('.')
|
||||||
|
->filter(fn (StorageAttributes $attributes) => $attributes->isFile())
|
||||||
|
->map(fn (StorageAttributes $attributes) => $attributes->path())
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$filesToDelete = array_diff($localFiles, $remoteFiles);
|
||||||
|
|
||||||
|
if (null !== $onStart) {
|
||||||
|
$onStart(count($remoteFiles));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($remoteFiles as $path) {
|
||||||
|
$content = $source->read($path);
|
||||||
|
$destination->write($path, $content);
|
||||||
|
if (null !== $onProgress) {
|
||||||
|
$onProgress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($filesToDelete as $path) {
|
||||||
|
$destination->delete($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'updated' => count($remoteFiles),
|
||||||
|
'deleted' => count($filesToDelete),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invalidateCaches(): int
|
||||||
|
{
|
||||||
|
$this->bpnCache->invalidateTags(self::CACHE_TAGS_TO_INVALIDATE);
|
||||||
|
|
||||||
|
return count(self::CACHE_TAGS_TO_INVALIDATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\ContingentCalendarEvent;
|
||||||
|
use App\BusProNet\Model\ContingentDateSummary;
|
||||||
|
use App\BusProNet\Model\ContingentRoomAvailability;
|
||||||
|
use App\BusProNet\Utility\BookingUrlUtility;
|
||||||
|
use App\BusProNet\XmlLoader\ContingentLoader;
|
||||||
|
use App\BusProNet\XmlLoader\TravelLoader;
|
||||||
|
use App\Exception\HotelNotInTravelException;
|
||||||
|
use App\Exception\TravelNotFoundException;
|
||||||
|
|
||||||
|
class ContingentDataService
|
||||||
|
{
|
||||||
|
private const STATUS_ORDER = [
|
||||||
|
'OK' => 0,
|
||||||
|
'ON_REQUEST' => 1,
|
||||||
|
'BLOCKED' => 2,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContingentLoader $contingentLoader,
|
||||||
|
private readonly TravelLoader $travelLoader,
|
||||||
|
private readonly BookingUrlUtility $bookingUrlUtility,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ContingentDateSummary>
|
||||||
|
*
|
||||||
|
* @throws TravelNotFoundException
|
||||||
|
* @throws HotelNotInTravelException
|
||||||
|
*/
|
||||||
|
public function getAvailableContingents(int $hotelId, int $dateId): array
|
||||||
|
{
|
||||||
|
$travel = $this->travelLoader->loadById($dateId, $hotelId);
|
||||||
|
|
||||||
|
if (null === $travel->dateFrom || null === $travel->dateTo) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->contingentLoader->loadByHotelId($hotelId);
|
||||||
|
$rows = $this->filterRowsByRange($data['rows'], $travel->dateFrom, $travel->dateTo);
|
||||||
|
$belKalStatusByDate = $this->getBelKalStatusByDate($rows);
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (true === ($row['isControlRoom'] ?? false)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateKey = $row['date']->format('Y-m-d');
|
||||||
|
|
||||||
|
if (false === isset($result[$dateKey])) {
|
||||||
|
$result[$dateKey] = new ContingentDateSummary($dateKey, null, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $row['minNights']) {
|
||||||
|
$result[$dateKey]->minNights = null === $result[$dateKey]->minNights
|
||||||
|
? $row['minNights']
|
||||||
|
: min($result[$dateKey]->minNights, $row['minNights']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[$dateKey]->total += (int) ($row['available'] ?? 0);
|
||||||
|
$result[$dateKey]->capacity += (int) ($row['pax'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($belKalStatusByDate as $dateKey => $status) {
|
||||||
|
if (false === isset($result[$dateKey])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('OK' !== $status) {
|
||||||
|
$result[$dateKey]->total = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, ContingentRoomAvailability>
|
||||||
|
*
|
||||||
|
* @throws TravelNotFoundException
|
||||||
|
* @throws HotelNotInTravelException
|
||||||
|
* @throws \InvalidArgumentException
|
||||||
|
*/
|
||||||
|
public function getAvailableRooms(
|
||||||
|
string $dateFrom,
|
||||||
|
string $dateTo,
|
||||||
|
int $hotelId,
|
||||||
|
int $dateId,
|
||||||
|
?string $myEpUrl = null,
|
||||||
|
): array {
|
||||||
|
$range = $this->parseDateRange($dateFrom, $dateTo);
|
||||||
|
$travel = $this->travelLoader->loadById($dateId, $hotelId);
|
||||||
|
|
||||||
|
if (null !== $travel->dateFrom && null !== $travel->dateTo) {
|
||||||
|
if ($range['from'] < $travel->dateFrom || $range['to'] > $travel->dateTo) {
|
||||||
|
throw new \InvalidArgumentException('Requested range must be within the travel date range');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->contingentLoader->loadByHotelId($hotelId);
|
||||||
|
$rows = $this->filterRowsByRange($data['rows'], $range['from'], $range['to']);
|
||||||
|
$belKalStatusByDate = $this->getBelKalStatusByDate($rows);
|
||||||
|
|
||||||
|
$nights = $range['from']->diff($range['to'])->days;
|
||||||
|
$bookingUrl = $this->bookingUrlUtility->build($hotelId, $dateId, $myEpUrl);
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (true === ($row['isControlRoom'] ?? false)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateKey = $row['date']->format('Y-m-d');
|
||||||
|
$status = $row['status'] ?? 'OK';
|
||||||
|
|
||||||
|
if (isset($belKalStatusByDate[$dateKey]) && 'OK' !== $belKalStatusByDate[$dateKey]) {
|
||||||
|
$status = $belKalStatusByDate[$dateKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
$minNights = $row['minNights'] ?? 0;
|
||||||
|
$additionalNightMinPrice = $row['additionalNightMinPrice'] ?? 0.0;
|
||||||
|
$minPrice = $row['minPrice'] ?? 0.0;
|
||||||
|
$extraNights = max(0, $nights - (int) $minNights);
|
||||||
|
|
||||||
|
$priceForSelection = $minPrice + ($extraNights * $additionalNightMinPrice);
|
||||||
|
|
||||||
|
$result[] = new ContingentRoomAvailability(
|
||||||
|
date: $dateKey,
|
||||||
|
roomCode: (string) $row['roomCode'],
|
||||||
|
roomLabel: (string) $row['roomLabel'],
|
||||||
|
bookingUrl: $bookingUrl,
|
||||||
|
pax: (int) ($row['pax'] ?? 0),
|
||||||
|
available: 'OK' === $status ? (int) ($row['available'] ?? 0) : 0,
|
||||||
|
status: $status,
|
||||||
|
minPrice: $row['minPrice'],
|
||||||
|
minNights: $row['minNights'],
|
||||||
|
additionalNightMinPrice: $row['additionalNightMinPrice'],
|
||||||
|
additionalNightMinNights: $row['additionalNightMinNights'],
|
||||||
|
priceForSelection: (float) $priceForSelection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, ContingentCalendarEvent>
|
||||||
|
*/
|
||||||
|
public function getCalendarEvents(int $hotelId, string $dateFrom, string $dateTo): array
|
||||||
|
{
|
||||||
|
$range = $this->parseDateRange($dateFrom, $dateTo);
|
||||||
|
$data = $this->contingentLoader->loadByHotelId($hotelId);
|
||||||
|
$rows = $this->filterRowsByRange($data['rows'], $range['from'], $range['to']);
|
||||||
|
|
||||||
|
$events = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$dateKey = $row['date']->format('Y-m-d');
|
||||||
|
|
||||||
|
if (false === isset($events[$dateKey])) {
|
||||||
|
$events[$dateKey] = new ContingentCalendarEvent($dateKey, 'OK', 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$events[$dateKey]->status = $this->mergeStatus(
|
||||||
|
$events[$dateKey]->status,
|
||||||
|
$row['status'] ?? 'OK'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (false === ($row['isControlRoom'] ?? false)) {
|
||||||
|
$events[$dateKey]->pax += (int) ($row['pax'] ?? 0);
|
||||||
|
$events[$dateKey]->available += (int) ($row['available'] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($events);
|
||||||
|
|
||||||
|
return array_values($events);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $rows
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function filterRowsByRange(array $rows, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter($rows, function (array $row) use ($dateFrom, $dateTo) {
|
||||||
|
if (false === isset($row['date']) || !$row['date'] instanceof \DateTimeImmutable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $row['date'] >= $dateFrom && $row['date'] <= $dateTo;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $rows
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function getBelKalStatusByDate(array $rows): array
|
||||||
|
{
|
||||||
|
$statuses = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (false === ($row['isControlRoom'] ?? false)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateKey = $row['date']->format('Y-m-d');
|
||||||
|
$statuses[$dateKey] = $this->mergeStatus($statuses[$dateKey] ?? 'OK', $row['status'] ?? 'OK');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mergeStatus(string $current, string $candidate): string
|
||||||
|
{
|
||||||
|
$currentOrder = self::STATUS_ORDER[$current] ?? 0;
|
||||||
|
$candidateOrder = self::STATUS_ORDER[$candidate] ?? 0;
|
||||||
|
|
||||||
|
return $candidateOrder > $currentOrder ? $candidate : $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{from: \DateTimeImmutable, to: \DateTimeImmutable}
|
||||||
|
*/
|
||||||
|
private function parseDateRange(string $dateFrom, string $dateTo): array
|
||||||
|
{
|
||||||
|
$from = $this->parseDate($dateFrom);
|
||||||
|
$to = $this->parseDate($dateTo);
|
||||||
|
|
||||||
|
if ($to < $from) {
|
||||||
|
throw new \InvalidArgumentException('dateTo must be on or after dateFrom');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['from' => $from, 'to' => $to];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseDate(string $date): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
$parsed = \DateTimeImmutable::createFromFormat('Y-m-d', $date);
|
||||||
|
|
||||||
|
if (false === $parsed || $parsed->format('Y-m-d') !== $date) {
|
||||||
|
throw new \InvalidArgumentException('Invalid date format, expected Y-m-d');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parsed->setTime(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use App\BusProNet\XmlLoader\HotelLoader;
|
|||||||
use League\Flysystem\FilesystemOperator;
|
use League\Flysystem\FilesystemOperator;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||||
|
|
||||||
class HotelDataLoaderTest extends TestCase
|
class HotelDataLoaderTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -32,7 +33,7 @@ class HotelDataLoaderTest extends TestCase
|
|||||||
->with('hotel.xml')
|
->with('hotel.xml')
|
||||||
->willReturn($xmlContent);
|
->willReturn($xmlContent);
|
||||||
|
|
||||||
$cache = new ArrayAdapter();
|
$cache = new TagAwareAdapter(new ArrayAdapter());
|
||||||
$loader = new HotelLoader($cache, $filesystem);
|
$loader = new HotelLoader($cache, $filesystem);
|
||||||
|
|
||||||
$hotels = $loader->loadAll();
|
$hotels = $loader->loadAll();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\BusProNet\XmlLoader\PickupLoader;
|
|||||||
use League\Flysystem\FilesystemOperator;
|
use League\Flysystem\FilesystemOperator;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||||
|
|
||||||
class PickupDataLoaderTest extends TestCase
|
class PickupDataLoaderTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -32,7 +33,7 @@ class PickupDataLoaderTest extends TestCase
|
|||||||
->with('zustiege.xml')
|
->with('zustiege.xml')
|
||||||
->willReturn($xmlContent);
|
->willReturn($xmlContent);
|
||||||
|
|
||||||
$cache = new ArrayAdapter();
|
$cache = new TagAwareAdapter(new ArrayAdapter());
|
||||||
$loader = new PickupLoader($cache, $filesystem);
|
$loader = new PickupLoader($cache, $filesystem);
|
||||||
|
|
||||||
$pickup = $loader->loadById(1);
|
$pickup = $loader->loadById(1);
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BusProNet\Utility;
|
||||||
|
|
||||||
|
use App\BusProNet\Utility\BookingUrlUtility;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class BookingUrlUtilityTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testBuildUsesConfiguredDefaultBaseUrlAsAbsoluteFallback(): void
|
||||||
|
{
|
||||||
|
$utility = new BookingUrlUtility('https://my.ep-reisen.de');
|
||||||
|
|
||||||
|
$url = $utility->build(157047, 11606);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
'https://my.ep-reisen.de/bookings/create?date_id=11606&hotel_id=157047',
|
||||||
|
$url
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildUsesCustomBaseUrlWhenProvided(): void
|
||||||
|
{
|
||||||
|
$utility = new BookingUrlUtility('https://my.ep-reisen.de');
|
||||||
|
|
||||||
|
$url = $utility->build(157047, 11606, 'https://my.example.test/');
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
'https://my.example.test/bookings/create?date_id=11606&hotel_id=157047',
|
||||||
|
$url
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildFallsBackToConfiguredDefaultWhenCustomBaseIsBlank(): void
|
||||||
|
{
|
||||||
|
$utility = new BookingUrlUtility('https://fallback.test');
|
||||||
|
|
||||||
|
$url = $utility->build(157047, 11606, ' ');
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
'https://fallback.test/bookings/create?date_id=11606&hotel_id=157047',
|
||||||
|
$url
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildReturnsRelativeUrlWhenNoBaseUrlAvailable(): void
|
||||||
|
{
|
||||||
|
$utility = new BookingUrlUtility('');
|
||||||
|
|
||||||
|
$url = $utility->build(157047, 11606);
|
||||||
|
|
||||||
|
$this->assertSame('/bookings/create?date_id=11606&hotel_id=157047', $url);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\BusProNet\XmlLoader;
|
||||||
|
|
||||||
|
use App\BusProNet\XmlLoader\ContingentLoader;
|
||||||
|
use App\BusProNet\XmlParser\ContingentParser;
|
||||||
|
use League\Flysystem\DirectoryListing;
|
||||||
|
use League\Flysystem\FileAttributes;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
|
use Symfony\Contracts\Cache\TagAwareCacheInterface;
|
||||||
|
|
||||||
|
class ContingentLoaderTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testGenerateFilesMapBuildsMappingAndTagsCache(): void
|
||||||
|
{
|
||||||
|
$cache = $this->createMock(TagAwareCacheInterface::class);
|
||||||
|
$filesystem = $this->createMock(FilesystemOperator::class);
|
||||||
|
$parser = $this->createMock(ContingentParser::class);
|
||||||
|
|
||||||
|
$filesystem->method('listContents')->willReturn(new DirectoryListing([
|
||||||
|
new FileAttributes('HotelZimmer_100.xml', 10),
|
||||||
|
new FileAttributes('HotelZimmer_200.xml', 10),
|
||||||
|
new FileAttributes('other.xml', 10),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$item = $this->createMock(ItemInterface::class);
|
||||||
|
$item->expects($this->once())->method('expiresAfter')->with(3 * 60 * 60);
|
||||||
|
$item->expects($this->once())->method('tag')->with(['xml-sync']);
|
||||||
|
|
||||||
|
$cache->expects($this->once())
|
||||||
|
->method('get')
|
||||||
|
->with('bpn_contingent_files', $this->isType('callable'))
|
||||||
|
->willReturnCallback(fn (string $key, callable $callback) => $callback($item));
|
||||||
|
|
||||||
|
$loader = new ContingentLoader($parser, $cache, $filesystem);
|
||||||
|
$map = $loader->generateFilesMap();
|
||||||
|
|
||||||
|
$this->assertSame([
|
||||||
|
100 => 'HotelZimmer_100.xml',
|
||||||
|
200 => 'HotelZimmer_200.xml',
|
||||||
|
], $map);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoadByHotelIdUsesRootLinkOneHopAndTagsCache(): void
|
||||||
|
{
|
||||||
|
$cache = $this->createMock(TagAwareCacheInterface::class);
|
||||||
|
$filesystem = $this->createMock(FilesystemOperator::class);
|
||||||
|
$parser = $this->createMock(ContingentParser::class);
|
||||||
|
|
||||||
|
$originalXml = '<hotelzimmer idbuspro_kontingent_aus="200"><unterbringungen /></hotelzimmer>';
|
||||||
|
$linkedXml = '<hotelzimmer><unterbringungen /></hotelzimmer>';
|
||||||
|
$parsed = ['roomTypes' => [], 'rows' => [['roomCode' => 'DZ']]];
|
||||||
|
|
||||||
|
$filesystem->method('listContents')->willReturn(new DirectoryListing([
|
||||||
|
new FileAttributes('HotelZimmer_100.xml', 10),
|
||||||
|
new FileAttributes('HotelZimmer_200.xml', 10),
|
||||||
|
]));
|
||||||
|
$filesystem->method('read')->willReturnMap([
|
||||||
|
['HotelZimmer_100.xml', $originalXml],
|
||||||
|
['HotelZimmer_200.xml', $linkedXml],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mapItem = $this->createMock(ItemInterface::class);
|
||||||
|
$mapItem->method('expiresAfter')->with(3 * 60 * 60);
|
||||||
|
$mapItem->method('tag')->with(['xml-sync']);
|
||||||
|
|
||||||
|
$hotelItem = $this->createMock(ItemInterface::class);
|
||||||
|
$hotelItem->method('expiresAfter')->with(3600);
|
||||||
|
$hotelItem->method('tag')->with(['xml-sync']);
|
||||||
|
|
||||||
|
$cache->method('get')->willReturnCallback(
|
||||||
|
function (string $key, callable $callback) use ($mapItem, $hotelItem) {
|
||||||
|
if ('bpn_contingent_files' === $key) {
|
||||||
|
return $callback($mapItem);
|
||||||
|
}
|
||||||
|
if ('contingent_hotel_100' === $key) {
|
||||||
|
return $callback($hotelItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$parser->expects($this->once())->method('parse')->with($linkedXml)->willReturn($parsed);
|
||||||
|
|
||||||
|
$loader = new ContingentLoader($parser, $cache, $filesystem);
|
||||||
|
$result = $loader->loadByHotelId(100);
|
||||||
|
|
||||||
|
$this->assertSame($parsed, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoadByHotelIdReturnsEmptyWhenLinkedTargetIsMissing(): void
|
||||||
|
{
|
||||||
|
$cache = $this->createMock(TagAwareCacheInterface::class);
|
||||||
|
$filesystem = $this->createMock(FilesystemOperator::class);
|
||||||
|
$parser = $this->createMock(ContingentParser::class);
|
||||||
|
|
||||||
|
$xml = '<hotelzimmer idbuspro_kontingent_aus="999"><unterbringungen /></hotelzimmer>';
|
||||||
|
|
||||||
|
$filesystem->method('listContents')->willReturn(new DirectoryListing([
|
||||||
|
new FileAttributes('HotelZimmer_100.xml', 10),
|
||||||
|
]));
|
||||||
|
$filesystem->method('read')->willReturn($xml);
|
||||||
|
|
||||||
|
$mapItem = $this->createMock(ItemInterface::class);
|
||||||
|
$mapItem->method('expiresAfter')->with(3 * 60 * 60);
|
||||||
|
$mapItem->method('tag')->with(['xml-sync']);
|
||||||
|
|
||||||
|
$hotelItem = $this->createMock(ItemInterface::class);
|
||||||
|
$hotelItem->method('expiresAfter')->with(3600);
|
||||||
|
$hotelItem->method('tag')->with(['xml-sync']);
|
||||||
|
|
||||||
|
$cache->method('get')->willReturnCallback(
|
||||||
|
function (string $key, callable $callback) use ($mapItem, $hotelItem) {
|
||||||
|
if ('bpn_contingent_files' === $key) {
|
||||||
|
return $callback($mapItem);
|
||||||
|
}
|
||||||
|
if ('contingent_hotel_100' === $key) {
|
||||||
|
return $callback($hotelItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$parser->expects($this->never())->method('parse');
|
||||||
|
|
||||||
|
$loader = new ContingentLoader($parser, $cache, $filesystem);
|
||||||
|
$result = $loader->loadByHotelId(100);
|
||||||
|
|
||||||
|
$this->assertSame(['roomTypes' => [], 'rows' => []], $result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\BusProNet\XmlParser;
|
||||||
|
|
||||||
|
use App\BusProNet\XmlParser\ContingentParser;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ContingentParserTest extends TestCase
|
||||||
|
{
|
||||||
|
private ContingentParser $parser;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->parser = new ContingentParser();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParseBuildsRoomTypesAndRowsWithLinksAndControlRoom(): void
|
||||||
|
{
|
||||||
|
$xml = <<<'XML'
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<hotelzimmer>
|
||||||
|
<unterbringungen>
|
||||||
|
<unterbringung idbuspro="10" code="DZ" zimmerbezeichnung="Double Room" pax_max="2" />
|
||||||
|
<unterbringung idbuspro="11" code="BelKal" zimmerbezeichnung="Control" pax_max="1" />
|
||||||
|
<unterbringung idbuspro="12" code="PDGS" zimmerbezeichnung="Pseudo" pax_max="1" />
|
||||||
|
<unterbringung idbuspro="13" code="EZ" zimmerbezeichnung="Single Room" pax_max="1" idbuspro_kontingent_aus="10" />
|
||||||
|
</unterbringungen>
|
||||||
|
<kapazitaeten>
|
||||||
|
<kapazitaet termin="24.02.2026">
|
||||||
|
<zimmerliste>
|
||||||
|
<zimmer idbuspro="10" kontingent="3" frei="1" status="" abpreis="100,00" abpreis_naechte="2" abpreis_verlaengerung="15,00" abpreis_verlaengerung_naechte="1" />
|
||||||
|
<zimmer idbuspro="11" kontingent="1" frei="1" status="A" abpreis="0" abpreis_naechte="0" abpreis_verlaengerung="0" abpreis_verlaengerung_naechte="0" />
|
||||||
|
</zimmerliste>
|
||||||
|
</kapazitaet>
|
||||||
|
</kapazitaeten>
|
||||||
|
</hotelzimmer>
|
||||||
|
XML;
|
||||||
|
|
||||||
|
$result = $this->parser->parse($xml);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('roomTypes', $result);
|
||||||
|
$this->assertArrayHasKey('rows', $result);
|
||||||
|
$this->assertCount(3, $result['roomTypes']); // PDGS skipped
|
||||||
|
$this->assertCount(3, $result['rows']);
|
||||||
|
|
||||||
|
$rowsByCode = [];
|
||||||
|
foreach ($result['rows'] as $row) {
|
||||||
|
$rowsByCode[$row['roomCode']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame('OK', $rowsByCode['DZ']['status']);
|
||||||
|
$this->assertSame(6, $rowsByCode['DZ']['pax']);
|
||||||
|
$this->assertSame(2, $rowsByCode['DZ']['available']);
|
||||||
|
$this->assertSame(100.0, $rowsByCode['DZ']['minPrice']);
|
||||||
|
$this->assertSame(2, $rowsByCode['DZ']['minNights']);
|
||||||
|
$this->assertSame(15.0, $rowsByCode['DZ']['additionalNightMinPrice']);
|
||||||
|
$this->assertSame(1, $rowsByCode['DZ']['additionalNightMinNights']);
|
||||||
|
$this->assertFalse($rowsByCode['DZ']['isControlRoom']);
|
||||||
|
|
||||||
|
// Linked room uses DZ contingent node but its own pax
|
||||||
|
$this->assertSame('Single Room', $rowsByCode['EZ']['roomLabel']);
|
||||||
|
$this->assertSame(3, $rowsByCode['EZ']['pax']);
|
||||||
|
$this->assertSame(1, $rowsByCode['EZ']['available']);
|
||||||
|
|
||||||
|
$this->assertSame('ON_REQUEST', $rowsByCode['BelKal']['status']);
|
||||||
|
$this->assertSame(0, $rowsByCode['BelKal']['pax']);
|
||||||
|
$this->assertSame(0, $rowsByCode['BelKal']['available']);
|
||||||
|
$this->assertTrue($rowsByCode['BelKal']['isControlRoom']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParseHandlesZeroValuesAndBlockedStatus(): void
|
||||||
|
{
|
||||||
|
$xml = <<<'XML'
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<hotelzimmer>
|
||||||
|
<unterbringungen>
|
||||||
|
<unterbringung idbuspro="20" code="DZ" zimmerbezeichnung="Double Room" pax_max="2" />
|
||||||
|
</unterbringungen>
|
||||||
|
<kapazitaeten>
|
||||||
|
<kapazitaet termin="25.02.2026">
|
||||||
|
<zimmerliste>
|
||||||
|
<zimmer idbuspro="20" kontingent="0" frei="0" status="S" abpreis="0" abpreis_naechte="0" abpreis_verlaengerung="0" abpreis_verlaengerung_naechte="0" />
|
||||||
|
</zimmerliste>
|
||||||
|
</kapazitaet>
|
||||||
|
</kapazitaeten>
|
||||||
|
</hotelzimmer>
|
||||||
|
XML;
|
||||||
|
|
||||||
|
$result = $this->parser->parse($xml);
|
||||||
|
$row = $result['rows'][0];
|
||||||
|
|
||||||
|
$this->assertSame('BLOCKED', $row['status']);
|
||||||
|
$this->assertSame(0, $row['pax']);
|
||||||
|
$this->assertSame(0, $row['available']);
|
||||||
|
$this->assertSame(0.0, $row['minPrice']);
|
||||||
|
$this->assertSame(0, $row['minNights']);
|
||||||
|
$this->assertSame(0.0, $row['additionalNightMinPrice']);
|
||||||
|
$this->assertSame(0, $row['additionalNightMinNights']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ namespace App\Tests\Command;
|
|||||||
|
|
||||||
use App\BusProNet\XmlLoader\TravelLoader;
|
use App\BusProNet\XmlLoader\TravelLoader;
|
||||||
use App\Command\BpnXmlSyncCommand;
|
use App\Command\BpnXmlSyncCommand;
|
||||||
|
use App\Service\BpnXmlSyncManager;
|
||||||
|
use App\Service\BpnXmlSnapshotRefreshManager;
|
||||||
use App\Service\TravelDataProvider;
|
use App\Service\TravelDataProvider;
|
||||||
use App\Service\TravelSnapshotManager;
|
use App\Service\TravelSnapshotManager;
|
||||||
use League\Flysystem\DirectoryListing;
|
use League\Flysystem\DirectoryListing;
|
||||||
@@ -14,17 +16,21 @@ use PHPUnit\Framework\TestCase;
|
|||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Tester\CommandTester;
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
use Symfony\Contracts\Cache\CacheInterface;
|
use Symfony\Contracts\Cache\TagAwareCacheInterface;
|
||||||
|
|
||||||
class BpnXmlSyncCommandTest extends TestCase
|
class BpnXmlSyncCommandTest extends TestCase
|
||||||
{
|
{
|
||||||
private FilesystemOperator $xmlSource;
|
private FilesystemOperator $xmlSource;
|
||||||
private FilesystemOperator $xmlExport;
|
private FilesystemOperator $xmlExport;
|
||||||
private CacheInterface $cache;
|
private FilesystemOperator $xmlSourceContingents;
|
||||||
|
private FilesystemOperator $xmlExportContingents;
|
||||||
|
private TagAwareCacheInterface $cache;
|
||||||
private LoggerInterface $logger;
|
private LoggerInterface $logger;
|
||||||
private TravelLoader $travelLoader;
|
private TravelLoader $travelLoader;
|
||||||
private TravelDataProvider $travelDataService;
|
private TravelDataProvider $travelDataService;
|
||||||
private TravelSnapshotManager $travelSnapshotService;
|
private TravelSnapshotManager $travelSnapshotService;
|
||||||
|
private BpnXmlSnapshotRefreshManager $snapshotRefreshManager;
|
||||||
|
private BpnXmlSyncManager $syncManager;
|
||||||
private BpnXmlSyncCommand $command;
|
private BpnXmlSyncCommand $command;
|
||||||
|
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
@@ -39,40 +45,55 @@ class BpnXmlSyncCommandTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->xmlSource = $this->createMock(FilesystemOperator::class);
|
$this->xmlSource = $this->createMock(FilesystemOperator::class);
|
||||||
$this->xmlExport = $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->logger = $this->createMock(LoggerInterface::class);
|
||||||
$this->travelLoader = $this->createMock(TravelLoader::class);
|
$this->travelLoader = $this->createMock(TravelLoader::class);
|
||||||
$this->travelDataService = $this->createMock(TravelDataProvider::class);
|
$this->travelDataService = $this->createMock(TravelDataProvider::class);
|
||||||
$this->travelSnapshotService = $this->createMock(TravelSnapshotManager::class);
|
$this->travelSnapshotService = $this->createMock(TravelSnapshotManager::class);
|
||||||
|
$this->snapshotRefreshManager = new BpnXmlSnapshotRefreshManager(
|
||||||
$this->command = new BpnXmlSyncCommand(
|
|
||||||
$this->xmlSource,
|
|
||||||
$this->xmlExport,
|
|
||||||
$this->cache,
|
|
||||||
$this->logger,
|
|
||||||
$this->travelLoader,
|
$this->travelLoader,
|
||||||
$this->travelDataService,
|
$this->travelDataService,
|
||||||
$this->travelSnapshotService,
|
$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
|
public function testSnapshotSyncIsTriggeredAfterSuccessfulFileDownload(): void
|
||||||
{
|
{
|
||||||
$this->xmlSource->method('read')
|
$newerTimestamp = "24.03.2026 12:00:00\nExport\n3 Dateien\n";
|
||||||
->willReturn("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('fileExists')->willReturn(true);
|
||||||
$this->xmlExport->method('read')
|
$this->xmlExport->method('read')->willReturn($olderTimestamp);
|
||||||
->willReturn("24.03.2026 10:00:00\nExport\n3 Dateien\n");
|
$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->xmlSourceContingents->method('read')->willReturn($olderTimestamp);
|
||||||
$this->xmlSource->method('listContents')
|
$this->xmlExportContingents->method('fileExists')->willReturn(true);
|
||||||
->willReturn(new DirectoryListing([]));
|
$this->xmlExportContingents->method('read')->willReturn($olderTimestamp);
|
||||||
$this->xmlExport->method('listContents')
|
$this->xmlSourceContingents->method('listContents')->willReturn(new DirectoryListing([]));
|
||||||
->willReturn(new DirectoryListing([]));
|
$this->xmlExportContingents->method('listContents')->willReturn(new DirectoryListing([]));
|
||||||
|
|
||||||
$fileMap = [
|
$fileMap = [
|
||||||
101 => ['hotels' => ['H1' => null, 'H2' => null]],
|
101 => ['hotels' => ['H1' => null, 'H2' => null]],
|
||||||
@@ -87,6 +108,15 @@ class BpnXmlSyncCommandTest extends TestCase
|
|||||||
->with($fileMap, $this->isInstanceOf(\Closure::class))
|
->with($fileMap, $this->isInstanceOf(\Closure::class))
|
||||||
->willReturn(['processed' => 2, 'failed' => 0]);
|
->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 = new CommandTester($this->command);
|
||||||
$tester->execute([]);
|
$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
|
public function testSnapshotSyncIsSkippedWhenLocalDataIsUpToDate(): void
|
||||||
{
|
{
|
||||||
@@ -102,9 +132,19 @@ class BpnXmlSyncCommandTest extends TestCase
|
|||||||
$this->xmlSource->method('read')->willReturn($sameTimestamp);
|
$this->xmlSource->method('read')->willReturn($sameTimestamp);
|
||||||
$this->xmlExport->method('fileExists')->willReturn(true);
|
$this->xmlExport->method('fileExists')->willReturn(true);
|
||||||
$this->xmlExport->method('read')->willReturn($sameTimestamp);
|
$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->travelLoader->expects($this->never())->method('generateFilesMap');
|
||||||
$this->travelDataService->expects($this->never())->method('syncSnapshotsFromXml');
|
$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 = new CommandTester($this->command);
|
||||||
$tester->execute([]);
|
$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.
|
* command still returns SUCCESS.
|
||||||
*/
|
*/
|
||||||
public function testSnapshotSyncIsSkippedAndWarningLoggedWhenFileMapGenerationFails(): void
|
public function testSnapshotSyncIsSkippedAndWarningLoggedWhenFileMapGenerationFails(): void
|
||||||
{
|
{
|
||||||
$this->xmlSource->method('read')
|
$newerTimestamp = "24.03.2026 12:00:00\nExport\n3 Dateien\n";
|
||||||
->willReturn("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('fileExists')->willReturn(true);
|
||||||
$this->xmlExport->method('read')
|
$this->xmlExport->method('read')->willReturn($olderTimestamp);
|
||||||
->willReturn("24.03.2026 10:00:00\nExport\n3 Dateien\n");
|
$this->xmlSource->method('listContents')->willReturn(new DirectoryListing([]));
|
||||||
|
$this->xmlExport->method('listContents')->willReturn(new DirectoryListing([]));
|
||||||
|
|
||||||
$this->xmlSource->method('listContents')
|
$this->xmlSourceContingents->method('read')->willReturn($olderTimestamp);
|
||||||
->willReturn(new DirectoryListing([]));
|
$this->xmlExportContingents->method('fileExists')->willReturn(true);
|
||||||
$this->xmlExport->method('listContents')
|
$this->xmlExportContingents->method('read')->willReturn($olderTimestamp);
|
||||||
->willReturn(new DirectoryListing([]));
|
$this->xmlSourceContingents->method('listContents')->willReturn(new DirectoryListing([]));
|
||||||
|
$this->xmlExportContingents->method('listContents')->willReturn(new DirectoryListing([]));
|
||||||
|
|
||||||
$this->travelLoader->expects($this->once())
|
$this->travelLoader->expects($this->once())
|
||||||
->method('generateFilesMap')
|
->method('generateFilesMap')
|
||||||
@@ -142,6 +185,8 @@ class BpnXmlSyncCommandTest extends TestCase
|
|||||||
);
|
);
|
||||||
|
|
||||||
$this->travelDataService->expects($this->never())->method('syncSnapshotsFromXml');
|
$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 = new CommandTester($this->command);
|
||||||
$tester->execute([]);
|
$tester->execute([]);
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\Api;
|
||||||
|
|
||||||
|
use App\Controller\Api\ContingentController;
|
||||||
|
use App\Service\ContingentDataService;
|
||||||
|
use App\Service\TravelDataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\DependencyInjection\Container;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Serializer\Encoder\JsonEncoder;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
|
||||||
|
use Symfony\Component\Serializer\Serializer;
|
||||||
|
|
||||||
|
class ContingentControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testByDateQueryResolvesCodeReferences(): void
|
||||||
|
{
|
||||||
|
$contingentDataService = $this->createMock(ContingentDataService::class);
|
||||||
|
$travelDataProvider = $this->createMock(TravelDataProvider::class);
|
||||||
|
|
||||||
|
$travelDataProvider
|
||||||
|
->expects($this->once())
|
||||||
|
->method('mapHotelCodeToId')
|
||||||
|
->with('SBW-HOTEL')
|
||||||
|
->willReturn(157047);
|
||||||
|
|
||||||
|
$travelDataProvider
|
||||||
|
->expects($this->once())
|
||||||
|
->method('mapDateCodeToId')
|
||||||
|
->with('SBW131225')
|
||||||
|
->willReturn(11606);
|
||||||
|
|
||||||
|
$contingentDataService
|
||||||
|
->expects($this->once())
|
||||||
|
->method('getAvailableContingents')
|
||||||
|
->with(157047, 11606)
|
||||||
|
->willReturn([]);
|
||||||
|
|
||||||
|
$controller = $this->createController($contingentDataService, $travelDataProvider);
|
||||||
|
$request = Request::create('/api/contingents', 'GET', [
|
||||||
|
'hotelRef' => 'SBW-HOTEL',
|
||||||
|
'dateRef' => 'sbw-13/12/25',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $controller->byDate($request);
|
||||||
|
|
||||||
|
$this->assertSame(200, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testByDateQueryRequiresDateReference(): void
|
||||||
|
{
|
||||||
|
$contingentDataService = $this->createMock(ContingentDataService::class);
|
||||||
|
$travelDataProvider = $this->createMock(TravelDataProvider::class);
|
||||||
|
|
||||||
|
$controller = $this->createController($contingentDataService, $travelDataProvider);
|
||||||
|
$request = Request::create('/api/contingents', 'GET', [
|
||||||
|
'hotelRef' => '157047',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $controller->byDate($request);
|
||||||
|
$payload = json_decode((string) $response->getContent(), true);
|
||||||
|
|
||||||
|
$this->assertSame(400, $response->getStatusCode());
|
||||||
|
$this->assertSame('dateRef is required', $payload['error']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testByDateQueryReturnsNotFoundForUnknownDateReference(): void
|
||||||
|
{
|
||||||
|
$contingentDataService = $this->createMock(ContingentDataService::class);
|
||||||
|
$travelDataProvider = $this->createMock(TravelDataProvider::class);
|
||||||
|
|
||||||
|
$travelDataProvider
|
||||||
|
->expects($this->once())
|
||||||
|
->method('mapHotelCodeToId')
|
||||||
|
->with('SBW-HOTEL')
|
||||||
|
->willReturn(157047);
|
||||||
|
|
||||||
|
$travelDataProvider
|
||||||
|
->expects($this->once())
|
||||||
|
->method('mapDateCodeToId')
|
||||||
|
->with('UNKNOWN')
|
||||||
|
->willReturn(null);
|
||||||
|
|
||||||
|
$contingentDataService
|
||||||
|
->expects($this->never())
|
||||||
|
->method('getAvailableContingents');
|
||||||
|
|
||||||
|
$controller = $this->createController($contingentDataService, $travelDataProvider);
|
||||||
|
$request = Request::create('/api/contingents', 'GET', [
|
||||||
|
'hotelRef' => 'SBW-HOTEL',
|
||||||
|
'dateRef' => 'unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $controller->byDate($request);
|
||||||
|
$payload = json_decode((string) $response->getContent(), true);
|
||||||
|
|
||||||
|
$this->assertSame(404, $response->getStatusCode());
|
||||||
|
$this->assertSame('Not found', $payload['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRoomsQueryRequiresDateRange(): void
|
||||||
|
{
|
||||||
|
$contingentDataService = $this->createMock(ContingentDataService::class);
|
||||||
|
$travelDataProvider = $this->createMock(TravelDataProvider::class);
|
||||||
|
|
||||||
|
$controller = $this->createController($contingentDataService, $travelDataProvider);
|
||||||
|
$request = Request::create('/api/contingents/rooms', 'GET', [
|
||||||
|
'hotelRef' => '157047',
|
||||||
|
'dateRef' => '11606',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $controller->rooms($request);
|
||||||
|
$payload = json_decode((string) $response->getContent(), true);
|
||||||
|
|
||||||
|
$this->assertSame(400, $response->getStatusCode());
|
||||||
|
$this->assertSame('dateFrom and dateTo are required', $payload['error']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testByDateQueryUsesNumericReferencesAsIdentifiers(): void
|
||||||
|
{
|
||||||
|
$contingentDataService = $this->createMock(ContingentDataService::class);
|
||||||
|
$travelDataProvider = $this->createMock(TravelDataProvider::class);
|
||||||
|
|
||||||
|
$travelDataProvider
|
||||||
|
->expects($this->never())
|
||||||
|
->method('mapHotelCodeToId');
|
||||||
|
|
||||||
|
$travelDataProvider
|
||||||
|
->expects($this->never())
|
||||||
|
->method('mapDateCodeToId');
|
||||||
|
|
||||||
|
$contingentDataService
|
||||||
|
->expects($this->once())
|
||||||
|
->method('getAvailableContingents')
|
||||||
|
->with(157047, 11606)
|
||||||
|
->willReturn([]);
|
||||||
|
|
||||||
|
$controller = $this->createController($contingentDataService, $travelDataProvider);
|
||||||
|
$request = Request::create('/api/contingents', 'GET', [
|
||||||
|
'hotelRef' => '157047',
|
||||||
|
'dateRef' => '11606',
|
||||||
|
]);
|
||||||
|
$response = $controller->byDate($request);
|
||||||
|
|
||||||
|
$this->assertSame(200, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRoomsQueryForwardsOptionalMyEpUrl(): void
|
||||||
|
{
|
||||||
|
$contingentDataService = $this->createMock(ContingentDataService::class);
|
||||||
|
$travelDataProvider = $this->createMock(TravelDataProvider::class);
|
||||||
|
|
||||||
|
$contingentDataService
|
||||||
|
->expects($this->once())
|
||||||
|
->method('getAvailableRooms')
|
||||||
|
->with('2026-03-01', '2026-03-08', 157047, 11606, 'https://my.ep-reisen.de')
|
||||||
|
->willReturn([]);
|
||||||
|
|
||||||
|
$controller = $this->createController($contingentDataService, $travelDataProvider);
|
||||||
|
$request = Request::create('/api/contingents/rooms', 'GET', [
|
||||||
|
'hotelRef' => '157047',
|
||||||
|
'dateRef' => '11606',
|
||||||
|
'dateFrom' => '2026-03-01',
|
||||||
|
'dateTo' => '2026-03-08',
|
||||||
|
'my_ep_url' => 'https://my.ep-reisen.de',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $controller->rooms($request);
|
||||||
|
|
||||||
|
$this->assertSame(200, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createController(
|
||||||
|
ContingentDataService $contingentDataService,
|
||||||
|
TravelDataProvider $travelDataProvider,
|
||||||
|
): ContingentController {
|
||||||
|
$controller = new ContingentController($contingentDataService, $travelDataProvider);
|
||||||
|
|
||||||
|
$container = new Container();
|
||||||
|
$container->set('serializer', new Serializer([new ObjectNormalizer()], [new JsonEncoder()]));
|
||||||
|
$controller->setContainer($container);
|
||||||
|
|
||||||
|
return $controller;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,12 +45,7 @@ class ParticipantEditDtoTest extends TestCase
|
|||||||
$mockServiceAgeEvaluator = $this->createMock(ServiceAgeEvaluator::class);
|
$mockServiceAgeEvaluator = $this->createMock(ServiceAgeEvaluator::class);
|
||||||
|
|
||||||
// Create custom validator factory that can inject dependencies
|
// Create custom validator factory that can inject dependencies
|
||||||
$validatorFactory = new class(
|
$validatorFactory = new class($mockVoucherService, $mockPriceCalculatorService, $mockParticipantEligibilityChecker, $mockServiceAgeEvaluator) implements ConstraintValidatorFactoryInterface {
|
||||||
$mockVoucherService,
|
|
||||||
$mockPriceCalculatorService,
|
|
||||||
$mockParticipantEligibilityChecker,
|
|
||||||
$mockServiceAgeEvaluator
|
|
||||||
) implements ConstraintValidatorFactoryInterface {
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly VoucherValidator $voucherService,
|
private readonly VoucherValidator $voucherService,
|
||||||
private readonly BookingPriceCalculator $priceCalculatorService,
|
private readonly BookingPriceCalculator $priceCalculatorService,
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ use App\Form\Model\ParticipantDto;
|
|||||||
use App\Form\Service\ParticipantFieldOptionsProvider;
|
use App\Form\Service\ParticipantFieldOptionsProvider;
|
||||||
use App\Service\BookingPriceCalculator;
|
use App\Service\BookingPriceCalculator;
|
||||||
use App\Service\InsuranceManager;
|
use App\Service\InsuranceManager;
|
||||||
use App\Service\ServiceLabelFormatter;
|
|
||||||
use App\Service\ServiceAvailabilityCalculator;
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
|
use App\Service\ServiceLabelFormatter;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ use App\Form\Model\ParticipantDto;
|
|||||||
use App\Form\Service\ParticipantFieldOptionsProvider;
|
use App\Form\Service\ParticipantFieldOptionsProvider;
|
||||||
use App\Service\BookingPriceCalculator;
|
use App\Service\BookingPriceCalculator;
|
||||||
use App\Service\InsuranceManager;
|
use App\Service\InsuranceManager;
|
||||||
use App\Service\ServiceLabelFormatter;
|
|
||||||
use App\Service\ServiceAvailabilityCalculator;
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
|
use App\Service\ServiceLabelFormatter;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Service;
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
use App\BusProNet\Constants;
|
use App\BusProNet\Constants;
|
||||||
use App\BusProNet\Model\Service;
|
|
||||||
use App\BusProNet\Model\Room;
|
use App\BusProNet\Model\Room;
|
||||||
|
use App\BusProNet\Model\Service;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\BusProNet\Service\BookingStatusRuleRegistry;
|
use App\BusProNet\Service\BookingStatusRuleRegistry;
|
||||||
use App\BusProNet\XmlLoader\AgencyLoader;
|
use App\BusProNet\XmlLoader\AgencyLoader;
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ use App\BusProNet\Model\Room;
|
|||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\Form\Model\BookingCreateContext;
|
use App\Form\Model\BookingCreateContext;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Model\BookingSummaryDto;
|
||||||
use App\Form\Model\ParticipantCardDataDto;
|
use App\Form\Model\ParticipantCardDataDto;
|
||||||
use App\Form\Model\ParticipantCardPriceDto;
|
use App\Form\Model\ParticipantCardPriceDto;
|
||||||
use App\Form\Model\BookingSummaryDto;
|
|
||||||
use App\Service\BookingCreateContextFactory;
|
use App\Service\BookingCreateContextFactory;
|
||||||
use App\Service\ParticipantCardAssembler;
|
|
||||||
use App\Service\BookingSummaryAssembler;
|
|
||||||
use App\Service\BookingPriceCalculator;
|
use App\Service\BookingPriceCalculator;
|
||||||
|
use App\Service\BookingSummaryAssembler;
|
||||||
|
use App\Service\ParticipantCardAssembler;
|
||||||
use App\Service\RoomPricingCalculator;
|
use App\Service\RoomPricingCalculator;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use App\BusProNet\Model\Booking;
|
|||||||
use App\BusProNet\Model\MutableData;
|
use App\BusProNet\Model\MutableData;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\BookingMutabilityDto;
|
|
||||||
use App\Form\Model\BookingEditContext;
|
use App\Form\Model\BookingEditContext;
|
||||||
|
use App\Form\Model\BookingMutabilityDto;
|
||||||
use App\Form\Model\BookingSummaryDto;
|
use App\Form\Model\BookingSummaryDto;
|
||||||
use App\Service\BookingEditContextFactory;
|
use App\Service\BookingEditContextFactory;
|
||||||
use App\Service\BookingSummaryAssembler;
|
use App\Service\BookingSummaryAssembler;
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ use App\Entity\User;
|
|||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Repository\BookingEditDraftRepository;
|
use App\Repository\BookingEditDraftRepository;
|
||||||
|
use App\Service\BookingChangeTracker;
|
||||||
use App\Service\BookingEditDraftManager;
|
use App\Service\BookingEditDraftManager;
|
||||||
use App\Service\BookingEditDraftMerger;
|
use App\Service\BookingEditDraftMerger;
|
||||||
use App\Service\BookingChangeTracker;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ use App\BusProNet\Model\Travel;
|
|||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Form\Model\RoomSelectionDto;
|
use App\Form\Model\RoomSelectionDto;
|
||||||
use App\Service\BookingPricingAssembler;
|
|
||||||
use App\Service\BookingPriceMismatchAnalyzer;
|
use App\Service\BookingPriceMismatchAnalyzer;
|
||||||
|
use App\Service\BookingPricingAssembler;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class BookingPriceMismatchAnalyzerTest extends TestCase
|
class BookingPriceMismatchAnalyzerTest extends TestCase
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Service;
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\DataProvider\CountryDataProvider;
|
||||||
use App\BusProNet\Model\Booking;
|
use App\BusProNet\Model\Booking;
|
||||||
use App\BusProNet\Model\Room;
|
use App\BusProNet\Model\Room;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\BusProNet\XmlLoader\HotelLoader;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\BookingSummaryDto;
|
use App\Form\Model\BookingSummaryDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
@@ -15,8 +17,6 @@ use App\Service\BookingPriceCalculator;
|
|||||||
use App\Service\BookingPricingAssembler;
|
use App\Service\BookingPricingAssembler;
|
||||||
use App\Service\BookingSummaryAssembler;
|
use App\Service\BookingSummaryAssembler;
|
||||||
use App\Service\CmsDataProvider;
|
use App\Service\CmsDataProvider;
|
||||||
use App\BusProNet\DataProvider\CountryDataProvider;
|
|
||||||
use App\BusProNet\XmlLoader\HotelLoader;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
use Symfony\Contracts\Cache\CacheInterface;
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\BusProNet\Utility\BookingUrlUtility;
|
||||||
|
use App\BusProNet\XmlLoader\ContingentLoader;
|
||||||
|
use App\BusProNet\XmlLoader\TravelLoader;
|
||||||
|
use App\Service\ContingentDataService;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ContingentDataServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private ContingentLoader $contingentLoader;
|
||||||
|
private TravelLoader $travelLoader;
|
||||||
|
private ContingentDataService $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->contingentLoader = $this->createMock(ContingentLoader::class);
|
||||||
|
$this->travelLoader = $this->createMock(TravelLoader::class);
|
||||||
|
|
||||||
|
$this->service = new ContingentDataService(
|
||||||
|
$this->contingentLoader,
|
||||||
|
$this->travelLoader,
|
||||||
|
new BookingUrlUtility('https://my.ep-reisen.de'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAvailableContingentsAggregatesAndAppliesBelKalOverride(): void
|
||||||
|
{
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->dateFrom = new \DateTimeImmutable('2026-02-01');
|
||||||
|
$travel->dateTo = new \DateTimeImmutable('2026-02-03');
|
||||||
|
|
||||||
|
$this->travelLoader->method('loadById')->with(123, 10)->willReturn($travel);
|
||||||
|
$this->contingentLoader->method('loadByHotelId')->with(10)->willReturn([
|
||||||
|
'rows' => [
|
||||||
|
[
|
||||||
|
'date' => new \DateTimeImmutable('2026-02-01'),
|
||||||
|
'isControlRoom' => false,
|
||||||
|
'available' => 4,
|
||||||
|
'pax' => 8,
|
||||||
|
'minNights' => 3,
|
||||||
|
'status' => 'OK',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'date' => new \DateTimeImmutable('2026-02-01'),
|
||||||
|
'isControlRoom' => false,
|
||||||
|
'available' => 2,
|
||||||
|
'pax' => 4,
|
||||||
|
'minNights' => 2,
|
||||||
|
'status' => 'OK',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'date' => new \DateTimeImmutable('2026-02-01'),
|
||||||
|
'isControlRoom' => true,
|
||||||
|
'available' => 0,
|
||||||
|
'pax' => 0,
|
||||||
|
'minNights' => null,
|
||||||
|
'status' => 'BLOCKED',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->service->getAvailableContingents(10, 123);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('2026-02-01', $result);
|
||||||
|
$summary = $result['2026-02-01'];
|
||||||
|
$this->assertSame('2026-02-01', $summary->date);
|
||||||
|
$this->assertSame(2, $summary->minNights);
|
||||||
|
$this->assertSame(12, $summary->capacity);
|
||||||
|
$this->assertSame(0, $summary->total); // BelKal override
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAvailableRoomsRejectsRangeOutsideTravelPeriod(): void
|
||||||
|
{
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->dateFrom = new \DateTimeImmutable('2026-02-10');
|
||||||
|
$travel->dateTo = new \DateTimeImmutable('2026-02-20');
|
||||||
|
|
||||||
|
$this->travelLoader->method('loadById')->with(123, 10)->willReturn($travel);
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Requested range must be within the travel date range');
|
||||||
|
|
||||||
|
$this->service->getAvailableRooms('2026-02-01', '2026-02-05', 10, 123);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAvailableRoomsCalculatesPriceAndAppliesBelKalStatusOverride(): void
|
||||||
|
{
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->dateFrom = new \DateTimeImmutable('2026-02-01');
|
||||||
|
$travel->dateTo = new \DateTimeImmutable('2026-02-10');
|
||||||
|
|
||||||
|
$this->travelLoader->method('loadById')->with(123, 10)->willReturn($travel);
|
||||||
|
$this->contingentLoader->method('loadByHotelId')->with(10)->willReturn([
|
||||||
|
'rows' => [
|
||||||
|
[
|
||||||
|
'date' => new \DateTimeImmutable('2026-02-03'),
|
||||||
|
'isControlRoom' => true,
|
||||||
|
'status' => 'ON_REQUEST',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'date' => new \DateTimeImmutable('2026-02-03'),
|
||||||
|
'isControlRoom' => false,
|
||||||
|
'roomCode' => 'DZ',
|
||||||
|
'roomLabel' => 'Double',
|
||||||
|
'pax' => 4,
|
||||||
|
'available' => 2,
|
||||||
|
'status' => 'OK',
|
||||||
|
'minPrice' => 100.0,
|
||||||
|
'minNights' => 2,
|
||||||
|
'additionalNightMinPrice' => 30.0,
|
||||||
|
'additionalNightMinNights' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->service->getAvailableRooms('2026-02-03', '2026-02-06', 10, 123);
|
||||||
|
|
||||||
|
$this->assertCount(1, $result);
|
||||||
|
$room = $result[0];
|
||||||
|
$this->assertSame('2026-02-03', $room->date);
|
||||||
|
$this->assertSame('ON_REQUEST', $room->status);
|
||||||
|
$this->assertSame(0, $room->available); // overridden
|
||||||
|
$this->assertSame(130.0, $room->priceForSelection); // 100 + (3-2)*30
|
||||||
|
$this->assertSame('https://my.ep-reisen.de/bookings/create?date_id=123&hotel_id=10', $room->bookingUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetCalendarEventsUsesWorstStatusAndExcludesControlRoomFromSums(): void
|
||||||
|
{
|
||||||
|
$this->contingentLoader->method('loadByHotelId')->with(10)->willReturn([
|
||||||
|
'rows' => [
|
||||||
|
[
|
||||||
|
'date' => new \DateTimeImmutable('2026-02-03'),
|
||||||
|
'isControlRoom' => false,
|
||||||
|
'status' => 'OK',
|
||||||
|
'pax' => 6,
|
||||||
|
'available' => 2,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'date' => new \DateTimeImmutable('2026-02-03'),
|
||||||
|
'isControlRoom' => true,
|
||||||
|
'status' => 'BLOCKED',
|
||||||
|
'pax' => 0,
|
||||||
|
'available' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$events = $this->service->getCalendarEvents(10, '2026-02-01', '2026-02-10');
|
||||||
|
|
||||||
|
$this->assertCount(1, $events);
|
||||||
|
$event = $events[0];
|
||||||
|
$this->assertSame('2026-02-03', $event->date);
|
||||||
|
$this->assertSame('BLOCKED', $event->status);
|
||||||
|
$this->assertSame(6, $event->pax);
|
||||||
|
$this->assertSame(2, $event->available);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,4 +176,3 @@ class MandatoryAdditionalServicesSelectedValidatorTest extends ConstraintValidat
|
|||||||
return $service;
|
return $service;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
use App\Kernel;
|
use App\Kernel;
|
||||||
use Symfony\Component\Dotenv\Dotenv;
|
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 = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
|
||||||
$kernel->boot();
|
$kernel->boot();
|
||||||
|
|||||||
Reference in New Issue
Block a user