feat: sync contingents for TYPO3 calendar hotels
This commit is contained in:
1 parent
ec36b61275
commit
97c6330a9a
24 files changed
+1067
-180
No files matched your search
@@ -526,7 +526,35 @@ Two differences from `calendar`:
|
||||
- **No price rule.** A day without a maintained price keeps the status the contingent reports; it is not forced to `BLOCKED`.
|
||||
- **Days the snapshot lacks are left out**, not returned as `BLOCKED`. The response is not contiguous: a day without an entry has no availability data, and the feed shows it as free, as it did when it read the upstream service directly.
|
||||
|
||||
Errors are the same as for `calendar`: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations for invalid parameters, and `502 {"error":"Failed to fetch contingent data."}` when there is no usable snapshot. For this endpoint the `502` is the important one: an empty list would show every day as free, so a consumer should answer with an error and let the portals keep their last import.
|
||||
- **Not limited to accommodations.** `hotelCode` may be any synced hotel code: an accommodation's calendar code, or the calendar hotel of a visible TYPO3 product (TYPO3's `/api/calendar-hotels`). Winter-only hotels without an accommodation are therefore served here, while `calendar` and `prices` still need an accommodation, since they need its prices.
|
||||
|
||||
Errors are the same as for `calendar`: `400 {"error":"Hotel not found for hotelCode."}` when the code is not synced, `400` with violations for invalid parameters, and `502 {"error":"Failed to fetch contingent data."}` when there is no usable snapshot. For this endpoint the `502` is the important one: an empty list would show every day as free, so a consumer should answer with an error and let the portals keep their last import.
|
||||
|
||||
#### `GET /api/contingents/status` — scope `api`
|
||||
|
||||
One row per synced hotel code, sorted by code, for an overview of which hotel supports which calendar:
|
||||
|
||||
```json
|
||||
{
|
||||
"hotelCode": "DGS",
|
||||
"name": "Sportclub Spinabad",
|
||||
"sources": ["accommodation", "typo3"],
|
||||
"accommodation": {"name": "Davos Glaris - Gruppenhaus Spinabad", "cmsCode": "DGSGRP"},
|
||||
"pricedFrom": "2027-06-01",
|
||||
"pricedTo": "2027-09-30",
|
||||
"syncedAt": "2026-10-29T10:15:00+01:00",
|
||||
"lastError": null,
|
||||
"failureCount": 0,
|
||||
"available": true
|
||||
}
|
||||
```
|
||||
|
||||
- `sources`: where the code comes from, `accommodation` and/or `typo3`.
|
||||
- `name`: the hotel's name in TYPO3's calendar hotel list, `null` for a code TYPO3 does not list.
|
||||
- `accommodation`: `null` without one; `cmsCode` is the code the accommodation is known by in TYPO3, which may differ from `hotelCode`.
|
||||
- `pricedFrom`/`pricedTo`: the span of the accommodation's prices, `null` without prices. `calendar` and `prices` work only for hotels that have them.
|
||||
- `syncedAt`, `lastError`, `failureCount`: the last successful sync (`null` if none) and the failures since.
|
||||
- `available`: whether `availability` currently serves the hotel, i.e. it has synced within the last 6 hours. `false` means that endpoint answers `502`.
|
||||
|
||||
---
|
||||
|
||||
@@ -543,6 +571,8 @@ Rollout order:
|
||||
|
||||
Thereafter the scheduler keeps it current; a sync failing for one hotel puts only that hotel into `502` and leaves the rest serving normally.
|
||||
|
||||
**Which hotels are synced.** Every accommodation's calendar code plus every code in TYPO3's `/api/calendar-hotels` (same `APP_CMS_API_KEY` as the other CMS calls). Each run fetches TYPO3's list first and stores it in `calendar_hotel`; when TYPO3 errors or answers with an empty list, the run logs a warning and uses the stored list, so TYPO3 being down never drops its hotels. `--hotel=<code>` accepts any code from either source. A full run deletes the snapshot of codes that are in neither source any more; they answer `400` from then on, and come back with the next run if they reappear.
|
||||
|
||||
### Freshness and propagation
|
||||
|
||||
There is deliberately **no change-notification endpoint**. Availability changes slowly, and the measured cost of simply refetching on a TTL is low enough that a polling/revision mechanism was not worth the extra moving parts on either side.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20261029120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Keys the contingent snapshot by hotel code instead of accommodation, and adds calendar_hotel for TYPO3\'s calendar hotel list.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE calendar_hotel (id INT AUTO_INCREMENT NOT NULL, code VARCHAR(16) NOT NULL, name VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_6F075DB777153098 (code), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
|
||||
// Backfill from the linked accommodation. accommodation_id stays, nullable and no longer
|
||||
// written, until a later migration drops it; that keeps this step reversible.
|
||||
$this->addSql('ALTER TABLE contingent_day ADD hotel_code VARCHAR(16) DEFAULT NULL, CHANGE accommodation_id accommodation_id INT DEFAULT NULL');
|
||||
$this->addSql('UPDATE contingent_day cd INNER JOIN accommodation a ON a.id = cd.accommodation_id SET cd.hotel_code = a.calendar_code');
|
||||
$this->addSql('DELETE FROM contingent_day WHERE hotel_code IS NULL OR hotel_code = \'\'');
|
||||
$this->addSql('ALTER TABLE contingent_day CHANGE hotel_code hotel_code VARCHAR(16) NOT NULL');
|
||||
$this->addSql('DROP INDEX uniq_contingent_day_accommodation_date ON contingent_day');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_contingent_day_hotel_code_date ON contingent_day (hotel_code, date)');
|
||||
|
||||
$this->addSql('ALTER TABLE contingent_sync_state ADD hotel_code VARCHAR(16) DEFAULT NULL, CHANGE accommodation_id accommodation_id INT DEFAULT NULL');
|
||||
$this->addSql('UPDATE contingent_sync_state s INNER JOIN accommodation a ON a.id = s.accommodation_id SET s.hotel_code = a.calendar_code');
|
||||
$this->addSql('DELETE FROM contingent_sync_state WHERE hotel_code IS NULL OR hotel_code = \'\'');
|
||||
$this->addSql('ALTER TABLE contingent_sync_state CHANGE hotel_code hotel_code VARCHAR(16) NOT NULL');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_737F167CC0D0E610 ON contingent_sync_state (hotel_code)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Rows of hotel codes without an accommodation cannot be expressed in the old schema.
|
||||
// The snapshot is a cache, so the next sync of the old code rebuilds whatever is lost.
|
||||
$this->addSql('UPDATE contingent_day cd INNER JOIN accommodation a ON a.calendar_code = cd.hotel_code SET cd.accommodation_id = a.id');
|
||||
$this->addSql('DELETE cd FROM contingent_day cd LEFT JOIN accommodation a ON a.calendar_code = cd.hotel_code WHERE a.id IS NULL');
|
||||
$this->addSql('DROP INDEX uniq_contingent_day_hotel_code_date ON contingent_day');
|
||||
$this->addSql('ALTER TABLE contingent_day DROP hotel_code, CHANGE accommodation_id accommodation_id INT NOT NULL');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_contingent_day_accommodation_date ON contingent_day (accommodation_id, date)');
|
||||
|
||||
$this->addSql('UPDATE contingent_sync_state s INNER JOIN accommodation a ON a.calendar_code = s.hotel_code SET s.accommodation_id = a.id');
|
||||
$this->addSql('DELETE s FROM contingent_sync_state s LEFT JOIN accommodation a ON a.calendar_code = s.hotel_code WHERE a.id IS NULL');
|
||||
$this->addSql('DROP INDEX UNIQ_737F167CC0D0E610 ON contingent_sync_state');
|
||||
$this->addSql('ALTER TABLE contingent_sync_state DROP hotel_code, CHANGE accommodation_id accommodation_id INT NOT NULL');
|
||||
|
||||
$this->addSql('DROP TABLE calendar_hotel');
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Repository\Groups\ContingentDayRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use App\Service\ContingentHotelDirectory;
|
||||
use App\Service\ContingentSnapshotManager;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -28,14 +27,18 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
*
|
||||
* Scheduled over the full horizon throughout: every 15 minutes during peak hours, hourly
|
||||
* outside them. The options exist for ad-hoc runs — a narrower --horizon-months, or --hotel
|
||||
* to re-sync a single accommodation while debugging.
|
||||
* to re-sync a single hotel code while debugging.
|
||||
*
|
||||
* Syncs every code of {@see ContingentHotelDirectory}: the accommodations' calendar codes plus
|
||||
* TYPO3's calendar hotels. A full run also drops the snapshot of codes that have left both
|
||||
* sources, so the snapshot tables only ever describe hotels that are still synced.
|
||||
*/
|
||||
class BpnSyncContingentsCommand extends Command
|
||||
{
|
||||
use LockableTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly AccommodationRepository $accommodationRepository,
|
||||
private readonly ContingentHotelDirectory $hotelDirectory,
|
||||
private readonly ContingentSyncStateRepository $syncStateRepository,
|
||||
private readonly ContingentDayRepository $dayRepository,
|
||||
private readonly ContingentSnapshotManager $snapshotManager,
|
||||
@@ -51,8 +54,8 @@ class BpnSyncContingentsCommand extends Command
|
||||
{
|
||||
$this
|
||||
->addOption('horizon-months', null, InputOption::VALUE_REQUIRED, 'How many months ahead to sync (defaults to the full horizon)', '24')
|
||||
->addOption('hotel', null, InputOption::VALUE_REQUIRED, 'Restrict the run to a single calendarCode')
|
||||
->addOption('stale-after', null, InputOption::VALUE_REQUIRED, 'Minimum minutes since the last sync before an accommodation is eligible (ignored with --force)', '0')
|
||||
->addOption('hotel', null, InputOption::VALUE_REQUIRED, 'Restrict the run to a single hotel code')
|
||||
->addOption('stale-after', null, InputOption::VALUE_REQUIRED, 'Minimum minutes since the last sync before a hotel is eligible (ignored with --force)', '0')
|
||||
->addOption('force', 'f', InputOption::VALUE_NONE, 'Sync even if the snapshot was refreshed recently')
|
||||
;
|
||||
}
|
||||
@@ -79,10 +82,13 @@ class BpnSyncContingentsCommand extends Command
|
||||
$dateFrom = $today->toDateTimeImmutable();
|
||||
$dateTo = $today->addMonths($horizonMonths)->toDateTimeImmutable();
|
||||
|
||||
$accommodations = $this->resolveAccommodations(is_string($hotelCode) ? $hotelCode : null);
|
||||
$this->hotelDirectory->refresh();
|
||||
// array_keys() turns a numeric code into an int.
|
||||
$allHotelCodes = array_map('strval', array_keys($this->hotelDirectory->all()));
|
||||
$hotelCodes = is_string($hotelCode) ? array_values(array_intersect($allHotelCodes, [$hotelCode])) : $allHotelCodes;
|
||||
|
||||
if ([] === $accommodations) {
|
||||
$io->warning('No accommodation with a calendarCode matched.');
|
||||
if ([] === $hotelCodes) {
|
||||
$io->warning('No accommodation calendarCode or TYPO3 calendar hotel matched.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
@@ -92,19 +98,19 @@ class BpnSyncContingentsCommand extends Command
|
||||
$changed = 0;
|
||||
$failedCodes = [];
|
||||
|
||||
foreach ($accommodations as $accommodation) {
|
||||
if (!$force && $this->isFresh($accommodation, $staleAfterMinutes)) {
|
||||
foreach ($hotelCodes as $code) {
|
||||
if (!$force && $this->isFresh($code, $staleAfterMinutes)) {
|
||||
++$skipped;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->snapshotManager->sync($accommodation, $dateFrom, $dateTo);
|
||||
$result = $this->snapshotManager->sync($code, $dateFrom, $dateTo);
|
||||
++$processed;
|
||||
|
||||
if (!$result->successful) {
|
||||
$failedCodes[] = (string) $accommodation->getCalendarCode();
|
||||
$io->warning(sprintf('%s: %s', (string) $accommodation->getCalendarCode(), (string) $result->error));
|
||||
$failedCodes[] = $code;
|
||||
$io->warning(sprintf('%s: %s', $code, (string) $result->error));
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -115,7 +121,7 @@ class BpnSyncContingentsCommand extends Command
|
||||
|
||||
$io->writeln(sprintf(
|
||||
'<info>%s</info>: %s (+%d ~%d -%d, %d beyond the previous horizon)',
|
||||
(string) $accommodation->getCalendarCode(),
|
||||
$code,
|
||||
$result->changed ? 'changed' : 'unchanged',
|
||||
$result->added,
|
||||
$result->updated,
|
||||
@@ -149,6 +155,21 @@ class BpnSyncContingentsCommand extends Command
|
||||
$deleted = $this->dayRepository->deleteBefore($today->subDay()->toDateTimeImmutable());
|
||||
$io->note(sprintf('Deleted %d outdated snapshot days.', $deleted));
|
||||
|
||||
// Only a full run knows the whole set. A code that left both sources is not served any
|
||||
// more (the availability endpoint answers 400 for it), so its snapshot is dead weight;
|
||||
// should it come back, the next run rebuilds it.
|
||||
if (!is_string($hotelCode)) {
|
||||
$orphanedDays = $this->dayRepository->deleteExceptHotelCodes($allHotelCodes);
|
||||
$orphanedStates = $this->syncStateRepository->deleteExceptHotelCodes($allHotelCodes);
|
||||
|
||||
if ($orphanedStates > 0) {
|
||||
$this->logger->info('Dropped the contingent snapshot of hotel codes that are no longer synced', [
|
||||
'hotels' => $orphanedStates,
|
||||
'days' => $orphanedDays,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Only a *total* failure is worth failing the task over: that means upstream is down or
|
||||
// the API key is rejected, and the scheduler's failure mail is genuinely actionable.
|
||||
// A single hotel breaking must stay quiet — this task runs every 15 minutes during the
|
||||
@@ -158,27 +179,13 @@ class BpnSyncContingentsCommand extends Command
|
||||
return $processed > 0 && $failed === $processed ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Accommodation[]
|
||||
*/
|
||||
private function resolveAccommodations(?string $hotelCode): array
|
||||
{
|
||||
if (null === $hotelCode) {
|
||||
return $this->accommodationRepository->findAllWithCalendarCode();
|
||||
}
|
||||
|
||||
$accommodation = $this->accommodationRepository->findOneByCalendarCode($hotelCode);
|
||||
|
||||
return null !== $accommodation ? [$accommodation] : [];
|
||||
}
|
||||
|
||||
private function isFresh(Accommodation $accommodation, int $staleAfterMinutes): bool
|
||||
private function isFresh(string $hotelCode, int $staleAfterMinutes): bool
|
||||
{
|
||||
if (0 === $staleAfterMinutes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt();
|
||||
$syncedAt = $this->syncStateRepository->findOneByHotelCode($hotelCode)?->getSyncedAt();
|
||||
|
||||
if (null === $syncedAt) {
|
||||
return false;
|
||||
|
||||
@@ -11,7 +11,9 @@ use App\Model\ContingentCalendarQuery;
|
||||
use App\Model\ContingentPricesQuery;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use App\Service\ContingentHotelDirectory;
|
||||
use App\Service\ContingentSnapshotReader;
|
||||
use App\Service\PriceTimelineBuilder;
|
||||
use Carbon\CarbonImmutable;
|
||||
@@ -33,6 +35,8 @@ class ContingentController extends AbstractController
|
||||
private readonly ContingentSnapshotReader $snapshotReader,
|
||||
private readonly PriceTimelineBuilder $priceTimelineBuilder,
|
||||
private readonly AccommodationPriceCoverage $priceCoverage,
|
||||
private readonly ContingentHotelDirectory $hotelDirectory,
|
||||
private readonly ContingentSyncStateRepository $syncStateRepository,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -107,7 +111,7 @@ class ContingentController extends AbstractController
|
||||
// An empty or stale snapshot must not be served as though every day were blocked: that
|
||||
// is a plausible-looking 200 nobody can distinguish from real data. Fail the way this
|
||||
// endpoint always failed instead, so existing consumers need no change.
|
||||
$statuses = $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo);
|
||||
$statuses = $this->snapshotReader->statusesFor($query->hotelCode, $dateFrom, $dateTo);
|
||||
|
||||
if (null === $statuses) {
|
||||
return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY);
|
||||
@@ -137,15 +141,16 @@ class ContingentController extends AbstractController
|
||||
* The snapshot as it is, for the iCal feed: no price rule, and days the snapshot lacks are
|
||||
* left out rather than blocked, so a portal shows them as free like it did with the feed
|
||||
* that read bpn-connect directly.
|
||||
*
|
||||
* Serves every synced hotel code, including TYPO3's calendar hotels that have no
|
||||
* accommodation (winter-only hotels).
|
||||
*/
|
||||
#[Route(path: '/contingents/availability', name: 'api_contingents_availability', methods: ['GET'])]
|
||||
public function availability(
|
||||
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
||||
ContingentCalendarQuery $query,
|
||||
): JsonResponse {
|
||||
$accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]);
|
||||
|
||||
if (null === $accommodation) {
|
||||
if (null === $this->hotelDirectory->find($query->hotelCode)) {
|
||||
$this->logger->error('Hotel not found for hotelcode '.$query->hotelCode);
|
||||
|
||||
return $this->json(['error' => 'Hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST);
|
||||
@@ -157,7 +162,7 @@ class ContingentController extends AbstractController
|
||||
|
||||
// Same reasoning as calendar(): a missing or stale snapshot must not pass for real data.
|
||||
// Here it matters even more, since an empty feed would show every day as free.
|
||||
$statuses = $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo);
|
||||
$statuses = $this->snapshotReader->statusesFor($query->hotelCode, $dateFrom, $dateTo);
|
||||
|
||||
if (null === $statuses) {
|
||||
return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY);
|
||||
@@ -174,6 +179,44 @@ class ContingentController extends AbstractController
|
||||
return $this->json($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per synced hotel code, for the overview of which hotel supports which calendar.
|
||||
*
|
||||
* `available` is whether `availability` currently serves the hotel; calendar and price table
|
||||
* additionally need an accommodation with prices, which `pricedFrom`/`pricedTo` show.
|
||||
*/
|
||||
#[Route(path: '/contingents/status', name: 'api_contingents_status', methods: ['GET'])]
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
$states = $this->syncStateRepository->findAllIndexedByHotelCode();
|
||||
|
||||
$data = [];
|
||||
|
||||
foreach ($this->hotelDirectory->all() as $hotel) {
|
||||
$state = $states[$hotel->code] ?? null;
|
||||
$accommodation = $hotel->accommodation;
|
||||
$bounds = null !== $accommodation ? $this->priceRepository->findPricedDateBoundsByHotelCode($hotel->code) : null;
|
||||
|
||||
$data[] = [
|
||||
'hotelCode' => $hotel->code,
|
||||
'name' => $hotel->calendarHotel?->getName(),
|
||||
'sources' => array_map(static fn ($source) => $source->value, $hotel->sources()),
|
||||
'accommodation' => null !== $accommodation ? [
|
||||
'name' => $accommodation->getName(),
|
||||
'cmsCode' => $accommodation->getEffectiveCmsCode(),
|
||||
] : null,
|
||||
'pricedFrom' => null !== $bounds ? $bounds['from']->format('Y-m-d') : null,
|
||||
'pricedTo' => null !== $bounds ? $bounds['to']->format('Y-m-d') : null,
|
||||
'syncedAt' => $state?->getSyncedAt()?->format(\DateTimeInterface::ATOM),
|
||||
'lastError' => $state?->getLastError(),
|
||||
'failureCount' => $state?->getFailureCount() ?? 0,
|
||||
'available' => $this->snapshotReader->isCurrent($state),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->json($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AccommodationPrice[] $prices
|
||||
* @param array<string, true> $covered
|
||||
|
||||
@@ -240,7 +240,7 @@ class Step1Controller extends AbstractController
|
||||
\DateTimeImmutable $dateTo,
|
||||
): array {
|
||||
$contingentStatus = null !== $accommodation
|
||||
? $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo) ?? []
|
||||
? $this->snapshotReader->statusesFor($hotelCode, $dateFrom, $dateTo) ?? []
|
||||
: [];
|
||||
|
||||
$prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Groups;
|
||||
|
||||
use App\Repository\Groups\CalendarHotelRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* Local copy of the last calendar hotel list TYPO3 answered successfully.
|
||||
*
|
||||
* Kept so the contingent sync still covers TYPO3's hotels while TYPO3 is unreachable. Written
|
||||
* only by {@see \App\Service\ContingentHotelDirectory::refresh()}, never by hand: the list is
|
||||
* TYPO3's, this is not a third place to maintain hotel codes in.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: CalendarHotelRepository::class)]
|
||||
#[ORM\Table(name: 'calendar_hotel')]
|
||||
class CalendarHotel
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 16, unique: true)]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $name = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCode(): ?string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function setCode(string $code): self
|
||||
{
|
||||
$this->code = $code;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): self
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,14 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
/**
|
||||
* Local snapshot of a single day of contingent availability as reported by bpn-connect.
|
||||
*
|
||||
* Keyed by hotel code, not by accommodation: the snapshot also covers TYPO3's calendar hotels,
|
||||
* which have no `Accommodation`.
|
||||
*
|
||||
* Written exclusively by the scheduled sync, never by a user, hence no Blameable/Timestampable.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ContingentDayRepository::class)]
|
||||
#[ORM\Table(name: 'contingent_day')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_contingent_day_accommodation_date', columns: ['accommodation_id', 'date'])]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_contingent_day_hotel_code_date', columns: ['hotel_code', 'date'])]
|
||||
class ContingentDay
|
||||
{
|
||||
#[ORM\Id]
|
||||
@@ -24,8 +27,15 @@ class ContingentDay
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 16)]
|
||||
private ?string $hotelCode = null;
|
||||
|
||||
/**
|
||||
* Superseded by $hotelCode and no longer written. Only rows from before the switch still
|
||||
* carry it; it stays mapped until the column is dropped, so the switch can be rolled back.
|
||||
*/
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Accommodation $accommodation = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
@@ -39,6 +49,18 @@ class ContingentDay
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getHotelCode(): ?string
|
||||
{
|
||||
return $this->hotelCode;
|
||||
}
|
||||
|
||||
public function setHotelCode(string $hotelCode): self
|
||||
{
|
||||
$this->hotelCode = $hotelCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAccommodation(): ?Accommodation
|
||||
{
|
||||
return $this->accommodation;
|
||||
|
||||
@@ -9,7 +9,7 @@ use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* Bookkeeping for the scheduled contingent sync of a single accommodation.
|
||||
* Bookkeeping for the scheduled contingent sync of a single hotel code.
|
||||
*
|
||||
* `changedAt` records when the sync last saw a day appear, vanish or switch status within the
|
||||
* horizon it had already reached; `horizonTo` is how far that reach extends.
|
||||
@@ -23,8 +23,14 @@ class ContingentSyncState
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 16, unique: true)]
|
||||
private ?string $hotelCode = null;
|
||||
|
||||
/**
|
||||
* Superseded by $hotelCode, see {@see ContingentDay::$accommodation}.
|
||||
*/
|
||||
#[ORM\OneToOne]
|
||||
#[ORM\JoinColumn(nullable: false, unique: true, onDelete: 'CASCADE')]
|
||||
#[ORM\JoinColumn(nullable: true, unique: true, onDelete: 'CASCADE')]
|
||||
private ?Accommodation $accommodation = null;
|
||||
|
||||
/**
|
||||
@@ -48,9 +54,9 @@ class ContingentSyncState
|
||||
#[ORM\Column]
|
||||
private int $failureCount = 0;
|
||||
|
||||
public function __construct(?Accommodation $accommodation = null)
|
||||
public function __construct(?string $hotelCode = null)
|
||||
{
|
||||
$this->accommodation = $accommodation;
|
||||
$this->hotelCode = $hotelCode;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -58,6 +64,11 @@ class ContingentSyncState
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getHotelCode(): ?string
|
||||
{
|
||||
return $this->hotelCode;
|
||||
}
|
||||
|
||||
public function getAccommodation(): ?Accommodation
|
||||
{
|
||||
return $this->accommodation;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* Where a hotel code in the contingent snapshot comes from. A code can have both.
|
||||
*/
|
||||
enum ContingentHotelSource: string
|
||||
{
|
||||
/** The calendarCode of a group house (`Accommodation`). */
|
||||
case Accommodation = 'accommodation';
|
||||
|
||||
/** The calendar hotel of a visible TYPO3 product, from TYPO3's `/api/calendar-hotels`. */
|
||||
case Typo3 = 'typo3';
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
/**
|
||||
* One entry of TYPO3's `/api/calendar-hotels`: the calendar hotel of a visible product.
|
||||
*
|
||||
* TYPO3 also lists the products per hotel; only the code and the name are kept here.
|
||||
*/
|
||||
final readonly class CmsCalendarHotel
|
||||
{
|
||||
public function __construct(
|
||||
public string $code,
|
||||
public string $name,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\CalendarHotel;
|
||||
use App\Enum\ContingentHotelSource;
|
||||
|
||||
/**
|
||||
* A hotel code the contingent snapshot is kept for, with whatever it is known as locally.
|
||||
*/
|
||||
final readonly class ContingentHotel
|
||||
{
|
||||
public function __construct(
|
||||
public string $code,
|
||||
public ?Accommodation $accommodation,
|
||||
public ?CalendarHotel $calendarHotel,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ContingentHotelSource>
|
||||
*/
|
||||
public function sources(): array
|
||||
{
|
||||
$sources = [];
|
||||
|
||||
if (null !== $this->accommodation) {
|
||||
$sources[] = ContingentHotelSource::Accommodation;
|
||||
}
|
||||
|
||||
if (null !== $this->calendarHotel) {
|
||||
$sources[] = ContingentHotelSource::Typo3;
|
||||
}
|
||||
|
||||
return $sources;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Groups;
|
||||
|
||||
use App\Entity\Groups\CalendarHotel;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<CalendarHotel>
|
||||
*/
|
||||
class CalendarHotelRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, CalendarHotel::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, CalendarHotel>
|
||||
*/
|
||||
public function findAllIndexedByCode(): array
|
||||
{
|
||||
$indexed = [];
|
||||
|
||||
foreach ($this->findAll() as $hotel) {
|
||||
$indexed[(string) $hotel->getCode()] = $hotel;
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Groups;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentDay;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
@@ -20,9 +19,8 @@ class ContingentDayRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the snapshot days within [dateFrom, dateTo] for the accommodation
|
||||
* identified by the given hotel code, keyed by Y-m-d. A null dateTo means
|
||||
* everything from dateFrom to the end of the snapshot.
|
||||
* Returns the snapshot days of a hotel code within [dateFrom, dateTo], keyed by Y-m-d.
|
||||
* A null dateTo means everything from dateFrom to the end of the snapshot.
|
||||
*
|
||||
* @return array<string, ContingentDay>
|
||||
*/
|
||||
@@ -32,8 +30,7 @@ class ContingentDayRepository extends ServiceEntityRepository
|
||||
?\DateTimeImmutable $dateTo,
|
||||
): array {
|
||||
$qb = $this->createQueryBuilder('cd')
|
||||
->join('cd.accommodation', 'a')
|
||||
->where('a.calendarCode = :hotelCode')
|
||||
->where('cd.hotelCode = :hotelCode')
|
||||
->andWhere('cd.date >= :dateFrom')
|
||||
->setParameter('hotelCode', $hotelCode)
|
||||
->setParameter('dateFrom', $dateFrom)
|
||||
@@ -50,31 +47,6 @@ class ContingentDayRepository extends ServiceEntityRepository
|
||||
return $this->indexByDate($days);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as above but for a known accommodation; used by the sync to diff against.
|
||||
*
|
||||
* @return array<string, ContingentDay>
|
||||
*/
|
||||
public function findByAccommodationAndDateRange(
|
||||
Accommodation $accommodation,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
\DateTimeImmutable $dateTo,
|
||||
): array {
|
||||
/** @var ContingentDay[] $days */
|
||||
$days = $this->createQueryBuilder('cd')
|
||||
->where('cd.accommodation = :accommodation')
|
||||
->andWhere('cd.date >= :dateFrom')
|
||||
->andWhere('cd.date <= :dateTo')
|
||||
->setParameter('accommodation', $accommodation)
|
||||
->setParameter('dateFrom', $dateFrom)
|
||||
->setParameter('dateTo', $dateTo)
|
||||
->orderBy('cd.date', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return $this->indexByDate($days);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retention: drops snapshot days that are in the past and can no longer be requested.
|
||||
*/
|
||||
@@ -88,6 +60,21 @@ class ContingentDayRepository extends ServiceEntityRepository
|
||||
->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the days of every hotel code that is no longer synced.
|
||||
*
|
||||
* @param non-empty-list<string> $hotelCodes the codes that are still synced
|
||||
*/
|
||||
public function deleteExceptHotelCodes(array $hotelCodes): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('cd')
|
||||
->delete()
|
||||
->where('cd.hotelCode NOT IN (:hotelCodes)')
|
||||
->setParameter('hotelCodes', $hotelCodes)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ContingentDay[] $days
|
||||
*
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Groups;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentSyncState;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
@@ -19,30 +18,37 @@ class ContingentSyncStateRepository extends ServiceEntityRepository
|
||||
parent::__construct($registry, ContingentSyncState::class);
|
||||
}
|
||||
|
||||
public function findOneByAccommodation(Accommodation $accommodation): ?ContingentSyncState
|
||||
public function findOneByHotelCode(string $hotelCode): ?ContingentSyncState
|
||||
{
|
||||
return $this->findOneBy(['accommodation' => $accommodation]);
|
||||
return $this->findOneBy(['hotelCode' => $hotelCode]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every sync state keyed by accommodation id, for the revisions endpoint.
|
||||
*
|
||||
* @return array<int, ContingentSyncState>
|
||||
* @return array<string, ContingentSyncState>
|
||||
*/
|
||||
public function findAllIndexedByAccommodationId(): array
|
||||
public function findAllIndexedByHotelCode(): array
|
||||
{
|
||||
$indexed = [];
|
||||
|
||||
foreach ($this->findAll() as $state) {
|
||||
$accommodation = $state->getAccommodation();
|
||||
|
||||
if (null === $accommodation || null === $accommodation->getId()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$indexed[$accommodation->getId()] = $state;
|
||||
$indexed[(string) $state->getHotelCode()] = $state;
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the sync state of every hotel code that is no longer synced.
|
||||
*
|
||||
* @param non-empty-list<string> $hotelCodes the codes that are still synced
|
||||
*/
|
||||
public function deleteExceptHotelCodes(array $hotelCodes): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('s')
|
||||
->delete()
|
||||
->where('s.hotelCode NOT IN (:hotelCodes)')
|
||||
->setParameter('hotelCodes', $hotelCodes)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Model\CmsCalendarHotel;
|
||||
use App\Model\CmsHotelData;
|
||||
use App\Model\CmsRegionData;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
@@ -169,6 +170,40 @@ class CmsDataProvider
|
||||
return is_numeric($value) ? (float) $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar hotels of TYPO3's visible products, or null when TYPO3 did not answer with a
|
||||
* usable list. Uncached: the only caller keeps its own copy of the last good answer.
|
||||
*
|
||||
* @return list<CmsCalendarHotel>|null
|
||||
*/
|
||||
public function fetchCalendarHotels(): ?array
|
||||
{
|
||||
try {
|
||||
$data = $this->httpClient->request('GET', 'api/calendar-hotels')->toArray();
|
||||
} catch (ExceptionInterface) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!array_is_list($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hotels = [];
|
||||
|
||||
foreach ($data as $entry) {
|
||||
$code = $entry['code'] ?? null;
|
||||
|
||||
if (!is_string($code) || '' === $code) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $entry['name'] ?? null;
|
||||
$hotels[] = new CmsCalendarHotel($code, is_string($name) && '' !== $name ? $name : $code);
|
||||
}
|
||||
|
||||
return $hotels;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function getProductDetails(string $productCode, ?string $hotelCode = null): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Groups\CalendarHotel;
|
||||
use App\Model\ContingentHotel;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Repository\Groups\CalendarHotelRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* The hotel codes the contingent snapshot is kept for: every accommodation's calendarCode plus
|
||||
* the calendar hotels of TYPO3's visible products.
|
||||
*
|
||||
* Neither source is maintained here. Accommodations are the Groups admin's, the TYPO3 list is
|
||||
* TYPO3's; `calendar_hotel` only holds the last list TYPO3 answered with, so an unreachable
|
||||
* TYPO3 does not make its hotels drop out of the sync.
|
||||
*/
|
||||
class ContingentHotelDirectory
|
||||
{
|
||||
// Same rule as the hotelCode query parameter of the contingent API: a code nobody can ask
|
||||
// for is not worth syncing, and it would not fit the column either.
|
||||
private const string CODE_PATTERN = '/^[A-Za-z0-9_-]{1,16}$/';
|
||||
|
||||
public function __construct(
|
||||
private readonly CmsDataProvider $cmsDataProvider,
|
||||
private readonly AccommodationRepository $accommodationRepository,
|
||||
private readonly CalendarHotelRepository $calendarHotelRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the stored TYPO3 list with the one TYPO3 serves now.
|
||||
*
|
||||
* An error or an empty list keeps the stored one: TYPO3 without a single calendar hotel is
|
||||
* a fault on its side, not every winter hotel being withdrawn at once.
|
||||
*/
|
||||
public function refresh(): bool
|
||||
{
|
||||
$fetched = [];
|
||||
|
||||
foreach ($this->cmsDataProvider->fetchCalendarHotels() ?? [] as $hotel) {
|
||||
if (1 !== preg_match(self::CODE_PATTERN, $hotel->code)) {
|
||||
$this->logger->warning('Ignoring TYPO3 calendar hotel with an unusable code', ['hotelCode' => $hotel->code]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$fetched[$hotel->code] = $hotel;
|
||||
}
|
||||
|
||||
if ([] === $fetched) {
|
||||
$this->logger->warning('TYPO3 calendar hotels unavailable, keeping the stored list');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$stored = $this->calendarHotelRepository->findAllIndexedByCode();
|
||||
|
||||
foreach ($fetched as $code => $hotel) {
|
||||
$calendarHotel = $stored[$code] ?? null;
|
||||
|
||||
if (null === $calendarHotel) {
|
||||
$this->entityManager->persist((new CalendarHotel())->setCode($code)->setName($hotel->name));
|
||||
} elseif ($calendarHotel->getName() !== $hotel->name) {
|
||||
$calendarHotel->setName($hotel->name);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_diff_key($stored, $fetched) as $calendarHotel) {
|
||||
$this->entityManager->remove($calendarHotel);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ContingentHotel> keyed and sorted by hotel code
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
$accommodations = [];
|
||||
|
||||
foreach ($this->accommodationRepository->findAllWithCalendarCode() as $accommodation) {
|
||||
$accommodations[(string) $accommodation->getCalendarCode()] = $accommodation;
|
||||
}
|
||||
|
||||
$calendarHotels = $this->calendarHotelRepository->findAllIndexedByCode();
|
||||
|
||||
$hotels = [];
|
||||
|
||||
foreach (array_keys($accommodations + $calendarHotels) as $code) {
|
||||
$code = (string) $code;
|
||||
$hotels[$code] = new ContingentHotel($code, $accommodations[$code] ?? null, $calendarHotels[$code] ?? null);
|
||||
}
|
||||
|
||||
ksort($hotels, \SORT_STRING);
|
||||
|
||||
return $hotels;
|
||||
}
|
||||
|
||||
public function find(string $hotelCode): ?ContingentHotel
|
||||
{
|
||||
if ('' === $hotelCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$accommodation = $this->accommodationRepository->findOneByCalendarCode($hotelCode);
|
||||
$calendarHotel = $this->calendarHotelRepository->findOneBy(['code' => $hotelCode]);
|
||||
|
||||
if (null === $accommodation && null === $calendarHotel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ContingentHotel($hotelCode, $accommodation, $calendarHotel);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ namespace App\Service;
|
||||
|
||||
use App\BpnConnect\ContingentsClient;
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentDay;
|
||||
use App\Entity\Groups\ContingentSyncState;
|
||||
use App\Model\ContingentSyncResult;
|
||||
@@ -34,24 +33,22 @@ class ContingentSnapshotManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes [dateFrom, dateTo] for a single accommodation.
|
||||
* Refreshes [dateFrom, dateTo] for a single hotel code.
|
||||
*
|
||||
* Days are fetched, diffed against what is stored and then persisted; the snapshot outside
|
||||
* the requested window is left untouched, so a narrow ad-hoc run cannot truncate what a
|
||||
* full-horizon run already stored.
|
||||
*/
|
||||
public function sync(
|
||||
Accommodation $accommodation,
|
||||
string $hotelCode,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
\DateTimeImmutable $dateTo,
|
||||
): ContingentSyncResult {
|
||||
$hotelCode = $accommodation->getCalendarCode();
|
||||
|
||||
if (null === $hotelCode || '' === $hotelCode) {
|
||||
return ContingentSyncResult::failed('Accommodation has no calendarCode.');
|
||||
if ('' === $hotelCode) {
|
||||
return ContingentSyncResult::failed('No hotel code given.');
|
||||
}
|
||||
|
||||
$state = $this->syncStateRepository->findOneByAccommodation($accommodation) ?? new ContingentSyncState($accommodation);
|
||||
$state = $this->syncStateRepository->findOneByHotelCode($hotelCode) ?? new ContingentSyncState($hotelCode);
|
||||
|
||||
// Read before recordSuccess() moves it: days beyond the horizon the previous run reached
|
||||
// are the window growing, not the contingent situation changing.
|
||||
@@ -67,7 +64,7 @@ class ContingentSnapshotManager
|
||||
return $this->recordFailure($state, $hotelCode, $e->getMessage());
|
||||
}
|
||||
|
||||
$existing = $this->dayRepository->findByAccommodationAndDateRange($accommodation, $dateFrom, $dateTo);
|
||||
$existing = $this->dayRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
|
||||
|
||||
// An empty upstream response would otherwise wipe the window and render the whole
|
||||
// period as BLOCKED. Treat it as an upstream problem and keep the last good snapshot.
|
||||
@@ -103,7 +100,7 @@ class ContingentSnapshotManager
|
||||
|
||||
if (null === $day) {
|
||||
$day = (new ContingentDay())
|
||||
->setAccommodation($accommodation)
|
||||
->setHotelCode($hotelCode)
|
||||
->setDate($date);
|
||||
|
||||
$this->entityManager->persist($day);
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\BpnConnect\Model\ContingentStatus;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentSyncState;
|
||||
use App\Repository\Groups\ContingentDayRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use Carbon\CarbonImmutable;
|
||||
@@ -46,17 +46,15 @@ class ContingentSnapshotReader
|
||||
* @return array<string, ContingentStatus>|null
|
||||
*/
|
||||
public function statusesFor(
|
||||
Accommodation $accommodation,
|
||||
string $hotelCode,
|
||||
\DateTimeImmutable $dateFrom,
|
||||
?\DateTimeImmutable $dateTo,
|
||||
): ?array {
|
||||
$hotelCode = $accommodation->getCalendarCode();
|
||||
|
||||
if (null === $hotelCode || '' === $hotelCode) {
|
||||
if ('' === $hotelCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->isUsable($accommodation, $hotelCode)) {
|
||||
if (!$this->isUsable($hotelCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -69,25 +67,36 @@ class ContingentSnapshotReader
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
private function isUsable(Accommodation $accommodation, string $hotelCode): bool
|
||||
/**
|
||||
* Whether a hotel with this sync state gets statuses from {@see self::statusesFor()}, without
|
||||
* the logging: for overviews that ask about every hotel at once.
|
||||
*/
|
||||
public function isCurrent(?ContingentSyncState $state): bool
|
||||
{
|
||||
$syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt();
|
||||
$syncedAt = $state?->getSyncedAt();
|
||||
|
||||
return null !== $syncedAt && $syncedAt >= CarbonImmutable::now()->modify(self::MAX_SNAPSHOT_AGE);
|
||||
}
|
||||
|
||||
private function isUsable(string $hotelCode): bool
|
||||
{
|
||||
$state = $this->syncStateRepository->findOneByHotelCode($hotelCode);
|
||||
|
||||
if ($this->isCurrent($state)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$syncedAt = $state?->getSyncedAt();
|
||||
|
||||
if (null === $syncedAt) {
|
||||
$this->logger->error('No contingent snapshot has been synced yet', ['hotelCode' => $hotelCode]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($syncedAt < CarbonImmutable::now()->modify(self::MAX_SNAPSHOT_AGE)) {
|
||||
} else {
|
||||
$this->logger->error('Contingent snapshot is stale', [
|
||||
'hotelCode' => $hotelCode,
|
||||
'syncedAt' => $syncedAt->format(\DateTimeInterface::ATOM),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@ namespace App\Tests\Command;
|
||||
|
||||
use App\Command\BpnSyncContingentsCommand;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\CalendarHotel;
|
||||
use App\Model\ContingentHotel;
|
||||
use App\Model\ContingentSyncResult;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Repository\Groups\ContingentDayRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use App\Service\ContingentHotelDirectory;
|
||||
use App\Service\ContingentSnapshotManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
@@ -53,36 +55,113 @@ class BpnSyncContingentsCommandTest extends TestCase
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testARunWithNoAccommodationsSucceeds(): void
|
||||
public function testARunWithNoHotelsSucceeds(): void
|
||||
{
|
||||
$tester = $this->runSync([], []);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $hotelCodes
|
||||
* @param ContingentSyncResult[] $results
|
||||
*/
|
||||
private function runSync(array $hotelCodes, array $results): CommandTester
|
||||
public function testSyncsAccommodationAndTypo3CodesAndDropsTheRest(): void
|
||||
{
|
||||
$accommodations = array_map(
|
||||
static fn (string $code) => (new Accommodation())->setCalendarCode($code),
|
||||
$hotelCodes,
|
||||
$manager = $this->createMock(ContingentSnapshotManager::class);
|
||||
$synced = [];
|
||||
$manager->expects(self::exactly(2))->method('sync')
|
||||
->willReturnCallback(static function (string $code) use (&$synced): ContingentSyncResult {
|
||||
$synced[] = $code;
|
||||
|
||||
return ContingentSyncResult::synced(false, 0, 0, 0, 0);
|
||||
});
|
||||
|
||||
$dayRepository = $this->createMock(ContingentDayRepository::class);
|
||||
$dayRepository->expects(self::once())->method('deleteExceptHotelCodes')->with(['DGS', 'DKI']);
|
||||
$syncStateRepository = $this->createMock(ContingentSyncStateRepository::class);
|
||||
$syncStateRepository->expects(self::once())->method('deleteExceptHotelCodes')->with(['DGS', 'DKI']);
|
||||
|
||||
$this->runSync(
|
||||
['DGS' => 'accommodation', 'DKI' => 'typo3'],
|
||||
manager: $manager,
|
||||
dayRepository: $dayRepository,
|
||||
syncStateRepository: $syncStateRepository,
|
||||
);
|
||||
|
||||
$accommodationRepository = $this->createStub(AccommodationRepository::class);
|
||||
$accommodationRepository->method('findAllWithCalendarCode')->willReturn($accommodations);
|
||||
self::assertSame(['DGS', 'DKI'], $synced);
|
||||
}
|
||||
|
||||
$manager = $this->createStub(ContingentSnapshotManager::class);
|
||||
if ([] !== $results) {
|
||||
$manager->method('sync')->willReturnOnConsecutiveCalls(...$results);
|
||||
public function testASingleHotelRunAcceptsATypo3OnlyCodeAndDropsNothing(): void
|
||||
{
|
||||
$manager = $this->createMock(ContingentSnapshotManager::class);
|
||||
$manager->expects(self::once())->method('sync')
|
||||
->with('DKI', self::anything(), self::anything())
|
||||
->willReturn(ContingentSyncResult::synced(false, 0, 0, 0, 0));
|
||||
|
||||
$dayRepository = $this->createMock(ContingentDayRepository::class);
|
||||
$dayRepository->expects(self::never())->method('deleteExceptHotelCodes');
|
||||
$syncStateRepository = $this->createMock(ContingentSyncStateRepository::class);
|
||||
$syncStateRepository->expects(self::never())->method('deleteExceptHotelCodes');
|
||||
|
||||
$tester = $this->runSync(
|
||||
['DGS' => 'accommodation', 'DKI' => 'typo3'],
|
||||
manager: $manager,
|
||||
dayRepository: $dayRepository,
|
||||
syncStateRepository: $syncStateRepository,
|
||||
options: ['--hotel' => 'DKI'],
|
||||
);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testASingleHotelRunIgnoresAnUnknownCode(): void
|
||||
{
|
||||
$manager = $this->createMock(ContingentSnapshotManager::class);
|
||||
$manager->expects(self::never())->method('sync');
|
||||
|
||||
$tester = $this->runSync(['DGS' => 'accommodation'], manager: $manager, options: ['--hotel' => 'NOPE']);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
self::assertStringContainsString('No accommodation calendarCode or TYPO3 calendar hotel matched', $tester->getDisplay());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, string> $hotels codes, or code => 'accommodation'|'typo3'
|
||||
* @param ContingentSyncResult[] $results
|
||||
* @param array<string, string> $options
|
||||
*/
|
||||
private function runSync(
|
||||
array $hotels,
|
||||
array $results = [],
|
||||
?ContingentSnapshotManager $manager = null,
|
||||
?ContingentDayRepository $dayRepository = null,
|
||||
?ContingentSyncStateRepository $syncStateRepository = null,
|
||||
array $options = [],
|
||||
): CommandTester {
|
||||
if (array_is_list($hotels)) {
|
||||
$hotels = array_fill_keys($hotels, 'accommodation');
|
||||
}
|
||||
|
||||
$directoryEntries = [];
|
||||
|
||||
foreach ($hotels as $code => $source) {
|
||||
$code = (string) $code;
|
||||
$directoryEntries[$code] = 'typo3' === $source
|
||||
? new ContingentHotel($code, null, (new CalendarHotel())->setCode($code)->setName($code))
|
||||
: new ContingentHotel($code, (new Accommodation())->setCalendarCode($code), null);
|
||||
}
|
||||
|
||||
$hotelDirectory = $this->createStub(ContingentHotelDirectory::class);
|
||||
$hotelDirectory->method('all')->willReturn($directoryEntries);
|
||||
|
||||
if (null === $manager) {
|
||||
$manager = $this->createStub(ContingentSnapshotManager::class);
|
||||
if ([] !== $results) {
|
||||
$manager->method('sync')->willReturnOnConsecutiveCalls(...$results);
|
||||
}
|
||||
}
|
||||
|
||||
$command = new BpnSyncContingentsCommand(
|
||||
$accommodationRepository,
|
||||
$this->createStub(ContingentSyncStateRepository::class),
|
||||
$this->createStub(ContingentDayRepository::class),
|
||||
$hotelDirectory,
|
||||
$syncStateRepository ?? $this->createStub(ContingentSyncStateRepository::class),
|
||||
$dayRepository ?? $this->createStub(ContingentDayRepository::class),
|
||||
$manager,
|
||||
new NullLogger(),
|
||||
);
|
||||
@@ -91,7 +170,7 @@ class BpnSyncContingentsCommandTest extends TestCase
|
||||
$application->addCommand($command);
|
||||
|
||||
$tester = new CommandTester($application->find('app:bpn:sync-contingents'));
|
||||
$tester->execute(['--horizon-months' => '1']);
|
||||
$tester->execute(['--horizon-months' => '1'] + $options);
|
||||
|
||||
// LockableTrait's lock lives for the life of the process, so without releasing it every
|
||||
// run after the first would exit early and these assertions would pass for the wrong reason.
|
||||
|
||||
@@ -8,10 +8,16 @@ use App\BpnConnect\Model\ContingentStatus;
|
||||
use App\Controller\Api\ContingentController;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Entity\Groups\CalendarHotel;
|
||||
use App\Entity\Groups\ContingentSyncState;
|
||||
use App\Model\ContingentCalendarQuery;
|
||||
use App\Model\ContingentHotel;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Repository\Groups\ContingentDayRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use App\Service\ContingentHotelDirectory;
|
||||
use App\Service\ContingentSnapshotReader;
|
||||
use App\Service\PriceTimelineBuilder;
|
||||
use Carbon\CarbonImmutable;
|
||||
@@ -216,15 +222,114 @@ class ContingentControllerTest extends TestCase
|
||||
|
||||
public function testAvailabilityRejectsAnUnknownHotel(): void
|
||||
{
|
||||
$accommodationRepository = $this->createStub(AccommodationRepository::class);
|
||||
$accommodationRepository->method('findOneBy')->willReturn(null);
|
||||
$hotelDirectory = $this->createStub(ContingentHotelDirectory::class);
|
||||
$hotelDirectory->method('find')->willReturn(null);
|
||||
|
||||
$response = $this->callAvailability([], accommodationRepository: $accommodationRepository);
|
||||
$response = $this->callAvailability([], hotelDirectory: $hotelDirectory);
|
||||
|
||||
self::assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
|
||||
self::assertSame('{"error":"Hotel not found for hotelCode."}', $response->getContent());
|
||||
}
|
||||
|
||||
public function testAvailabilityServesATypo3HotelWithoutAnAccommodation(): void
|
||||
{
|
||||
$hotelDirectory = $this->createStub(ContingentHotelDirectory::class);
|
||||
$hotelDirectory->method('find')->willReturn(new ContingentHotel('DKI', null, (new CalendarHotel())->setCode('DKI')->setName('Sportclub Schwendi')));
|
||||
|
||||
$snapshotReader = $this->createMock(ContingentSnapshotReader::class);
|
||||
$snapshotReader->expects(self::once())
|
||||
->method('statusesFor')
|
||||
->with('DKI', self::anything(), self::anything())
|
||||
->willReturn(['2026-09-01' => ContingentStatus::Ok]);
|
||||
|
||||
$response = $this->callAvailability(null, new ContingentCalendarQuery('DKI', '2026-09-01', '2026-09-03'), $snapshotReader, $hotelDirectory);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('[{"date":"2026-09-01","status":"OK"}]', $response->getContent());
|
||||
}
|
||||
|
||||
public function testStatusListsEverySyncedHotel(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow('2026-10-29 10:30:00');
|
||||
|
||||
$accommodation = (new Accommodation())->setName('Gruppenhaus Spinabad')->setCalendarCode('DGS')->setCmsCode('SERDGS');
|
||||
$hotelDirectory = $this->createStub(ContingentHotelDirectory::class);
|
||||
$hotelDirectory->method('all')->willReturn([
|
||||
'DGS' => new ContingentHotel('DGS', $accommodation, (new CalendarHotel())->setCode('DGS')->setName('Sportclub Spinabad')),
|
||||
'DKI' => new ContingentHotel('DKI', null, (new CalendarHotel())->setCode('DKI')->setName('Sportclub Schwendi')),
|
||||
'DWW' => new ContingentHotel('DWW', null, (new CalendarHotel())->setCode('DWW')->setName('Sportclub Weissfluh')),
|
||||
]);
|
||||
|
||||
$syncStateRepository = $this->createStub(ContingentSyncStateRepository::class);
|
||||
$syncStateRepository->method('findAllIndexedByHotelCode')->willReturn([
|
||||
'DGS' => (new ContingentSyncState('DGS'))->recordSuccess(new \DateTimeImmutable('2026-10-29 10:15:00+01:00'), new \DateTimeImmutable('2028-10-29')),
|
||||
'DKI' => (new ContingentSyncState('DKI'))->recordFailure('unknown hotel code'),
|
||||
]);
|
||||
|
||||
$priceRepository = $this->createMock(AccommodationPriceRepository::class);
|
||||
$priceRepository->expects(self::once())
|
||||
->method('findPricedDateBoundsByHotelCode')
|
||||
->with('DGS')
|
||||
->willReturn(['from' => new \DateTimeImmutable('2027-06-01'), 'to' => new \DateTimeImmutable('2027-09-30')]);
|
||||
|
||||
$controller = new ContingentController(
|
||||
$this->createStub(AccommodationRepository::class),
|
||||
$priceRepository,
|
||||
new ContingentSnapshotReader(
|
||||
$this->createStub(ContingentDayRepository::class),
|
||||
$syncStateRepository,
|
||||
$this->createStub(LoggerInterface::class),
|
||||
),
|
||||
new PriceTimelineBuilder(),
|
||||
new AccommodationPriceCoverage($this->createStub(AccommodationPriceRepository::class)),
|
||||
$hotelDirectory,
|
||||
$syncStateRepository,
|
||||
$this->createStub(LoggerInterface::class),
|
||||
);
|
||||
$controller->setContainer(new Container());
|
||||
|
||||
$data = json_decode((string) $controller->status()->getContent(), true);
|
||||
|
||||
self::assertSame([
|
||||
[
|
||||
'hotelCode' => 'DGS',
|
||||
'name' => 'Sportclub Spinabad',
|
||||
'sources' => ['accommodation', 'typo3'],
|
||||
'accommodation' => ['name' => 'Gruppenhaus Spinabad', 'cmsCode' => 'SERDGS'],
|
||||
'pricedFrom' => '2027-06-01',
|
||||
'pricedTo' => '2027-09-30',
|
||||
'syncedAt' => '2026-10-29T10:15:00+01:00',
|
||||
'lastError' => null,
|
||||
'failureCount' => 0,
|
||||
'available' => true,
|
||||
],
|
||||
[
|
||||
'hotelCode' => 'DKI',
|
||||
'name' => 'Sportclub Schwendi',
|
||||
'sources' => ['typo3'],
|
||||
'accommodation' => null,
|
||||
'pricedFrom' => null,
|
||||
'pricedTo' => null,
|
||||
'syncedAt' => null,
|
||||
'lastError' => 'unknown hotel code',
|
||||
'failureCount' => 1,
|
||||
'available' => false,
|
||||
],
|
||||
[
|
||||
'hotelCode' => 'DWW',
|
||||
'name' => 'Sportclub Weissfluh',
|
||||
'sources' => ['typo3'],
|
||||
'accommodation' => null,
|
||||
'pricedFrom' => null,
|
||||
'pricedTo' => null,
|
||||
'syncedAt' => null,
|
||||
'lastError' => null,
|
||||
'failureCount' => 0,
|
||||
'available' => false,
|
||||
],
|
||||
], $data);
|
||||
}
|
||||
|
||||
public function testAvailabilityWithoutARangeReadsFromTodayToTheEndOfTheSnapshot(): void
|
||||
{
|
||||
CarbonImmutable::setTestNow('2026-10-29 09:00:00');
|
||||
@@ -263,13 +368,17 @@ class ContingentControllerTest extends TestCase
|
||||
?array $statuses,
|
||||
?ContingentCalendarQuery $query = null,
|
||||
?ContingentSnapshotReader $snapshotReader = null,
|
||||
?AccommodationRepository $accommodationRepository = null,
|
||||
?ContingentHotelDirectory $hotelDirectory = null,
|
||||
): Response {
|
||||
if (null === $accommodationRepository) {
|
||||
$accommodationRepository = $this->createStub(AccommodationRepository::class);
|
||||
$accommodationRepository->method('findOneBy')->willReturn((new Accommodation())->setCalendarCode('HOTEL1')->setCurrency('EUR'));
|
||||
if (null === $hotelDirectory) {
|
||||
$hotelDirectory = $this->createStub(ContingentHotelDirectory::class);
|
||||
$hotelDirectory->method('find')->willReturn(new ContingentHotel('HOTEL1', (new Accommodation())->setCalendarCode('HOTEL1'), null));
|
||||
}
|
||||
|
||||
// The hotel is looked up in the directory: an accommodation is not required here.
|
||||
$accommodationRepository = $this->createMock(AccommodationRepository::class);
|
||||
$accommodationRepository->expects(self::never())->method(self::anything());
|
||||
|
||||
if (null === $snapshotReader) {
|
||||
$snapshotReader = $this->createStub(ContingentSnapshotReader::class);
|
||||
$snapshotReader->method('statusesFor')->willReturn($statuses);
|
||||
@@ -285,6 +394,8 @@ class ContingentControllerTest extends TestCase
|
||||
$snapshotReader,
|
||||
new PriceTimelineBuilder(),
|
||||
new AccommodationPriceCoverage($this->createStub(AccommodationPriceRepository::class)),
|
||||
$hotelDirectory,
|
||||
$this->createStub(ContingentSyncStateRepository::class),
|
||||
$this->createStub(LoggerInterface::class),
|
||||
);
|
||||
$controller->setContainer(new Container());
|
||||
@@ -343,6 +454,8 @@ class ContingentControllerTest extends TestCase
|
||||
$snapshotReader,
|
||||
new PriceTimelineBuilder(),
|
||||
new AccommodationPriceCoverage($this->createStub(AccommodationPriceRepository::class)),
|
||||
$this->createStub(ContingentHotelDirectory::class),
|
||||
$this->createStub(ContingentSyncStateRepository::class),
|
||||
$this->createStub(LoggerInterface::class),
|
||||
);
|
||||
|
||||
@@ -361,6 +474,8 @@ class ContingentControllerTest extends TestCase
|
||||
$this->createStub(ContingentSnapshotReader::class),
|
||||
new PriceTimelineBuilder(),
|
||||
new AccommodationPriceCoverage($this->createStub(AccommodationPriceRepository::class)),
|
||||
$this->createStub(ContingentHotelDirectory::class),
|
||||
$this->createStub(ContingentSyncStateRepository::class),
|
||||
$this->createStub(LoggerInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,4 +79,40 @@ class CmsDataProviderTest extends TestCase
|
||||
|
||||
self::assertNull($provider->getHotelDetails('UNKNOWN'));
|
||||
}
|
||||
|
||||
public function testFetchCalendarHotelsKeepsCodeAndName(): void
|
||||
{
|
||||
$client = new MockHttpClient(function (string $method, string $url) {
|
||||
self::assertSame('https://cms.test/api/calendar-hotels', $url);
|
||||
|
||||
return new MockResponse(json_encode([
|
||||
['code' => 'ASCGR', 'name' => 'Ahrntal - Gruppenhaus Christiler', 'products' => [['uid' => 710, 'name' => 'Gruppenhaus Christiler', 'season' => 'w']]],
|
||||
['code' => 'DKI', 'name' => '', 'products' => []],
|
||||
['code' => '', 'name' => 'No code'],
|
||||
['name' => 'Missing code'],
|
||||
], JSON_THROW_ON_ERROR));
|
||||
}, 'https://cms.test/');
|
||||
|
||||
$hotels = (new CmsDataProvider($client, new ArrayAdapter()))->fetchCalendarHotels();
|
||||
|
||||
self::assertNotNull($hotels);
|
||||
self::assertSame([['ASCGR', 'Ahrntal - Gruppenhaus Christiler'], ['DKI', 'DKI']], array_map(
|
||||
static fn ($hotel) => [$hotel->code, $hotel->name],
|
||||
$hotels,
|
||||
));
|
||||
}
|
||||
|
||||
public function testFetchCalendarHotelsIsNullOnAnErrorResponse(): void
|
||||
{
|
||||
$client = new MockHttpClient(new MockResponse('Forbidden', ['http_code' => 403]), 'https://cms.test/');
|
||||
|
||||
self::assertNull((new CmsDataProvider($client, new ArrayAdapter()))->fetchCalendarHotels());
|
||||
}
|
||||
|
||||
public function testFetchCalendarHotelsIsNullWhenTheBodyIsNotAList(): void
|
||||
{
|
||||
$client = new MockHttpClient(new MockResponse('{"success":false}'), 'https://cms.test/');
|
||||
|
||||
self::assertNull((new CmsDataProvider($client, new ArrayAdapter()))->fetchCalendarHotels());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\CalendarHotel;
|
||||
use App\Enum\ContingentHotelSource;
|
||||
use App\Model\CmsCalendarHotel;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Repository\Groups\CalendarHotelRepository;
|
||||
use App\Service\CmsDataProvider;
|
||||
use App\Service\ContingentHotelDirectory;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\MockObject\Stub;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class ContingentHotelDirectoryTest extends TestCase
|
||||
{
|
||||
private CmsDataProvider&Stub $cmsDataProvider;
|
||||
private AccommodationRepository&Stub $accommodationRepository;
|
||||
private CalendarHotelRepository&Stub $calendarHotelRepository;
|
||||
private EntityManagerInterface&Stub $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->cmsDataProvider = $this->createStub(CmsDataProvider::class);
|
||||
$this->accommodationRepository = $this->createStub(AccommodationRepository::class);
|
||||
$this->calendarHotelRepository = $this->createStub(CalendarHotelRepository::class);
|
||||
$this->entityManager = $this->createStub(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
public function testRefreshAddsRenamesAndRemovesStoredHotels(): void
|
||||
{
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
|
||||
$renamed = (new CalendarHotel())->setCode('DKI')->setName('Schwendi');
|
||||
$dropped = (new CalendarHotel())->setCode('ASB')->setName('Sportclub Bader');
|
||||
$this->calendarHotelRepository->method('findAllIndexedByCode')->willReturn(['DKI' => $renamed, 'ASB' => $dropped]);
|
||||
$this->cmsDataProvider->method('fetchCalendarHotels')->willReturn([
|
||||
new CmsCalendarHotel('DKI', 'Sportclub Schwendi'),
|
||||
new CmsCalendarHotel('DWW', 'Sportclub Weissfluh'),
|
||||
]);
|
||||
|
||||
$this->entityManager->expects(self::once())->method('persist')
|
||||
->with(self::callback(static fn (CalendarHotel $hotel) => 'DWW' === $hotel->getCode() && 'Sportclub Weissfluh' === $hotel->getName()));
|
||||
$this->entityManager->expects(self::once())->method('remove')->with($dropped);
|
||||
$this->entityManager->expects(self::once())->method('flush');
|
||||
|
||||
self::assertTrue($this->directory()->refresh());
|
||||
self::assertSame('Sportclub Schwendi', $renamed->getName());
|
||||
}
|
||||
|
||||
public function testRefreshKeepsTheStoredListWhenTypo3IsUnavailable(): void
|
||||
{
|
||||
$this->assertKeepsStoredList(null);
|
||||
}
|
||||
|
||||
public function testRefreshKeepsTheStoredListWhenTypo3ReturnsNoHotels(): void
|
||||
{
|
||||
$this->assertKeepsStoredList([]);
|
||||
}
|
||||
|
||||
public function testRefreshIgnoresCodesTheApiCouldNotBeAskedFor(): void
|
||||
{
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
|
||||
$this->calendarHotelRepository->method('findAllIndexedByCode')->willReturn([]);
|
||||
$this->cmsDataProvider->method('fetchCalendarHotels')->willReturn([
|
||||
new CmsCalendarHotel('DKI', 'Sportclub Schwendi'),
|
||||
new CmsCalendarHotel('ABCDEFGHIJKLMNOPQ', 'Too long'),
|
||||
new CmsCalendarHotel('A B', 'Space'),
|
||||
]);
|
||||
|
||||
$this->entityManager->expects(self::once())->method('persist')
|
||||
->with(self::callback(static fn (CalendarHotel $hotel) => 'DKI' === $hotel->getCode()));
|
||||
|
||||
$this->directory()->refresh();
|
||||
}
|
||||
|
||||
public function testAllIsTheUnionOfAccommodationsAndTypo3Hotels(): void
|
||||
{
|
||||
$this->accommodationRepository->method('findAllWithCalendarCode')->willReturn([
|
||||
(new Accommodation())->setCalendarCode('DGS'),
|
||||
(new Accommodation())->setCalendarCode('SERASCSO'),
|
||||
]);
|
||||
$this->calendarHotelRepository->method('findAllIndexedByCode')->willReturn([
|
||||
'ASCGR' => (new CalendarHotel())->setCode('ASCGR')->setName('Christiler'),
|
||||
'DGS' => (new CalendarHotel())->setCode('DGS')->setName('Spinabad'),
|
||||
]);
|
||||
|
||||
$hotels = $this->directory()->all();
|
||||
|
||||
self::assertSame(['ASCGR', 'DGS', 'SERASCSO'], array_keys($hotels));
|
||||
self::assertSame([ContingentHotelSource::Typo3], $hotels['ASCGR']->sources());
|
||||
self::assertSame([ContingentHotelSource::Accommodation, ContingentHotelSource::Typo3], $hotels['DGS']->sources());
|
||||
self::assertSame([ContingentHotelSource::Accommodation], $hotels['SERASCSO']->sources());
|
||||
}
|
||||
|
||||
public function testFindKnowsATypo3OnlyHotel(): void
|
||||
{
|
||||
$this->accommodationRepository->method('findOneByCalendarCode')->willReturn(null);
|
||||
$this->calendarHotelRepository->method('findOneBy')->willReturn((new CalendarHotel())->setCode('DKI')->setName('Schwendi'));
|
||||
|
||||
self::assertSame('DKI', $this->directory()->find('DKI')?->code);
|
||||
}
|
||||
|
||||
public function testFindReturnsNullForAnUnknownCode(): void
|
||||
{
|
||||
$this->accommodationRepository->method('findOneByCalendarCode')->willReturn(null);
|
||||
$this->calendarHotelRepository->method('findOneBy')->willReturn(null);
|
||||
|
||||
self::assertNull($this->directory()->find('NOPE'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<CmsCalendarHotel>|null $fetched
|
||||
*/
|
||||
private function assertKeepsStoredList(?array $fetched): void
|
||||
{
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->calendarHotelRepository->method('findAllIndexedByCode')->willReturn([
|
||||
'DKI' => (new CalendarHotel())->setCode('DKI')->setName('Sportclub Schwendi'),
|
||||
]);
|
||||
$this->cmsDataProvider->method('fetchCalendarHotels')->willReturn($fetched);
|
||||
|
||||
$this->entityManager->expects(self::never())->method('remove');
|
||||
$this->entityManager->expects(self::never())->method('flush');
|
||||
$logger->expects(self::once())->method('warning');
|
||||
|
||||
self::assertFalse($this->directory($logger)->refresh());
|
||||
}
|
||||
|
||||
private function directory(?LoggerInterface $logger = null): ContingentHotelDirectory
|
||||
{
|
||||
return new ContingentHotelDirectory(
|
||||
$this->cmsDataProvider,
|
||||
$this->accommodationRepository,
|
||||
$this->calendarHotelRepository,
|
||||
$this->entityManager,
|
||||
$logger ?? $this->createStub(LoggerInterface::class),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ use App\BpnConnect\Model\ContingentCalendarEntry;
|
||||
use App\BpnConnect\Model\ContingentCalendarMeta;
|
||||
use App\BpnConnect\Model\ContingentCalendarResponse;
|
||||
use App\BpnConnect\Model\ContingentStatus;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentDay;
|
||||
use App\Entity\Groups\ContingentSyncState;
|
||||
use App\Repository\Groups\ContingentDayRepository;
|
||||
@@ -40,16 +39,23 @@ class ContingentSnapshotManagerTest extends TestCase
|
||||
{
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
||||
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
new ContingentCalendarEntry('2026-07-02', ContingentStatus::Blocked),
|
||||
]));
|
||||
|
||||
$this->entityManager->expects(self::exactly(3))->method('persist');
|
||||
$persisted = [];
|
||||
$this->entityManager->expects(self::exactly(3))->method('persist')
|
||||
->willReturnCallback(static function (object $entity) use (&$persisted): void {
|
||||
$persisted[] = $entity;
|
||||
});
|
||||
|
||||
$result = $this->sync();
|
||||
|
||||
$days = array_values(array_filter($persisted, static fn (object $entity) => $entity instanceof ContingentDay));
|
||||
self::assertSame(['HOTEL1', 'HOTEL1'], array_map(static fn (ContingentDay $day) => $day->getHotelCode(), $days));
|
||||
|
||||
self::assertTrue($result->successful);
|
||||
self::assertTrue($result->changed);
|
||||
self::assertSame(2, $result->added);
|
||||
@@ -67,7 +73,7 @@ class ContingentSnapshotManagerTest extends TestCase
|
||||
'2026-07-03' => $this->day('2026-07-03', ContingentStatus::Ok),
|
||||
];
|
||||
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn($existing);
|
||||
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn($existing);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
new ContingentCalendarEntry('2026-07-02', ContingentStatus::OnRequest),
|
||||
@@ -85,10 +91,10 @@ class ContingentSnapshotManagerTest extends TestCase
|
||||
|
||||
public function testAnIdenticalUpstreamResponseDoesNotMoveChangedAt(): void
|
||||
{
|
||||
$state = new ContingentSyncState($this->accommodation());
|
||||
$state = new ContingentSyncState('HOTEL1');
|
||||
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
|
||||
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
|
||||
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
|
||||
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
|
||||
]);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
@@ -107,11 +113,11 @@ class ContingentSnapshotManagerTest extends TestCase
|
||||
$this->dayRepository = $this->createMock(ContingentDayRepository::class);
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
|
||||
$state = new ContingentSyncState($this->accommodation());
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||
$state = new ContingentSyncState('HOTEL1');
|
||||
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
|
||||
$this->client->method('getContingentCalendar')->willThrowException(new BpnConnectException('upstream down'));
|
||||
|
||||
$this->dayRepository->expects(self::never())->method('findByAccommodationAndDateRange');
|
||||
$this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
|
||||
$this->entityManager->expects(self::never())->method('remove');
|
||||
|
||||
$result = $this->sync();
|
||||
@@ -126,9 +132,9 @@ class ContingentSnapshotManagerTest extends TestCase
|
||||
{
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
|
||||
$state = new ContingentSyncState($this->accommodation());
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
|
||||
$state = new ContingentSyncState('HOTEL1');
|
||||
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
|
||||
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
|
||||
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
|
||||
]);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([]));
|
||||
@@ -143,7 +149,7 @@ class ContingentSnapshotManagerTest extends TestCase
|
||||
|
||||
public function testDaysOutsideTheRequestedWindowAreIgnored(): void
|
||||
{
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
||||
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-06-30', ContingentStatus::Ok),
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
@@ -157,11 +163,11 @@ class ContingentSnapshotManagerTest extends TestCase
|
||||
|
||||
public function testNearTermSyncDoesNotShrinkTheHorizonEstablishedByTheFullRun(): void
|
||||
{
|
||||
$state = new ContingentSyncState($this->accommodation());
|
||||
$state = new ContingentSyncState('HOTEL1');
|
||||
$state->recordSuccess(new \DateTimeImmutable('2026-08-20'), new \DateTimeImmutable('2028-08-20'));
|
||||
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
||||
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
|
||||
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
]));
|
||||
@@ -174,11 +180,11 @@ class ContingentSnapshotManagerTest extends TestCase
|
||||
|
||||
public function testHorizonGrowsWhenAFurtherWindowIsSynced(): void
|
||||
{
|
||||
$state = new ContingentSyncState($this->accommodation());
|
||||
$state = new ContingentSyncState('HOTEL1');
|
||||
$state->recordSuccess(new \DateTimeImmutable('2026-08-20'), new \DateTimeImmutable('2026-01-01'));
|
||||
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
||||
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
|
||||
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
]));
|
||||
@@ -190,11 +196,11 @@ class ContingentSnapshotManagerTest extends TestCase
|
||||
|
||||
public function testTheRollingWindowGrowingIsNotAChange(): void
|
||||
{
|
||||
$state = new ContingentSyncState($this->accommodation());
|
||||
$state = new ContingentSyncState('HOTEL1');
|
||||
$state->recordSuccess(new \DateTimeImmutable('2026-06-30'), new \DateTimeImmutable('2026-07-02'));
|
||||
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
|
||||
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
|
||||
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
|
||||
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
|
||||
'2026-07-02' => $this->day('2026-07-02', ContingentStatus::Ok),
|
||||
]);
|
||||
@@ -225,17 +231,12 @@ class ContingentSnapshotManagerTest extends TestCase
|
||||
);
|
||||
|
||||
return $manager->sync(
|
||||
$this->accommodation(),
|
||||
'HOTEL1',
|
||||
new \DateTimeImmutable('2026-07-01'),
|
||||
new \DateTimeImmutable('2026-07-03'),
|
||||
);
|
||||
}
|
||||
|
||||
private function accommodation(): Accommodation
|
||||
{
|
||||
return (new Accommodation())->setCalendarCode('HOTEL1');
|
||||
}
|
||||
|
||||
private function day(string $date, ContingentStatus $status): ContingentDay
|
||||
{
|
||||
return (new ContingentDay())
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BpnConnect\Model\ContingentStatus;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentDay;
|
||||
use App\Entity\Groups\ContingentSyncState;
|
||||
use App\Repository\Groups\ContingentDayRepository;
|
||||
@@ -31,7 +30,7 @@ class ContingentSnapshotReaderTest extends TestCase
|
||||
{
|
||||
$this->dayRepository = $this->createMock(ContingentDayRepository::class);
|
||||
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn(null);
|
||||
$this->syncStateRepository->method('findOneByHotelCode')->willReturn(null);
|
||||
$this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
|
||||
|
||||
self::assertNull($this->read());
|
||||
@@ -41,7 +40,7 @@ class ContingentSnapshotReaderTest extends TestCase
|
||||
{
|
||||
$this->dayRepository = $this->createMock(ContingentDayRepository::class);
|
||||
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-7 hours'));
|
||||
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($this->state('-7 hours'));
|
||||
$this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
|
||||
|
||||
self::assertNull($this->read());
|
||||
@@ -49,7 +48,7 @@ class ContingentSnapshotReaderTest extends TestCase
|
||||
|
||||
public function testReturnsStatusesWhenRecentlySynced(): void
|
||||
{
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-10 minutes'));
|
||||
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($this->state('-10 minutes'));
|
||||
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
|
||||
'2026-09-01' => $this->day(ContingentStatus::Ok),
|
||||
'2026-09-02' => $this->day(ContingentStatus::OnRequest),
|
||||
@@ -63,25 +62,39 @@ class ContingentSnapshotReaderTest extends TestCase
|
||||
|
||||
public function testStillUsableJustInsideTheStalenessWindow(): void
|
||||
{
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-5 hours'));
|
||||
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($this->state('-5 hours'));
|
||||
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
|
||||
|
||||
self::assertSame([], $this->read());
|
||||
}
|
||||
|
||||
public function testReturnsNullForAnAccommodationWithoutACalendarCode(): void
|
||||
public function testReturnsNullForAnEmptyHotelCode(): void
|
||||
{
|
||||
$this->syncStateRepository = $this->createMock(ContingentSyncStateRepository::class);
|
||||
|
||||
$this->syncStateRepository->expects(self::never())->method('findOneByAccommodation');
|
||||
$this->syncStateRepository->expects(self::never())->method('findOneByHotelCode');
|
||||
|
||||
self::assertNull($this->read(new Accommodation()));
|
||||
self::assertNull($this->read(''));
|
||||
}
|
||||
|
||||
public function testIsCurrentMatchesWhatStatusesForServes(): void
|
||||
{
|
||||
$reader = new ContingentSnapshotReader(
|
||||
$this->dayRepository,
|
||||
$this->syncStateRepository,
|
||||
$this->createStub(LoggerInterface::class),
|
||||
);
|
||||
|
||||
self::assertFalse($reader->isCurrent(null));
|
||||
self::assertFalse($reader->isCurrent(new ContingentSyncState('HOTEL1')));
|
||||
self::assertFalse($reader->isCurrent($this->state('-7 hours')));
|
||||
self::assertTrue($reader->isCurrent($this->state('-5 hours')));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ContingentStatus>|null
|
||||
*/
|
||||
private function read(?Accommodation $accommodation = null): ?array
|
||||
private function read(string $hotelCode = 'HOTEL1'): ?array
|
||||
{
|
||||
$reader = new ContingentSnapshotReader(
|
||||
$this->dayRepository,
|
||||
@@ -90,7 +103,7 @@ class ContingentSnapshotReaderTest extends TestCase
|
||||
);
|
||||
|
||||
return $reader->statusesFor(
|
||||
$accommodation ?? (new Accommodation())->setCalendarCode('HOTEL1'),
|
||||
$hotelCode,
|
||||
new \DateTimeImmutable('2026-09-01'),
|
||||
new \DateTimeImmutable('2026-09-03'),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user