Compare commits
14
Commits
dea7a3dc97
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67c685f6c1 | ||
|
|
7aad54e6f2 | ||
|
|
b41820c39d | ||
|
|
fab89dead6 | ||
|
|
443a3ed248 | ||
|
|
5b133f724d | ||
|
|
5f8a586385 | ||
|
|
e863025fb1 | ||
|
|
91a38faecd | ||
|
|
1d3263373e | ||
|
|
f8ff4b82a3 | ||
|
|
d6c2cfaf1c | ||
|
|
cfe901b9c2 | ||
|
|
8443dae5ad |
+46
-33
@@ -1,21 +1,24 @@
|
||||
name: myep
|
||||
type: php
|
||||
docroot: public
|
||||
php_version: "8.4"
|
||||
webserver_type: apache-fpm
|
||||
xdebug_enabled: false
|
||||
additional_hostnames:
|
||||
- mysnz
|
||||
- mysbw
|
||||
- myser
|
||||
additional_fqdns: []
|
||||
- mysnz
|
||||
- mysbw
|
||||
- myser
|
||||
additional_fqdns: [ ]
|
||||
database:
|
||||
type: mysql
|
||||
version: "8.4"
|
||||
type: mariadb
|
||||
version: "11.8"
|
||||
webimage_extra_packages: [ ripgrep, bubblewrap, socat ]
|
||||
use_dns_when_possible: true
|
||||
timezone: Europe/Berlin
|
||||
composer_version: "2"
|
||||
web_environment: []
|
||||
corepack_enable: false
|
||||
web_environment: [ ]
|
||||
nodejs_version: "22"
|
||||
corepack_enable: false
|
||||
|
||||
# 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,
|
||||
# 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
|
||||
# information on the different project types
|
||||
|
||||
# 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
|
||||
# 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.
|
||||
|
||||
# webimage: <docker_image>
|
||||
@@ -43,10 +46,18 @@ nodejs_version: "22"
|
||||
# database:
|
||||
# type: <dbtype> # mysql, mariadb, postgres
|
||||
# version: <version> # database version, like "10.11" or "8.0"
|
||||
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8
|
||||
# MySQL versions can be 5.5-8.0, 8.4
|
||||
# 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, 9.7
|
||||
# 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_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)
|
||||
|
||||
# xhprof_mode: [prepend|xhgui|global]
|
||||
# Set to "xhgui" to enable XHGui features
|
||||
# "xhgui" will become default in a future major release
|
||||
# Default is "xhgui"
|
||||
|
||||
# webserver_type: nginx-fpm, apache-fpm, generic
|
||||
|
||||
@@ -85,22 +95,24 @@ nodejs_version: "22"
|
||||
# commands are executed.
|
||||
|
||||
# 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.
|
||||
# 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)
|
||||
# - stable
|
||||
# - preview
|
||||
# - snapshot
|
||||
# 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.
|
||||
# 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,
|
||||
# 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'.
|
||||
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation.
|
||||
|
||||
# corepack_enable: false
|
||||
# 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.
|
||||
# - "none": disables performance optimization 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
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# dbimage_extra_packages: [telnet,netcat]
|
||||
# dbimage_extra_packages: [netcat, telnet, sudo]
|
||||
# Extra Debian packages that are needed in the dbimage can be added here
|
||||
|
||||
# use_dns_when_possible: true
|
||||
@@ -207,12 +217,15 @@ nodejs_version: "22"
|
||||
# project_tld: ddev.site
|
||||
# The top-level domain used for project URLs
|
||||
# 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
|
||||
# Provide extra flags to the "ngrok http" command, see
|
||||
# https://ngrok.com/docs/agent/config/v3/#agent-configuration or run "ngrok http -h"
|
||||
# share_default_provider: ngrok
|
||||
# The default share provider to use for "ddev share"
|
||||
# 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
|
||||
# If true, DDEV will not create CMS-specific settings files like
|
||||
@@ -274,7 +287,7 @@ nodejs_version: "22"
|
||||
# override_config: false
|
||||
# By default, config.*.yaml files are *merged* into the configuration
|
||||
# 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.
|
||||
# However, with "override_config: true" in a particular config.*.yaml file,
|
||||
# 'use_dns_when_possible: false' can override the existing values, and
|
||||
@@ -284,7 +297,7 @@ nodejs_version: "22"
|
||||
# web_environment: []
|
||||
# or
|
||||
# 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.
|
||||
|
||||
# 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="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"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
|
||||
Generated
+94
-90
@@ -468,16 +468,16 @@
|
||||
},
|
||||
{
|
||||
"name": "doctrine/doctrine-bundle",
|
||||
"version": "2.19.0",
|
||||
"version": "2.19.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/DoctrineBundle.git",
|
||||
"reference": "07b90f707b82981097731c419f546e7ba97fba3c"
|
||||
"reference": "72b53688c30451a7c65addd50b2167d31459724a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/07b90f707b82981097731c419f546e7ba97fba3c",
|
||||
"reference": "07b90f707b82981097731c419f546e7ba97fba3c",
|
||||
"url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/72b53688c30451a7c65addd50b2167d31459724a",
|
||||
"reference": "72b53688c30451a7c65addd50b2167d31459724a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -572,7 +572,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/DoctrineBundle/issues",
|
||||
"source": "https://github.com/doctrine/DoctrineBundle/tree/2.19.0"
|
||||
"source": "https://github.com/doctrine/DoctrineBundle/tree/2.19.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -588,7 +588,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-07-23T14:52:05+00:00"
|
||||
"time": "2026-09-08T11:48:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/doctrine-migrations-bundle",
|
||||
@@ -1115,16 +1115,16 @@
|
||||
},
|
||||
{
|
||||
"name": "doctrine/orm",
|
||||
"version": "3.7.0",
|
||||
"version": "3.7.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/orm.git",
|
||||
"reference": "f25c5e5385d3a0bca2dc364d556d3b83a06559ed"
|
||||
"reference": "037807a39697890dcede8b766c5efe8a1b4855fe"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/orm/zipball/f25c5e5385d3a0bca2dc364d556d3b83a06559ed",
|
||||
"reference": "f25c5e5385d3a0bca2dc364d556d3b83a06559ed",
|
||||
"url": "https://api.github.com/repos/doctrine/orm/zipball/037807a39697890dcede8b766c5efe8a1b4855fe",
|
||||
"reference": "037807a39697890dcede8b766c5efe8a1b4855fe",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1199,9 +1199,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/orm/issues",
|
||||
"source": "https://github.com/doctrine/orm/tree/3.7.0"
|
||||
"source": "https://github.com/doctrine/orm/tree/3.7.1"
|
||||
},
|
||||
"time": "2026-09-07T19:35:17+00:00"
|
||||
"time": "2026-09-14T05:45:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/persistence",
|
||||
@@ -2064,33 +2064,34 @@
|
||||
},
|
||||
{
|
||||
"name": "knplabs/knp-paginator-bundle",
|
||||
"version": "v6.10.0",
|
||||
"version": "v6.11.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/KnpLabs/KnpPaginatorBundle.git",
|
||||
"reference": "8d41f8ed47d880f8fa569389ffa4fecfbc5b8d41"
|
||||
"reference": "a91a854720df2dbfd04cbf8c44c3dad6ce22fdca"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/KnpLabs/KnpPaginatorBundle/zipball/8d41f8ed47d880f8fa569389ffa4fecfbc5b8d41",
|
||||
"reference": "8d41f8ed47d880f8fa569389ffa4fecfbc5b8d41",
|
||||
"url": "https://api.github.com/repos/KnpLabs/KnpPaginatorBundle/zipball/a91a854720df2dbfd04cbf8c44c3dad6ce22fdca",
|
||||
"reference": "a91a854720df2dbfd04cbf8c44c3dad6ce22fdca",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"knplabs/knp-components": "^4.4 || ^5.0",
|
||||
"php": "^8.1",
|
||||
"symfony/config": "^6.4 || ^7.0 || ^8.0",
|
||||
"symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0",
|
||||
"symfony/event-dispatcher": "^6.4 || ^7.0 || ^8.0",
|
||||
"symfony/http-foundation": "^6.4 || ^7.0 || ^8.0",
|
||||
"symfony/http-kernel": "^6.4 || ^7.0 || ^8.0",
|
||||
"symfony/routing": "^6.4 || ^7.0 || ^8.0"
|
||||
"knplabs/knp-components": "^5.0",
|
||||
"php": "^8.2",
|
||||
"symfony/config": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/event-dispatcher": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/http-foundation": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/http-kernel": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/routing": "^6.4 || ^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.12",
|
||||
"phpunit/phpunit": "^10.5 || ^11.5 || ^12.2",
|
||||
"symfony/templating": "^6.4 || ^7.0 || ^8.0",
|
||||
"symfony/translation": "^6.4 || ^7.0 || ^8.0",
|
||||
"phpstan/phpstan": "^2.2",
|
||||
"phpstan/phpstan-strict-rules": "^2.0",
|
||||
"phpunit/phpunit": "^11.5 || ^12.5 || ^13.2",
|
||||
"symfony/templating": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/translation": "^6.4 || ^7.4 || ^8.0",
|
||||
"twig/twig": "^3.0"
|
||||
},
|
||||
"suggest": {
|
||||
@@ -2135,9 +2136,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/KnpLabs/KnpPaginatorBundle/issues",
|
||||
"source": "https://github.com/KnpLabs/KnpPaginatorBundle/tree/v6.10.0"
|
||||
"source": "https://github.com/KnpLabs/KnpPaginatorBundle/tree/v6.11.0"
|
||||
},
|
||||
"time": "2025-11-29T09:14:09+00:00"
|
||||
"time": "2026-09-11T08:30:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lcobucci/jwt",
|
||||
@@ -3195,16 +3196,16 @@
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.11.0",
|
||||
"version": "3.12.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Seldaek/monolog.git",
|
||||
"reference": "147f303310f06334f03f409e49d7ad1e275ff05a"
|
||||
"reference": "72c534fc0ab181ef52d92a68382318631e301608"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/147f303310f06334f03f409e49d7ad1e275ff05a",
|
||||
"reference": "147f303310f06334f03f409e49d7ad1e275ff05a",
|
||||
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/72c534fc0ab181ef52d92a68382318631e301608",
|
||||
"reference": "72c534fc0ab181ef52d92a68382318631e301608",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3230,6 +3231,7 @@
|
||||
"phpstan/phpstan-strict-rules": "^2",
|
||||
"phpunit/phpunit": "^10.5.17 || ^11.0.7",
|
||||
"predis/predis": "^1.1 || ^2",
|
||||
"psr/clock": "^1.0",
|
||||
"rollbar/rollbar": "^4.0",
|
||||
"ruflin/elastica": "^7 || ^8",
|
||||
"symfony/mailer": "^5.4 || ^6",
|
||||
@@ -3248,6 +3250,7 @@
|
||||
"graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
|
||||
"mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
|
||||
"php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
|
||||
"psr/clock": "Required to pass a clock to the Logger and control the timestamp of log records",
|
||||
"rollbar/rollbar": "Allow sending log messages to Rollbar",
|
||||
"ruflin/elastica": "Allow sending log messages to an Elastic Search server"
|
||||
},
|
||||
@@ -3282,7 +3285,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Seldaek/monolog/issues",
|
||||
"source": "https://github.com/Seldaek/monolog/tree/3.11.0"
|
||||
"source": "https://github.com/Seldaek/monolog/tree/3.12.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3294,7 +3297,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-02T12:39:56+00:00"
|
||||
"time": "2026-09-09T08:34:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nelexa/zip",
|
||||
@@ -3371,16 +3374,16 @@
|
||||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "3.13.2",
|
||||
"version": "3.14.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/CarbonPHP/carbon.git",
|
||||
"reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb"
|
||||
"reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb",
|
||||
"reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb",
|
||||
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/0023eaa2c9110e47446dd512a263c69c40cd41f2",
|
||||
"reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3472,7 +3475,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-08T11:40:35+00:00"
|
||||
"time": "2026-09-12T20:45:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nyholm/psr7",
|
||||
@@ -12292,16 +12295,16 @@
|
||||
},
|
||||
{
|
||||
"name": "friendsofphp/php-cs-fixer",
|
||||
"version": "v3.95.24",
|
||||
"version": "v3.95.25",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
|
||||
"reference": "9a4b8050f8768646bf3693ee7416978ceeca6206"
|
||||
"reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/9a4b8050f8768646bf3693ee7416978ceeca6206",
|
||||
"reference": "9a4b8050f8768646bf3693ee7416978ceeca6206",
|
||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/2cdfc1f3daf173d83a1ebb6177949b58c95de6ff",
|
||||
"reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12384,7 +12387,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
|
||||
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.24"
|
||||
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.25"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -12392,7 +12395,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-31T15:31:12+00:00"
|
||||
"time": "2026-09-08T11:11:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "myclabs/deep-copy",
|
||||
@@ -12456,16 +12459,16 @@
|
||||
},
|
||||
{
|
||||
"name": "nikic/php-parser",
|
||||
"version": "v5.8.0",
|
||||
"version": "v5.9.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nikic/PHP-Parser.git",
|
||||
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
|
||||
"reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
|
||||
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
|
||||
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def",
|
||||
"reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12507,9 +12510,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/nikic/PHP-Parser/issues",
|
||||
"source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0"
|
||||
"source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0"
|
||||
},
|
||||
"time": "2026-07-04T14:30:18+00:00"
|
||||
"time": "2026-09-13T18:51:52+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phar-io/manifest",
|
||||
@@ -12631,11 +12634,11 @@
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "2.2.13",
|
||||
"version": "2.2.14",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260",
|
||||
"reference": "9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac",
|
||||
"reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12691,7 +12694,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-03T20:38:19+00:00"
|
||||
"time": "2026-09-12T21:39:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan-doctrine",
|
||||
@@ -13191,16 +13194,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "12.5.34",
|
||||
"version": "12.5.35",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f"
|
||||
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f",
|
||||
"reference": "6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/345ac22e42cb14cdc7aa8428655d8bb8c82a4aab",
|
||||
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -13269,7 +13272,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.34"
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.35"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -13277,7 +13280,7 @@
|
||||
"type": "other"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-27T08:38:28+00:00"
|
||||
"time": "2026-09-09T04:48:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "react/cache",
|
||||
@@ -13807,21 +13810,21 @@
|
||||
},
|
||||
{
|
||||
"name": "rector/rector",
|
||||
"version": "2.6.6",
|
||||
"version": "2.6.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/rectorphp/rector.git",
|
||||
"reference": "ca069d6c79feaa6651b423c15d101a27a436093e"
|
||||
"reference": "f4aa224edb9b0630fb43826d4cb6a49b62e48d4c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/ca069d6c79feaa6651b423c15d101a27a436093e",
|
||||
"reference": "ca069d6c79feaa6651b423c15d101a27a436093e",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/f4aa224edb9b0630fb43826d4cb6a49b62e48d4c",
|
||||
"reference": "f4aa224edb9b0630fb43826d4cb6a49b62e48d4c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4|^8.0",
|
||||
"phpstan/phpstan": "^2.2.10"
|
||||
"phpstan/phpstan": "^2.2.14"
|
||||
},
|
||||
"conflict": {
|
||||
"rector/rector-doctrine": "*",
|
||||
@@ -13852,7 +13855,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/rectorphp/rector/issues",
|
||||
"source": "https://github.com/rectorphp/rector/tree/2.6.6"
|
||||
"source": "https://github.com/rectorphp/rector/tree/2.6.7"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -13860,7 +13863,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-02T09:38:46+00:00"
|
||||
"time": "2026-09-13T20:17:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
@@ -15054,32 +15057,32 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/maker-bundle",
|
||||
"version": "v1.67.0",
|
||||
"version": "1.68.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/maker-bundle.git",
|
||||
"reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516"
|
||||
"reference": "1b0fb459594fdeb1d183065451c5e954babeb16c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/maker-bundle/zipball/6ce8b313845f16bcf385ee3cb31d8b24e30d5516",
|
||||
"reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516",
|
||||
"url": "https://api.github.com/repos/symfony/maker-bundle/zipball/1b0fb459594fdeb1d183065451c5e954babeb16c",
|
||||
"reference": "1b0fb459594fdeb1d183065451c5e954babeb16c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-runtime-api": "^2.1",
|
||||
"doctrine/inflector": "^2.0",
|
||||
"nikic/php-parser": "^5.0",
|
||||
"php": ">=8.1",
|
||||
"symfony/config": "^6.4|^7.0|^8.0",
|
||||
"symfony/console": "^6.4|^7.0|^8.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"php": ">=8.2",
|
||||
"symfony/config": "^7.4|^8.1",
|
||||
"symfony/console": "^7.4|^8.1",
|
||||
"symfony/dependency-injection": "^7.4|^8.1",
|
||||
"symfony/deprecation-contracts": "^2.2|^3",
|
||||
"symfony/filesystem": "^6.4|^7.0|^8.0",
|
||||
"symfony/finder": "^6.4|^7.0|^8.0",
|
||||
"symfony/framework-bundle": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-kernel": "^6.4|^7.0|^8.0",
|
||||
"symfony/process": "^6.4|^7.0|^8.0"
|
||||
"symfony/filesystem": "^7.4|^8.1",
|
||||
"symfony/finder": "^7.4|^8.1",
|
||||
"symfony/framework-bundle": "^7.4|^8.1",
|
||||
"symfony/http-kernel": "^7.4|^8.1",
|
||||
"symfony/process": "^7.4|^8.1"
|
||||
},
|
||||
"conflict": {
|
||||
"doctrine/doctrine-bundle": "<2.10",
|
||||
@@ -15090,11 +15093,12 @@
|
||||
"doctrine/doctrine-bundle": "^2.10|^3.0",
|
||||
"doctrine/orm": "^2.15|^3",
|
||||
"doctrine/persistence": "^3.1|^4.0",
|
||||
"symfony/http-client": "^6.4|^7.0|^8.0",
|
||||
"symfony/phpunit-bridge": "^6.4.1|^7.0|^8.0",
|
||||
"symfony/security-core": "^6.4|^7.0|^8.0",
|
||||
"symfony/security-http": "^6.4|^7.0|^8.0",
|
||||
"symfony/yaml": "^6.4|^7.0|^8.0",
|
||||
"symfony/doctrine-bridge": "^7.4|^8.1",
|
||||
"symfony/http-client": "^7.4|^8.1",
|
||||
"symfony/phpunit-bridge": "^7.4|^8.1",
|
||||
"symfony/security-core": "^7.4|^8.1",
|
||||
"symfony/security-http": "^7.4|^8.1",
|
||||
"symfony/yaml": "^7.4|^8.1",
|
||||
"twig/twig": "^3.0|^4.x-dev"
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
@@ -15129,7 +15133,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/symfony/maker-bundle/issues",
|
||||
"source": "https://github.com/symfony/maker-bundle/tree/v1.67.0"
|
||||
"source": "https://github.com/symfony/maker-bundle/tree/1.68.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -15149,7 +15153,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-18T13:39:06+00:00"
|
||||
"time": "2026-09-12T21:13:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/phpunit-bridge",
|
||||
|
||||
@@ -26,6 +26,7 @@ framework:
|
||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||
App\Message\MailjetNewsletterEventMessage: async
|
||||
App\Message\CreateClickUpBookingTaskMessage: async
|
||||
App\Message\RoleNominationMessage: async
|
||||
|
||||
# Route your messages to the transports
|
||||
# 'App\Message\YourMessage': async
|
||||
|
||||
@@ -2,9 +2,12 @@ framework:
|
||||
router:
|
||||
utf8: true
|
||||
|
||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||
#default_uri: http://localhost
|
||||
# How URLs are generated where there is no request to take the host from: scheduled
|
||||
# commands and, notably, the Messenger workers that send mail off the request path.
|
||||
# Without it those links would point at http://localhost. Only ever consulted in a
|
||||
# non-HTTP context, so web requests keep using their own host — which matters here,
|
||||
# since one codebase serves four brands.
|
||||
default_uri: '%env(APP_BASE_URL)%'
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
|
||||
@@ -3,13 +3,14 @@ zenstruck_schedule:
|
||||
|
||||
mailer:
|
||||
service: mailer
|
||||
default_to: [email protected]
|
||||
default_from: info@ep-reisen.de
|
||||
default_to: [email protected]
|
||||
default_from: noreply@ep-reisen.de
|
||||
subject_prefix: "[MyE&P]"
|
||||
|
||||
schedule_extensions:
|
||||
email_on_failure:
|
||||
enabled: true
|
||||
to: [email protected]
|
||||
|
||||
tasks:
|
||||
- task: app:cleanup:xml-dumps
|
||||
|
||||
@@ -31,6 +31,12 @@ parameters:
|
||||
10321389: 'Reisen-Alert Stubaital'
|
||||
10554990: 'Reisen-Alert Ski & Boarderweek'
|
||||
|
||||
# Email domains whose accounts are members of staff. BusPro has no CRM selection expressing
|
||||
# "works here", so ROLE_EMPLOYEE is derived from the address instead. Matched exactly on the
|
||||
# domain part, never as a suffix.
|
||||
employee_email_domains:
|
||||
- 'ep-reisen.de'
|
||||
|
||||
# BusProNet "Hausleitung {CODE}" CRM selections, by selection id.
|
||||
# DEPLOYMENT-CRITICAL: roles and hotel codes are synced on every login, so an id missing
|
||||
# here does not merely fail to nominate a Hausleitung — it revokes the role and the hotel
|
||||
@@ -299,6 +305,10 @@ services:
|
||||
arguments:
|
||||
$houseManagerIds: '%bpn_crm_house_manager_ids%'
|
||||
|
||||
App\Security\EmployeeDomainMatcher:
|
||||
arguments:
|
||||
$domains: '%employee_email_domains%'
|
||||
|
||||
App\Service\DomainConfigProvider:
|
||||
arguments:
|
||||
$domainConfig: '%domain_config%'
|
||||
|
||||
+2
-33
@@ -77,47 +77,16 @@ $rsyncOptions = [
|
||||
];
|
||||
|
||||
host('prod')
|
||||
->setHostname('185.243.135.29')
|
||||
->setRemoteUser('p704161')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/home/www/p704161/html/myep')
|
||||
->set('writable_mode', 'chmod')
|
||||
->set('http_user', 'p704161')
|
||||
->set('bin/php', '/usr/local/bin/php')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
|
||||
;
|
||||
|
||||
host('staging')
|
||||
->setHostname('185.243.135.29')
|
||||
->setRemoteUser('p704161')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/home/www/p704161/html/myep-staging')
|
||||
->set('writable_mode', 'chmod')
|
||||
->set('http_user', 'p704161')
|
||||
->set('bin/php', '/usr/local/bin/php')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging')
|
||||
->add('shared_files', [
|
||||
'public/.htpasswd',
|
||||
])
|
||||
;
|
||||
|
||||
host('hetzner')
|
||||
->setHostname('dedi10193.your-server.de')
|
||||
->setRemoteUser('myepsf')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/usr/home/myepsf/public_html/myep')
|
||||
->setDeployPath('/usr/home/myepsf/public_html/prod')
|
||||
->set('bin/php', '/usr/bin/php')
|
||||
->set('http_user', 'myepsf')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging')
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
|
||||
->add('shared_files', [
|
||||
'public/.htpasswd',
|
||||
])
|
||||
|
||||
@@ -276,6 +276,8 @@ Single booking by UUID. `404 {"message":"Not found"}` if unknown.
|
||||
"accommodationDiscount": null,
|
||||
"boardServiceDiscount": null,
|
||||
"additionalServicesDiscount": null,
|
||||
"totalDiscountAmount": null,
|
||||
"totalDiscountLabel": null,
|
||||
"totalPrice": 123456,
|
||||
"pricingCurrency": "EUR",
|
||||
"pricingVersion": 3,
|
||||
@@ -283,7 +285,20 @@ Single booking by UUID. `404 {"message":"Not found"}` if unknown.
|
||||
}
|
||||
```
|
||||
|
||||
`totalPrice` and the `*Discount` fields are integer **minor units** (cents) — unlike the contingent endpoints. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure.
|
||||
`totalPrice` and `totalDiscountAmount` are integer **minor units** (cents) — unlike the contingent endpoints. The three `*Discount` fields are whole **percentages** (1–100) or `null`, not amounts. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure.
|
||||
|
||||
The first three discounts each apply to one section of the breakdown: `accommodationDiscount` to `basePrice + additionalPersonsPrice`, `boardServiceDiscount` to `boardPrice`, `additionalServicesDiscount` to `servicesPrice`. Surcharges and running costs sit in `total` but in no section, so they are never discounted.
|
||||
|
||||
`totalDiscountAmount` is a freely named discount on the whole price, labelled by `totalDiscountLabel`. It is a fixed sum, not a percentage, and is subtracted **after** the three section discounts have reduced `total`. It is capped at what is left of that subtotal, so `discountedTotal` is never negative and the amount shown in the breakdown may be smaller than `totalDiscountAmount` itself. The section discounts are each rounded to the nearest cent as they are applied.
|
||||
|
||||
`priceBreakdown` carries the resulting rows, all in minor units:
|
||||
|
||||
| field | meaning |
|
||||
| --- | --- |
|
||||
| `discounts` | list of `{label, percent, amount}` for the section discounts; entries rounding to `0` are omitted, and `label` already includes its `Rabatt ` prefix |
|
||||
| `discountSubtotal` | `total` minus the section discounts — present only when `totalDiscountAmount` applies *and* at least one section discount did, otherwise `null` |
|
||||
| `totalDiscountDetails` | `{label, amount}` for the total discount, or `null`; `amount` is the capped figure actually subtracted |
|
||||
| `discountedTotal` | the final price, equal to the booking's `totalPrice` |
|
||||
|
||||
#### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api`
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260908145156 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add a generic labelled discount on the accommodation booking total';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE accommodation_booking ADD total_discount INT DEFAULT NULL, ADD total_discount_label VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE accommodation_booking DROP total_discount, DROP total_discount_label');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* The percentage column is dropped rather than converted: it shipped with Version20260908145156
|
||||
* but no booking was ever given a value, so there is nothing to carry over.
|
||||
*/
|
||||
final class Version20260908161500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Store the accommodation booking total discount as an absolute amount in minor units instead of a percentage';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE accommodation_booking DROP total_discount, ADD total_discount_amount INT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE accommodation_booking DROP total_discount_amount, ADD total_discount INT DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
use function Symfony\Component\String\u;
|
||||
|
||||
/**
|
||||
|
||||
@@ -114,12 +114,13 @@ class BpnSyncContingentsCommand extends Command
|
||||
}
|
||||
|
||||
$io->writeln(sprintf(
|
||||
'<info>%s</info>: %s (+%d ~%d -%d)',
|
||||
'<info>%s</info>: %s (+%d ~%d -%d, %d beyond the previous horizon)',
|
||||
(string) $accommodation->getCalendarCode(),
|
||||
$result->changed ? 'changed' : 'unchanged',
|
||||
$result->added,
|
||||
$result->updated,
|
||||
$result->removed,
|
||||
$result->extended,
|
||||
), OutputInterface::VERBOSITY_VERBOSE);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,11 +50,12 @@ final class DbAnonymizeCommand extends Command
|
||||
}
|
||||
|
||||
$io->success(sprintf(
|
||||
'Anonymized %d users, %d newsletter consents, %d newsletter opt-in requests, and %d booking drafts.',
|
||||
'Anonymized %d users, %d newsletter consents, %d newsletter opt-in requests, %d booking drafts, and %d accommodation bookings.',
|
||||
$report['users'],
|
||||
$report['newsletterConsents'],
|
||||
$report['newsletterOptInRequests'],
|
||||
$report['bookingEditDrafts'],
|
||||
$report['accommodationBookings'],
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
@@ -334,10 +334,10 @@ class ImportGroupsLegacyDataCommand extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, string>> $rows
|
||||
* @param list<array<string, string>> $rows
|
||||
* @param array<int, Accommodation> $accommodationByUid
|
||||
* @param list<int> $knownUids
|
||||
* @param list<array{season: Season, dateFrom: \DateTimeImmutable, dateTo: \DateTimeImmutable}> $seasonRanges
|
||||
* @param list<int> $knownUids
|
||||
* @param list<array{season: Season, dateFrom: \DateTimeImmutable, dateTo: \DateTimeImmutable}> $seasonRanges
|
||||
*
|
||||
* @return array{0: int, 1: list<int>, 2: int}
|
||||
*/
|
||||
@@ -526,11 +526,11 @@ class ImportGroupsLegacyDataCommand extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, Accommodation> $accommodationByUid
|
||||
* @param list<AdditionalService> $additionalServicesNeedingDefaultRange only these
|
||||
* (titles without a leading year) get the accommodation-
|
||||
* wide fallback range; year-prefixed ones already have
|
||||
* their scoped range set in importAdditionalServices().
|
||||
* @param array<int, Accommodation> $accommodationByUid
|
||||
* @param list<AdditionalService> $additionalServicesNeedingDefaultRange only these
|
||||
* (titles without a leading year) get the accommodation-
|
||||
* wide fallback range; year-prefixed ones already have
|
||||
* their scoped range set in importAdditionalServices()
|
||||
*/
|
||||
private function applyDateRangeToServices(array $accommodationByUid, array $additionalServicesNeedingDefaultRange): void
|
||||
{
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\BusProNet\Model\PersonalDataUpdateResponse;
|
||||
use App\Entity\User;
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Form\PersonalDataType;
|
||||
|
||||
@@ -19,7 +19,7 @@ class CalendarController extends AbstractController
|
||||
{
|
||||
return $this->render('admin/accommodation/_calendar_section.html.twig', [
|
||||
'accommodation' => $accommodation,
|
||||
'startMonth' => $request->query->get('month'),
|
||||
'startMonth' => $request->query->get('month'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ class EditController extends AbstractController
|
||||
'current_board_service' => $currentBoardService,
|
||||
'current_additional_services' => $currentAdditionalServices,
|
||||
'assignable_managers' => $assignableManagers,
|
||||
'currency' => $booking->getPricingCurrency() ?? $accommodation?->getCurrency() ?? 'EUR',
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ class CreateController extends AbstractController
|
||||
'date_range' => [
|
||||
'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'),
|
||||
'to' => $accommodationPrice->getDateTo()->format('Y-m-d'),
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
|
||||
|
||||
@@ -52,7 +52,7 @@ class EditController extends AbstractController
|
||||
'date_range' => [
|
||||
'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'),
|
||||
'to' => $accommodationPrice->getDateTo()->format('Y-m-d'),
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
|
||||
|
||||
@@ -4,14 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AdditionalService;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Form\Admin\Groups\AdditionalServiceType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
@@ -106,7 +106,7 @@ class Step2Controller extends AbstractController
|
||||
$selectedAdditionalServices = $this->resolveSelectedAdditionalServices($dto, $services['additionalServices']);
|
||||
|
||||
$priceBreakdown = null;
|
||||
if ($dto->dateFrom !== null && $dto->dateTo !== null) {
|
||||
if (null !== $dto->dateFrom && null !== $dto->dateTo) {
|
||||
$priceBreakdown = $this->priceCalculator->calculate(
|
||||
$dto->paxCount,
|
||||
$dto->minorsCount,
|
||||
@@ -168,7 +168,7 @@ class Step2Controller extends AbstractController
|
||||
|
||||
return array_values(array_filter(
|
||||
$additionalServices,
|
||||
fn(AdditionalService $s) => isset($selectedIds[$s->getId()]),
|
||||
fn (AdditionalService $s) => isset($selectedIds[$s->getId()]),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,19 +6,20 @@ namespace App\Dashboard\Widget;
|
||||
|
||||
use App\Dashboard\Contract\DashboardWidgetProviderInterface;
|
||||
use App\Entity\User;
|
||||
use App\Form\Model\Filter\AbstractListFilterDto;
|
||||
use App\Model\DashboardWidget;
|
||||
use App\Model\DashboardWidgetEntry;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\Role;
|
||||
use App\Service\RoleApprovalUrlGenerator;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* The accounts waiting on an administrator to act.
|
||||
*
|
||||
* A nomination grants nothing until it is approved, and nothing else in the application
|
||||
* announces that one is waiting — without this widget an administrator only finds out by
|
||||
* opening the user list.
|
||||
* A nomination grants nothing until it is approved. RoleNominationHandler emails the
|
||||
* administrators the moment one appears, but that fires once, on the login that produced it —
|
||||
* this widget is the standing list, and the only thing that still shows a nomination somebody
|
||||
* has left sitting.
|
||||
*/
|
||||
class PendingRoleApprovalsWidgetProvider implements DashboardWidgetProviderInterface
|
||||
{
|
||||
@@ -27,6 +28,7 @@ class PendingRoleApprovalsWidgetProvider implements DashboardWidgetProviderInter
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly RoleApprovalUrlGenerator $approvalUrlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -46,7 +48,7 @@ class PendingRoleApprovalsWidgetProvider implements DashboardWidgetProviderInter
|
||||
'Offene Rollenfreigaben',
|
||||
array_map(fn (User $user): DashboardWidgetEntry => new DashboardWidgetEntry(
|
||||
$this->label($user),
|
||||
$this->approvalUrl($user),
|
||||
$this->approvalUrlGenerator->forUser($user),
|
||||
'user',
|
||||
), $this->userRepository->findWithPendingRoles(self::LIMIT)),
|
||||
'Keine offenen Rollenfreigaben.',
|
||||
@@ -65,20 +67,4 @@ class PendingRoleApprovalsWidgetProvider implements DashboardWidgetProviderInter
|
||||
|
||||
return sprintf('%s: %s', $user->getDisplayName(), implode(', ', $nominated));
|
||||
}
|
||||
|
||||
/**
|
||||
* The user list, filtered down to this account.
|
||||
*
|
||||
* Approving happens in a modal that app_admin_user_permissions renders as a bare fragment,
|
||||
* so it cannot be linked to directly. The filtered list is one click away from it and shows
|
||||
* the nomination badge on the way. The query string is flat because the list filter forms
|
||||
* declare no block prefix.
|
||||
*/
|
||||
private function approvalUrl(User $user): string
|
||||
{
|
||||
return $this->urlGenerator->generate('app_admin_user', [
|
||||
AbstractListFilterDto::MARKER => 1,
|
||||
'q' => $user->getEmail(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,18 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
||||
#[Assert\Range(min: 1, max: 100)]
|
||||
private ?int $additionalServicesDiscount = null;
|
||||
|
||||
/** Absolute discount on the total, in minor units. Applied after the three section discounts. */
|
||||
#[ORM\Column(nullable: true)]
|
||||
#[Assert\Positive]
|
||||
private ?int $totalDiscountAmount = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Expression(
|
||||
'null === this.getTotalDiscountAmount() or (null !== value and "" !== value)',
|
||||
message: 'Bitte eine Bezeichnung für den Rabatt angeben.',
|
||||
)]
|
||||
private ?string $totalDiscountLabel = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(onDelete: 'SET NULL')]
|
||||
private ?User $managedBy = null;
|
||||
@@ -638,6 +650,30 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTotalDiscountAmount(): ?int
|
||||
{
|
||||
return $this->totalDiscountAmount;
|
||||
}
|
||||
|
||||
public function setTotalDiscountAmount(?int $totalDiscountAmount): self
|
||||
{
|
||||
$this->totalDiscountAmount = $totalDiscountAmount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTotalDiscountLabel(): ?string
|
||||
{
|
||||
return $this->totalDiscountLabel;
|
||||
}
|
||||
|
||||
public function setTotalDiscountLabel(?string $totalDiscountLabel): self
|
||||
{
|
||||
$this->totalDiscountLabel = $totalDiscountLabel;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getManagedBy(): ?User
|
||||
{
|
||||
return $this->managedBy;
|
||||
|
||||
@@ -11,8 +11,8 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
/**
|
||||
* Bookkeeping for the scheduled contingent sync of a single accommodation.
|
||||
*
|
||||
* Holds the fingerprint of the stored contingent days, which is how the sync decides whether
|
||||
* anything actually changed, and `changedAt` records when it last did.
|
||||
* `changedAt` records when the sync last saw a day appear, vanish or switch status within the
|
||||
* horizon it had already reached; `horizonTo` is how far that reach extends.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ContingentSyncStateRepository::class)]
|
||||
#[ORM\Table(name: 'contingent_sync_state')]
|
||||
@@ -28,13 +28,7 @@ class ContingentSyncState
|
||||
private ?Accommodation $accommodation = null;
|
||||
|
||||
/**
|
||||
* sha256 over the stored contingent days, contingent status only.
|
||||
*/
|
||||
#[ORM\Column(length: 64)]
|
||||
private string $contentHash = '';
|
||||
|
||||
/**
|
||||
* Last time the fingerprint actually changed. Kept for operators, not exposed via the API.
|
||||
* Last time the stored snapshot actually changed. Kept for operators, not exposed via the API.
|
||||
*/
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $changedAt = null;
|
||||
@@ -76,18 +70,6 @@ class ContingentSyncState
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContentHash(): string
|
||||
{
|
||||
return $this->contentHash;
|
||||
}
|
||||
|
||||
public function setContentHash(string $contentHash): self
|
||||
{
|
||||
$this->contentHash = $contentHash;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getChangedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->changedAt;
|
||||
|
||||
@@ -9,7 +9,7 @@ enum PriceType: string
|
||||
|
||||
public function priority(): int
|
||||
{
|
||||
return match($this) {
|
||||
return match ($this) {
|
||||
self::OVERRIDE => 1,
|
||||
self::DISCOUNT => 2,
|
||||
};
|
||||
@@ -17,7 +17,7 @@ enum PriceType: string
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match($this) {
|
||||
return match ($this) {
|
||||
self::OVERRIDE => 'Override',
|
||||
self::DISCOUNT => 'Rabatt',
|
||||
};
|
||||
|
||||
@@ -10,9 +10,9 @@ use App\Service\AdditionalServiceExclusionResolver;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
@@ -96,6 +96,6 @@ class AccommodationStep2Type extends AbstractType
|
||||
'max_adolescent_age' => 0,
|
||||
]);
|
||||
$resolver->setAllowedTypes('max_adolescent_age', 'int');
|
||||
$resolver->setAllowedTypes('additional_services', AdditionalService::class . '[]');
|
||||
$resolver->setAllowedTypes('additional_services', AdditionalService::class.'[]');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Positive;
|
||||
use Symfony\Component\Validator\Constraints\Range;
|
||||
|
||||
/** @extends AbstractType<AccommodationBooking> */
|
||||
@@ -158,6 +160,23 @@ class AccommodationBookingType extends AbstractType
|
||||
new Range(min: 1, max: 100),
|
||||
],
|
||||
])
|
||||
// A fixed sum, not a percentage, applied to the total after the three section
|
||||
// discounts. The label is not marked required here — an amount may legitimately be
|
||||
// absent — but the entity refuses an amount without one, so no unnamed discount can
|
||||
// reach the customer.
|
||||
->add('totalDiscountAmount', MoneyType::class, [
|
||||
'label' => 'Rabatt Gesamtpreis',
|
||||
'currency' => $options['currency'],
|
||||
'divisor' => 100,
|
||||
'required' => false,
|
||||
'constraints' => [
|
||||
new Positive(),
|
||||
],
|
||||
])
|
||||
->add('totalDiscountLabel', TextType::class, [
|
||||
'label' => 'Bezeichnung Rabatt Gesamtpreis',
|
||||
'required' => false,
|
||||
])
|
||||
->add('remarks', TextareaType::class, [
|
||||
'label' => 'Bemerkungen',
|
||||
'required' => false,
|
||||
@@ -187,6 +206,7 @@ class AccommodationBookingType extends AbstractType
|
||||
'current_board_service' => null,
|
||||
'current_additional_services' => [],
|
||||
'assignable_managers' => [],
|
||||
'currency' => 'EUR',
|
||||
]);
|
||||
$resolver->setAllowedTypes('max_adolescent_age', 'int');
|
||||
$resolver->setAllowedTypes('board_services', 'array');
|
||||
@@ -194,5 +214,6 @@ class AccommodationBookingType extends AbstractType
|
||||
$resolver->setAllowedTypes('current_board_service', ['null', BoardService::class]);
|
||||
$resolver->setAllowedTypes('current_additional_services', 'array');
|
||||
$resolver->setAllowedTypes('assignable_managers', 'array');
|
||||
$resolver->setAllowedTypes('currency', 'string');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,12 +37,12 @@ class AccommodationPriceType extends AbstractType
|
||||
'class' => PriceType::class,
|
||||
'required' => false,
|
||||
'placeholder' => 'Standard',
|
||||
'choice_label' => fn($item) => $item->label(),
|
||||
'choice_label' => fn ($item) => $item->label(),
|
||||
])
|
||||
->add('season', EnumType::class, [
|
||||
'label' => 'Saison',
|
||||
'class' => Season::class,
|
||||
'choice_label' => fn($item) => $item->token(),
|
||||
'choice_label' => fn ($item) => $item->token(),
|
||||
])
|
||||
->add('includedPax', IntegerType::class, [
|
||||
'label' => 'Inklusiv-Personen',
|
||||
|
||||
@@ -35,7 +35,7 @@ class AdditionalServiceType extends AbstractType
|
||||
->add('type', EnumType::class, [
|
||||
'label' => 'Preistyp',
|
||||
'class' => AdditionalServiceTypeEnum::class,
|
||||
'choice_label' => fn($item) => $item->label(),
|
||||
'choice_label' => fn ($item) => $item->label(),
|
||||
])
|
||||
->add('price', MoneyType::class, [
|
||||
'label' => 'Preis',
|
||||
|
||||
@@ -28,7 +28,7 @@ class AccommodationBookingDto
|
||||
#[Assert\NotNull(message: 'required', groups: ['step_2'])]
|
||||
public ?int $selectedBoardServiceId = null;
|
||||
|
||||
/** @var list<int>*/
|
||||
/** @var list<int> */
|
||||
public array $selectedAdditionalServiceIds = [];
|
||||
|
||||
public bool $isInquiry = false;
|
||||
|
||||
@@ -13,7 +13,7 @@ class BookingEditContext
|
||||
{
|
||||
/**
|
||||
* @param array<int, ParticipantCardDataDto>|null $cardsData
|
||||
* @param array<int, list<string>> $missingValueLabelsByParticipantIndex
|
||||
* @param array<int, list<string>> $missingValueLabelsByParticipantIndex
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly BookingDto $bookingDto,
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Form\Model;
|
||||
use App\Validator\Constraints as AppAssert;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
use function Symfony\Component\String\u;
|
||||
|
||||
/**
|
||||
|
||||
@@ -199,7 +199,7 @@ class ParticipantTransportationDiscountReplacementFieldHandler extends AbstractP
|
||||
$services[$oldService->id]->mapping = array_values(
|
||||
array_filter(
|
||||
$services[$oldService->id]->mapping,
|
||||
fn(int $idx): bool => $idx !== $participantIndex,
|
||||
fn (int $idx): bool => $idx !== $participantIndex,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
/**
|
||||
* Dispatched when the BusPro CRM newly nominates an account for one or more administrative roles,
|
||||
* so that the administrators who can approve it are told there is something waiting in
|
||||
* /admin/user.
|
||||
*
|
||||
* Carries the ids only, never the User: the handler runs in a separate process, where a
|
||||
* serialized entity would be stale by the time it is read.
|
||||
*/
|
||||
final class RoleNominationMessage
|
||||
{
|
||||
/**
|
||||
* @param string[] $roles the nominated roles themselves, not their _PENDING markers
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $userId,
|
||||
public readonly array $roles,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\Email\Mailer;
|
||||
use App\Message\RoleNominationMessage;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\Role;
|
||||
use App\Service\RoleApprovalUrlGenerator;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Tells the administrators that an account is waiting for a role to be approved.
|
||||
*
|
||||
* Runs off the request: mail is routed sync in this application, so sending it inline would put
|
||||
* SMTP latency and SMTP failures into the login path.
|
||||
*/
|
||||
#[AsMessageHandler]
|
||||
final class RoleNominationHandler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly Mailer $mailer,
|
||||
private readonly RoleApprovalUrlGenerator $approvalUrlGenerator,
|
||||
private readonly LoggerInterface $authLogger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(RoleNominationMessage $message): void
|
||||
{
|
||||
$user = $this->userRepository->find($message->userId);
|
||||
|
||||
if (null === $user) {
|
||||
// The account is gone — a retry cannot bring it back.
|
||||
$this->authLogger->warning('Nominated user not found, skipping the role nomination email', [
|
||||
'userId' => $message->userId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$recipients = array_values(array_filter(array_map(
|
||||
static fn ($admin): ?string => $admin->getEmail(),
|
||||
$this->userRepository->findAdministrators(),
|
||||
)));
|
||||
|
||||
if ([] === $recipients) {
|
||||
// Worth a warning rather than a silent return: nobody can approve the nomination, and
|
||||
// without this line nobody would find out that the notification goes nowhere.
|
||||
$this->authLogger->warning('No administrator to notify about a role nomination', [
|
||||
'userId' => $message->userId,
|
||||
'roles' => $message->roles,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$labels = Role::labels();
|
||||
|
||||
$this->mailer->createAndSendEmail(
|
||||
[
|
||||
'user' => $user,
|
||||
'roles' => array_values(array_map(
|
||||
static fn (string $role): string => $labels[$role] ?? $role,
|
||||
$message->roles,
|
||||
)),
|
||||
// Absolute: generated in a worker, where there is no request to borrow a host
|
||||
// from (see framework.router.default_uri).
|
||||
'approvalUrl' => $this->approvalUrlGenerator->forUser($user, UrlGeneratorInterface::ABSOLUTE_URL),
|
||||
],
|
||||
[
|
||||
'template' => 'email/role_nomination.html.twig',
|
||||
'subject' => 'Neue Rollen-Freischaltung angefordert',
|
||||
'to' => $recipients,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,13 @@ final readonly class AccommodationBookingApiResponse
|
||||
#[Groups(['api:single'])]
|
||||
public ?int $additionalServicesDiscount;
|
||||
|
||||
/** An absolute amount in minor units, subtracted after the three section discounts above. */
|
||||
#[Groups(['api:single'])]
|
||||
public ?int $totalDiscountAmount;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?string $totalDiscountLabel;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?int $totalPrice;
|
||||
|
||||
@@ -115,6 +122,8 @@ final readonly class AccommodationBookingApiResponse
|
||||
$this->accommodationDiscount = $booking->getAccommodationDiscount();
|
||||
$this->boardServiceDiscount = $booking->getBoardServiceDiscount();
|
||||
$this->additionalServicesDiscount = $booking->getAdditionalServicesDiscount();
|
||||
$this->totalDiscountAmount = $booking->getTotalDiscountAmount();
|
||||
$this->totalDiscountLabel = $booking->getTotalDiscountLabel();
|
||||
$this->totalPrice = $booking->getTotalPrice();
|
||||
$this->pricingCurrency = $booking->getPricingCurrency();
|
||||
$this->pricingVersion = $booking->getPricingVersion();
|
||||
|
||||
@@ -13,19 +13,24 @@ readonly class ContingentSyncResult
|
||||
public bool $successful,
|
||||
public bool $changed,
|
||||
public int $added,
|
||||
public int $extended,
|
||||
public int $updated,
|
||||
public int $removed,
|
||||
public ?string $error = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function synced(bool $changed, int $added, int $updated, int $removed): self
|
||||
/**
|
||||
* $added counts days newly stored within the horizon the previous run already reached;
|
||||
* $extended counts days beyond it, which is the rolling window growing rather than a change.
|
||||
*/
|
||||
public static function synced(bool $changed, int $added, int $extended, int $updated, int $removed): self
|
||||
{
|
||||
return new self(true, $changed, $added, $updated, $removed);
|
||||
return new self(true, $changed, $added, $extended, $updated, $removed);
|
||||
}
|
||||
|
||||
public static function failed(string $error): self
|
||||
{
|
||||
return new self(false, false, 0, 0, 0, $error);
|
||||
return new self(false, false, 0, 0, 0, 0, $error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,30 +71,6 @@ class ContingentDayRepository extends ServiceEntityRepository
|
||||
return $this->indexByDate($days);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the accommodation's complete stored snapshot as an ordered "Y-m-d:STATUS" list.
|
||||
*
|
||||
* Scalar hydration keeps the fingerprint cheap: the entities themselves are of no interest here.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function findStatusFingerprintParts(Accommodation $accommodation): array
|
||||
{
|
||||
/** @var array<int, array{date: \DateTimeImmutable, status: \App\BpnConnect\Model\ContingentStatus}> $rows */
|
||||
$rows = $this->createQueryBuilder('cd')
|
||||
->select('cd.date', 'cd.status')
|
||||
->where('cd.accommodation = :accommodation')
|
||||
->setParameter('accommodation', $accommodation)
|
||||
->orderBy('cd.date', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
return array_map(
|
||||
static fn (array $row) => $row['date']->format('Y-m-d').':'.$row['status']->value,
|
||||
$rows,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retention: drops snapshot days that are in the past and can no longer be requested.
|
||||
*/
|
||||
|
||||
@@ -71,6 +71,32 @@ class UserRepository extends ServiceEntityRepository
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* The administrators who can approve a role nomination.
|
||||
*
|
||||
* The quote-anchored needle is load-bearing. ROLE_ADMIN_PENDING lives in the same JSON column,
|
||||
* and an unanchored '%ROLE_ADMIN%' would match it — which would mail the very people whose own
|
||||
* nomination is unapproved about other people's nominations. The result is filtered through
|
||||
* Role::effectiveOnly() as well, so the guarantee does not rest on the LIKE alone.
|
||||
*
|
||||
* @return User[]
|
||||
*/
|
||||
public function findAdministrators(): array
|
||||
{
|
||||
/** @var User[] $candidates */
|
||||
$candidates = $this->createQueryBuilder('u')
|
||||
->andWhere('u.roles LIKE :admin')
|
||||
->setParameter('admin', '%"'.Role::ADMIN.'"%')
|
||||
->orderBy('u.email', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return array_values(array_filter(
|
||||
$candidates,
|
||||
static fn (User $user): bool => \in_array(Role::ADMIN, Role::effectiveOnly($user->getRoles()), true),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Everyone worth filtering an accommodation booking by: current groups staff, plus whoever
|
||||
* a booking is still assigned to even after losing the role — otherwise a booking assigned
|
||||
|
||||
@@ -12,12 +12,14 @@ use App\BusProNet\Model\CrmAttributes;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\Entity\User;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Message\RoleNominationMessage;
|
||||
use App\Service\ProfileCompletenessChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||
@@ -34,8 +36,10 @@ use Symfony\Component\Security\Http\Util\TargetPathTrait;
|
||||
*
|
||||
* The password is kept, RSA-encrypted, because every later BPN call needs it again.
|
||||
*
|
||||
* BusPro owns the whole role set and the hotel codes: both are synced on every login, in both
|
||||
* directions, so anything the CRM no longer reports is withdrawn here. What the CRM claims is
|
||||
* BusPro owns the whole role set bar one, and the hotel codes: both are synced on every login, in
|
||||
* both directions, so anything the CRM no longer reports is withdrawn here. The exception is
|
||||
* ROLE_EMPLOYEE, which BusPro has no selection for and which is derived from the account's email
|
||||
* domain — passed to Role::sync() as a claim, so it is granted and revoked by the same machinery. What the CRM claims is
|
||||
* not automatically granted, though — Role::sync() turns an administrative claim into a
|
||||
* nomination that an administrator has to approve in /admin/user, because BusPro backend users
|
||||
* can edit their own CRM selections and would otherwise make themselves administrators.
|
||||
@@ -51,6 +55,8 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
||||
private readonly Crypt $crypt,
|
||||
private readonly ProfileCompletenessChecker $completenessChecker,
|
||||
private readonly LoggerInterface $authLogger,
|
||||
private readonly EmployeeDomainMatcher $employeeDomainMatcher,
|
||||
private readonly MessageBusInterface $messageBus,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -117,7 +123,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
||||
->setProfileComplete($this->completenessChecker->isComplete($personalData))
|
||||
;
|
||||
|
||||
$this->syncFromCrm($user, $crmAttributes);
|
||||
$nominated = $this->syncFromCrm($user, $crmAttributes);
|
||||
|
||||
// Registered only once it is fully populated: syncFromCrm() logs on a channel that writes
|
||||
// to the database, and an account already managed at that point would be flushed
|
||||
@@ -126,14 +132,24 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
||||
$this->entityManager->persist($user);
|
||||
$this->entityManager->flush();
|
||||
|
||||
// After the flush, deliberately: a first login has no id before it, and the transport is
|
||||
// Doctrine-backed, so a message queued ahead of a failing flush would announce a
|
||||
// nomination that was never stored.
|
||||
if ([] !== $nominated) {
|
||||
$this->messageBus->dispatch(new RoleNominationMessage((int) $user->getId(), $nominated));
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes back what the CRM currently claims: the roles per Role::sync() and the hotel codes
|
||||
* verbatim. Both replace what is stored, which is what makes BusPro the source of truth.
|
||||
*
|
||||
* @return string[] the roles this login newly nominated the account for — the roles
|
||||
* themselves, not their markers, and empty whenever nothing changed
|
||||
*/
|
||||
private function syncFromCrm(User $user, CrmAttributes $crmAttributes): void
|
||||
private function syncFromCrm(User $user, CrmAttributes $crmAttributes): array
|
||||
{
|
||||
$previousRoles = $user->getRoles();
|
||||
|
||||
@@ -148,25 +164,43 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
||||
// An existing account keeps everything it has. A brand new one still needs a role,
|
||||
// and an empty claim set is exactly what Role::sync() answers with the fallback.
|
||||
if ([] !== Role::assignedOnly($previousRoles)) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
$claimedRoles = $crmAttributes->roles;
|
||||
|
||||
// Not a CRM claim: BusPro has no selection for it, so the account's own address decides.
|
||||
// Passing it in as a claim rather than setting it afterwards is what makes it revocable —
|
||||
// Role::sync() strips every stored role the claim set does not contain.
|
||||
if ($this->employeeDomainMatcher->isEmployee($user->getEmail())) {
|
||||
$claimedRoles[] = Role::EMPLOYEE;
|
||||
}
|
||||
|
||||
$user
|
||||
->setRoles(Role::sync($previousRoles, $crmAttributes->roles))
|
||||
->setRoles(Role::sync($previousRoles, $claimedRoles))
|
||||
->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes)))
|
||||
;
|
||||
|
||||
$nominated = array_diff(Role::pendingOnly($user->getRoles()), Role::pendingOnly($previousRoles));
|
||||
$nominated = array_values(array_diff(
|
||||
Role::pendingOnly($user->getRoles()),
|
||||
Role::pendingOnly($previousRoles),
|
||||
));
|
||||
|
||||
if ([] !== $nominated) {
|
||||
// The CRM claims an administrative role for somebody who does not hold it. It grants
|
||||
// nothing until an administrator approves it in /admin/user.
|
||||
$this->authLogger->info('Nominated for administrative roles by the BPN CRM', [
|
||||
'email' => $user->getEmail(),
|
||||
'roles' => array_values($nominated),
|
||||
]);
|
||||
if ([] === $nominated) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// The CRM claims an administrative role for somebody who does not hold it. It grants
|
||||
// nothing until an administrator approves it in /admin/user.
|
||||
$this->authLogger->info('Nominated for administrative roles by the BPN CRM', [
|
||||
'email' => $user->getEmail(),
|
||||
'roles' => $nominated,
|
||||
]);
|
||||
|
||||
// Only the newly appeared markers reach this point, so a repeat login with a nomination
|
||||
// still standing announces nothing. That difference is the whole de-duplication.
|
||||
return array_keys(Role::nominatedFrom($nominated));
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
/**
|
||||
* Decides whether an account belongs to a member of staff, by its email domain.
|
||||
*
|
||||
* BusPro has no CRM selection expressing "works here", so the email address is the only signal
|
||||
* available. The domains are configuration (%employee_email_domains%) rather than a constant
|
||||
* because they are deployment-specific, in the same way the brand hosts and the CRM selection ids
|
||||
* are.
|
||||
*
|
||||
* Matching is exact on the domain part and never on a suffix: "mail.ep-reisen.de" and
|
||||
* "notep-reisen.de" are not "ep-reisen.de". A suffix match here would hand ROLE_EMPLOYEE to
|
||||
* anybody able to register a domain ending in the configured one.
|
||||
*/
|
||||
final class EmployeeDomainMatcher
|
||||
{
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
private readonly array $domains;
|
||||
|
||||
/**
|
||||
* @param string[] $domains
|
||||
*/
|
||||
public function __construct(array $domains)
|
||||
{
|
||||
$this->domains = array_values(array_filter(array_map(
|
||||
static fn (string $domain): string => strtolower(trim($domain, " \t\n\r\0\x0B.@")),
|
||||
$domains,
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* A malformed or missing identifier is simply not an employee. It must not throw: this runs
|
||||
* inside the authentication path, where an exception would turn a bad address into a failed
|
||||
* login rather than a login without the role.
|
||||
*/
|
||||
public function isEmployee(?string $email): bool
|
||||
{
|
||||
if (null === $email || [] === $this->domains) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$position = strrpos($email, '@');
|
||||
|
||||
if (false === $position) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$domain = strtolower(substr($email, $position + 1));
|
||||
|
||||
return \in_array($domain, $this->domains, true);
|
||||
}
|
||||
}
|
||||
+17
-3
@@ -16,6 +16,13 @@ namespace App\Security;
|
||||
* nothing until an administrator approves it in /admin/user. The CRM's word alone is enough to
|
||||
* take a role away, never to hand it out, and an administrator's word alone is enough for
|
||||
* neither.
|
||||
*
|
||||
* One role is not claimed by the CRM at all: ROLE_EMPLOYEE is derived from the account's own
|
||||
* email domain, because BusPro has no selection expressing "works here". It is passed to sync()
|
||||
* as a claim alongside the CRM's, so it is granted and revoked by exactly the same machinery.
|
||||
* That widens a claim from "what the CRM reports" to "what the CRM reports plus what the account
|
||||
* itself implies", and nothing more: ROLE_EMPLOYEE is not administrative, so it cannot reach the
|
||||
* nomination path, and the CRM remains the only source for every role that grants privileges.
|
||||
*/
|
||||
final class Role
|
||||
{
|
||||
@@ -32,6 +39,7 @@ final class Role
|
||||
public const HOUSE_MANAGER = 'ROLE_HOUSE_MANAGER';
|
||||
public const GROUPS_ADMIN = 'ROLE_GROUPS_ADMIN';
|
||||
public const GROUPS_MANAGER = 'ROLE_GROUPS_MANAGER';
|
||||
public const EMPLOYEE = 'ROLE_EMPLOYEE';
|
||||
|
||||
/**
|
||||
* Appended to an administrative role to mark it as claimed by the CRM but not yet approved.
|
||||
@@ -55,17 +63,21 @@ final class Role
|
||||
self::HOUSE_MANAGER,
|
||||
self::GROUPS_ADMIN,
|
||||
self::GROUPS_MANAGER,
|
||||
self::EMPLOYEE,
|
||||
];
|
||||
|
||||
/**
|
||||
* Roles the CRM grants outright. ROLE_CUSTOMER is never claimed by BusPro — it is the
|
||||
* fallback for an account left without any effective role, and exclusive with the others.
|
||||
* Roles granted outright, without an approval step. ROLE_CUSTOMER is never claimed by BusPro —
|
||||
* it is the fallback for an account left without any effective role, and exclusive with the
|
||||
* others. ROLE_EMPLOYEE is not claimed by BusPro either: it is derived from the account's own
|
||||
* email domain (see EmployeeDomainMatcher) and, being effective, displaces that fallback.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
public const UNCONDITIONAL = [
|
||||
self::TEAMER,
|
||||
self::CUSTOMER,
|
||||
self::EMPLOYEE,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -180,7 +192,8 @@ final class Role
|
||||
* Nothing here can raise a privilege: step 3 only ever produces markers.
|
||||
*
|
||||
* @param string[] $storedRoles
|
||||
* @param string[] $claimedRoles what the CRM reports
|
||||
* @param string[] $claimedRoles what the CRM reports, plus the roles derived from the account
|
||||
* itself (ROLE_EMPLOYEE); anything outside self::ALL is ignored
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
@@ -274,6 +287,7 @@ final class Role
|
||||
self::HOUSE_MANAGER => 'Hausleitung',
|
||||
self::GROUPS_ADMIN => 'Preisrechner Admin',
|
||||
self::GROUPS_MANAGER => 'Preisrechner',
|
||||
self::EMPLOYEE => 'Mitarbeiter:in',
|
||||
];
|
||||
|
||||
foreach (self::ADMINISTRATIVE as $role) {
|
||||
|
||||
@@ -37,14 +37,18 @@ class AccommodationBookingBreakdownCalculator
|
||||
*
|
||||
* @param array<string, mixed> $breakdown
|
||||
*
|
||||
* @return array<string, mixed> the breakdown plus `discounts` and `discountedTotal`
|
||||
* @return array<string, mixed> the breakdown plus `discounts`, `discountSubtotal`,
|
||||
* `totalDiscountDetails` and `discountedTotal`
|
||||
*/
|
||||
public function withDiscounts(AccommodationBooking $booking, array $breakdown): array
|
||||
{
|
||||
// The labels carry their own "Rabatt" prefix so that every row renders as plain
|
||||
// {label} {percent} %, while the freely named discount on the total below is a fixed
|
||||
// amount and renders as its label alone.
|
||||
$buckets = [
|
||||
['Unterkunft', $booking->getAccommodationDiscount(), (int) ($breakdown['basePrice'] ?? 0) + (int) ($breakdown['additionalPersonsPrice'] ?? 0)],
|
||||
['Verpflegung', $booking->getBoardServiceDiscount(), (int) ($breakdown['boardPrice'] ?? 0)],
|
||||
['Zusatzleistungen', $booking->getAdditionalServicesDiscount(), (int) ($breakdown['servicesPrice'] ?? 0)],
|
||||
['Rabatt Unterkunft', $booking->getAccommodationDiscount(), (int) ($breakdown['basePrice'] ?? 0) + (int) ($breakdown['additionalPersonsPrice'] ?? 0)],
|
||||
['Rabatt Verpflegung', $booking->getBoardServiceDiscount(), (int) ($breakdown['boardPrice'] ?? 0)],
|
||||
['Rabatt Zusatzleistungen', $booking->getAdditionalServicesDiscount(), (int) ($breakdown['servicesPrice'] ?? 0)],
|
||||
];
|
||||
|
||||
$discounts = [];
|
||||
@@ -63,10 +67,26 @@ class AccommodationBookingBreakdownCalculator
|
||||
$discounts[] = ['label' => $label, 'percent' => $percent, 'amount' => $amount];
|
||||
}
|
||||
|
||||
$breakdown['discounts'] = $discounts;
|
||||
// Deliberately not $booking->getTotalPrice(): refreshPriceSnapshot() calls this while
|
||||
// computing the new total, where the stored one is still the outdated value.
|
||||
$breakdown['discountedTotal'] = (int) ($breakdown['total'] ?? 0) - $discountSum;
|
||||
$subtotal = (int) ($breakdown['total'] ?? 0) - $discountSum;
|
||||
|
||||
// A fixed sum rather than a percentage, subtracted after the section discounts. Clamped to
|
||||
// what is left of the subtotal so that a discount larger than the price cannot produce a
|
||||
// negative total, the same guard BookingSummaryAssembler applies to vouchers.
|
||||
$totalDiscountAmount = min($booking->getTotalDiscountAmount() ?? 0, max(0, $subtotal));
|
||||
|
||||
$breakdown['discounts'] = $discounts;
|
||||
$breakdown['totalDiscountDetails'] = 0 !== $totalDiscountAmount
|
||||
? [
|
||||
'label' => (string) $booking->getTotalDiscountLabel(),
|
||||
'amount' => $totalDiscountAmount,
|
||||
]
|
||||
: null;
|
||||
// Only worth a row when it sits between two sets of discounts — with no section discount
|
||||
// above it, a subtotal would merely restate the total one line up.
|
||||
$breakdown['discountSubtotal'] = 0 !== $totalDiscountAmount && [] !== $discounts ? $subtotal : null;
|
||||
$breakdown['discountedTotal'] = $subtotal - $totalDiscountAmount;
|
||||
|
||||
return $breakdown;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ use Dompdf\Options;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\String\Slugger\AsciiSlugger;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
@@ -81,10 +80,6 @@ class AccommodationBookingPdfGenerator
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Named after arrival date and group rather than the entity id, which is a local
|
||||
* detail with no counterpart in BusPro and would read as a booking number.
|
||||
*/
|
||||
/**
|
||||
* @throws \RuntimeException when the booking carries no price information
|
||||
*/
|
||||
@@ -95,14 +90,13 @@ class AccommodationBookingPdfGenerator
|
||||
|
||||
private function buildFilename(AccommodationBooking $booking): string
|
||||
{
|
||||
$groupName = (new AsciiSlugger('de'))->slug((string) $booking->getGroupName())->lower()->toString();
|
||||
$parts = array_filter([
|
||||
$booking->getDateFrom()?->format('Y-m-d'),
|
||||
$booking->getAccommodation()?->getCalendarCode(),
|
||||
'Buchung',
|
||||
]);
|
||||
|
||||
return sprintf(
|
||||
'%s-%s%s.pdf',
|
||||
mb_strtolower($booking->recordLabel()),
|
||||
$booking->getDateFrom()?->format('Y-m-d') ?? 'ohne-datum',
|
||||
'' !== $groupName ? '-'.$groupName : ''
|
||||
);
|
||||
return implode(' ', $parts).'.pdf';
|
||||
}
|
||||
|
||||
private function logoDataUri(): string
|
||||
|
||||
@@ -43,7 +43,7 @@ class AdditionalServiceExclusionResolver
|
||||
|
||||
$submittedIds = array_filter(
|
||||
$submittedIds,
|
||||
fn(int $id) => !$this->conflicts($rules, $addedId, $id),
|
||||
fn (int $id) => !$this->conflicts($rules, $addedId, $id),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,9 @@ use App\Form\Model\ParticipantDto;
|
||||
*
|
||||
* Service selections are gated by travel-level mutability flags (additionalServicesMutable,
|
||||
* transportationServicesMutable, pickupsMutable). Insurance is always applied regardless
|
||||
* of mutability. Merge strategy (only apply if resolves to a valid service) is used for
|
||||
* single-select fields; overwrite strategy is used for multi-select and boolean fields.
|
||||
* of mutability. Merge strategy (only apply if it resolves against the travel data) is used
|
||||
* for single-select fields, room assignment included; overwrite strategy is used for
|
||||
* multi-select, boolean and free-text fields.
|
||||
*/
|
||||
class BookingEditDraftMerger
|
||||
{
|
||||
@@ -52,7 +53,7 @@ class BookingEditDraftMerger
|
||||
|
||||
// Room assignment
|
||||
if (true === isset($data['roomAssignment']) && true === is_array($data['roomAssignment'])) {
|
||||
$this->applyRoomAssignment($participant, $data['roomAssignment']);
|
||||
$this->applyRoomAssignment($participant, $data['roomAssignment'], $travel);
|
||||
}
|
||||
|
||||
// License plate
|
||||
@@ -156,12 +157,27 @@ class BookingEditDraftMerger
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
private function applyRoomAssignment(ParticipantDto $participant, array $data): void
|
||||
/**
|
||||
* Applies the drafted room assignment to the participant.
|
||||
*
|
||||
* The room id uses the same merge strategy as single-select services: it is only
|
||||
* applied when it resolves to a room of the current travel. A draft may therefore move
|
||||
* a participant to another room, but never unassign one. Edit mode renders the room as
|
||||
* static text and never submits assignedRoomId, so a null in the draft is only ever an
|
||||
* artefact of a snapshot taken before BusPro reported the assignment - replaying it
|
||||
* would drop the participant's room, both in the UI and in the outbound zuordnung.
|
||||
*
|
||||
* remarksRoom keeps overwrite semantics: it is free text the user can deliberately clear.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function applyRoomAssignment(ParticipantDto $participant, array $data, Travel $travel): void
|
||||
{
|
||||
if (true === array_key_exists('assignedRoomId', $data)) {
|
||||
$participant->assignedRoomId = $data['assignedRoomId'];
|
||||
$assignedRoomId = $data['assignedRoomId'] ?? null;
|
||||
if (null !== $assignedRoomId && null !== $travel->getRoomById((int) $assignedRoomId)) {
|
||||
$participant->assignedRoomId = (int) $assignedRoomId;
|
||||
}
|
||||
|
||||
if (true === array_key_exists('remarksRoom', $data)) {
|
||||
$participant->remarksRoom = $data['remarksRoom'];
|
||||
}
|
||||
|
||||
@@ -510,5 +510,4 @@ class BpnXmlAnonymizer
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class CalendarGridBuilder
|
||||
$months = [];
|
||||
$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");
|
||||
$months[] = [
|
||||
'month' => $monthStart,
|
||||
@@ -54,7 +54,7 @@ class CalendarGridBuilder
|
||||
|
||||
while ($current <= $gridEnd) {
|
||||
$week = [];
|
||||
for ($d = 0; $d < 7; $d++) {
|
||||
for ($d = 0; $d < 7; ++$d) {
|
||||
$week[] = [
|
||||
'date' => $current,
|
||||
'inMonth' => $current->format('Y-m') === $monthKey,
|
||||
|
||||
@@ -53,6 +53,10 @@ class ContingentSnapshotManager
|
||||
|
||||
$state = $this->syncStateRepository->findOneByAccommodation($accommodation) ?? new ContingentSyncState($accommodation);
|
||||
|
||||
// Read before recordSuccess() moves it: days beyond the horizon the previous run reached
|
||||
// are the window growing, not the contingent situation changing.
|
||||
$previousHorizonTo = $state->getHorizonTo();
|
||||
|
||||
try {
|
||||
$calendar = $this->contingentsClient->getContingentCalendar(
|
||||
$hotelCode,
|
||||
@@ -72,6 +76,7 @@ class ContingentSnapshotManager
|
||||
}
|
||||
|
||||
$added = 0;
|
||||
$extended = 0;
|
||||
$updated = 0;
|
||||
$seen = [];
|
||||
|
||||
@@ -102,7 +107,12 @@ class ContingentSnapshotManager
|
||||
->setDate($date);
|
||||
|
||||
$this->entityManager->persist($day);
|
||||
++$added;
|
||||
|
||||
if (null !== $previousHorizonTo && $date > $previousHorizonTo) {
|
||||
++$extended;
|
||||
} else {
|
||||
++$added;
|
||||
}
|
||||
} elseif ($day->getStatus() === $entry->status) {
|
||||
continue;
|
||||
} else {
|
||||
@@ -124,26 +134,21 @@ class ContingentSnapshotManager
|
||||
$this->entityManager->flush();
|
||||
|
||||
$now = CarbonImmutable::now()->toDateTimeImmutable();
|
||||
$hash = $this->fingerprint($accommodation);
|
||||
$changed = $hash !== $state->getContentHash();
|
||||
|
||||
// The diff is the change signal. A digest of the stored snapshot cannot be one: the window
|
||||
// rolls forward a day at a time, so every digest would cover a different span than the one
|
||||
// it is compared against and every run would report a change.
|
||||
$changed = $added > 0 || $updated > 0 || $removed > 0;
|
||||
|
||||
if ($changed) {
|
||||
$state->setContentHash($hash)->setChangedAt($now);
|
||||
$state->setChangedAt($now);
|
||||
}
|
||||
|
||||
$state->recordSuccess($now, $dateTo);
|
||||
$this->entityManager->persist($state);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return ContingentSyncResult::synced($changed, $added, $updated, $removed);
|
||||
}
|
||||
|
||||
/**
|
||||
* sha256 over the accommodation's complete stored snapshot, contingent status only.
|
||||
*/
|
||||
public function fingerprint(Accommodation $accommodation): string
|
||||
{
|
||||
return hash('sha256', implode('|', $this->dayRepository->findStatusFingerprintParts($accommodation)));
|
||||
return ContingentSyncResult::synced($changed, $added, $extended, $updated, $removed);
|
||||
}
|
||||
|
||||
private function recordFailure(ContingentSyncState $state, string $hotelCode, string $error): ContingentSyncResult
|
||||
|
||||
@@ -5,12 +5,13 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\BookingEditDraft;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\NewsletterConsent;
|
||||
use App\Entity\NewsletterOptInRequest;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Faker\Factory;
|
||||
use Faker\Generator;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
@@ -49,7 +50,7 @@ class DatabaseAnonymizer
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{users: int, newsletterConsents: int, newsletterOptInRequests: int, bookingEditDrafts: int}
|
||||
* @return array{users: int, newsletterConsents: int, newsletterOptInRequests: int, bookingEditDrafts: int, accommodationBookings: int}
|
||||
*/
|
||||
public function anonymizeAll(int $batchSize = self::DEFAULT_BATCH_SIZE): array
|
||||
{
|
||||
@@ -80,6 +81,13 @@ class DatabaseAnonymizer
|
||||
fn (BookingEditDraft $draft) => $this->anonymizeBookingEditDraft($draft),
|
||||
$batchSize,
|
||||
),
|
||||
// Last, so that a booking whose email matches a mirrored BusPro account resolves to
|
||||
// the synthetic identity the user pass has already minted for that person.
|
||||
'accommodationBookings' => $this->anonymizeQuery(
|
||||
'SELECT b FROM App\Entity\Groups\AccommodationBooking b ORDER BY b.id ASC',
|
||||
fn (AccommodationBooking $booking) => $this->anonymizeAccommodationBooking($booking),
|
||||
$batchSize,
|
||||
),
|
||||
];
|
||||
|
||||
$this->entityManager->flush();
|
||||
@@ -108,8 +116,8 @@ class DatabaseAnonymizer
|
||||
|
||||
$consent->setEmail($identity['email']);
|
||||
$consent->setNames(
|
||||
$this->shouldReplaceName($consent->getFirstName()) ? $identity['firstName'] : $consent->getFirstName(),
|
||||
$this->shouldReplaceName($consent->getLastName()) ? $identity['lastName'] : $consent->getLastName(),
|
||||
$this->hasReplaceableValue($consent->getFirstName()) ? $identity['firstName'] : $consent->getFirstName(),
|
||||
$this->hasReplaceableValue($consent->getLastName()) ? $identity['lastName'] : $consent->getLastName(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,8 +131,8 @@ class DatabaseAnonymizer
|
||||
|
||||
$request->setEmail($identity['email']);
|
||||
$request->setNames(
|
||||
$this->shouldReplaceName($request->getFirstName()) ? $identity['firstName'] : $request->getFirstName(),
|
||||
$this->shouldReplaceName($request->getLastName()) ? $identity['lastName'] : $request->getLastName(),
|
||||
$this->hasReplaceableValue($request->getFirstName()) ? $identity['firstName'] : $request->getFirstName(),
|
||||
$this->hasReplaceableValue($request->getLastName()) ? $identity['lastName'] : $request->getLastName(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,6 +144,58 @@ class DatabaseAnonymizer
|
||||
$draft->replaceFormData($formData);
|
||||
}
|
||||
|
||||
/**
|
||||
* The uuid is deliberately left alone: it is what the signed customer access link resolves
|
||||
* against, so regenerating it would silently invalidate every link in an anonymized dump.
|
||||
* The salutation stays too, being no more identifying than the gender it stands for.
|
||||
*/
|
||||
public function anonymizeAccommodationBooking(AccommodationBooking $booking): void
|
||||
{
|
||||
$identity = $this->resolveIdentity(
|
||||
email: $booking->getEmail(),
|
||||
firstName: $booking->getFirstName(),
|
||||
lastName: $booking->getLastName(),
|
||||
);
|
||||
|
||||
// Only fields that actually hold something are replaced, so a half-prepared draft stays
|
||||
// half-prepared instead of becoming a record the `edit` group would suddenly accept.
|
||||
if ($this->hasReplaceableValue($booking->getGroupName())) {
|
||||
$booking->setGroupName($identity['groupName']);
|
||||
}
|
||||
|
||||
if ($this->hasReplaceableValue($booking->getFirstName())) {
|
||||
$booking->setFirstName($identity['firstName']);
|
||||
}
|
||||
|
||||
if ($this->hasReplaceableValue($booking->getLastName())) {
|
||||
$booking->setLastName($identity['lastName']);
|
||||
}
|
||||
|
||||
if ($this->hasReplaceableValue($booking->getEmail())) {
|
||||
$booking->setEmail($identity['email']);
|
||||
}
|
||||
|
||||
if ($this->hasReplaceableValue($booking->getPhone())) {
|
||||
$booking->setPhone($identity['phone']);
|
||||
}
|
||||
|
||||
if ($this->hasReplaceableValue($booking->getStreet())) {
|
||||
$booking->setStreet($identity['street']);
|
||||
}
|
||||
|
||||
if ($this->hasReplaceableValue($booking->getZip())) {
|
||||
$booking->setZip($identity['postCode']);
|
||||
}
|
||||
|
||||
if ($this->hasReplaceableValue($booking->getCity())) {
|
||||
$booking->setCity($identity['city']);
|
||||
}
|
||||
|
||||
if ($this->hasReplaceableValue($booking->getRemarks())) {
|
||||
$booking->setRemarks($this->placeholderText($identity, 'remarks'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(object):void $anonymize
|
||||
*/
|
||||
@@ -230,7 +290,7 @@ class DatabaseAnonymizer
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $personalData
|
||||
* @param array<string, mixed> $personalData
|
||||
* @param array<string, string> $identity
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
@@ -265,7 +325,7 @@ class DatabaseAnonymizer
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $addressData
|
||||
* @param array<string, mixed> $addressData
|
||||
* @param array<string, string> $identity
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
@@ -296,7 +356,7 @@ class DatabaseAnonymizer
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $bankAccountData
|
||||
* @param array<string, mixed> $bankAccountData
|
||||
* @param array<string, string>|null $identity
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
@@ -385,6 +445,7 @@ class DatabaseAnonymizer
|
||||
'city' => $this->faker->city(),
|
||||
'country' => self::SYNTHETIC_COUNTRY,
|
||||
'district' => $this->faker->citySuffix(),
|
||||
'groupName' => $this->faker->company(),
|
||||
'accountHolder' => $firstName.' '.$lastName,
|
||||
'bankName' => $this->faker->company(),
|
||||
'iban' => self::SYNTHETIC_IBAN,
|
||||
@@ -403,7 +464,7 @@ class DatabaseAnonymizer
|
||||
return $type.':'.mb_strtolower($normalized);
|
||||
}
|
||||
|
||||
private function shouldReplaceName(?string $value): bool
|
||||
private function hasReplaceableValue(?string $value): bool
|
||||
{
|
||||
return null !== $this->stringOrNull($value);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ class GroupsPriceCalculator
|
||||
|
||||
/**
|
||||
* @param array<string, int|float> $config
|
||||
*
|
||||
* @return array<string, int|float>
|
||||
*/
|
||||
private function resolveConfig(array $config): array
|
||||
@@ -47,8 +48,8 @@ class GroupsPriceCalculator
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AccommodationPrice[] $prices all prices overlapping the booking window
|
||||
* @param AdditionalService[] $additionalServices only the selected services
|
||||
* @param AccommodationPrice[] $prices all prices overlapping the booking window
|
||||
* @param AdditionalService[] $additionalServices only the selected services
|
||||
*
|
||||
* @return array{
|
||||
* effectivePax: int,
|
||||
@@ -88,7 +89,7 @@ class GroupsPriceCalculator
|
||||
$prices,
|
||||
$boardService?->getPrice(),
|
||||
array_map(
|
||||
fn(AdditionalService $s) => [
|
||||
fn (AdditionalService $s) => [
|
||||
'label' => $s->getLabel() ?? '',
|
||||
'price' => $s->getPrice() ?? 0,
|
||||
'type' => $s->getType(),
|
||||
@@ -103,7 +104,7 @@ class GroupsPriceCalculator
|
||||
* Same calculation but using frozen snapshot data from an AccommodationBooking entity
|
||||
* instead of live catalog entities.
|
||||
*
|
||||
* @param AccommodationPrice[] $prices all prices overlapping the booking window
|
||||
* @param AccommodationPrice[] $prices all prices overlapping the booking window
|
||||
* @param list<array{label: string, price: int, type: string, originalServiceId: int|null}> $additionalServiceSnapshots
|
||||
*
|
||||
* @return array{
|
||||
@@ -136,7 +137,7 @@ class GroupsPriceCalculator
|
||||
string $currency,
|
||||
): array {
|
||||
$items = array_map(
|
||||
fn(array $s) => [
|
||||
fn (array $s) => [
|
||||
'label' => $s['label'],
|
||||
'price' => $s['price'],
|
||||
'type' => AdditionalServiceType::tryFrom($s['type']) ?? AdditionalServiceType::Flat,
|
||||
@@ -158,7 +159,7 @@ class GroupsPriceCalculator
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AccommodationPrice[] $prices
|
||||
* @param AccommodationPrice[] $prices
|
||||
* @param list<array{label: string, price: int, type: AdditionalServiceType}> $additionalItems
|
||||
*
|
||||
* @return array{
|
||||
@@ -193,7 +194,7 @@ class GroupsPriceCalculator
|
||||
// Derive effectivePax: children 0–3 don't count, but never drop below includedPax
|
||||
$firstCandidates = array_values(array_filter(
|
||||
$prices,
|
||||
fn(AccommodationPrice $p) => $p->getDateFrom() <= $dateFrom && $p->getDateTo() >= $dateFrom,
|
||||
fn (AccommodationPrice $p) => $p->getDateFrom() <= $dateFrom && $p->getDateTo() >= $dateFrom,
|
||||
));
|
||||
$firstWinner = $this->priceTimelineBuilder->resolveWinner($firstCandidates);
|
||||
$includedPaxFloor = $firstWinner?->getIncludedPax() ?? 1;
|
||||
@@ -224,11 +225,11 @@ class GroupsPriceCalculator
|
||||
|
||||
$candidates = array_values(array_filter(
|
||||
$prices,
|
||||
fn(AccommodationPrice $p) => $p->getDateFrom() <= $segStart && $p->getDateTo() >= $segStart,
|
||||
fn (AccommodationPrice $p) => $p->getDateFrom() <= $segStart && $p->getDateTo() >= $segStart,
|
||||
));
|
||||
$winner = $this->priceTimelineBuilder->resolveWinner($candidates) ?? $lastWinner;
|
||||
|
||||
if ($winner !== null) {
|
||||
if (null !== $winner) {
|
||||
$lastWinner = $winner;
|
||||
$basePrice += ($winner->getPricePerNight() ?? 0) * $segNights;
|
||||
$includedPax = $winner->getIncludedPax() ?? 0;
|
||||
@@ -254,7 +255,7 @@ class GroupsPriceCalculator
|
||||
// Rule 4: undersubscription surcharge (only when a paid board is selected)
|
||||
$undersubscriptionSurcharge = 0;
|
||||
$undersubscriptionThreshold = null;
|
||||
if ($paidBoardPricePerPersonNight !== null) {
|
||||
if (null !== $paidBoardPricePerPersonNight) {
|
||||
$surcharge30 = (int) round(('CHF' === $currency ? $this->config['undersubscription30Chf'] : $this->config['undersubscription30Eur']) * 100);
|
||||
$surcharge40 = (int) round(('CHF' === $currency ? $this->config['undersubscription40Chf'] : $this->config['undersubscription40Eur']) * 100);
|
||||
|
||||
@@ -269,7 +270,7 @@ class GroupsPriceCalculator
|
||||
|
||||
// Rule 5: board price
|
||||
$boardPrice = 0;
|
||||
if ($paidBoardPricePerPersonNight !== null) {
|
||||
if (null !== $paidBoardPricePerPersonNight) {
|
||||
$boardPrice = $paidBoardPricePerPersonNight * $effectivePax * $nights;
|
||||
}
|
||||
|
||||
|
||||
@@ -115,10 +115,10 @@ class InsuranceManager
|
||||
* participant's price - this splits the given insurances by type and evaluates
|
||||
* each group against the appropriate price before merging the results back together.
|
||||
*
|
||||
* @param array<Insurance> $insurances Available insurances to filter
|
||||
* @param ParticipantDto $participant The participant to match insurances for
|
||||
* @param BookingDto $booking The booking context for additional criteria
|
||||
* @param float $individualPrice The participant's individual travel price (excluding insurance)
|
||||
* @param array<Insurance> $insurances Available insurances to filter
|
||||
* @param ParticipantDto $participant The participant to match insurances for
|
||||
* @param BookingDto $booking The booking context for additional criteria
|
||||
* @param float $individualPrice The participant's individual travel price (excluding insurance)
|
||||
* @param float $totalBookingPrice The total booking price across all participants (excluding insurance)
|
||||
*
|
||||
* @return array<Insurance> Filtered array of eligible insurances, sorted by price
|
||||
|
||||
@@ -39,8 +39,7 @@ class ParticipantCardAssembler
|
||||
BookingDto $bookingDto,
|
||||
int $index,
|
||||
?array $precomputedPrices = null,
|
||||
): ParticipantCardDataDto
|
||||
{
|
||||
): ParticipantCardDataDto {
|
||||
$participant = $bookingDto->participants[$index] ?? null;
|
||||
|
||||
if (null === $participant) {
|
||||
@@ -142,8 +141,7 @@ class ParticipantCardAssembler
|
||||
BookingDto $bookingDto,
|
||||
int $index,
|
||||
?array $precomputedPrices = null,
|
||||
): ParticipantCardPriceDto
|
||||
{
|
||||
): ParticipantCardPriceDto {
|
||||
// Check if canceled (only possible in edit mode when booking property is set)
|
||||
$isCanceled = ($bookingDto->booking?->participantsStatus[$index] ?? null) === 'S';
|
||||
|
||||
@@ -189,8 +187,7 @@ class ParticipantCardAssembler
|
||||
BookingDto $bookingDto,
|
||||
int $index,
|
||||
bool $forceStrictRequired = false,
|
||||
): ParticipantCardDataDto
|
||||
{
|
||||
): ParticipantCardDataDto {
|
||||
return $this->getCardDataWithValidationInternal(
|
||||
$bookingDto,
|
||||
$index,
|
||||
@@ -262,7 +259,7 @@ class ParticipantCardAssembler
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $validationGroups
|
||||
* @param array<string> $validationGroups
|
||||
* @param array<int, float>|null $precomputedPrices
|
||||
*/
|
||||
private function getCardDataWithValidationInternal(
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Model\Address;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\Entity\User;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Security\Crypt;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
@@ -86,6 +86,7 @@ class ParticipantFormSupport
|
||||
|
||||
/**
|
||||
* @param array<string, int> $options
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function resolveOptions(array $options): array
|
||||
|
||||
@@ -69,7 +69,7 @@ class PriceTimelineBuilder
|
||||
$lastDefault = null;
|
||||
|
||||
// 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];
|
||||
$segEndNext = $boundaries[$i + 1];
|
||||
|
||||
@@ -81,12 +81,12 @@ class PriceTimelineBuilder
|
||||
|
||||
$candidates = array_filter(
|
||||
$prices,
|
||||
fn($p) => $p->getDateFrom() <= $segStart && $p->getDateTo() >= $segStart,
|
||||
fn ($p) => $p->getDateFrom() <= $segStart && $p->getDateTo() >= $segStart,
|
||||
);
|
||||
|
||||
$winner = $this->resolveWinner($candidates);
|
||||
|
||||
if ($winner === null) {
|
||||
if (null === $winner) {
|
||||
// Gap: reset merge state so the next winner always opens a new row.
|
||||
$lastWinner = null;
|
||||
$lastDefault = null;
|
||||
@@ -94,8 +94,8 @@ class PriceTimelineBuilder
|
||||
}
|
||||
|
||||
$defaultWinner = null;
|
||||
if ($winner->getType() === PriceType::DISCOUNT) {
|
||||
$defaults = array_filter($candidates, fn($p) => $p->getType() === null);
|
||||
if (PriceType::DISCOUNT === $winner->getType()) {
|
||||
$defaults = array_filter($candidates, fn ($p) => null === $p->getType());
|
||||
$defaultWinner = $this->resolveWinner($defaults);
|
||||
}
|
||||
|
||||
@@ -125,8 +125,8 @@ class PriceTimelineBuilder
|
||||
includedPax: $winner->getIncludedPax(),
|
||||
pricePerNight: round($winner->getPricePerNight() / 100, 2),
|
||||
priceAdditionalPerson: round($winner->getPriceAdditionalPerson() / 100, 2),
|
||||
defaultPricePerNight: $defaultWinner !== null ? round($defaultWinner->getPricePerNight() / 100, 2) : null,
|
||||
defaultPriceAdditionalPerson: $defaultWinner !== null ? round($defaultWinner->getPriceAdditionalPerson() / 100, 2) : null,
|
||||
defaultPricePerNight: null !== $defaultWinner ? round($defaultWinner->getPricePerNight() / 100, 2) : null,
|
||||
defaultPriceAdditionalPerson: null !== $defaultWinner ? round($defaultWinner->getPriceAdditionalPerson() / 100, 2) : null,
|
||||
currency: $currency,
|
||||
type: $winner->getType()?->value,
|
||||
);
|
||||
@@ -150,7 +150,7 @@ class PriceTimelineBuilder
|
||||
{
|
||||
$winner = null;
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($winner === null) {
|
||||
if (null === $winner) {
|
||||
$winner = $candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Form\Model\Filter\AbstractListFilterDto;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Where to send somebody who should act on a role nomination.
|
||||
*
|
||||
* Deliberately not app_admin_user_permissions. That route renders the approval modal, which
|
||||
* extends htmx_modal_admin.html.twig — a bare <div> with no page chrome, meant to be swapped
|
||||
* into a page that is already open. Following it as a normal navigation, which is what a link
|
||||
* in an email or a dashboard does, yields an unstyled fragment.
|
||||
*
|
||||
* The user list filtered down to the one account is one click away from the modal and shows the
|
||||
* nomination badge on the way. The query string is flat because the list filter forms declare no
|
||||
* block prefix.
|
||||
*/
|
||||
class RoleApprovalUrlGenerator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
public function forUser(User $user, int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
|
||||
{
|
||||
return $this->urlGenerator->generate(
|
||||
'app_admin_user',
|
||||
[
|
||||
AbstractListFilterDto::MARKER => 1,
|
||||
'q' => $user->getEmail(),
|
||||
],
|
||||
$referenceType,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,9 @@ use App\Model\CalendarMonth;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
|
||||
|
||||
#[AsTwigComponent('calendar', template: 'components/calendar/index.html.twig')]
|
||||
class Calendar
|
||||
@@ -55,8 +55,8 @@ class Calendar
|
||||
$this->accommodation = $accommodation;
|
||||
$this->startMonth = $startMonth;
|
||||
|
||||
if ($startMonth !== null && preg_match('/^\d{4}-\d{2}$/', $startMonth)) {
|
||||
$start = CarbonImmutable::createFromFormat('Y-m-d', $startMonth . '-01');
|
||||
if (null !== $startMonth && preg_match('/^\d{4}-\d{2}$/', $startMonth)) {
|
||||
$start = CarbonImmutable::createFromFormat('Y-m-d', $startMonth.'-01');
|
||||
} else {
|
||||
$allDates = $this->collectAllDates();
|
||||
$start = empty($allDates)
|
||||
@@ -73,7 +73,7 @@ class Calendar
|
||||
|
||||
$months = [];
|
||||
$current = $start;
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
for ($i = 0; $i < 3; ++$i) {
|
||||
$months[] = $this->buildMonth($current, $map, $blockedDates);
|
||||
$current = $current->addMonth();
|
||||
}
|
||||
@@ -81,22 +81,22 @@ class Calendar
|
||||
$this->months = $months;
|
||||
|
||||
$prevStart = $start->subMonths(3);
|
||||
$prevEnd = $prevStart->addMonths(2);
|
||||
$prevEnd = $prevStart->addMonths(2);
|
||||
$this->prevMonth = $prevStart->format('Y-m');
|
||||
$this->prevLabel = self::MONTHS[$prevStart->month] . ' – ' . self::MONTHS[$prevEnd->month] . ' ' . $prevEnd->year;
|
||||
$this->prevLabel = self::MONTHS[$prevStart->month].' – '.self::MONTHS[$prevEnd->month].' '.$prevEnd->year;
|
||||
|
||||
$nextStart = $start->addMonths(3);
|
||||
$nextEnd = $nextStart->addMonths(2);
|
||||
$nextEnd = $nextStart->addMonths(2);
|
||||
$this->nextMonth = $nextStart->format('Y-m');
|
||||
$this->nextLabel = self::MONTHS[$nextStart->month] . ' – ' . self::MONTHS[$nextEnd->month] . ' ' . $nextEnd->year;
|
||||
$this->nextLabel = self::MONTHS[$nextStart->month].' – '.self::MONTHS[$nextEnd->month].' '.$nextEnd->year;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, true> keyed by Y-m-d, only BLOCKED dates present
|
||||
* @return array<string, true> keyed by Y-m-d, only BLOCKED dates present
|
||||
*/
|
||||
private function fetchBlockedDates(string $hotelCode, string $dateFrom, string $dateTo): array
|
||||
{
|
||||
if ($hotelCode === '') {
|
||||
if ('' === $hotelCode) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ class Calendar
|
||||
|
||||
$blocked = [];
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -144,26 +144,26 @@ class Calendar
|
||||
$dates = [];
|
||||
|
||||
foreach ($this->accommodation->getAccommodationPrices() as $price) {
|
||||
if ($price->getDateFrom() !== null) {
|
||||
if (null !== $price->getDateFrom()) {
|
||||
$dates[] = CarbonImmutable::instance($price->getDateFrom());
|
||||
}
|
||||
if ($price->getDateTo() !== null) {
|
||||
if (null !== $price->getDateTo()) {
|
||||
$dates[] = CarbonImmutable::instance($price->getDateTo());
|
||||
}
|
||||
}
|
||||
foreach ($this->accommodation->getBoardServices() as $service) {
|
||||
if ($service->getDateFrom() !== null) {
|
||||
if (null !== $service->getDateFrom()) {
|
||||
$dates[] = CarbonImmutable::instance($service->getDateFrom());
|
||||
}
|
||||
if ($service->getDateTo() !== null) {
|
||||
if (null !== $service->getDateTo()) {
|
||||
$dates[] = CarbonImmutable::instance($service->getDateTo());
|
||||
}
|
||||
}
|
||||
foreach ($this->accommodation->getAdditionalServices() as $service) {
|
||||
if ($service->getDateFrom() !== null) {
|
||||
if (null !== $service->getDateFrom()) {
|
||||
$dates[] = CarbonImmutable::instance($service->getDateFrom());
|
||||
}
|
||||
if ($service->getDateTo() !== null) {
|
||||
if (null !== $service->getDateTo()) {
|
||||
$dates[] = CarbonImmutable::instance($service->getDateTo());
|
||||
}
|
||||
}
|
||||
@@ -198,11 +198,11 @@ class Calendar
|
||||
$map = [];
|
||||
|
||||
foreach ($this->accommodation->getAccommodationPrices() as $price) {
|
||||
if ($price->getDateFrom() === null || $price->getDateTo() === null) {
|
||||
if (null === $price->getDateFrom() || null === $price->getDateTo()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$priceFormatted = number_format($price->getPricePerNight() / 100, 2, ',', '.') . ' €';
|
||||
$priceFormatted = number_format($price->getPricePerNight() / 100, 2, ',', '.').' €';
|
||||
$label = sprintf('Preis: %s/Nacht (min. %d Nächte)', $priceFormatted, $price->getMinNights());
|
||||
|
||||
$day = CarbonImmutable::instance($price->getDateFrom());
|
||||
@@ -216,7 +216,7 @@ class Calendar
|
||||
$key = $day->format('Y-m-d');
|
||||
$map[$key] ??= ['price' => null, 'board' => null, 'additional' => null];
|
||||
$existing = $map[$key]['price'];
|
||||
$replace = $existing === null
|
||||
$replace = null === $existing
|
||||
|| $priority > $existing['priority']
|
||||
|| ($priority === $existing['priority'] && $span < $existing['span']);
|
||||
if ($replace) {
|
||||
@@ -227,11 +227,11 @@ class Calendar
|
||||
}
|
||||
|
||||
foreach ($this->accommodation->getBoardServices() as $service) {
|
||||
if ($service->getDateFrom() === null || $service->getDateTo() === null) {
|
||||
if (null === $service->getDateFrom() || null === $service->getDateTo()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = 'Verpflegung: ' . $service->getLabel();
|
||||
$label = 'Verpflegung: '.$service->getLabel();
|
||||
|
||||
$day = CarbonImmutable::instance($service->getDateFrom());
|
||||
$end = CarbonImmutable::instance($service->getDateTo());
|
||||
@@ -241,7 +241,7 @@ class Calendar
|
||||
while ($day <= $end && $day <= $limit) {
|
||||
$key = $day->format('Y-m-d');
|
||||
$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];
|
||||
}
|
||||
$day = $day->addDay();
|
||||
@@ -249,14 +249,14 @@ class Calendar
|
||||
}
|
||||
|
||||
foreach ($this->accommodation->getAdditionalServices() as $service) {
|
||||
if ($service->getDateFrom() === null || $service->getDateTo() === null) {
|
||||
if (null === $service->getDateFrom() || null === $service->getDateTo()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$typeLabel = $service->getType() !== null
|
||||
$typeLabel = null !== $service->getType()
|
||||
? 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());
|
||||
$end = CarbonImmutable::instance($service->getDateTo());
|
||||
@@ -266,7 +266,7 @@ class Calendar
|
||||
while ($day <= $end && $day <= $limit) {
|
||||
$key = $day->format('Y-m-d');
|
||||
$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];
|
||||
}
|
||||
$day = $day->addDay();
|
||||
@@ -293,8 +293,8 @@ class Calendar
|
||||
$firstDow = ($start->dayOfWeek + 6) % 7;
|
||||
|
||||
$days = [];
|
||||
for ($d = 1; $d <= $start->daysInMonth; $d++) {
|
||||
$dateStr = $month->format('Y-m') . '-' . str_pad((string) $d, 2, '0', STR_PAD_LEFT);
|
||||
for ($d = 1; $d <= $start->daysInMonth; ++$d) {
|
||||
$dateStr = $month->format('Y-m').'-'.str_pad((string) $d, 2, '0', STR_PAD_LEFT);
|
||||
$coverage = $map[$dateStr] ?? [];
|
||||
|
||||
$parts = [];
|
||||
@@ -325,7 +325,7 @@ class Calendar
|
||||
}
|
||||
|
||||
return new CalendarMonth(
|
||||
label: self::MONTHS[$month->month] . ' ' . $month->year,
|
||||
label: self::MONTHS[$month->month].' '.$month->year,
|
||||
firstDow: $firstDow,
|
||||
days: $days,
|
||||
);
|
||||
|
||||
@@ -47,9 +47,7 @@
|
||||
{{ form_row(form.boardService) }}
|
||||
{% endif %}
|
||||
{% if form.selectedAdditionalServices is defined %}
|
||||
<div class="lg:col-span-2">
|
||||
{{ form_row(form.selectedAdditionalServices) }}
|
||||
</div>
|
||||
{{ form_row(form.selectedAdditionalServices) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -76,9 +74,15 @@
|
||||
{{ form_row(form.managedBy) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ form_row(form.accommodationDiscount) }}
|
||||
{{ form_row(form.boardServiceDiscount) }}
|
||||
{{ form_row(form.additionalServicesDiscount) }}
|
||||
<div class="lg:col-span-2 grid lg:grid-cols-3 gap-y-4 lg:gap-x-8">
|
||||
{{ form_row(form.accommodationDiscount) }}
|
||||
{{ form_row(form.boardServiceDiscount) }}
|
||||
{{ form_row(form.additionalServicesDiscount) }}
|
||||
</div>
|
||||
<div class="lg:col-span-2 grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
|
||||
{{ form_row(form.totalDiscountLabel) }}
|
||||
{{ form_row(form.totalDiscountAmount) }}
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
{{ form_row(form.remarks) }}
|
||||
</div>
|
||||
|
||||
@@ -84,6 +84,9 @@
|
||||
{% if booking.additionalServicesDiscount is not null %}
|
||||
{% set discounts = discounts | merge(['Zusatzleistungen ' ~ booking.additionalServicesDiscount ~ '%']) %}
|
||||
{% endif %}
|
||||
{% if booking.totalDiscountAmount is not null %}
|
||||
{% set discounts = discounts | merge([(booking.totalDiscountLabel ?: 'Gesamtpreis') ~ ' ' ~ ((booking.totalDiscountAmount / 100) | format_currency(booking.pricingCurrency ?: 'EUR'))]) %}
|
||||
{% endif %}
|
||||
<a href="{{ edit_url }}">{{ discounts | length > 0 ? (discounts | join(', ')) : '–' }}</a>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
|
||||
@@ -148,11 +148,23 @@
|
||||
</tr>
|
||||
{% for discount in priceBreakdown.discounts %}
|
||||
<tr class="text-green-700">
|
||||
<td class="pt-1">Rabatt {{ discount.label }} {{ discount.percent }} %</td>
|
||||
<td class="pt-1">{{ discount.label }} {{ discount.percent }} %</td>
|
||||
<td class="pt-1 text-right whitespace-nowrap">– {{ (discount.amount / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if priceBreakdown.discounts is not empty %}
|
||||
{% if priceBreakdown.discountSubtotal is not null %}
|
||||
<tr class="border-t border-gray-200">
|
||||
<td class="pt-2">Zwischensumme</td>
|
||||
<td class="pt-2 text-right whitespace-nowrap">{{ (priceBreakdown.discountSubtotal / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if priceBreakdown.totalDiscountDetails is not null %}
|
||||
<tr class="text-green-700">
|
||||
<td class="pt-1">{{ priceBreakdown.totalDiscountDetails.label }}</td>
|
||||
<td class="pt-1 text-right whitespace-nowrap">– {{ (priceBreakdown.totalDiscountDetails.amount / 100) | format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if priceBreakdown.discounts is not empty or priceBreakdown.totalDiscountDetails is not null %}
|
||||
<tr class="border-t border-gray-200 font-bold text-green-700">
|
||||
<td class="pt-2">Gesamtpreis nach Rabatt</td>
|
||||
<td class="pt-2 text-right whitespace-nowrap">{{ (priceBreakdown.discountedTotal / 100) | format_currency(currency) }}</td>
|
||||
|
||||
@@ -75,11 +75,25 @@
|
||||
</tr>
|
||||
{% for discount in priceBreakdown.discounts %}
|
||||
<tr>
|
||||
<td style="{{ cell }}">Rabatt {{ discount.label }} {{ discount.percent }} %</td>
|
||||
<td style="{{ cell }}">{{ discount.label }} {{ discount.percent }} %</td>
|
||||
<td style="{{ amount }}">– {{ (discount.amount / 100)|format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if priceBreakdown.discounts is not empty %}
|
||||
{% if priceBreakdown.discountSubtotal is not null %}
|
||||
<tr>
|
||||
<td style="{{ cell }} border-top: 1px solid #d1d5db;">Zwischensumme</td>
|
||||
<td style="{{ amount }} border-top: 1px solid #d1d5db;">
|
||||
{{ (priceBreakdown.discountSubtotal / 100)|format_currency(currency) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if priceBreakdown.totalDiscountDetails is not null %}
|
||||
<tr>
|
||||
<td style="{{ cell }}">{{ priceBreakdown.totalDiscountDetails.label }}</td>
|
||||
<td style="{{ amount }}">– {{ (priceBreakdown.totalDiscountDetails.amount / 100)|format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if priceBreakdown.discounts is not empty or priceBreakdown.totalDiscountDetails is not null %}
|
||||
<tr>
|
||||
<td style="{{ cell }} border-top: 1px solid #d1d5db; font-weight: bold;">Gesamtpreis nach Rabatt</td>
|
||||
<td style="{{ amount }} border-top: 1px solid #d1d5db; font-weight: bold;">
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends 'email/layout.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<h1>
|
||||
Neue Rollen-Freischaltung
|
||||
</h1>
|
||||
<p>
|
||||
Für ein MyE&P-Konto wurde im BusPro-CRM eine administrative Rolle hinterlegt. Sie muss
|
||||
freigeschaltet werden, bevor sie gilt.
|
||||
</p>
|
||||
<p>
|
||||
<strong>{{ user.displayName }}</strong>
|
||||
<br>
|
||||
{{ user.email }}
|
||||
</p>
|
||||
<p>
|
||||
Beantragt {{ roles|length > 1 ? 'wurden folgende Rollen' : 'wurde folgende Rolle' }}:
|
||||
{% for role in roles %}
|
||||
<br>
|
||||
- {{ role }}
|
||||
{% endfor %}
|
||||
</p>
|
||||
<p>
|
||||
<a href="{{ approvalUrl }}" class="button">
|
||||
Freischaltung prüfen
|
||||
</a>
|
||||
</p>
|
||||
<p class="small">
|
||||
Solange die Freischaltung aussteht, hat die Rolle keinerlei Wirkung — das Konto kann damit
|
||||
nichts tun. Möchtest du sie nicht erteilen, ignoriere diese E-Mail einfach. Die Rolle wird
|
||||
erst entfernt, wenn die Auswahl im CRM entfernt wird.
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -152,11 +152,23 @@
|
||||
</tr>
|
||||
{% for discount in ctx.priceBreakdown.discounts %}
|
||||
<tr class="text-green-700">
|
||||
<td class="p-2">Rabatt {{ discount.label }} {{ discount.percent }} %</td>
|
||||
<td class="p-2">{{ discount.label }} {{ discount.percent }} %</td>
|
||||
<td class="p-2 text-right whitespace-nowrap">– {{ (discount.amount / 100)|format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if ctx.priceBreakdown.discounts is not empty %}
|
||||
{% if ctx.priceBreakdown.discountSubtotal is not null %}
|
||||
<tr class="border-t border-primary-bg">
|
||||
<td class="p-2">Zwischensumme</td>
|
||||
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.discountSubtotal / 100)|format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if ctx.priceBreakdown.totalDiscountDetails is not null %}
|
||||
<tr class="text-green-700">
|
||||
<td class="p-2">{{ ctx.priceBreakdown.totalDiscountDetails.label }}</td>
|
||||
<td class="p-2 text-right whitespace-nowrap">– {{ (ctx.priceBreakdown.totalDiscountDetails.amount / 100)|format_currency(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if ctx.priceBreakdown.discounts is not empty or ctx.priceBreakdown.totalDiscountDetails is not null %}
|
||||
<tr class="border-t border-primary-bg font-bold text-base text-green-700">
|
||||
<td class="p-2">Gesamtpreis nach Rabatt</td>
|
||||
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.discountedTotal / 100)|format_currency(currency) }}</td>
|
||||
|
||||
@@ -36,9 +36,9 @@ class BpnSyncContingentsCommandTest extends TestCase
|
||||
public function testASingleFailingHotelDoesNotFailTheTask(): void
|
||||
{
|
||||
$tester = $this->runSync(['A', 'B', 'C'], [
|
||||
ContingentSyncResult::synced(true, 3, 0, 0),
|
||||
ContingentSyncResult::synced(true, 3, 0, 0, 0),
|
||||
ContingentSyncResult::failed('unknown hotel code'),
|
||||
ContingentSyncResult::synced(false, 0, 0, 0),
|
||||
ContingentSyncResult::synced(false, 0, 0, 0, 0),
|
||||
]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
@@ -48,7 +48,7 @@ class BpnSyncContingentsCommandTest extends TestCase
|
||||
|
||||
public function testASuccessfulRunSucceeds(): void
|
||||
{
|
||||
$tester = $this->runSync(['A'], [ContingentSyncResult::synced(true, 3, 1, 0)]);
|
||||
$tester = $this->runSync(['A'], [ContingentSyncResult::synced(true, 3, 0, 1, 0)]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ class DbAnonymizeCommandTest extends TestCase
|
||||
'newsletterConsents' => 2,
|
||||
'newsletterOptInRequests' => 3,
|
||||
'bookingEditDrafts' => 4,
|
||||
'accommodationBookings' => 5,
|
||||
]);
|
||||
|
||||
$tester = new CommandTester($this->createCommand($anonymizer, 'dev'));
|
||||
@@ -42,7 +43,7 @@ class DbAnonymizeCommandTest extends TestCase
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
self::assertStringContainsString(
|
||||
'Anonymized 1 users, 2 newsletter consents, 3 newsletter opt-in requests, and 4 booking drafts.',
|
||||
'Anonymized 1 users, 2 newsletter consents, 3 newsletter opt-in requests, 4 booking drafts, and 5 accommodation bookings.',
|
||||
$this->normalizeConsoleOutput($tester->getDisplay()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ class AccommodationBookingControllerTest extends TestCase
|
||||
$booking->setAccommodationDiscount(10);
|
||||
$booking->setBoardServiceDiscount(20);
|
||||
$booking->setAdditionalServicesDiscount(30);
|
||||
$booking->setTotalDiscountAmount(5000);
|
||||
$booking->setTotalDiscountLabel('Treuerabatt');
|
||||
$booking->setStatus(AccommodationBookingStatus::Confirmed);
|
||||
$booking->setOrigin(AccommodationBookingOrigin::Direct);
|
||||
$booking->setAcceptedAt(new \DateTimeImmutable('2026-07-15T10:00:00+00:00'));
|
||||
@@ -127,6 +129,8 @@ class AccommodationBookingControllerTest extends TestCase
|
||||
'accommodationDiscount' => 10,
|
||||
'boardServiceDiscount' => 20,
|
||||
'additionalServicesDiscount' => 30,
|
||||
'totalDiscountAmount' => 5000,
|
||||
'totalDiscountLabel' => 'Treuerabatt',
|
||||
'totalPrice' => 11111,
|
||||
'pricingCurrency' => 'EUR',
|
||||
'pricingVersion' => 1,
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider;
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\Role;
|
||||
use App\Service\RoleApprovalUrlGenerator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
@@ -76,6 +77,10 @@ class PendingRoleApprovalsWidgetProviderTest extends TestCase
|
||||
},
|
||||
);
|
||||
|
||||
return new PendingRoleApprovalsWidgetProvider($repository, $urlGenerator);
|
||||
return new PendingRoleApprovalsWidgetProvider(
|
||||
$repository,
|
||||
$urlGenerator,
|
||||
new RoleApprovalUrlGenerator($urlGenerator),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,42 @@ class AccommodationBookingTypeTest extends TestCase
|
||||
self::assertSame(['email', 'firstName', 'groupName', 'lastName'], $properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* The label is printed verbatim on the customer's PDF, so an amount without one is
|
||||
* refused — and in the Default group, so that even a draft cannot store the pair half-set.
|
||||
*/
|
||||
public function testTotalDiscountRequiresALabel(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setTotalDiscountAmount(5000);
|
||||
|
||||
$violations = $this->validate($booking, ['Default']);
|
||||
|
||||
self::assertCount(1, $violations);
|
||||
self::assertSame('totalDiscountLabel', $violations[0]->getPropertyPath());
|
||||
}
|
||||
|
||||
public function testALabelledTotalDiscountPassesValidation(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setTotalDiscountAmount(5000);
|
||||
$booking->setTotalDiscountLabel('Treuerabatt');
|
||||
|
||||
self::assertCount(0, $this->validate($booking, ['Default']));
|
||||
}
|
||||
|
||||
/**
|
||||
* The reverse is harmless — without an amount the label never reaches a breakdown row —
|
||||
* so it is deliberately left valid rather than blocking a half-typed edit.
|
||||
*/
|
||||
public function testALabelWithoutAnAmountIsAccepted(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setTotalDiscountLabel('Treuerabatt');
|
||||
|
||||
self::assertCount(0, $this->validate($booking, ['Default']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\MessageHandler;
|
||||
|
||||
use App\Email\Mailer;
|
||||
use App\Entity\User;
|
||||
use App\Message\RoleNominationMessage;
|
||||
use App\MessageHandler\RoleNominationHandler;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\Role;
|
||||
use App\Service\RoleApprovalUrlGenerator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* The notification goes to the people who can act on it, and to nobody else: an account merely
|
||||
* nominated for ROLE_ADMIN must not be told about other people's nominations.
|
||||
*/
|
||||
class RoleNominationHandlerTest extends TestCase
|
||||
{
|
||||
/** @var array<string, mixed>|null */
|
||||
private ?array $sentOptions = null;
|
||||
|
||||
/** @var array<string, mixed>|null */
|
||||
private ?array $sentContext = null;
|
||||
|
||||
private ?string $generatedRoute = null;
|
||||
|
||||
private ?int $generatedReferenceType = null;
|
||||
|
||||
public function testMailsEveryAdministrator(): void
|
||||
{
|
||||
$handler = $this->handler(
|
||||
$this->user(7, '[email protected]'),
|
||||
[
|
||||
$this->user(1, '[email protected]', [Role::ADMIN]),
|
||||
$this->user(2, '[email protected]', [Role::ADMIN]),
|
||||
],
|
||||
);
|
||||
|
||||
$handler(new RoleNominationMessage(7, [Role::ADMIN]));
|
||||
|
||||
self::assertSame(['[email protected]', '[email protected]'], $this->sentOptions['to']);
|
||||
self::assertSame('email/role_nomination.html.twig', $this->sentOptions['template']);
|
||||
// Labelled for a human, not the raw role string.
|
||||
self::assertSame(['Administration'], $this->sentContext['roles']);
|
||||
// The filtered user list, not app_admin_user_permissions: that route renders a bare htmx
|
||||
// fragment, which a mail client following the link would show unstyled.
|
||||
self::assertSame('app_admin_user', $this->generatedRoute);
|
||||
// Absolute, because a worker has no request to borrow a host from.
|
||||
self::assertSame(UrlGeneratorInterface::ABSOLUTE_URL, $this->generatedReferenceType);
|
||||
self::assertSame('https://my.ep-reisen.de/generated', $this->sentContext['approvalUrl']);
|
||||
}
|
||||
|
||||
public function testAMissingAccountIsANoOp(): void
|
||||
{
|
||||
$handler = $this->handler(null, [$this->user(1, '[email protected]', [Role::ADMIN])]);
|
||||
|
||||
$handler(new RoleNominationMessage(7, [Role::ADMIN]));
|
||||
|
||||
self::assertNull($this->sentOptions);
|
||||
}
|
||||
|
||||
public function testWithoutAnAdministratorNothingIsSent(): void
|
||||
{
|
||||
$handler = $this->handler($this->user(7, '[email protected]'), []);
|
||||
|
||||
$handler(new RoleNominationMessage(7, [Role::ADMIN]));
|
||||
|
||||
self::assertNull($this->sentOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function user(int $id, string $email, array $roles = []): User
|
||||
{
|
||||
$user = (new User($email))->setRoles($roles);
|
||||
|
||||
// The id is generated by Doctrine and has no setter, but the notification links to it.
|
||||
$property = new \ReflectionProperty(User::class, 'id');
|
||||
$property->setValue($user, $id);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param User[] $administrators
|
||||
*/
|
||||
private function handler(?User $nominee, array $administrators): RoleNominationHandler
|
||||
{
|
||||
$userRepository = $this->createStub(UserRepository::class);
|
||||
$userRepository->method('find')->willReturn($nominee);
|
||||
$userRepository->method('findAdministrators')->willReturn($administrators);
|
||||
|
||||
$mailer = $this->createStub(Mailer::class);
|
||||
$mailer
|
||||
->method('createAndSendEmail')
|
||||
->willReturnCallback(function (array $context, array $options): void {
|
||||
$this->sentContext = $context;
|
||||
$this->sentOptions = $options;
|
||||
})
|
||||
;
|
||||
|
||||
// Records what was asked of the router rather than imitating its output: what matters is
|
||||
// which route the mail points at and that it is absolute, not how a query string is escaped.
|
||||
$urlGenerator = $this->createStub(UrlGeneratorInterface::class);
|
||||
$urlGenerator
|
||||
->method('generate')
|
||||
->willReturnCallback(function (string $route, array $parameters, int $referenceType): string {
|
||||
$this->generatedRoute = $route;
|
||||
$this->generatedReferenceType = $referenceType;
|
||||
|
||||
return 'https://my.ep-reisen.de/generated';
|
||||
})
|
||||
;
|
||||
|
||||
return new RoleNominationHandler(
|
||||
$userRepository,
|
||||
$mailer,
|
||||
new RoleApprovalUrlGenerator($urlGenerator),
|
||||
$this->createStub(LoggerInterface::class),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@ use App\BusProNet\Model\CrmAttributes;
|
||||
use App\BusProNet\Model\CrmSelectionGroup;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\Entity\User;
|
||||
use App\Message\RoleNominationMessage;
|
||||
use App\Security\BpnAuthenticator;
|
||||
use App\Security\Crypt;
|
||||
use App\Security\EmployeeDomainMatcher;
|
||||
use App\Security\Role;
|
||||
use App\Service\ProfileCompletenessChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -18,6 +20,8 @@ use Doctrine\ORM\EntityRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
|
||||
@@ -27,6 +31,14 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
*/
|
||||
class BpnAuthenticatorTest extends TestCase
|
||||
{
|
||||
/** @var object[] messages the authenticator dispatched during the login under test */
|
||||
private array $dispatched = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->dispatched = [];
|
||||
}
|
||||
|
||||
public function testNewAccountIsSeededFromTheCrm(): void
|
||||
{
|
||||
$persisted = null;
|
||||
@@ -196,6 +208,86 @@ class BpnAuthenticatorTest extends TestCase
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
public function testStaffEmailDomainGrantsTheEmployeeRole(): void
|
||||
{
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator($this->crmAttributes([], []), null, $persisted);
|
||||
|
||||
$user = $this->loadUser($authenticator, '[email protected]');
|
||||
|
||||
// Effective, so it displaces the customer fallback the same account would get otherwise.
|
||||
self::assertSame(['ROLE_USER', Role::EMPLOYEE], $user->getRoles());
|
||||
}
|
||||
|
||||
public function testAnotherEmailDomainStillFallsBackToCustomer(): void
|
||||
{
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator($this->crmAttributes([], []), null, $persisted);
|
||||
|
||||
$user = $this->loadUser($authenticator, '[email protected]');
|
||||
|
||||
self::assertSame(['ROLE_USER', Role::CUSTOMER], $user->getRoles());
|
||||
}
|
||||
|
||||
public function testEmployeeRoleIsWithdrawnWhenTheAddressIsNoLongerStaff(): void
|
||||
{
|
||||
$existing = (new User('[email protected]'))->setRoles([Role::EMPLOYEE]);
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator($this->crmAttributes([], []), $existing, $persisted);
|
||||
|
||||
$user = $this->loadUser($authenticator, '[email protected]');
|
||||
|
||||
self::assertSame(['ROLE_USER', Role::CUSTOMER], $user->getRoles());
|
||||
}
|
||||
|
||||
public function testANewNominationIsAnnouncedOnce(): void
|
||||
{
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), null, $persisted);
|
||||
|
||||
$user = $this->loadUser($authenticator);
|
||||
|
||||
self::assertCount(1, $this->dispatched);
|
||||
$message = $this->dispatched[0];
|
||||
self::assertInstanceOf(RoleNominationMessage::class, $message);
|
||||
// The role itself, not its marker: the marker is an internal bookkeeping detail.
|
||||
self::assertSame([Role::ADMIN], $message->roles);
|
||||
self::assertContains(Role::pending(Role::ADMIN), $user->getRoles());
|
||||
}
|
||||
|
||||
public function testAStandingNominationIsNotAnnouncedAgain(): void
|
||||
{
|
||||
$existing = (new User('[email protected]'))->setRoles([Role::pending(Role::ADMIN)]);
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted);
|
||||
|
||||
$this->loadUser($authenticator);
|
||||
|
||||
// The nomination has not changed, so there is nothing new to tell an administrator about.
|
||||
self::assertSame([], $this->dispatched);
|
||||
}
|
||||
|
||||
public function testAnApprovedRoleIsNotAnnouncedAsANomination(): void
|
||||
{
|
||||
$existing = (new User('[email protected]'))->setRoles([Role::ADMIN]);
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted);
|
||||
|
||||
$this->loadUser($authenticator);
|
||||
|
||||
self::assertSame([], $this->dispatched);
|
||||
}
|
||||
|
||||
public function testALoginWithoutANominationAnnouncesNothing(): void
|
||||
{
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator($this->crmAttributes([Role::TEAMER], []), null, $persisted);
|
||||
|
||||
$this->loadUser($authenticator);
|
||||
|
||||
self::assertSame([], $this->dispatched);
|
||||
}
|
||||
|
||||
private function authenticator(
|
||||
CrmAttributes $crmAttributes,
|
||||
?User $existing,
|
||||
@@ -231,6 +323,16 @@ class BpnAuthenticatorTest extends TestCase
|
||||
$completenessChecker = $this->createStub(ProfileCompletenessChecker::class);
|
||||
$completenessChecker->method('isComplete')->willReturn(true);
|
||||
|
||||
$messageBus = $this->createStub(MessageBusInterface::class);
|
||||
$messageBus
|
||||
->method('dispatch')
|
||||
->willReturnCallback(function (object $message): Envelope {
|
||||
$this->dispatched[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
})
|
||||
;
|
||||
|
||||
return new BpnAuthenticator(
|
||||
$this->createStub(UrlGeneratorInterface::class),
|
||||
$apiClient,
|
||||
@@ -238,13 +340,15 @@ class BpnAuthenticatorTest extends TestCase
|
||||
$crypt,
|
||||
$completenessChecker,
|
||||
$this->createStub(LoggerInterface::class),
|
||||
new EmployeeDomainMatcher(['ep-reisen.de']),
|
||||
$messageBus,
|
||||
);
|
||||
}
|
||||
|
||||
private function loadUser(BpnAuthenticator $authenticator): User
|
||||
private function loadUser(BpnAuthenticator $authenticator, string $email = '[email protected]'): User
|
||||
{
|
||||
$request = new Request();
|
||||
$request->request->set('_username', '[email protected]');
|
||||
$request->request->set('_username', $email);
|
||||
$request->request->set('_password', 'secret');
|
||||
|
||||
$badge = $authenticator->authenticate($request)->getBadge(UserBadge::class);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Security;
|
||||
|
||||
use App\Security\EmployeeDomainMatcher;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The domain is matched exactly, never as a suffix: a suffix match would hand ROLE_EMPLOYEE to
|
||||
* anybody able to register a domain ending in the configured one.
|
||||
*/
|
||||
class EmployeeDomainMatcherTest extends TestCase
|
||||
{
|
||||
#[DataProvider('addresses')]
|
||||
public function testRecognisesAnEmployeeAddress(?string $email, bool $expected): void
|
||||
{
|
||||
$matcher = new EmployeeDomainMatcher(['ep-reisen.de']);
|
||||
|
||||
self::assertSame($expected, $matcher->isEmployee($email));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{0: ?string, 1: bool}>
|
||||
*/
|
||||
public static function addresses(): iterable
|
||||
{
|
||||
yield 'the configured domain' => ['[email protected]', true];
|
||||
yield 'mixed case is still the same domain' => ['[email protected]', true];
|
||||
yield 'plus addressing does not touch the domain' => ['[email protected]', true];
|
||||
yield 'an at sign in the local part' => ['"odd@name"@ep-reisen.de', true];
|
||||
|
||||
yield 'another domain' => ['[email protected]', false];
|
||||
yield 'a subdomain is not the domain' => ['[email protected]', false];
|
||||
yield 'a domain merely ending in it' => ['[email protected]', false];
|
||||
yield 'the domain as a prefix' => ['[email protected]', false];
|
||||
yield 'no at sign at all' => ['ep-reisen.de', false];
|
||||
yield 'empty' => ['', false];
|
||||
yield 'null' => [null, false];
|
||||
}
|
||||
|
||||
public function testWithoutConfiguredDomainsNobodyIsAnEmployee(): void
|
||||
{
|
||||
self::assertFalse((new EmployeeDomainMatcher([]))->isEmployee('[email protected]'));
|
||||
}
|
||||
|
||||
public function testConfiguredDomainsAreNormalised(): void
|
||||
{
|
||||
$matcher = new EmployeeDomainMatcher([' EP-Reisen.DE ', '@example.org']);
|
||||
|
||||
self::assertTrue($matcher->isEmployee('[email protected]'));
|
||||
self::assertTrue($matcher->isEmployee('[email protected]'));
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,35 @@ class RoleTest extends TestCase
|
||||
self::assertSame([Role::ADMIN => 'Administration'], Role::nominatedFrom($roles));
|
||||
}
|
||||
|
||||
public function testEmployeeIsGrantedOutrightAndDisplacesTheCustomerFallback(): void
|
||||
{
|
||||
// The claim does not come from the CRM, but it travels the same path as one.
|
||||
self::assertSame([Role::EMPLOYEE], Role::sync([], [Role::EMPLOYEE]));
|
||||
}
|
||||
|
||||
public function testEmployeeIsRevokedOnceItIsNoLongerClaimed(): void
|
||||
{
|
||||
// Somebody whose address left the staff domain: no claim, so the role goes, and with no
|
||||
// effective role left the fallback returns.
|
||||
self::assertSame([Role::CUSTOMER], Role::sync([Role::EMPLOYEE], []));
|
||||
}
|
||||
|
||||
public function testEmployeeDoesNotShortCircuitTheNominationOfAnAdministrativeRole(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[Role::EMPLOYEE, Role::pending(Role::ADMIN)],
|
||||
Role::sync([], [Role::EMPLOYEE, Role::ADMIN]),
|
||||
);
|
||||
}
|
||||
|
||||
public function testEmployeeAndTeamerCoexist(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[Role::TEAMER, Role::EMPLOYEE],
|
||||
Role::sync([], [Role::TEAMER, Role::EMPLOYEE]),
|
||||
);
|
||||
}
|
||||
|
||||
public function testEveryRoleAndNominationHasALabel(): void
|
||||
{
|
||||
$labels = Role::labels();
|
||||
|
||||
@@ -37,7 +37,7 @@ class AccommodationBookingBreakdownCalculatorTest extends TestCase
|
||||
|
||||
$breakdown = $this->createCalculator()->compute($booking);
|
||||
|
||||
self::assertSame([['label' => 'Unterkunft', 'percent' => 10, 'amount' => 1000]], $breakdown['discounts']);
|
||||
self::assertSame([['label' => 'Rabatt Unterkunft', 'percent' => 10, 'amount' => 1000]], $breakdown['discounts']);
|
||||
self::assertSame(12345 - 1000, $breakdown['discountedTotal']);
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@ class AccommodationBookingBreakdownCalculatorTest extends TestCase
|
||||
|
||||
// accommodation: (8000+2000)*10% = 1000, board: 1000*20% = 200, services: 500*50% = 250
|
||||
self::assertSame([
|
||||
['label' => 'Unterkunft', 'percent' => 10, 'amount' => 1000],
|
||||
['label' => 'Verpflegung', 'percent' => 20, 'amount' => 200],
|
||||
['label' => 'Zusatzleistungen', 'percent' => 50, 'amount' => 250],
|
||||
['label' => 'Rabatt Unterkunft', 'percent' => 10, 'amount' => 1000],
|
||||
['label' => 'Rabatt Verpflegung', 'percent' => 20, 'amount' => 200],
|
||||
['label' => 'Rabatt Zusatzleistungen', 'percent' => 50, 'amount' => 250],
|
||||
], $breakdown['discounts']);
|
||||
self::assertSame(12345 - 1000 - 200 - 250, $breakdown['discountedTotal']);
|
||||
}
|
||||
@@ -94,9 +94,82 @@ class AccommodationBookingBreakdownCalculatorTest extends TestCase
|
||||
$breakdown = $this->createCalculator()->withDiscounts(new AccommodationBooking(), $this->breakdown());
|
||||
|
||||
self::assertSame([], $breakdown['discounts']);
|
||||
self::assertNull($breakdown['totalDiscountDetails']);
|
||||
self::assertNull($breakdown['discountSubtotal']);
|
||||
self::assertSame(12345, $breakdown['discountedTotal']);
|
||||
}
|
||||
|
||||
/**
|
||||
* On its own the total discount has nothing to sit below, so no subtotal row is offered and
|
||||
* the amount comes straight off the plain total.
|
||||
*/
|
||||
public function testTotalDiscountAloneAppliesToTheTotalWithoutASubtotal(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setTotalDiscountAmount(1235);
|
||||
$booking->setTotalDiscountLabel('Treuerabatt');
|
||||
|
||||
$breakdown = $this->createCalculator()->withDiscounts($booking, $this->breakdown());
|
||||
|
||||
self::assertSame([], $breakdown['discounts']);
|
||||
self::assertSame(['label' => 'Treuerabatt', 'amount' => 1235], $breakdown['totalDiscountDetails']);
|
||||
self::assertNull($breakdown['discountSubtotal']);
|
||||
self::assertSame(12345 - 1235, $breakdown['discountedTotal']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The worked example from the specification: a fixed 50,00 € off the 930,00 € left by the
|
||||
* section discounts, which is where the Zwischensumme row comes from.
|
||||
*/
|
||||
public function testTotalDiscountIsSubtractedFromTheSubtotalAfterSectionDiscounts(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setAccommodationDiscount(10);
|
||||
$booking->setBoardServiceDiscount(5);
|
||||
$booking->setTotalDiscountAmount(5000);
|
||||
$booking->setTotalDiscountLabel('Treuerabatt');
|
||||
|
||||
$breakdown = $this->createCalculator()->withDiscounts($booking, [
|
||||
'total' => 100000,
|
||||
'basePrice' => 60000,
|
||||
'boardPrice' => 20000,
|
||||
]);
|
||||
|
||||
// accommodation: 60000*10% = 6000, board: 20000*5% = 1000 → subtotal 93000
|
||||
self::assertSame(93000, $breakdown['discountSubtotal']);
|
||||
self::assertSame(['label' => 'Treuerabatt', 'amount' => 5000], $breakdown['totalDiscountDetails']);
|
||||
self::assertSame(88000, $breakdown['discountedTotal']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A sum larger than the price is clamped rather than refused: prices move after the discount
|
||||
* was agreed, and a negative total would be worse than a free stay.
|
||||
*/
|
||||
public function testTotalDiscountLargerThanTheSubtotalIsClampedToIt(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setTotalDiscountAmount(20000);
|
||||
$booking->setTotalDiscountLabel('Treuerabatt');
|
||||
|
||||
$breakdown = $this->createCalculator()->withDiscounts($booking, $this->breakdown());
|
||||
|
||||
self::assertSame(['label' => 'Treuerabatt', 'amount' => 12345], $breakdown['totalDiscountDetails']);
|
||||
self::assertSame(0, $breakdown['discountedTotal']);
|
||||
}
|
||||
|
||||
public function testTotalDiscountThatAmountsToNothingIsSkipped(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setTotalDiscountAmount(2000);
|
||||
$booking->setTotalDiscountLabel('Treuerabatt');
|
||||
|
||||
$breakdown = $this->createCalculator()->withDiscounts($booking, ['total' => 0]);
|
||||
|
||||
self::assertNull($breakdown['totalDiscountDetails']);
|
||||
self::assertNull($breakdown['discountSubtotal']);
|
||||
self::assertSame(0, $breakdown['discountedTotal']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The derived keys must never reach the database: refreshPriceSnapshot() freezes exactly
|
||||
* what computeCurrent() returns.
|
||||
@@ -112,6 +185,8 @@ class AccommodationBookingBreakdownCalculatorTest extends TestCase
|
||||
$breakdown = $this->createCalculator()->computeCurrent($booking);
|
||||
|
||||
self::assertArrayNotHasKey('discounts', $breakdown);
|
||||
self::assertArrayNotHasKey('discountSubtotal', $breakdown);
|
||||
self::assertArrayNotHasKey('totalDiscountDetails', $breakdown);
|
||||
self::assertArrayNotHasKey('discountedTotal', $breakdown);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,26 +27,26 @@ class AccommodationBookingPdfGeneratorTest extends TestCase
|
||||
self::assertSame(1, $this->pageCount($pdf));
|
||||
}
|
||||
|
||||
public function testTheDownloadIsAnAttachmentNamedAfterTheGroup(): void
|
||||
public function testTheDownloadIsAnAttachmentNamedAfterArrivalDateAndHotel(): void
|
||||
{
|
||||
$response = $this->createGenerator($this->breakdown())->createDownloadResponse($this->booking());
|
||||
|
||||
self::assertSame('application/pdf', $response->headers->get('Content-Type'));
|
||||
self::assertStringContainsString('attachment;', (string) $response->headers->get('Content-Disposition'));
|
||||
self::assertStringContainsString('buchung-2026-07-06-schulklasse-7b.pdf', (string) $response->headers->get('Content-Disposition'));
|
||||
self::assertStringContainsString('2026-07-06 BERGBLICK Buchung.pdf', (string) $response->headers->get('Content-Disposition'));
|
||||
}
|
||||
|
||||
public function testADocumentForAnUnacceptedOfferIsNamedAsOne(): void
|
||||
public function testADocumentForAnUnacceptedOfferKeepsTheSameFilename(): void
|
||||
{
|
||||
// The document describes whatever the record is at the time it is produced, so it
|
||||
// must not call an offer nobody has accepted a booking.
|
||||
// 'Buchung' in the filename is a literal, not the record label: the office files
|
||||
// these documents by date and house, and a second spelling would break that order.
|
||||
$booking = $this->booking();
|
||||
$booking->setAcceptedAt(null);
|
||||
$booking->setConfirmedAt(null);
|
||||
|
||||
$response = $this->createGenerator($this->breakdown())->createDownloadResponse($booking);
|
||||
|
||||
self::assertStringContainsString('angebot-2026-07-06-schulklasse-7b.pdf', (string) $response->headers->get('Content-Disposition'));
|
||||
self::assertStringContainsString('2026-07-06 BERGBLICK Buchung.pdf', (string) $response->headers->get('Content-Disposition'));
|
||||
}
|
||||
|
||||
public function testABookingWithoutPricesCannotBeRendered(): void
|
||||
@@ -74,6 +74,7 @@ class AccommodationBookingPdfGeneratorTest extends TestCase
|
||||
{
|
||||
$accommodation = new Accommodation();
|
||||
$accommodation->setName('Haus Bergblick');
|
||||
$accommodation->setCalendarCode('BERGBLICK');
|
||||
$accommodation->setMaxAdolescentAge(11);
|
||||
$accommodation->setCurrency('EUR');
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BusProNet\Model\Room;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Service\BookingEditDraftMerger;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests that draft restoration never unassigns a room.
|
||||
*
|
||||
* Edit mode renders the room as static text and never submits assignedRoomId, so a null in
|
||||
* the draft payload is always an artefact of a snapshot taken before BusPro reported the
|
||||
* assignment - typically for seats added to a group booking shortly before the draft was
|
||||
* saved. Replaying such a null would strip the room from the UI and from the outbound
|
||||
* zuordnung, so the room id uses the same merge strategy as single-select services.
|
||||
*/
|
||||
class BookingEditDraftMergerRoomAssignmentTest extends TestCase
|
||||
{
|
||||
private BookingEditDraftMerger $merger;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->merger = new BookingEditDraftMerger();
|
||||
}
|
||||
|
||||
public function testDraftDoesNotUnassignRoomWhenDraftValueIsNull(): void
|
||||
{
|
||||
$participant = new ParticipantDto();
|
||||
$participant->mutable = true;
|
||||
$participant->assignedRoomId = 74;
|
||||
|
||||
$travel = $this->createTravel([74 => $this->createRoom(74, 'Bett im Mehrbettzimmer')]);
|
||||
|
||||
$this->apply($travel, $participant, ['assignedRoomId' => null]);
|
||||
|
||||
$this->assertSame(74, $participant->assignedRoomId);
|
||||
}
|
||||
|
||||
public function testDraftAppliesRoomThatResolvesAgainstTravel(): void
|
||||
{
|
||||
$participant = new ParticipantDto();
|
||||
$participant->mutable = true;
|
||||
$participant->assignedRoomId = 74;
|
||||
|
||||
$travel = $this->createTravel([
|
||||
74 => $this->createRoom(74, 'Bett im Mehrbettzimmer'),
|
||||
75 => $this->createRoom(75, 'Doppelzimmer'),
|
||||
]);
|
||||
|
||||
$this->apply($travel, $participant, ['assignedRoomId' => 75]);
|
||||
|
||||
$this->assertSame(75, $participant->assignedRoomId);
|
||||
}
|
||||
|
||||
public function testDraftSkipsRoomThatIsUnknownToTravel(): void
|
||||
{
|
||||
$participant = new ParticipantDto();
|
||||
$participant->mutable = true;
|
||||
$participant->assignedRoomId = 74;
|
||||
|
||||
$travel = $this->createTravel([74 => $this->createRoom(74, 'Bett im Mehrbettzimmer')]);
|
||||
|
||||
$this->apply($travel, $participant, ['assignedRoomId' => 999]);
|
||||
|
||||
$this->assertSame(74, $participant->assignedRoomId);
|
||||
}
|
||||
|
||||
public function testDraftClearsRoomRemarks(): void
|
||||
{
|
||||
$participant = new ParticipantDto();
|
||||
$participant->mutable = true;
|
||||
$participant->assignedRoomId = 74;
|
||||
$participant->remarksRoom = 'Bitte mit Lisa';
|
||||
|
||||
$travel = $this->createTravel([74 => $this->createRoom(74, 'Bett im Mehrbettzimmer')]);
|
||||
|
||||
$this->apply($travel, $participant, ['assignedRoomId' => null, 'remarksRoom' => null]);
|
||||
|
||||
$this->assertNull($participant->remarksRoom);
|
||||
$this->assertSame(74, $participant->assignedRoomId);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $roomAssignment */
|
||||
private function apply(Travel $travel, ParticipantDto $participant, array $roomAssignment): void
|
||||
{
|
||||
$dto = new BookingDto($travel, 1);
|
||||
$dto->participants = [1 => $participant];
|
||||
|
||||
$this->merger->apply($dto, 1, $participant, ['roomAssignment' => $roomAssignment], $travel);
|
||||
}
|
||||
|
||||
/** @param array<int, Room> $rooms */
|
||||
private function createTravel(array $rooms): Travel
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->rooms = $rooms;
|
||||
|
||||
return $travel;
|
||||
}
|
||||
|
||||
private function createRoom(int $id, string $label): Room
|
||||
{
|
||||
$room = new Room();
|
||||
$room->id = $id;
|
||||
$room->label = $label;
|
||||
|
||||
return $room;
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,6 @@ class ContingentSnapshotManagerTest extends TestCase
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn(['2026-07-01:OK', '2026-07-02:BLOCKED']);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
new ContingentCalendarEntry('2026-07-02', ContingentStatus::Blocked),
|
||||
@@ -69,7 +68,6 @@ class ContingentSnapshotManagerTest extends TestCase
|
||||
];
|
||||
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn($existing);
|
||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn(['2026-07-01:OK']);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
new ContingentCalendarEntry('2026-07-02', ContingentStatus::OnRequest),
|
||||
@@ -85,16 +83,14 @@ class ContingentSnapshotManagerTest extends TestCase
|
||||
self::assertSame(ContingentStatus::OnRequest, $existing['2026-07-02']->getStatus());
|
||||
}
|
||||
|
||||
public function testUnchangedFingerprintDoesNotMoveChangedAt(): void
|
||||
public function testAnIdenticalUpstreamResponseDoesNotMoveChangedAt(): void
|
||||
{
|
||||
$state = new ContingentSyncState($this->accommodation());
|
||||
$state->setContentHash(hash('sha256', '2026-07-01:OK'));
|
||||
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
|
||||
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
|
||||
]);
|
||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn(['2026-07-01:OK']);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
]));
|
||||
@@ -148,7 +144,6 @@ class ContingentSnapshotManagerTest extends TestCase
|
||||
public function testDaysOutsideTheRequestedWindowAreIgnored(): void
|
||||
{
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn([]);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-06-30', ContingentStatus::Ok),
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
@@ -167,7 +162,6 @@ class ContingentSnapshotManagerTest extends TestCase
|
||||
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn([]);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
]));
|
||||
@@ -185,7 +179,6 @@ class ContingentSnapshotManagerTest extends TestCase
|
||||
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn([]);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
]));
|
||||
@@ -195,6 +188,32 @@ class ContingentSnapshotManagerTest extends TestCase
|
||||
self::assertSame('2026-07-03', $state->getHorizonTo()?->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function testTheRollingWindowGrowingIsNotAChange(): void
|
||||
{
|
||||
$state = new ContingentSyncState($this->accommodation());
|
||||
$state->recordSuccess(new \DateTimeImmutable('2026-06-30'), new \DateTimeImmutable('2026-07-02'));
|
||||
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
|
||||
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
|
||||
'2026-07-02' => $this->day('2026-07-02', ContingentStatus::Ok),
|
||||
]);
|
||||
// Same two days as before plus the one the window just grew by, which is what every
|
||||
// scheduled run sees after midnight.
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
new ContingentCalendarEntry('2026-07-02', ContingentStatus::Ok),
|
||||
new ContingentCalendarEntry('2026-07-03', ContingentStatus::Ok),
|
||||
]));
|
||||
|
||||
$result = $this->sync();
|
||||
|
||||
self::assertFalse($result->changed);
|
||||
self::assertSame(0, $result->added);
|
||||
self::assertSame(1, $result->extended);
|
||||
self::assertNull($state->getChangedAt());
|
||||
}
|
||||
|
||||
private function sync(): \App\Model\ContingentSyncResult
|
||||
{
|
||||
$manager = new ContingentSnapshotManager(
|
||||
|
||||
@@ -5,9 +5,12 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Entity\BookingEditDraft;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\NewsletterConsent;
|
||||
use App\Entity\NewsletterOptInRequest;
|
||||
use App\Entity\User;
|
||||
use App\Enum\Groups\AccommodationBookingOrigin;
|
||||
use App\Enum\Groups\AccommodationBookingStatus;
|
||||
use App\Service\DatabaseAnonymizer;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -127,6 +130,25 @@ class DatabaseAnonymizerTest extends TestCase
|
||||
$draft->setDateId(55);
|
||||
$draft->setHotelId(77);
|
||||
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setGroupName('Gymnasium Musterstadt, Klasse 10b');
|
||||
$booking->setSalutation('Frau');
|
||||
$booking->setFirstName('Mia');
|
||||
$booking->setLastName('Muster');
|
||||
$booking->setEmail('[email protected]');
|
||||
$booking->setPhone('+49123456789');
|
||||
$booking->setStreet('Main Street 1');
|
||||
$booking->setZip('12345');
|
||||
$booking->setCity('Berlin');
|
||||
$booking->setRemarks('Ruft immer donnerstags an.');
|
||||
$booking->setDateFrom(new \DateTimeImmutable('2027-01-10'));
|
||||
$booking->setDateTo(new \DateTimeImmutable('2027-01-17'));
|
||||
$booking->setPaxCount(42);
|
||||
$booking->setPriceSnapshot(['accommodation' => 1000], 1000, 'EUR', 2);
|
||||
$booking->setStatus(AccommodationBookingStatus::Confirmed);
|
||||
$booking->setOrigin(AccommodationBookingOrigin::Direct);
|
||||
$originalBookingUuid = $booking->getUuid();
|
||||
|
||||
$originalConsentCreatedAt = $consent->getCreatedAt();
|
||||
$originalConsentUpdatedAt = $consent->getUpdatedAt();
|
||||
$originalRequestCreatedAt = $request->getCreatedAt();
|
||||
@@ -137,6 +159,7 @@ class DatabaseAnonymizerTest extends TestCase
|
||||
$service->anonymizeNewsletterConsent($consent);
|
||||
$service->anonymizeNewsletterOptInRequest($request);
|
||||
$service->anonymizeBookingEditDraft($draft);
|
||||
$service->anonymizeAccommodationBooking($booking);
|
||||
|
||||
self::assertStringEndsWith('@example.test', $user->getEmail());
|
||||
self::assertSame('plain-text-password', $user->getPassword());
|
||||
@@ -186,5 +209,49 @@ class DatabaseAnonymizerTest extends TestCase
|
||||
self::assertSame(77, $draft->getHotelId());
|
||||
self::assertSame($originalDraftCreatedAt, $draft->getCreatedAt());
|
||||
self::assertSame($originalDraftUpdatedAt, $draft->getUpdatedAt());
|
||||
|
||||
self::assertStringEndsWith('@example.test', (string) $booking->getEmail());
|
||||
self::assertSame($user->getEmail(), $booking->getEmail());
|
||||
self::assertSame($consent->getFirstName(), $booking->getFirstName());
|
||||
self::assertSame($consent->getLastName(), $booking->getLastName());
|
||||
self::assertNotSame('Gymnasium Musterstadt, Klasse 10b', $booking->getGroupName());
|
||||
self::assertNotSame('Ruft immer donnerstags an.', $booking->getRemarks());
|
||||
self::assertNotSame('+49123456789', $booking->getPhone());
|
||||
self::assertNotSame('Main Street 1', $booking->getStreet());
|
||||
self::assertNotSame('12345', $booking->getZip());
|
||||
self::assertNotSame('Berlin', $booking->getCity());
|
||||
self::assertSame($originalBookingUuid, $booking->getUuid());
|
||||
self::assertSame('Frau', $booking->getSalutation());
|
||||
self::assertSame('2027-01-10', $booking->getDateFrom()?->format('Y-m-d'));
|
||||
self::assertSame('2027-01-17', $booking->getDateTo()?->format('Y-m-d'));
|
||||
self::assertSame(42, $booking->getPaxCount());
|
||||
self::assertSame(1000, $booking->getTotalPrice());
|
||||
self::assertSame(['accommodation' => 1000], $booking->getPriceBreakdown());
|
||||
self::assertSame('EUR', $booking->getPricingCurrency());
|
||||
self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus());
|
||||
self::assertSame(AccommodationBookingOrigin::Direct, $booking->getOrigin());
|
||||
}
|
||||
|
||||
public function testAnonymizeAccommodationBookingLeavesUnsetContactFieldsNull(): void
|
||||
{
|
||||
$service = new DatabaseAnonymizer(
|
||||
$this->createStub(EntityManagerInterface::class),
|
||||
new NullLogger(),
|
||||
);
|
||||
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setEmail('[email protected]');
|
||||
|
||||
$service->anonymizeAccommodationBooking($booking);
|
||||
|
||||
self::assertStringEndsWith('@example.test', (string) $booking->getEmail());
|
||||
self::assertNull($booking->getGroupName());
|
||||
self::assertNull($booking->getFirstName());
|
||||
self::assertNull($booking->getLastName());
|
||||
self::assertNull($booking->getPhone());
|
||||
self::assertNull($booking->getStreet());
|
||||
self::assertNull($booking->getZip());
|
||||
self::assertNull($booking->getCity());
|
||||
self::assertNull($booking->getRemarks());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user