fix: derive contingent change signal from the sync diff

This commit is contained in:
2026-09-11 19:34:29 +02:00
parent e863025fb1
commit 5f8a586385
8 changed files with 87 additions and 73 deletions
+26
View File
@@ -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');
}
}
+2 -1
View File
@@ -114,12 +114,13 @@ class BpnSyncContingentsCommand extends Command
}
$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(),
$result->changed ? 'changed' : 'unchanged',
$result->added,
$result->updated,
$result->removed,
$result->extended,
), OutputInterface::VERBOSITY_VERBOSE);
}
+3 -21
View File
@@ -11,8 +11,8 @@ use Doctrine\ORM\Mapping as ORM;
/**
* 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
* anything actually changed, and `changedAt` records when it last did.
* `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.
*/
#[ORM\Entity(repositoryClass: ContingentSyncStateRepository::class)]
#[ORM\Table(name: 'contingent_sync_state')]
@@ -28,13 +28,7 @@ class ContingentSyncState
private ?Accommodation $accommodation = null;
/**
* sha256 over the stored contingent days, contingent status only.
*/
#[ORM\Column(length: 64)]
private string $contentHash = '';
/**
* Last time the fingerprint actually changed. Kept for operators, not exposed via the API.
* Last time the stored snapshot actually changed. Kept for operators, not exposed via the API.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $changedAt = null;
@@ -76,18 +70,6 @@ class ContingentSyncState
return $this;
}
public function getContentHash(): string
{
return $this->contentHash;
}
public function setContentHash(string $contentHash): self
{
$this->contentHash = $contentHash;
return $this;
}
public function getChangedAt(): ?\DateTimeImmutable
{
return $this->changedAt;
+8 -3
View File
@@ -13,19 +13,24 @@ readonly class ContingentSyncResult
public bool $successful,
public bool $changed,
public int $added,
public int $extended,
public int $updated,
public int $removed,
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
{
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);
}
/**
* 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.
*/
+17 -12
View File
@@ -53,6 +53,10 @@ class ContingentSnapshotManager
$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 {
$calendar = $this->contingentsClient->getContingentCalendar(
$hotelCode,
@@ -72,6 +76,7 @@ class ContingentSnapshotManager
}
$added = 0;
$extended = 0;
$updated = 0;
$seen = [];
@@ -102,7 +107,12 @@ class ContingentSnapshotManager
->setDate($date);
$this->entityManager->persist($day);
if (null !== $previousHorizonTo && $date > $previousHorizonTo) {
++$extended;
} else {
++$added;
}
} elseif ($day->getStatus() === $entry->status) {
continue;
} else {
@@ -124,26 +134,21 @@ class ContingentSnapshotManager
$this->entityManager->flush();
$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) {
$state->setContentHash($hash)->setChangedAt($now);
$state->setChangedAt($now);
}
$state->recordSuccess($now, $dateTo);
$this->entityManager->persist($state);
$this->entityManager->flush();
return ContingentSyncResult::synced($changed, $added, $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)));
return ContingentSyncResult::synced($changed, $added, $extended, $updated, $removed);
}
private function recordFailure(ContingentSyncState $state, string $hotelCode, string $error): ContingentSyncResult
@@ -36,9 +36,9 @@ class BpnSyncContingentsCommandTest extends TestCase
public function testASingleFailingHotelDoesNotFailTheTask(): void
{
$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::synced(false, 0, 0, 0),
ContingentSyncResult::synced(false, 0, 0, 0, 0),
]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
@@ -48,7 +48,7 @@ class BpnSyncContingentsCommandTest extends TestCase
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());
}
@@ -41,7 +41,6 @@ class ContingentSnapshotManagerTest extends TestCase
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$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([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-02', ContingentStatus::Blocked),
@@ -69,7 +68,6 @@ class ContingentSnapshotManagerTest extends TestCase
];
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn($existing);
$this->dayRepository->method('findStatusFingerprintParts')->willReturn(['2026-07-01:OK']);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-02', ContingentStatus::OnRequest),
@@ -85,16 +83,14 @@ class ContingentSnapshotManagerTest extends TestCase
self::assertSame(ContingentStatus::OnRequest, $existing['2026-07-02']->getStatus());
}
public function testUnchangedFingerprintDoesNotMoveChangedAt(): void
public function testAnIdenticalUpstreamResponseDoesNotMoveChangedAt(): void
{
$state = new ContingentSyncState($this->accommodation());
$state->setContentHash(hash('sha256', '2026-07-01:OK'));
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
'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([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
]));
@@ -148,7 +144,6 @@ class ContingentSnapshotManagerTest extends TestCase
public function testDaysOutsideTheRequestedWindowAreIgnored(): void
{
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->dayRepository->method('findStatusFingerprintParts')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-06-30', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
@@ -167,7 +162,6 @@ class ContingentSnapshotManagerTest extends TestCase
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->dayRepository->method('findStatusFingerprintParts')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
]));
@@ -185,7 +179,6 @@ class ContingentSnapshotManagerTest extends TestCase
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->dayRepository->method('findStatusFingerprintParts')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
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'));
}
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
{
$manager = new ContingentSnapshotManager(