Compare commits

...
4 Commits
37 changed files with 224 additions and 201 deletions
+41 -28
View File
@@ -1,3 +1,4 @@
name: myep
type: php type: php
docroot: public docroot: public
php_version: "8.4" php_version: "8.4"
@@ -9,13 +10,15 @@ additional_hostnames:
- myser - myser
additional_fqdns: [ ] additional_fqdns: [ ]
database: database:
type: mysql type: mariadb
version: "8.4" version: "11.8"
webimage_extra_packages: [ ripgrep, bubblewrap, socat ]
use_dns_when_possible: true use_dns_when_possible: true
timezone: Europe/Berlin
composer_version: "2" composer_version: "2"
web_environment: [ ] web_environment: [ ]
corepack_enable: false
nodejs_version: "22" nodejs_version: "22"
corepack_enable: false
# Key features of DDEV's config.yaml: # Key features of DDEV's config.yaml:
@@ -24,16 +27,16 @@ nodejs_version: "22"
# If the name is omitted, the project will take the name of the enclosing directory, # If the name is omitted, the project will take the name of the enclosing directory,
# which is useful if you want to have a copy of the project side by side with this one. # which is useful if you want to have a copy of the project side by side with this one.
# type: <projecttype> # backdrop, cakephp, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, generic, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress # type: <projecttype> # asterios, backdrop, cakephp, codeigniter, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, drupal12, generic, joomla, laravel, magento, magento2, maho, modx, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock
# See https://docs.ddev.com/en/stable/users/quickstart/ for more # See https://docs.ddev.com/en/stable/users/quickstart/ for more
# information on the different project types # information on the different project types
# docroot: <relative_path> # Relative path to the directory containing index.php. # docroot: <relative_path> # Relative path to the directory containing index.php.
# php_version: "8.3" # PHP version to use, "5.6" through "8.5" # php_version: "8.4" # PHP version to use, "5.6" through "8.5"
# You can explicitly specify the webimage but this # You can explicitly specify the webimage but this
# is not recommended, as the images are often closely tied to DDEV's' behavior, # is not recommended, as the images are often closely tied to DDEV's behavior,
# so this can break upgrades. # so this can break upgrades.
# webimage: <docker_image> # webimage: <docker_image>
@@ -43,10 +46,18 @@ nodejs_version: "22"
# database: # database:
# type: <dbtype> # mysql, mariadb, postgres # type: <dbtype> # mysql, mariadb, postgres
# version: <version> # database version, like "10.11" or "8.0" # version: <version> # database version, like "10.11" or "8.0"
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8 # MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8, 12.3
# MySQL versions can be 5.5-8.0, 8.4 # MySQL versions can be 5.5-8.0, 8.4, 9.7
# PostgreSQL versions can be 9-18 # PostgreSQL versions can be 9-18
# You can explicitly specify the dbimage but this
# is not recommended, as the images are often closely tied to DDEV's behavior,
# so this can break upgrades.
# dbimage: <docker_image>
# Its unusual to change this option, and we dont recommend it without Docker experience and a good reason.
# Typically, this means additions to the existing db image using a .ddev/db-build/Dockerfile.*
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80) # router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443) # router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
@@ -66,8 +77,7 @@ nodejs_version: "22"
# bind_all_ports is used (normally with router disabled) # bind_all_ports is used (normally with router disabled)
# xhprof_mode: [prepend|xhgui|global] # xhprof_mode: [prepend|xhgui|global]
# Set to "xhgui" to enable XHGui features # Default is "xhgui"
# "xhgui" will become default in a future major release
# webserver_type: nginx-fpm, apache-fpm, generic # webserver_type: nginx-fpm, apache-fpm, generic
@@ -85,22 +95,24 @@ nodejs_version: "22"
# commands are executed. # commands are executed.
# composer_version: "2" # composer_version: "2"
# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1 # You can set it to "" or "2" (default) for Composer v2
# to use the latest major version available at the time your container is built. # to use the latest major version available at the time your container is built.
# It is also possible to use each other Composer version channel. This includes: # It is also possible to use any other Composer version channel. This includes:
# - 2.2 (latest Composer LTS version) # - 2.2 (latest Composer LTS version)
# - stable # - stable
# - preview # - preview
# - snapshot # - snapshot
# Alternatively, an explicit Composer version may be specified, for example "2.2.18". # Alternatively, an explicit Composer version may be specified, for example "2.2.18".
# To reinstall Composer after the image was built, run "ddev debug rebuild". # To reinstall Composer after the image was built, run "ddev utility rebuild".
# nodejs_version: "22" # nodejs_root: <relative_path>
# Relative path to the directory containing the Node.js version file from the
# project root. Only used with "nodejs_version: auto" or "nodejs_version: engine".
# nodejs_version: "24"
# change from the default system Node.js version to any other version. # change from the default system Node.js version to any other version.
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information # See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation, # and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation.
# Note that using of 'ddev nvm' is discouraged because "nodejs_version" is much easier to use,
# can specify any version, and is more robust than using 'nvm'.
# corepack_enable: false # corepack_enable: false
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm # Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
@@ -160,9 +172,7 @@ nodejs_version: "22"
# - "global": uses the value from the global config. # - "global": uses the value from the global config.
# - "none": disables performance optimization for this project. # - "none": disables performance optimization for this project.
# - "mutagen": enables Mutagen for this project. # - "mutagen": enables Mutagen for this project.
# - "nfs": enables NFS for this project.
# #
# See https://docs.ddev.com/en/stable/users/install/performance/#nfs
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen # See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
# fail_on_hook_fail: False # fail_on_hook_fail: False
@@ -192,10 +202,10 @@ nodejs_version: "22"
# The mailpit port is not normally bound on the host at all, instead being routed # The mailpit port is not normally bound on the host at all, instead being routed
# through ddev-router, but it can be bound directly to localhost if specified here. # through ddev-router, but it can be bound directly to localhost if specified here.
# webimage_extra_packages: [php7.4-tidy, php-bcmath] # webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
# Extra Debian packages that are needed in the webimage can be added here # Extra Debian packages that are needed in the webimage can be added here
# dbimage_extra_packages: [telnet,netcat] # dbimage_extra_packages: [netcat, telnet, sudo]
# Extra Debian packages that are needed in the dbimage can be added here # Extra Debian packages that are needed in the dbimage can be added here
# use_dns_when_possible: true # use_dns_when_possible: true
@@ -207,12 +217,15 @@ nodejs_version: "22"
# project_tld: ddev.site # project_tld: ddev.site
# The top-level domain used for project URLs # The top-level domain used for project URLs
# The default "ddev.site" allows DNS lookup via a wildcard # The default "ddev.site" allows DNS lookup via a wildcard
# If you prefer you can change this to "ddev.local" to preserve
# pre-v1.9 behavior.
# ngrok_args: --basic-auth username:pass1234 # share_default_provider: ngrok
# Provide extra flags to the "ngrok http" command, see # The default share provider to use for "ddev share"
# https://ngrok.com/docs/agent/config/v3/#agent-configuration or run "ngrok http -h" # Defaults to global configuration, usually "ngrok"
# Can be "ngrok" or "cloudflared" or the name of a custom provider from .ddev/share-providers/
# share_provider_args: --basic-auth username:pass1234
# Provide extra flags to the share provider script
# See https://docs.ddev.com/en/stable/users/configuration/config/#share_provider_args
# disable_settings_management: false # disable_settings_management: false
# If true, DDEV will not create CMS-specific settings files like # If true, DDEV will not create CMS-specific settings files like
@@ -274,7 +287,7 @@ nodejs_version: "22"
# override_config: false # override_config: false
# By default, config.*.yaml files are *merged* into the configuration # By default, config.*.yaml files are *merged* into the configuration
# But this means that some things can't be overridden # But this means that some things can't be overridden
# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge # For example, if you have 'use_dns_when_possible: true' you can't override it with a merge
# and you can't erase existing hooks or all environment variables. # and you can't erase existing hooks or all environment variables.
# However, with "override_config: true" in a particular config.*.yaml file, # However, with "override_config: true" in a particular config.*.yaml file,
# 'use_dns_when_possible: false' can override the existing values, and # 'use_dns_when_possible: false' can override the existing values, and
@@ -284,7 +297,7 @@ nodejs_version: "22"
# web_environment: [] # web_environment: []
# or # or
# additional_hostnames: [] # additional_hostnames: []
# can have their intended affect. 'override_config' affects only behavior of the # can have their intended effect. 'override_config' affects only behavior of the
# config.*.yaml file it exists in. # config.*.yaml file it exists in.
# Many DDEV commands can be extended to run tasks before or after the # Many DDEV commands can be extended to run tasks before or after the
+1 -1
View File
@@ -25,7 +25,7 @@ APP_SECRET=642ead390b8a38f0a017ec84d5362129
# #
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4" # DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.4.0&charset=utf8mb4" DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=mariadb-11.8.9&charset=utf8mb4"
# DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8" # DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
+3 -2
View File
@@ -3,13 +3,14 @@ zenstruck_schedule:
mailer: mailer:
service: mailer service: mailer
default_to: [email protected] default_to: [email protected]
default_from: info@ep-reisen.de default_from: noreply@ep-reisen.de
subject_prefix: "[MyE&P]" subject_prefix: "[MyE&P]"
schedule_extensions: schedule_extensions:
email_on_failure: email_on_failure:
enabled: true enabled: true
to: [email protected]
tasks: tasks:
- task: app:cleanup:xml-dumps - task: app:cleanup:xml-dumps
+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');
}
}
+1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\BusProNet\Model; namespace App\BusProNet\Model;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use function Symfony\Component\String\u; use function Symfony\Component\String\u;
/** /**
+2 -1
View File
@@ -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);
} }
@@ -530,7 +530,7 @@ class ImportGroupsLegacyDataCommand extends Command
* @param list<AdditionalService> $additionalServicesNeedingDefaultRange only these * @param list<AdditionalService> $additionalServicesNeedingDefaultRange only these
* (titles without a leading year) get the accommodation- * (titles without a leading year) get the accommodation-
* wide fallback range; year-prefixed ones already have * wide fallback range; year-prefixed ones already have
* their scoped range set in importAdditionalServices(). * their scoped range set in importAdditionalServices()
*/ */
private function applyDateRangeToServices(array $accommodationByUid, array $additionalServicesNeedingDefaultRange): void private function applyDateRangeToServices(array $accommodationByUid, array $additionalServicesNeedingDefaultRange): void
{ {
@@ -8,7 +8,6 @@ use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData; use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\PersonalDataUpdateResponse;
use App\Entity\User; use App\Entity\User;
use App\Exception\NewsletterProviderException; use App\Exception\NewsletterProviderException;
use App\Form\PersonalDataType; use App\Form\PersonalDataType;
@@ -46,7 +46,7 @@ class CreateController extends AbstractController
'date_range' => [ 'date_range' => [
'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'), 'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'),
'to' => $accommodationPrice->getDateTo()->format('Y-m-d'), 'to' => $accommodationPrice->getDateTo()->format('Y-m-d'),
] ],
]); ]);
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [ $returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
@@ -52,7 +52,7 @@ class EditController extends AbstractController
'date_range' => [ 'date_range' => [
'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'), 'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'),
'to' => $accommodationPrice->getDateTo()->format('Y-m-d'), 'to' => $accommodationPrice->getDateTo()->format('Y-m-d'),
] ],
]); ]);
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [ $returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
@@ -4,14 +4,11 @@ declare(strict_types=1);
namespace App\Controller\Admin\AdditionalService; namespace App\Controller\Admin\AdditionalService;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AdditionalService; use App\Entity\Groups\AdditionalService;
use App\Form\Admin\Groups\AdditionalServiceType; use App\Form\Admin\Groups\AdditionalServiceType;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -106,7 +106,7 @@ class Step2Controller extends AbstractController
$selectedAdditionalServices = $this->resolveSelectedAdditionalServices($dto, $services['additionalServices']); $selectedAdditionalServices = $this->resolveSelectedAdditionalServices($dto, $services['additionalServices']);
$priceBreakdown = null; $priceBreakdown = null;
if ($dto->dateFrom !== null && $dto->dateTo !== null) { if (null !== $dto->dateFrom && null !== $dto->dateTo) {
$priceBreakdown = $this->priceCalculator->calculate( $priceBreakdown = $this->priceCalculator->calculate(
$dto->paxCount, $dto->paxCount,
$dto->minorsCount, $dto->minorsCount,
+3 -21
View File
@@ -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;
+1 -1
View File
@@ -10,9 +10,9 @@ use App\Service\AdditionalServiceExclusionResolver;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents; use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
/** /**
+1
View File
@@ -7,6 +7,7 @@ namespace App\Form\Model;
use App\Validator\Constraints as AppAssert; use App\Validator\Constraints as AppAssert;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Context\ExecutionContextInterface;
use function Symfony\Component\String\u; use function Symfony\Component\String\u;
/** /**
+8 -3
View File
@@ -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.
*/ */
-1
View File
@@ -510,5 +510,4 @@ class BpnXmlAnonymizer
return null; return null;
} }
} }
+2 -2
View File
@@ -21,7 +21,7 @@ class CalendarGridBuilder
$months = []; $months = [];
$base = $from->modify('first day of this month')->setTime(0, 0, 0); $base = $from->modify('first day of this month')->setTime(0, 0, 0);
for ($i = 0; $i < $count; $i++) { for ($i = 0; $i < $count; ++$i) {
$monthStart = $base->modify("+{$i} months"); $monthStart = $base->modify("+{$i} months");
$months[] = [ $months[] = [
'month' => $monthStart, 'month' => $monthStart,
@@ -54,7 +54,7 @@ class CalendarGridBuilder
while ($current <= $gridEnd) { while ($current <= $gridEnd) {
$week = []; $week = [];
for ($d = 0; $d < 7; $d++) { for ($d = 0; $d < 7; ++$d) {
$week[] = [ $week[] = [
'date' => $current, 'date' => $current,
'inMonth' => $current->format('Y-m') === $monthKey, 'inMonth' => $current->format('Y-m') === $monthKey,
+17 -12
View File
@@ -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
+4 -3
View File
@@ -30,6 +30,7 @@ class GroupsPriceCalculator
/** /**
* @param array<string, int|float> $config * @param array<string, int|float> $config
*
* @return array<string, int|float> * @return array<string, int|float>
*/ */
private function resolveConfig(array $config): array private function resolveConfig(array $config): array
@@ -228,7 +229,7 @@ class GroupsPriceCalculator
)); ));
$winner = $this->priceTimelineBuilder->resolveWinner($candidates) ?? $lastWinner; $winner = $this->priceTimelineBuilder->resolveWinner($candidates) ?? $lastWinner;
if ($winner !== null) { if (null !== $winner) {
$lastWinner = $winner; $lastWinner = $winner;
$basePrice += ($winner->getPricePerNight() ?? 0) * $segNights; $basePrice += ($winner->getPricePerNight() ?? 0) * $segNights;
$includedPax = $winner->getIncludedPax() ?? 0; $includedPax = $winner->getIncludedPax() ?? 0;
@@ -254,7 +255,7 @@ class GroupsPriceCalculator
// Rule 4: undersubscription surcharge (only when a paid board is selected) // Rule 4: undersubscription surcharge (only when a paid board is selected)
$undersubscriptionSurcharge = 0; $undersubscriptionSurcharge = 0;
$undersubscriptionThreshold = null; $undersubscriptionThreshold = null;
if ($paidBoardPricePerPersonNight !== null) { if (null !== $paidBoardPricePerPersonNight) {
$surcharge30 = (int) round(('CHF' === $currency ? $this->config['undersubscription30Chf'] : $this->config['undersubscription30Eur']) * 100); $surcharge30 = (int) round(('CHF' === $currency ? $this->config['undersubscription30Chf'] : $this->config['undersubscription30Eur']) * 100);
$surcharge40 = (int) round(('CHF' === $currency ? $this->config['undersubscription40Chf'] : $this->config['undersubscription40Eur']) * 100); $surcharge40 = (int) round(('CHF' === $currency ? $this->config['undersubscription40Chf'] : $this->config['undersubscription40Eur']) * 100);
@@ -269,7 +270,7 @@ class GroupsPriceCalculator
// Rule 5: board price // Rule 5: board price
$boardPrice = 0; $boardPrice = 0;
if ($paidBoardPricePerPersonNight !== null) { if (null !== $paidBoardPricePerPersonNight) {
$boardPrice = $paidBoardPricePerPersonNight * $effectivePax * $nights; $boardPrice = $paidBoardPricePerPersonNight * $effectivePax * $nights;
} }
+3 -6
View File
@@ -39,8 +39,7 @@ class ParticipantCardAssembler
BookingDto $bookingDto, BookingDto $bookingDto,
int $index, int $index,
?array $precomputedPrices = null, ?array $precomputedPrices = null,
): ParticipantCardDataDto ): ParticipantCardDataDto {
{
$participant = $bookingDto->participants[$index] ?? null; $participant = $bookingDto->participants[$index] ?? null;
if (null === $participant) { if (null === $participant) {
@@ -142,8 +141,7 @@ class ParticipantCardAssembler
BookingDto $bookingDto, BookingDto $bookingDto,
int $index, int $index,
?array $precomputedPrices = null, ?array $precomputedPrices = null,
): ParticipantCardPriceDto ): ParticipantCardPriceDto {
{
// Check if canceled (only possible in edit mode when booking property is set) // Check if canceled (only possible in edit mode when booking property is set)
$isCanceled = ($bookingDto->booking?->participantsStatus[$index] ?? null) === 'S'; $isCanceled = ($bookingDto->booking?->participantsStatus[$index] ?? null) === 'S';
@@ -189,8 +187,7 @@ class ParticipantCardAssembler
BookingDto $bookingDto, BookingDto $bookingDto,
int $index, int $index,
bool $forceStrictRequired = false, bool $forceStrictRequired = false,
): ParticipantCardDataDto ): ParticipantCardDataDto {
{
return $this->getCardDataWithValidationInternal( return $this->getCardDataWithValidationInternal(
$bookingDto, $bookingDto,
$index, $index,
-1
View File
@@ -8,7 +8,6 @@ use App\BusProNet\ApiClient;
use App\BusProNet\Model\Address; use App\BusProNet\Model\Address;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
use App\Entity\User; use App\Entity\User;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
use App\Security\Crypt; use App\Security\Crypt;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
+1
View File
@@ -86,6 +86,7 @@ class ParticipantFormSupport
/** /**
* @param array<string, int> $options * @param array<string, int> $options
*
* @return array<string, int> * @return array<string, int>
*/ */
private function resolveOptions(array $options): array private function resolveOptions(array $options): array
+7 -7
View File
@@ -69,7 +69,7 @@ class PriceTimelineBuilder
$lastDefault = null; $lastDefault = null;
// Step 2: walk each segment [start, end) and resolve the effective price. // Step 2: walk each segment [start, end) and resolve the effective price.
for ($i = 0, $count = count($boundaries) - 1; $i < $count; $i++) { for ($i = 0, $count = count($boundaries) - 1; $i < $count; ++$i) {
$segStart = $boundaries[$i]; $segStart = $boundaries[$i];
$segEndNext = $boundaries[$i + 1]; $segEndNext = $boundaries[$i + 1];
@@ -86,7 +86,7 @@ class PriceTimelineBuilder
$winner = $this->resolveWinner($candidates); $winner = $this->resolveWinner($candidates);
if ($winner === null) { if (null === $winner) {
// Gap: reset merge state so the next winner always opens a new row. // Gap: reset merge state so the next winner always opens a new row.
$lastWinner = null; $lastWinner = null;
$lastDefault = null; $lastDefault = null;
@@ -94,8 +94,8 @@ class PriceTimelineBuilder
} }
$defaultWinner = null; $defaultWinner = null;
if ($winner->getType() === PriceType::DISCOUNT) { if (PriceType::DISCOUNT === $winner->getType()) {
$defaults = array_filter($candidates, fn($p) => $p->getType() === null); $defaults = array_filter($candidates, fn ($p) => null === $p->getType());
$defaultWinner = $this->resolveWinner($defaults); $defaultWinner = $this->resolveWinner($defaults);
} }
@@ -125,8 +125,8 @@ class PriceTimelineBuilder
includedPax: $winner->getIncludedPax(), includedPax: $winner->getIncludedPax(),
pricePerNight: round($winner->getPricePerNight() / 100, 2), pricePerNight: round($winner->getPricePerNight() / 100, 2),
priceAdditionalPerson: round($winner->getPriceAdditionalPerson() / 100, 2), priceAdditionalPerson: round($winner->getPriceAdditionalPerson() / 100, 2),
defaultPricePerNight: $defaultWinner !== null ? round($defaultWinner->getPricePerNight() / 100, 2) : null, defaultPricePerNight: null !== $defaultWinner ? round($defaultWinner->getPricePerNight() / 100, 2) : null,
defaultPriceAdditionalPerson: $defaultWinner !== null ? round($defaultWinner->getPriceAdditionalPerson() / 100, 2) : null, defaultPriceAdditionalPerson: null !== $defaultWinner ? round($defaultWinner->getPriceAdditionalPerson() / 100, 2) : null,
currency: $currency, currency: $currency,
type: $winner->getType()?->value, type: $winner->getType()?->value,
); );
@@ -150,7 +150,7 @@ class PriceTimelineBuilder
{ {
$winner = null; $winner = null;
foreach ($candidates as $candidate) { foreach ($candidates as $candidate) {
if ($winner === null) { if (null === $winner) {
$winner = $candidate; $winner = $candidate;
continue; continue;
} }
+20 -20
View File
@@ -13,9 +13,9 @@ use App\Model\CalendarMonth;
use App\Service\AccommodationPriceCoverage; use App\Service\AccommodationPriceCoverage;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent('calendar', template: 'components/calendar/index.html.twig')] #[AsTwigComponent('calendar', template: 'components/calendar/index.html.twig')]
class Calendar class Calendar
@@ -55,7 +55,7 @@ class Calendar
$this->accommodation = $accommodation; $this->accommodation = $accommodation;
$this->startMonth = $startMonth; $this->startMonth = $startMonth;
if ($startMonth !== null && preg_match('/^\d{4}-\d{2}$/', $startMonth)) { if (null !== $startMonth && preg_match('/^\d{4}-\d{2}$/', $startMonth)) {
$start = CarbonImmutable::createFromFormat('Y-m-d', $startMonth.'-01'); $start = CarbonImmutable::createFromFormat('Y-m-d', $startMonth.'-01');
} else { } else {
$allDates = $this->collectAllDates(); $allDates = $this->collectAllDates();
@@ -73,7 +73,7 @@ class Calendar
$months = []; $months = [];
$current = $start; $current = $start;
for ($i = 0; $i < 3; $i++) { for ($i = 0; $i < 3; ++$i) {
$months[] = $this->buildMonth($current, $map, $blockedDates); $months[] = $this->buildMonth($current, $map, $blockedDates);
$current = $current->addMonth(); $current = $current->addMonth();
} }
@@ -96,7 +96,7 @@ class Calendar
*/ */
private function fetchBlockedDates(string $hotelCode, string $dateFrom, string $dateTo): array private function fetchBlockedDates(string $hotelCode, string $dateFrom, string $dateTo): array
{ {
if ($hotelCode === '') { if ('' === $hotelCode) {
return []; return [];
} }
@@ -116,7 +116,7 @@ class Calendar
$blocked = []; $blocked = [];
foreach ($response->data as $entry) { foreach ($response->data as $entry) {
if ($entry->status === ContingentStatus::Blocked) { if (ContingentStatus::Blocked === $entry->status) {
$blocked[(new \DateTimeImmutable($entry->date))->format('Y-m-d')] = true; $blocked[(new \DateTimeImmutable($entry->date))->format('Y-m-d')] = true;
} }
} }
@@ -144,26 +144,26 @@ class Calendar
$dates = []; $dates = [];
foreach ($this->accommodation->getAccommodationPrices() as $price) { foreach ($this->accommodation->getAccommodationPrices() as $price) {
if ($price->getDateFrom() !== null) { if (null !== $price->getDateFrom()) {
$dates[] = CarbonImmutable::instance($price->getDateFrom()); $dates[] = CarbonImmutable::instance($price->getDateFrom());
} }
if ($price->getDateTo() !== null) { if (null !== $price->getDateTo()) {
$dates[] = CarbonImmutable::instance($price->getDateTo()); $dates[] = CarbonImmutable::instance($price->getDateTo());
} }
} }
foreach ($this->accommodation->getBoardServices() as $service) { foreach ($this->accommodation->getBoardServices() as $service) {
if ($service->getDateFrom() !== null) { if (null !== $service->getDateFrom()) {
$dates[] = CarbonImmutable::instance($service->getDateFrom()); $dates[] = CarbonImmutable::instance($service->getDateFrom());
} }
if ($service->getDateTo() !== null) { if (null !== $service->getDateTo()) {
$dates[] = CarbonImmutable::instance($service->getDateTo()); $dates[] = CarbonImmutable::instance($service->getDateTo());
} }
} }
foreach ($this->accommodation->getAdditionalServices() as $service) { foreach ($this->accommodation->getAdditionalServices() as $service) {
if ($service->getDateFrom() !== null) { if (null !== $service->getDateFrom()) {
$dates[] = CarbonImmutable::instance($service->getDateFrom()); $dates[] = CarbonImmutable::instance($service->getDateFrom());
} }
if ($service->getDateTo() !== null) { if (null !== $service->getDateTo()) {
$dates[] = CarbonImmutable::instance($service->getDateTo()); $dates[] = CarbonImmutable::instance($service->getDateTo());
} }
} }
@@ -198,7 +198,7 @@ class Calendar
$map = []; $map = [];
foreach ($this->accommodation->getAccommodationPrices() as $price) { foreach ($this->accommodation->getAccommodationPrices() as $price) {
if ($price->getDateFrom() === null || $price->getDateTo() === null) { if (null === $price->getDateFrom() || null === $price->getDateTo()) {
continue; continue;
} }
@@ -216,7 +216,7 @@ class Calendar
$key = $day->format('Y-m-d'); $key = $day->format('Y-m-d');
$map[$key] ??= ['price' => null, 'board' => null, 'additional' => null]; $map[$key] ??= ['price' => null, 'board' => null, 'additional' => null];
$existing = $map[$key]['price']; $existing = $map[$key]['price'];
$replace = $existing === null $replace = null === $existing
|| $priority > $existing['priority'] || $priority > $existing['priority']
|| ($priority === $existing['priority'] && $span < $existing['span']); || ($priority === $existing['priority'] && $span < $existing['span']);
if ($replace) { if ($replace) {
@@ -227,7 +227,7 @@ class Calendar
} }
foreach ($this->accommodation->getBoardServices() as $service) { foreach ($this->accommodation->getBoardServices() as $service) {
if ($service->getDateFrom() === null || $service->getDateTo() === null) { if (null === $service->getDateFrom() || null === $service->getDateTo()) {
continue; continue;
} }
@@ -241,7 +241,7 @@ class Calendar
while ($day <= $end && $day <= $limit) { while ($day <= $end && $day <= $limit) {
$key = $day->format('Y-m-d'); $key = $day->format('Y-m-d');
$map[$key] ??= ['price' => null, 'board' => null, 'additional' => null]; $map[$key] ??= ['price' => null, 'board' => null, 'additional' => null];
if ($map[$key]['board'] === null || $span < $map[$key]['board']['span']) { if (null === $map[$key]['board'] || $span < $map[$key]['board']['span']) {
$map[$key]['board'] = ['label' => $label, 'span' => $span]; $map[$key]['board'] = ['label' => $label, 'span' => $span];
} }
$day = $day->addDay(); $day = $day->addDay();
@@ -249,14 +249,14 @@ class Calendar
} }
foreach ($this->accommodation->getAdditionalServices() as $service) { foreach ($this->accommodation->getAdditionalServices() as $service) {
if ($service->getDateFrom() === null || $service->getDateTo() === null) { if (null === $service->getDateFrom() || null === $service->getDateTo()) {
continue; continue;
} }
$typeLabel = $service->getType() !== null $typeLabel = null !== $service->getType()
? self::TYPE_LABELS[$service->getType()->value] ? self::TYPE_LABELS[$service->getType()->value]
: ''; : '';
$label = sprintf('Zusatz: %s%s', $service->getLabel(), $typeLabel !== '' ? ' (' . $typeLabel . ')' : ''); $label = sprintf('Zusatz: %s%s', $service->getLabel(), '' !== $typeLabel ? ' ('.$typeLabel.')' : '');
$day = CarbonImmutable::instance($service->getDateFrom()); $day = CarbonImmutable::instance($service->getDateFrom());
$end = CarbonImmutable::instance($service->getDateTo()); $end = CarbonImmutable::instance($service->getDateTo());
@@ -266,7 +266,7 @@ class Calendar
while ($day <= $end && $day <= $limit) { while ($day <= $end && $day <= $limit) {
$key = $day->format('Y-m-d'); $key = $day->format('Y-m-d');
$map[$key] ??= ['price' => null, 'board' => null, 'additional' => null]; $map[$key] ??= ['price' => null, 'board' => null, 'additional' => null];
if ($map[$key]['additional'] === null || $span < $map[$key]['additional']['span']) { if (null === $map[$key]['additional'] || $span < $map[$key]['additional']['span']) {
$map[$key]['additional'] = ['label' => $label, 'span' => $span]; $map[$key]['additional'] = ['label' => $label, 'span' => $span];
} }
$day = $day->addDay(); $day = $day->addDay();
@@ -293,7 +293,7 @@ class Calendar
$firstDow = ($start->dayOfWeek + 6) % 7; $firstDow = ($start->dayOfWeek + 6) % 7;
$days = []; $days = [];
for ($d = 1; $d <= $start->daysInMonth; $d++) { for ($d = 1; $d <= $start->daysInMonth; ++$d) {
$dateStr = $month->format('Y-m').'-'.str_pad((string) $d, 2, '0', STR_PAD_LEFT); $dateStr = $month->format('Y-m').'-'.str_pad((string) $d, 2, '0', STR_PAD_LEFT);
$coverage = $map[$dateStr] ?? []; $coverage = $map[$dateStr] ?? [];
@@ -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(