Compare commits
4
Commits
1d3263373e
...
5b133f724d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b133f724d | ||
|
|
5f8a586385 | ||
|
|
e863025fb1 | ||
|
|
91a38faecd |
+41
-28
@@ -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>
|
||||||
|
# It’s unusual to change this option, and we don’t 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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -510,5 +510,4 @@ class BpnXmlAnonymizer
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user