fix: derive contingent change signal from the sync diff
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260911170000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Drops contingent_sync_state.content_hash: the rolling sync window makes a snapshot digest unusable as a change signal.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE contingent_sync_state DROP content_hash');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE contingent_sync_state ADD content_hash VARCHAR(64) NOT NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,12 +114,13 @@ class BpnSyncContingentsCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
$io->writeln(sprintf(
|
$io->writeln(sprintf(
|
||||||
'<info>%s</info>: %s (+%d ~%d -%d)',
|
'<info>%s</info>: %s (+%d ~%d -%d, %d beyond the previous horizon)',
|
||||||
(string) $accommodation->getCalendarCode(),
|
(string) $accommodation->getCalendarCode(),
|
||||||
$result->changed ? 'changed' : 'unchanged',
|
$result->changed ? 'changed' : 'unchanged',
|
||||||
$result->added,
|
$result->added,
|
||||||
$result->updated,
|
$result->updated,
|
||||||
$result->removed,
|
$result->removed,
|
||||||
|
$result->extended,
|
||||||
), OutputInterface::VERBOSITY_VERBOSE);
|
), OutputInterface::VERBOSITY_VERBOSE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ 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 accommodation.
|
||||||
*
|
*
|
||||||
* Holds the fingerprint of the stored contingent days, which is how the sync decides whether
|
* `changedAt` records when the sync last saw a day appear, vanish or switch status within the
|
||||||
* anything actually changed, and `changedAt` records when it last did.
|
* horizon it had already reached; `horizonTo` is how far that reach extends.
|
||||||
*/
|
*/
|
||||||
#[ORM\Entity(repositoryClass: ContingentSyncStateRepository::class)]
|
#[ORM\Entity(repositoryClass: ContingentSyncStateRepository::class)]
|
||||||
#[ORM\Table(name: 'contingent_sync_state')]
|
#[ORM\Table(name: 'contingent_sync_state')]
|
||||||
@@ -28,13 +28,7 @@ class ContingentSyncState
|
|||||||
private ?Accommodation $accommodation = null;
|
private ?Accommodation $accommodation = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* sha256 over the stored contingent days, contingent status only.
|
* Last time the stored snapshot actually changed. Kept for operators, not exposed via the API.
|
||||||
*/
|
|
||||||
#[ORM\Column(length: 64)]
|
|
||||||
private string $contentHash = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Last time the fingerprint actually changed. Kept for operators, not exposed via the API.
|
|
||||||
*/
|
*/
|
||||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
private ?\DateTimeImmutable $changedAt = null;
|
private ?\DateTimeImmutable $changedAt = null;
|
||||||
@@ -76,18 +70,6 @@ class ContingentSyncState
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getContentHash(): string
|
|
||||||
{
|
|
||||||
return $this->contentHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setContentHash(string $contentHash): self
|
|
||||||
{
|
|
||||||
$this->contentHash = $contentHash;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getChangedAt(): ?\DateTimeImmutable
|
public function getChangedAt(): ?\DateTimeImmutable
|
||||||
{
|
{
|
||||||
return $this->changedAt;
|
return $this->changedAt;
|
||||||
|
|||||||
@@ -13,19 +13,24 @@ readonly class ContingentSyncResult
|
|||||||
public bool $successful,
|
public bool $successful,
|
||||||
public bool $changed,
|
public bool $changed,
|
||||||
public int $added,
|
public int $added,
|
||||||
|
public int $extended,
|
||||||
public int $updated,
|
public int $updated,
|
||||||
public int $removed,
|
public int $removed,
|
||||||
public ?string $error = null,
|
public ?string $error = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function synced(bool $changed, int $added, int $updated, int $removed): self
|
/**
|
||||||
|
* $added counts days newly stored within the horizon the previous run already reached;
|
||||||
|
* $extended counts days beyond it, which is the rolling window growing rather than a change.
|
||||||
|
*/
|
||||||
|
public static function synced(bool $changed, int $added, int $extended, int $updated, int $removed): self
|
||||||
{
|
{
|
||||||
return new self(true, $changed, $added, $updated, $removed);
|
return new self(true, $changed, $added, $extended, $updated, $removed);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function failed(string $error): self
|
public static function failed(string $error): self
|
||||||
{
|
{
|
||||||
return new self(false, false, 0, 0, 0, $error);
|
return new self(false, false, 0, 0, 0, 0, $error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,30 +71,6 @@ class ContingentDayRepository extends ServiceEntityRepository
|
|||||||
return $this->indexByDate($days);
|
return $this->indexByDate($days);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the accommodation's complete stored snapshot as an ordered "Y-m-d:STATUS" list.
|
|
||||||
*
|
|
||||||
* Scalar hydration keeps the fingerprint cheap: the entities themselves are of no interest here.
|
|
||||||
*
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function findStatusFingerprintParts(Accommodation $accommodation): array
|
|
||||||
{
|
|
||||||
/** @var array<int, array{date: \DateTimeImmutable, status: \App\BpnConnect\Model\ContingentStatus}> $rows */
|
|
||||||
$rows = $this->createQueryBuilder('cd')
|
|
||||||
->select('cd.date', 'cd.status')
|
|
||||||
->where('cd.accommodation = :accommodation')
|
|
||||||
->setParameter('accommodation', $accommodation)
|
|
||||||
->orderBy('cd.date', 'ASC')
|
|
||||||
->getQuery()
|
|
||||||
->getArrayResult();
|
|
||||||
|
|
||||||
return array_map(
|
|
||||||
static fn (array $row) => $row['date']->format('Y-m-d').':'.$row['status']->value,
|
|
||||||
$rows,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retention: drops snapshot days that are in the past and can no longer be requested.
|
* Retention: drops snapshot days that are in the past and can no longer be requested.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ class ContingentSnapshotManager
|
|||||||
|
|
||||||
$state = $this->syncStateRepository->findOneByAccommodation($accommodation) ?? new ContingentSyncState($accommodation);
|
$state = $this->syncStateRepository->findOneByAccommodation($accommodation) ?? new ContingentSyncState($accommodation);
|
||||||
|
|
||||||
|
// Read before recordSuccess() moves it: days beyond the horizon the previous run reached
|
||||||
|
// are the window growing, not the contingent situation changing.
|
||||||
|
$previousHorizonTo = $state->getHorizonTo();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$calendar = $this->contingentsClient->getContingentCalendar(
|
$calendar = $this->contingentsClient->getContingentCalendar(
|
||||||
$hotelCode,
|
$hotelCode,
|
||||||
@@ -72,6 +76,7 @@ class ContingentSnapshotManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
$added = 0;
|
$added = 0;
|
||||||
|
$extended = 0;
|
||||||
$updated = 0;
|
$updated = 0;
|
||||||
$seen = [];
|
$seen = [];
|
||||||
|
|
||||||
@@ -102,7 +107,12 @@ class ContingentSnapshotManager
|
|||||||
->setDate($date);
|
->setDate($date);
|
||||||
|
|
||||||
$this->entityManager->persist($day);
|
$this->entityManager->persist($day);
|
||||||
|
|
||||||
|
if (null !== $previousHorizonTo && $date > $previousHorizonTo) {
|
||||||
|
++$extended;
|
||||||
|
} else {
|
||||||
++$added;
|
++$added;
|
||||||
|
}
|
||||||
} elseif ($day->getStatus() === $entry->status) {
|
} elseif ($day->getStatus() === $entry->status) {
|
||||||
continue;
|
continue;
|
||||||
} else {
|
} else {
|
||||||
@@ -124,26 +134,21 @@ class ContingentSnapshotManager
|
|||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
$now = CarbonImmutable::now()->toDateTimeImmutable();
|
$now = CarbonImmutable::now()->toDateTimeImmutable();
|
||||||
$hash = $this->fingerprint($accommodation);
|
|
||||||
$changed = $hash !== $state->getContentHash();
|
// The diff is the change signal. A digest of the stored snapshot cannot be one: the window
|
||||||
|
// rolls forward a day at a time, so every digest would cover a different span than the one
|
||||||
|
// it is compared against and every run would report a change.
|
||||||
|
$changed = $added > 0 || $updated > 0 || $removed > 0;
|
||||||
|
|
||||||
if ($changed) {
|
if ($changed) {
|
||||||
$state->setContentHash($hash)->setChangedAt($now);
|
$state->setChangedAt($now);
|
||||||
}
|
}
|
||||||
|
|
||||||
$state->recordSuccess($now, $dateTo);
|
$state->recordSuccess($now, $dateTo);
|
||||||
$this->entityManager->persist($state);
|
$this->entityManager->persist($state);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
return ContingentSyncResult::synced($changed, $added, $updated, $removed);
|
return ContingentSyncResult::synced($changed, $added, $extended, $updated, $removed);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* sha256 over the accommodation's complete stored snapshot, contingent status only.
|
|
||||||
*/
|
|
||||||
public function fingerprint(Accommodation $accommodation): string
|
|
||||||
{
|
|
||||||
return hash('sha256', implode('|', $this->dayRepository->findStatusFingerprintParts($accommodation)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function recordFailure(ContingentSyncState $state, string $hotelCode, string $error): ContingentSyncResult
|
private function recordFailure(ContingentSyncState $state, string $hotelCode, string $error): ContingentSyncResult
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ class BpnSyncContingentsCommandTest extends TestCase
|
|||||||
public function testASingleFailingHotelDoesNotFailTheTask(): void
|
public function testASingleFailingHotelDoesNotFailTheTask(): void
|
||||||
{
|
{
|
||||||
$tester = $this->runSync(['A', 'B', 'C'], [
|
$tester = $this->runSync(['A', 'B', 'C'], [
|
||||||
ContingentSyncResult::synced(true, 3, 0, 0),
|
ContingentSyncResult::synced(true, 3, 0, 0, 0),
|
||||||
ContingentSyncResult::failed('unknown hotel code'),
|
ContingentSyncResult::failed('unknown hotel code'),
|
||||||
ContingentSyncResult::synced(false, 0, 0, 0),
|
ContingentSyncResult::synced(false, 0, 0, 0, 0),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||||
@@ -48,7 +48,7 @@ class BpnSyncContingentsCommandTest extends TestCase
|
|||||||
|
|
||||||
public function testASuccessfulRunSucceeds(): void
|
public function testASuccessfulRunSucceeds(): void
|
||||||
{
|
{
|
||||||
$tester = $this->runSync(['A'], [ContingentSyncResult::synced(true, 3, 1, 0)]);
|
$tester = $this->runSync(['A'], [ContingentSyncResult::synced(true, 3, 0, 1, 0)]);
|
||||||
|
|
||||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ class ContingentSnapshotManagerTest extends TestCase
|
|||||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
|
||||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
||||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn(['2026-07-01:OK', '2026-07-02:BLOCKED']);
|
|
||||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||||
new ContingentCalendarEntry('2026-07-02', ContingentStatus::Blocked),
|
new ContingentCalendarEntry('2026-07-02', ContingentStatus::Blocked),
|
||||||
@@ -69,7 +68,6 @@ class ContingentSnapshotManagerTest extends TestCase
|
|||||||
];
|
];
|
||||||
|
|
||||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn($existing);
|
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn($existing);
|
||||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn(['2026-07-01:OK']);
|
|
||||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||||
new ContingentCalendarEntry('2026-07-02', ContingentStatus::OnRequest),
|
new ContingentCalendarEntry('2026-07-02', ContingentStatus::OnRequest),
|
||||||
@@ -85,16 +83,14 @@ class ContingentSnapshotManagerTest extends TestCase
|
|||||||
self::assertSame(ContingentStatus::OnRequest, $existing['2026-07-02']->getStatus());
|
self::assertSame(ContingentStatus::OnRequest, $existing['2026-07-02']->getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUnchangedFingerprintDoesNotMoveChangedAt(): void
|
public function testAnIdenticalUpstreamResponseDoesNotMoveChangedAt(): void
|
||||||
{
|
{
|
||||||
$state = new ContingentSyncState($this->accommodation());
|
$state = new ContingentSyncState($this->accommodation());
|
||||||
$state->setContentHash(hash('sha256', '2026-07-01:OK'));
|
|
||||||
|
|
||||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
|
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
|
||||||
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
|
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
|
||||||
]);
|
]);
|
||||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn(['2026-07-01:OK']);
|
|
||||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||||
]));
|
]));
|
||||||
@@ -148,7 +144,6 @@ class ContingentSnapshotManagerTest extends TestCase
|
|||||||
public function testDaysOutsideTheRequestedWindowAreIgnored(): void
|
public function testDaysOutsideTheRequestedWindowAreIgnored(): void
|
||||||
{
|
{
|
||||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
||||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn([]);
|
|
||||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||||
new ContingentCalendarEntry('2026-06-30', ContingentStatus::Ok),
|
new ContingentCalendarEntry('2026-06-30', ContingentStatus::Ok),
|
||||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||||
@@ -167,7 +162,6 @@ class ContingentSnapshotManagerTest extends TestCase
|
|||||||
|
|
||||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
||||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn([]);
|
|
||||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||||
]));
|
]));
|
||||||
@@ -185,7 +179,6 @@ class ContingentSnapshotManagerTest extends TestCase
|
|||||||
|
|
||||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
||||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn([]);
|
|
||||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||||
]));
|
]));
|
||||||
@@ -195,6 +188,32 @@ class ContingentSnapshotManagerTest extends TestCase
|
|||||||
self::assertSame('2026-07-03', $state->getHorizonTo()?->format('Y-m-d'));
|
self::assertSame('2026-07-03', $state->getHorizonTo()?->format('Y-m-d'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testTheRollingWindowGrowingIsNotAChange(): void
|
||||||
|
{
|
||||||
|
$state = new ContingentSyncState($this->accommodation());
|
||||||
|
$state->recordSuccess(new \DateTimeImmutable('2026-06-30'), new \DateTimeImmutable('2026-07-02'));
|
||||||
|
|
||||||
|
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||||
|
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
|
||||||
|
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
|
||||||
|
'2026-07-02' => $this->day('2026-07-02', ContingentStatus::Ok),
|
||||||
|
]);
|
||||||
|
// Same two days as before plus the one the window just grew by, which is what every
|
||||||
|
// scheduled run sees after midnight.
|
||||||
|
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||||
|
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||||
|
new ContingentCalendarEntry('2026-07-02', ContingentStatus::Ok),
|
||||||
|
new ContingentCalendarEntry('2026-07-03', ContingentStatus::Ok),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$result = $this->sync();
|
||||||
|
|
||||||
|
self::assertFalse($result->changed);
|
||||||
|
self::assertSame(0, $result->added);
|
||||||
|
self::assertSame(1, $result->extended);
|
||||||
|
self::assertNull($state->getChangedAt());
|
||||||
|
}
|
||||||
|
|
||||||
private function sync(): \App\Model\ContingentSyncResult
|
private function sync(): \App\Model\ContingentSyncResult
|
||||||
{
|
{
|
||||||
$manager = new ContingentSnapshotManager(
|
$manager = new ContingentSnapshotManager(
|
||||||
|
|||||||
Reference in New Issue
Block a user