Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3221008f90 | ||
|
|
b27639c7fa | ||
|
|
ca1ab17595 | ||
|
|
c5685e6d7c | ||
|
|
0a5602fd41 | ||
|
|
f3745f918e | ||
|
|
65c1a7bd12 | ||
|
|
d0e7ba017c | ||
|
|
be0e15b3d4 | ||
|
|
f5e6d2105d | ||
|
|
0c6126010f | ||
|
|
29f295abf6 | ||
|
|
2aa98b81fe | ||
|
|
56adf72d12 | ||
|
|
773cd4f847 | ||
|
|
876e69e62c | ||
|
|
96b7b3f451 | ||
|
|
e751eed4a0 | ||
|
|
bad916cfb7 | ||
|
|
cc3d32fcbb | ||
|
|
79709e3a3e | ||
|
|
ab7d00b35d | ||
|
|
679a864906 | ||
|
|
f080d05dd6 | ||
|
|
00637042e2 | ||
|
|
fbb3ebb8c1 | ||
|
|
ec83ad598f | ||
|
|
2cb4871268 | ||
|
|
c1415dee41 | ||
|
|
540cd8eeb7 | ||
|
|
725e32daab | ||
|
|
160ebef39e | ||
|
|
672fc30d7e | ||
|
|
ba4f66f117 | ||
|
|
12fe4d8e15 | ||
|
|
5d7ffd7d58 | ||
|
|
6a8328a564 | ||
|
|
019de4e705 | ||
|
|
a1d1fdde14 | ||
|
|
1d14f00bb7 | ||
|
|
042037867b | ||
|
|
5f6ff13ce7 | ||
|
|
f08d9a9f40 | ||
|
|
7b49375efa | ||
|
|
91522ab32d | ||
|
|
a3e8c737de | ||
|
|
67c685f6c1 | ||
|
|
7aad54e6f2 | ||
|
|
b41820c39d | ||
|
|
fab89dead6 | ||
|
|
443a3ed248 | ||
|
|
5b133f724d | ||
|
|
5f8a586385 | ||
|
|
e863025fb1 | ||
|
|
91a38faecd | ||
|
|
1d3263373e | ||
|
|
f8ff4b82a3 | ||
|
|
d6c2cfaf1c | ||
|
|
cfe901b9c2 | ||
|
|
8443dae5ad | ||
|
|
dea7a3dc97 | ||
|
|
9e3be24c51 | ||
|
|
e744ebbe33 | ||
|
|
45ca0d2b46 | ||
|
|
f12561c62b | ||
|
|
976b30ccc0 | ||
|
|
198fbfe6a2 | ||
|
|
acb719730c | ||
|
|
0e3d4cbedb | ||
|
|
715fae8e33 | ||
|
|
f595183c05 | ||
|
|
98e0cfcf6f | ||
|
|
e14d29f7d4 | ||
|
|
24df764efb | ||
|
|
e3c08afe73 | ||
|
|
81b724c11a | ||
|
|
bfad61f1dd | ||
|
|
010cfe56b2 |
No files matched your search
+47
-34
@@ -1,21 +1,24 @@
|
|||||||
|
name: myep
|
||||||
type: php
|
type: php
|
||||||
docroot: public
|
docroot: public
|
||||||
php_version: "8.3"
|
php_version: "8.4"
|
||||||
webserver_type: apache-fpm
|
webserver_type: apache-fpm
|
||||||
xdebug_enabled: false
|
xdebug_enabled: false
|
||||||
additional_hostnames:
|
additional_hostnames:
|
||||||
- mysnz
|
- mysnz
|
||||||
- mysbw
|
- mysbw
|
||||||
- myser
|
- myser
|
||||||
additional_fqdns: []
|
additional_fqdns: [ ]
|
||||||
database:
|
database:
|
||||||
type: mysql
|
type: mariadb
|
||||||
version: "8.4"
|
version: "11.8"
|
||||||
|
webimage_extra_packages: [ ripgrep, bubblewrap, socat ]
|
||||||
use_dns_when_possible: true
|
use_dns_when_possible: true
|
||||||
|
timezone: Europe/Berlin
|
||||||
composer_version: "2"
|
composer_version: "2"
|
||||||
web_environment: []
|
web_environment: [ ]
|
||||||
corepack_enable: false
|
|
||||||
nodejs_version: "22"
|
nodejs_version: "22"
|
||||||
|
corepack_enable: false
|
||||||
|
|
||||||
# Key features of DDEV's config.yaml:
|
# Key features of DDEV's config.yaml:
|
||||||
|
|
||||||
@@ -24,16 +27,16 @@ nodejs_version: "22"
|
|||||||
# If the name is omitted, the project will take the name of the enclosing directory,
|
# If the name is omitted, the project will take the name of the enclosing directory,
|
||||||
# which is useful if you want to have a copy of the project side by side with this one.
|
# which is useful if you want to have a copy of the project side by side with this one.
|
||||||
|
|
||||||
# type: <projecttype> # backdrop, cakephp, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, generic, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress
|
# type: <projecttype> # asterios, backdrop, cakephp, codeigniter, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, drupal12, generic, joomla, laravel, magento, magento2, maho, modx, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock
|
||||||
# See https://docs.ddev.com/en/stable/users/quickstart/ for more
|
# See https://docs.ddev.com/en/stable/users/quickstart/ for more
|
||||||
# information on the different project types
|
# information on the different project types
|
||||||
|
|
||||||
# docroot: <relative_path> # Relative path to the directory containing index.php.
|
# docroot: <relative_path> # Relative path to the directory containing index.php.
|
||||||
|
|
||||||
# php_version: "8.3" # PHP version to use, "5.6" through "8.5"
|
# php_version: "8.4" # PHP version to use, "5.6" through "8.5"
|
||||||
|
|
||||||
# You can explicitly specify the webimage but this
|
# You can explicitly specify the webimage but this
|
||||||
# is not recommended, as the images are often closely tied to DDEV's' behavior,
|
# is not recommended, as the images are often closely tied to DDEV's behavior,
|
||||||
# so this can break upgrades.
|
# so this can break upgrades.
|
||||||
|
|
||||||
# webimage: <docker_image>
|
# webimage: <docker_image>
|
||||||
@@ -43,10 +46,18 @@ nodejs_version: "22"
|
|||||||
# database:
|
# database:
|
||||||
# type: <dbtype> # mysql, mariadb, postgres
|
# type: <dbtype> # mysql, mariadb, postgres
|
||||||
# version: <version> # database version, like "10.11" or "8.0"
|
# version: <version> # database version, like "10.11" or "8.0"
|
||||||
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8
|
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8, 12.3
|
||||||
# MySQL versions can be 5.5-8.0, 8.4
|
# MySQL versions can be 5.5-8.0, 8.4, 9.7
|
||||||
# PostgreSQL versions can be 9-18
|
# PostgreSQL versions can be 9-18
|
||||||
|
|
||||||
|
# You can explicitly specify the dbimage but this
|
||||||
|
# is not recommended, as the images are often closely tied to DDEV's behavior,
|
||||||
|
# so this can break upgrades.
|
||||||
|
|
||||||
|
# dbimage: <docker_image>
|
||||||
|
# It’s unusual to change this option, and we don’t recommend it without Docker experience and a good reason.
|
||||||
|
# Typically, this means additions to the existing db image using a .ddev/db-build/Dockerfile.*
|
||||||
|
|
||||||
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
|
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
|
||||||
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
|
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
|
||||||
|
|
||||||
@@ -66,8 +77,7 @@ nodejs_version: "22"
|
|||||||
# bind_all_ports is used (normally with router disabled)
|
# bind_all_ports is used (normally with router disabled)
|
||||||
|
|
||||||
# xhprof_mode: [prepend|xhgui|global]
|
# xhprof_mode: [prepend|xhgui|global]
|
||||||
# Set to "xhgui" to enable XHGui features
|
# Default is "xhgui"
|
||||||
# "xhgui" will become default in a future major release
|
|
||||||
|
|
||||||
# webserver_type: nginx-fpm, apache-fpm, generic
|
# webserver_type: nginx-fpm, apache-fpm, generic
|
||||||
|
|
||||||
@@ -85,22 +95,24 @@ nodejs_version: "22"
|
|||||||
# commands are executed.
|
# commands are executed.
|
||||||
|
|
||||||
# composer_version: "2"
|
# composer_version: "2"
|
||||||
# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1
|
# You can set it to "" or "2" (default) for Composer v2
|
||||||
# to use the latest major version available at the time your container is built.
|
# to use the latest major version available at the time your container is built.
|
||||||
# It is also possible to use each other Composer version channel. This includes:
|
# It is also possible to use any other Composer version channel. This includes:
|
||||||
# - 2.2 (latest Composer LTS version)
|
# - 2.2 (latest Composer LTS version)
|
||||||
# - stable
|
# - stable
|
||||||
# - preview
|
# - preview
|
||||||
# - snapshot
|
# - snapshot
|
||||||
# Alternatively, an explicit Composer version may be specified, for example "2.2.18".
|
# Alternatively, an explicit Composer version may be specified, for example "2.2.18".
|
||||||
# To reinstall Composer after the image was built, run "ddev debug rebuild".
|
# To reinstall Composer after the image was built, run "ddev utility rebuild".
|
||||||
|
|
||||||
# nodejs_version: "22"
|
# nodejs_root: <relative_path>
|
||||||
|
# Relative path to the directory containing the Node.js version file from the
|
||||||
|
# project root. Only used with "nodejs_version: auto" or "nodejs_version: engine".
|
||||||
|
|
||||||
|
# nodejs_version: "24"
|
||||||
# change from the default system Node.js version to any other version.
|
# change from the default system Node.js version to any other version.
|
||||||
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
|
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
|
||||||
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation,
|
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation.
|
||||||
# Note that using of 'ddev nvm' is discouraged because "nodejs_version" is much easier to use,
|
|
||||||
# can specify any version, and is more robust than using 'nvm'.
|
|
||||||
|
|
||||||
# corepack_enable: false
|
# corepack_enable: false
|
||||||
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
|
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
|
||||||
@@ -160,9 +172,7 @@ nodejs_version: "22"
|
|||||||
# - "global": uses the value from the global config.
|
# - "global": uses the value from the global config.
|
||||||
# - "none": disables performance optimization for this project.
|
# - "none": disables performance optimization for this project.
|
||||||
# - "mutagen": enables Mutagen for this project.
|
# - "mutagen": enables Mutagen for this project.
|
||||||
# - "nfs": enables NFS for this project.
|
|
||||||
#
|
#
|
||||||
# See https://docs.ddev.com/en/stable/users/install/performance/#nfs
|
|
||||||
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
|
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
|
||||||
|
|
||||||
# fail_on_hook_fail: False
|
# fail_on_hook_fail: False
|
||||||
@@ -192,10 +202,10 @@ nodejs_version: "22"
|
|||||||
# The mailpit port is not normally bound on the host at all, instead being routed
|
# The mailpit port is not normally bound on the host at all, instead being routed
|
||||||
# through ddev-router, but it can be bound directly to localhost if specified here.
|
# through ddev-router, but it can be bound directly to localhost if specified here.
|
||||||
|
|
||||||
# webimage_extra_packages: [php7.4-tidy, php-bcmath]
|
# webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
|
||||||
# Extra Debian packages that are needed in the webimage can be added here
|
# Extra Debian packages that are needed in the webimage can be added here
|
||||||
|
|
||||||
# dbimage_extra_packages: [telnet,netcat]
|
# dbimage_extra_packages: [netcat, telnet, sudo]
|
||||||
# Extra Debian packages that are needed in the dbimage can be added here
|
# Extra Debian packages that are needed in the dbimage can be added here
|
||||||
|
|
||||||
# use_dns_when_possible: true
|
# use_dns_when_possible: true
|
||||||
@@ -207,12 +217,15 @@ nodejs_version: "22"
|
|||||||
# project_tld: ddev.site
|
# project_tld: ddev.site
|
||||||
# The top-level domain used for project URLs
|
# The top-level domain used for project URLs
|
||||||
# The default "ddev.site" allows DNS lookup via a wildcard
|
# The default "ddev.site" allows DNS lookup via a wildcard
|
||||||
# If you prefer you can change this to "ddev.local" to preserve
|
|
||||||
# pre-v1.9 behavior.
|
|
||||||
|
|
||||||
# ngrok_args: --basic-auth username:pass1234
|
# share_default_provider: ngrok
|
||||||
# Provide extra flags to the "ngrok http" command, see
|
# The default share provider to use for "ddev share"
|
||||||
# https://ngrok.com/docs/agent/config/v3/#agent-configuration or run "ngrok http -h"
|
# Defaults to global configuration, usually "ngrok"
|
||||||
|
# Can be "ngrok" or "cloudflared" or the name of a custom provider from .ddev/share-providers/
|
||||||
|
|
||||||
|
# share_provider_args: --basic-auth username:pass1234
|
||||||
|
# Provide extra flags to the share provider script
|
||||||
|
# See https://docs.ddev.com/en/stable/users/configuration/config/#share_provider_args
|
||||||
|
|
||||||
# disable_settings_management: false
|
# disable_settings_management: false
|
||||||
# If true, DDEV will not create CMS-specific settings files like
|
# If true, DDEV will not create CMS-specific settings files like
|
||||||
@@ -274,7 +287,7 @@ nodejs_version: "22"
|
|||||||
# override_config: false
|
# override_config: false
|
||||||
# By default, config.*.yaml files are *merged* into the configuration
|
# By default, config.*.yaml files are *merged* into the configuration
|
||||||
# But this means that some things can't be overridden
|
# But this means that some things can't be overridden
|
||||||
# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge
|
# For example, if you have 'use_dns_when_possible: true' you can't override it with a merge
|
||||||
# and you can't erase existing hooks or all environment variables.
|
# and you can't erase existing hooks or all environment variables.
|
||||||
# However, with "override_config: true" in a particular config.*.yaml file,
|
# However, with "override_config: true" in a particular config.*.yaml file,
|
||||||
# 'use_dns_when_possible: false' can override the existing values, and
|
# 'use_dns_when_possible: false' can override the existing values, and
|
||||||
@@ -284,7 +297,7 @@ nodejs_version: "22"
|
|||||||
# web_environment: []
|
# web_environment: []
|
||||||
# or
|
# or
|
||||||
# additional_hostnames: []
|
# additional_hostnames: []
|
||||||
# can have their intended affect. 'override_config' affects only behavior of the
|
# can have their intended effect. 'override_config' affects only behavior of the
|
||||||
# config.*.yaml file it exists in.
|
# config.*.yaml file it exists in.
|
||||||
|
|
||||||
# Many DDEV commands can be extended to run tasks before or after the
|
# Many DDEV commands can be extended to run tasks before or after the
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ APP_SECRET=642ead390b8a38f0a017ec84d5362129
|
|||||||
#
|
#
|
||||||
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
|
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
|
||||||
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
|
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
|
||||||
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.4.0&charset=utf8mb4"
|
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=mariadb-11.8.9&charset=utf8mb4"
|
||||||
# DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8"
|
# DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8"
|
||||||
###< doctrine/doctrine-bundle ###
|
###< doctrine/doctrine-bundle ###
|
||||||
|
|
||||||
@@ -92,10 +92,21 @@ APP_TRAVEL_PREFER_REMOTE=false
|
|||||||
APP_TRAVEL_ENABLE_FALLBACK=true
|
APP_TRAVEL_ENABLE_FALLBACK=true
|
||||||
APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
|
APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
|
||||||
|
|
||||||
|
# Compensates a BusPro export defect: a <zustieg_rueck> can omit its preis although the booking
|
||||||
|
# engine still bills the return-leg Abfahrtsortzuschlag, so we undercharge by exactly one leg.
|
||||||
|
# A boarding point costs the same in both directions, so the stated price is applied to both.
|
||||||
|
# Off = report the export verbatim. Provisional until the vendor fixes the export; see
|
||||||
|
# docs/buspro-database-reference.md section A7.
|
||||||
|
# Flipping this needs `app:bpn:xml-cache-invalidate` and `app:bpn:refresh-travel-snapshot`,
|
||||||
|
# because the cached travels and TravelSnapshot rows carry the parsed price.
|
||||||
|
APP_BPN_LEVEL_BOARDING_POINT_PRICES=true
|
||||||
|
|
||||||
# Emails
|
# Emails
|
||||||
APP_DEFAULT_EMAIL_FROM=[email protected]
|
APP_DEFAULT_EMAIL_FROM=[email protected]
|
||||||
APP_DEFAULT_EMAIL_TO=[email protected]
|
APP_DEFAULT_EMAIL_TO=[email protected]
|
||||||
ACCOMMODATION_INQUIRY_EMAIL=[email protected]
|
ACCOMMODATION_INQUIRY_EMAIL=[email protected]
|
||||||
|
# Comma-separated; recipients of the role nomination notification. Empty means disable notification.
|
||||||
|
APP_ROLE_NOMINATION_EMAILS=
|
||||||
|
|
||||||
# Global common defaults
|
# Global common defaults
|
||||||
APP_SEASON_WINTER_FROM=2026-10-01
|
APP_SEASON_WINTER_FROM=2026-10-01
|
||||||
@@ -112,19 +123,11 @@ APP_UNDERSUBSCRIPTION_SURCHARGE_40_CHF=2.5
|
|||||||
# Use 'O' during beta phase, switch to 'F' for production
|
# Use 'O' during beta phase, switch to 'F' for production
|
||||||
DEFAULT_BOOKING_STATUS=F
|
DEFAULT_BOOKING_STATUS=F
|
||||||
|
|
||||||
XML_EXPORT_PATH="%kernel.project_dir%/var/xmlexport"
|
SFTP_XML_SOURCE_HOST=
|
||||||
XML_EXPORT_CONTINGENTS_PATH="%kernel.project_dir%/var/xmlexportzimmer"
|
SFTP_XML_SOURCE_PORT=
|
||||||
JSON_EXPORT_PATH="%kernel.project_dir%/var/jsonexport"
|
SFTP_XML_SOURCE_USER=
|
||||||
|
SFTP_XML_SOURCE_PASSWORD=
|
||||||
SFTP_XML_EXPORT_HOST=
|
SFTP_XML_SOURCE_ROOT=
|
||||||
SFTP_XML_EXPORT_PORT=
|
|
||||||
SFTP_XML_EXPORT_USER=
|
|
||||||
SFTP_XML_EXPORT_PASSWORD=
|
|
||||||
|
|
||||||
SFTP_XML_EXPORT_CONTINGENTS_HOST=
|
|
||||||
SFTP_XML_EXPORT_CONTINGENTS_PORT=
|
|
||||||
SFTP_XML_EXPORT_CONTINGENTS_USER=
|
|
||||||
SFTP_XML_EXPORT_CONTINGENTS_PASSWORD=
|
|
||||||
|
|
||||||
###> league/oauth2-server-bundle ###
|
###> league/oauth2-server-bundle ###
|
||||||
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/secret/private.key
|
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/secret/private.key
|
||||||
@@ -137,3 +140,8 @@ OAUTH_ENCRYPTION_KEY=580084fd179e67399467f59ee96658ac
|
|||||||
# postgresql+advisory://db_user:db_password@localhost/db_name
|
# postgresql+advisory://db_user:db_password@localhost/db_name
|
||||||
LOCK_DSN=flock
|
LOCK_DSN=flock
|
||||||
###< symfony/lock ###
|
###< symfony/lock ###
|
||||||
|
|
||||||
|
MAINTENANCE_MODE_ENABLED=false
|
||||||
|
MAINTENANCE_MODE_IP_WHITELIST=188.245.62.21
|
||||||
|
MAINTENANCE_MODE_ROUTE_WHITELIST=
|
||||||
|
MAINTENANCE_MODE_PATH_WHITELIST=/api,/token
|
||||||
+5
-5
@@ -8,13 +8,9 @@
|
|||||||
/vendor/
|
/vendor/
|
||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
###> phpunit/phpunit ###
|
|
||||||
/phpunit.xml
|
|
||||||
.phpunit.result.cache
|
|
||||||
###< phpunit/phpunit ###
|
|
||||||
|
|
||||||
###> symfony/phpunit-bridge ###
|
###> symfony/phpunit-bridge ###
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
.phpunit.cache
|
||||||
/phpunit.xml
|
/phpunit.xml
|
||||||
###< symfony/phpunit-bridge ###
|
###< symfony/phpunit-bridge ###
|
||||||
|
|
||||||
@@ -37,3 +33,7 @@ yarn-error.log
|
|||||||
###> phpstan/phpstan ###
|
###> phpstan/phpstan ###
|
||||||
phpstan.neon
|
phpstan.neon
|
||||||
###< phpstan/phpstan ###
|
###< phpstan/phpstan ###
|
||||||
|
|
||||||
|
###> config reference ###
|
||||||
|
/config/reference.php
|
||||||
|
###< config reference ###
|
||||||
+38
-38
@@ -4,7 +4,7 @@
|
|||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.1",
|
"php": ">=8.4",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-dom": "*",
|
"ext-dom": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
@@ -29,39 +29,39 @@
|
|||||||
"phpstan/phpdoc-parser": "^2.0",
|
"phpstan/phpdoc-parser": "^2.0",
|
||||||
"spatie/crypto": "^2.1",
|
"spatie/crypto": "^2.1",
|
||||||
"symfony/apache-pack": "^1.0",
|
"symfony/apache-pack": "^1.0",
|
||||||
"symfony/asset": "6.4.*",
|
"symfony/asset": "7.4.*",
|
||||||
"symfony/console": "6.4.*",
|
"symfony/console": "7.4.*",
|
||||||
"symfony/doctrine-messenger": "6.4.*",
|
"symfony/doctrine-messenger": "7.4.*",
|
||||||
"symfony/dom-crawler": "6.4.*",
|
"symfony/dom-crawler": "7.4.*",
|
||||||
"symfony/dotenv": "6.4.*",
|
"symfony/dotenv": "7.4.*",
|
||||||
"symfony/expression-language": "6.4.*",
|
"symfony/expression-language": "7.4.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/form": "6.4.*",
|
"symfony/form": "7.4.*",
|
||||||
"symfony/framework-bundle": "6.4.*",
|
"symfony/framework-bundle": "7.4.*",
|
||||||
"symfony/html-sanitizer": "6.4.*",
|
"symfony/html-sanitizer": "7.4.*",
|
||||||
"symfony/http-client": "6.4.*",
|
"symfony/http-client": "7.4.*",
|
||||||
"symfony/intl": "6.4.*",
|
"symfony/intl": "7.4.*",
|
||||||
"symfony/lock": "6.4.*",
|
"symfony/lock": "7.4.*",
|
||||||
"symfony/mailer": "6.4.*",
|
"symfony/mailer": "7.4.*",
|
||||||
"symfony/mime": "6.4.*",
|
"symfony/mime": "7.4.*",
|
||||||
"symfony/monolog-bundle": "^3.0",
|
"symfony/monolog-bundle": "^3.0",
|
||||||
"symfony/notifier": "6.4.*",
|
"symfony/notifier": "7.4.*",
|
||||||
"symfony/process": "6.4.*",
|
"symfony/process": "7.4.*",
|
||||||
"symfony/property-access": "6.4.*",
|
"symfony/property-access": "7.4.*",
|
||||||
"symfony/property-info": "6.4.*",
|
"symfony/property-info": "7.4.*",
|
||||||
"symfony/runtime": "6.4.*",
|
"symfony/runtime": "7.4.*",
|
||||||
"symfony/security-bundle": "6.4.*",
|
"symfony/security-bundle": "7.4.*",
|
||||||
"symfony/serializer": "6.4.*",
|
"symfony/serializer": "7.4.*",
|
||||||
"symfony/stimulus-bundle": "^2.21",
|
"symfony/stimulus-bundle": "^2.21",
|
||||||
"symfony/string": "6.4.*",
|
"symfony/string": "7.4.*",
|
||||||
"symfony/translation": "6.4.*",
|
"symfony/translation": "7.4.*",
|
||||||
"symfony/twig-bundle": "6.4.*",
|
"symfony/twig-bundle": "7.4.*",
|
||||||
"symfony/uid": "6.4.*",
|
"symfony/uid": "7.4.*",
|
||||||
"symfony/ux-twig-component": "^2.36",
|
"symfony/ux-twig-component": "^2.36",
|
||||||
"symfony/validator": "6.4.*",
|
"symfony/validator": "7.4.*",
|
||||||
"symfony/web-link": "6.4.*",
|
"symfony/web-link": "7.4.*",
|
||||||
"symfony/webpack-encore-bundle": "^2.2",
|
"symfony/webpack-encore-bundle": "^2.2",
|
||||||
"symfony/yaml": "6.4.*",
|
"symfony/yaml": "7.4.*",
|
||||||
"twig/extra-bundle": "^2.12|^3.0",
|
"twig/extra-bundle": "^2.12|^3.0",
|
||||||
"twig/html-extra": "^3.15",
|
"twig/html-extra": "^3.15",
|
||||||
"twig/intl-extra": "*",
|
"twig/intl-extra": "*",
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
},
|
},
|
||||||
"sort-packages": true,
|
"sort-packages": true,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "8.3.30"
|
"php": "8.4.24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
"extra": {
|
"extra": {
|
||||||
"symfony": {
|
"symfony": {
|
||||||
"allow-contrib": true,
|
"allow-contrib": true,
|
||||||
"require": "6.4.*",
|
"require": "7.4.*",
|
||||||
"docker": true
|
"docker": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -129,14 +129,14 @@
|
|||||||
"phpstan/phpstan": "^2.1",
|
"phpstan/phpstan": "^2.1",
|
||||||
"phpstan/phpstan-doctrine": "^2.0",
|
"phpstan/phpstan-doctrine": "^2.0",
|
||||||
"phpstan/phpstan-symfony": "^2.0",
|
"phpstan/phpstan-symfony": "^2.0",
|
||||||
"phpunit/phpunit": "^9.5",
|
"phpunit/phpunit": "^12",
|
||||||
"rector/rector": "^2.4",
|
"rector/rector": "^2.4",
|
||||||
"symfony/browser-kit": "6.4.*",
|
"symfony/browser-kit": "7.4.*",
|
||||||
"symfony/css-selector": "6.4.*",
|
"symfony/css-selector": "7.4.*",
|
||||||
"symfony/debug-bundle": "6.4.*",
|
"symfony/debug-bundle": "7.4.*",
|
||||||
"symfony/maker-bundle": "^1.0",
|
"symfony/maker-bundle": "^1.0",
|
||||||
"symfony/phpunit-bridge": "^7.1",
|
"symfony/phpunit-bridge": "^7.4",
|
||||||
"symfony/stopwatch": "6.4.*",
|
"symfony/stopwatch": "7.4.*",
|
||||||
"symfony/web-profiler-bundle": "6.4.*"
|
"symfony/web-profiler-bundle": "7.4.*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+1885
-1754
File diff suppressed because it is too large.
Load diff
@@ -9,8 +9,7 @@ doctrine:
|
|||||||
profiling_collect_backtrace: '%kernel.debug%'
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
use_savepoints: true
|
use_savepoints: true
|
||||||
orm:
|
orm:
|
||||||
auto_generate_proxy_classes: true
|
enable_native_lazy_objects: true
|
||||||
enable_lazy_ghost_objects: true
|
|
||||||
validate_xml_mapping: true
|
validate_xml_mapping: true
|
||||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||||
auto_mapping: true
|
auto_mapping: true
|
||||||
@@ -33,8 +32,6 @@ when@test:
|
|||||||
when@prod:
|
when@prod:
|
||||||
doctrine:
|
doctrine:
|
||||||
orm:
|
orm:
|
||||||
auto_generate_proxy_classes: false
|
|
||||||
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
|
|
||||||
query_cache_driver:
|
query_cache_driver:
|
||||||
type: pool
|
type: pool
|
||||||
pool: doctrine.system_cache_pool
|
pool: doctrine.system_cache_pool
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ flysystem:
|
|||||||
storages:
|
storages:
|
||||||
xml_export.storage:
|
xml_export.storage:
|
||||||
local:
|
local:
|
||||||
directory: '%env(resolve:XML_EXPORT_PATH)%'
|
directory: '%kernel.project_dir%/var/xmlexport'
|
||||||
xml_source.storage:
|
xml_source.storage:
|
||||||
sftp:
|
sftp:
|
||||||
host: '%env(SFTP_XML_EXPORT_HOST)%'
|
host: '%env(SFTP_XML_SOURCE_HOST)%'
|
||||||
port: '%env(int:SFTP_XML_EXPORT_PORT)%'
|
port: '%env(int:SFTP_XML_SOURCE_PORT)%'
|
||||||
username: '%env(SFTP_XML_EXPORT_USER)%'
|
username: '%env(SFTP_XML_SOURCE_USER)%'
|
||||||
password: '%env(SFTP_XML_EXPORT_PASSWORD)%'
|
password: '%env(SFTP_XML_SOURCE_PASSWORD)%'
|
||||||
|
root: '%env(SFTP_XML_SOURCE_ROOT)%'
|
||||||
xml_dump.storage:
|
xml_dump.storage:
|
||||||
local:
|
local:
|
||||||
directory: '%kernel.project_dir%/var/bpn'
|
directory: '%kernel.project_dir%/var/bpn'
|
||||||
json_export.storage:
|
json_export.storage:
|
||||||
local:
|
local:
|
||||||
directory: '%env(resolve:JSON_EXPORT_PATH)%'
|
directory: '%kernel.project_dir%/var/jsonexport'
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||||
framework:
|
framework:
|
||||||
secret: '%env(APP_SECRET)%'
|
secret: '%env(APP_SECRET)%'
|
||||||
annotations: false
|
|
||||||
http_method_override: false
|
http_method_override: false
|
||||||
handle_all_throwables: true
|
handle_all_throwables: true
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ league_oauth2_server:
|
|||||||
doctrine: null
|
doctrine: null
|
||||||
client:
|
client:
|
||||||
allow_plaintext_secrets: false
|
allow_plaintext_secrets: false
|
||||||
|
# App\Entity\OAuth2Client adds the required_roles column that
|
||||||
|
# App\EventListener\AuthorizationCodeListener gates authorization on.
|
||||||
|
classname: App\Entity\OAuth2Client
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
league_oauth2_server:
|
league_oauth2_server:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ framework:
|
|||||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||||
App\Message\MailjetNewsletterEventMessage: async
|
App\Message\MailjetNewsletterEventMessage: async
|
||||||
App\Message\CreateClickUpBookingTaskMessage: async
|
App\Message\CreateClickUpBookingTaskMessage: async
|
||||||
|
App\Message\RoleNominationMessage: async
|
||||||
|
|
||||||
# Route your messages to the transports
|
# Route your messages to the transports
|
||||||
# 'App\Message\YourMessage': async
|
# 'App\Message\YourMessage': async
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
framework:
|
||||||
|
property_info:
|
||||||
|
with_constructor_extractor: true
|
||||||
@@ -2,9 +2,12 @@ framework:
|
|||||||
router:
|
router:
|
||||||
utf8: true
|
utf8: true
|
||||||
|
|
||||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
# How URLs are generated where there is no request to take the host from: scheduled
|
||||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
# commands and, notably, the Messenger workers that send mail off the request path.
|
||||||
#default_uri: http://localhost
|
# 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:
|
when@prod:
|
||||||
framework:
|
framework:
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ security:
|
|||||||
roles: [ ROLE_MAILJET_WEBHOOK ]
|
roles: [ ROLE_MAILJET_WEBHOOK ]
|
||||||
|
|
||||||
role_hierarchy:
|
role_hierarchy:
|
||||||
|
# ROLE_CUSTOMER_EXPERT gates the booking-draft and log surfaces, which used to be
|
||||||
|
# ROLE_ADMIN only. Inheriting it here is what keeps those surfaces open to administrators
|
||||||
|
# after the #[IsGranted] attributes moved to the narrower role.
|
||||||
ROLE_ADMIN:
|
ROLE_ADMIN:
|
||||||
- ROLE_GROUPS_ADMIN
|
- ROLE_GROUPS_ADMIN
|
||||||
|
- ROLE_CUSTOMER_EXPERT
|
||||||
ROLE_GROUPS_ADMIN:
|
ROLE_GROUPS_ADMIN:
|
||||||
- ROLE_GROUPS_MANAGER
|
- ROLE_GROUPS_MANAGER
|
||||||
firewalls:
|
firewalls:
|
||||||
|
|||||||
@@ -14,4 +14,6 @@ when@test:
|
|||||||
intercept_redirects: false
|
intercept_redirects: false
|
||||||
|
|
||||||
framework:
|
framework:
|
||||||
profiler: { collect: false }
|
profiler:
|
||||||
|
collect: false
|
||||||
|
collect_serializer_data: true
|
||||||
@@ -3,13 +3,14 @@ zenstruck_schedule:
|
|||||||
|
|
||||||
mailer:
|
mailer:
|
||||||
service: mailer
|
service: mailer
|
||||||
default_to: [email protected]
|
default_to: [email protected]
|
||||||
default_from: info@ep-reisen.de
|
default_from: noreply@ep-reisen.de
|
||||||
subject_prefix: "[MyE&P]"
|
subject_prefix: "[MyE&P]"
|
||||||
|
|
||||||
schedule_extensions:
|
schedule_extensions:
|
||||||
email_on_failure:
|
email_on_failure:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
to: [email protected]
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
- task: app:cleanup:xml-dumps
|
- task: app:cleanup:xml-dumps
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
||||||
# This file is the entry point to configure your own services.
|
# This file is the entry point to configure your own services.
|
||||||
# Files in the packages/ subdirectory configure your dependencies.
|
# Files in the packages/ subdirectory configure your dependencies.
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ parameters:
|
|||||||
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
|
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
|
||||||
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
|
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
|
||||||
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
|
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
|
||||||
|
role_nomination_notification_emails: '%env(csv:APP_ROLE_NOMINATION_EMAILS)%'
|
||||||
|
|
||||||
# MailJet list ids and their labels
|
# MailJet list ids and their labels
|
||||||
mailjet_lists:
|
mailjet_lists:
|
||||||
@@ -30,6 +32,12 @@ parameters:
|
|||||||
10321389: 'Reisen-Alert Stubaital'
|
10321389: 'Reisen-Alert Stubaital'
|
||||||
10554990: 'Reisen-Alert Ski & Boarderweek'
|
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.
|
# BusProNet "Hausleitung {CODE}" CRM selections, by selection id.
|
||||||
# DEPLOYMENT-CRITICAL: roles and hotel codes are synced on every login, so an id missing
|
# 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
|
# here does not merely fail to nominate a Hausleitung — it revokes the role and the hotel
|
||||||
@@ -177,6 +185,10 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$retentionBufferDays: '%travel_snapshot_retention_buffer_days%'
|
$retentionBufferDays: '%travel_snapshot_retention_buffer_days%'
|
||||||
|
|
||||||
|
App\BusProNet\XmlParser\TravelParser:
|
||||||
|
arguments:
|
||||||
|
$levelBoardingPointPrices: '%env(bool:APP_BPN_LEVEL_BOARDING_POINT_PRICES)%'
|
||||||
|
|
||||||
App\Service\TravelDataProvider:
|
App\Service\TravelDataProvider:
|
||||||
arguments:
|
arguments:
|
||||||
$logger: '@monolog.logger.core'
|
$logger: '@monolog.logger.core'
|
||||||
@@ -195,6 +207,10 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$cache: '@bpn.cache'
|
$cache: '@bpn.cache'
|
||||||
|
|
||||||
|
App\BusProNet\Utility\CountryCodeMapper:
|
||||||
|
arguments:
|
||||||
|
$logger: '@monolog.logger.bpn'
|
||||||
|
|
||||||
App\BusProNet\XmlLoader\HotelLoader:
|
App\BusProNet\XmlLoader\HotelLoader:
|
||||||
arguments:
|
arguments:
|
||||||
$cache: '@bpn.cache'
|
$cache: '@bpn.cache'
|
||||||
@@ -207,6 +223,11 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$cache: '@bpn.cache'
|
$cache: '@bpn.cache'
|
||||||
|
|
||||||
|
App\Service\TravelExportBuilder:
|
||||||
|
arguments:
|
||||||
|
$cache: '@bpn.cache'
|
||||||
|
$logger: '@monolog.logger.bpn'
|
||||||
|
|
||||||
App\Service\VoucherValidator:
|
App\Service\VoucherValidator:
|
||||||
arguments:
|
arguments:
|
||||||
$cache: '@cache.app'
|
$cache: '@cache.app'
|
||||||
@@ -259,6 +280,7 @@ services:
|
|||||||
- '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule'
|
- '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule'
|
||||||
|
|
||||||
# Dashboard Widgets
|
# Dashboard Widgets
|
||||||
|
App\Dashboard\Widget\StuckBookingDraftsWidgetProvider: ~
|
||||||
App\Dashboard\Widget\OpenGroupBookingsWidgetProvider: ~
|
App\Dashboard\Widget\OpenGroupBookingsWidgetProvider: ~
|
||||||
App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider: ~
|
App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider: ~
|
||||||
App\Dashboard\Widget\RecentLogEntriesWidgetProvider: ~
|
App\Dashboard\Widget\RecentLogEntriesWidgetProvider: ~
|
||||||
@@ -267,6 +289,7 @@ services:
|
|||||||
App\Dashboard\DashboardWidgetRegistry:
|
App\Dashboard\DashboardWidgetRegistry:
|
||||||
arguments:
|
arguments:
|
||||||
$providers:
|
$providers:
|
||||||
|
- '@App\Dashboard\Widget\StuckBookingDraftsWidgetProvider'
|
||||||
- '@App\Dashboard\Widget\OpenGroupBookingsWidgetProvider'
|
- '@App\Dashboard\Widget\OpenGroupBookingsWidgetProvider'
|
||||||
- '@App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider'
|
- '@App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider'
|
||||||
- '@App\Dashboard\Widget\RecentLogEntriesWidgetProvider'
|
- '@App\Dashboard\Widget\RecentLogEntriesWidgetProvider'
|
||||||
@@ -298,6 +321,14 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$houseManagerIds: '%bpn_crm_house_manager_ids%'
|
$houseManagerIds: '%bpn_crm_house_manager_ids%'
|
||||||
|
|
||||||
|
App\Security\EmployeeDomainMatcher:
|
||||||
|
arguments:
|
||||||
|
$domains: '%employee_email_domains%'
|
||||||
|
|
||||||
|
App\MessageHandler\RoleNominationHandler:
|
||||||
|
arguments:
|
||||||
|
$notificationRecipients: '%role_nomination_notification_emails%'
|
||||||
|
|
||||||
App\Service\DomainConfigProvider:
|
App\Service\DomainConfigProvider:
|
||||||
arguments:
|
arguments:
|
||||||
$domainConfig: '%domain_config%'
|
$domainConfig: '%domain_config%'
|
||||||
@@ -356,3 +387,11 @@ services:
|
|||||||
$apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
|
$apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
|
||||||
$defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%'
|
$defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%'
|
||||||
$contactMetadataFields: '%mailjet_contact_metadata_fields%'
|
$contactMetadataFields: '%mailjet_contact_metadata_fields%'
|
||||||
|
|
||||||
|
App\EventListener\MaintenanceModeListener:
|
||||||
|
arguments:
|
||||||
|
$options:
|
||||||
|
enabled: '%env(bool:MAINTENANCE_MODE_ENABLED)%'
|
||||||
|
whitelisted_ips: '%env(csv:MAINTENANCE_MODE_IP_WHITELIST)%'
|
||||||
|
whitelisted_routes: '%env(csv:MAINTENANCE_MODE_ROUTE_WHITELIST)%'
|
||||||
|
whitelisted_paths: '%env(csv:MAINTENANCE_MODE_PATH_WHITELIST)%'
|
||||||
+32
-36
@@ -9,7 +9,6 @@ require 'recipe/symfony.php';
|
|||||||
add('shared_dirs', [
|
add('shared_dirs', [
|
||||||
'config/secret',
|
'config/secret',
|
||||||
'var/bpn',
|
'var/bpn',
|
||||||
'var/log',
|
|
||||||
'var/sessions',
|
'var/sessions',
|
||||||
'var/jsonexport',
|
'var/jsonexport',
|
||||||
'var/xmlexport',
|
'var/xmlexport',
|
||||||
@@ -35,6 +34,8 @@ $rsyncOptions = [
|
|||||||
'.jj',
|
'.jj',
|
||||||
'node_modules',
|
'node_modules',
|
||||||
'.editorconfig',
|
'.editorconfig',
|
||||||
|
'.env.local',
|
||||||
|
'.env.local.php',
|
||||||
'.env.dev.local',
|
'.env.dev.local',
|
||||||
'.env.dev.local.php',
|
'.env.dev.local.php',
|
||||||
'.env.test',
|
'.env.test',
|
||||||
@@ -76,75 +77,70 @@ $rsyncOptions = [
|
|||||||
'timeout' => 300,
|
'timeout' => 300,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Shared by all hosts.
|
||||||
|
set('bin/php', '/usr/bin/php');
|
||||||
|
set('http_user', 'myepsf');
|
||||||
|
// Must stay here: contrib/rsync.php sets the same key, but its __DIR__ is the vendor dir.
|
||||||
|
set('rsync_src', __DIR__);
|
||||||
|
set('rsync', $rsyncOptions);
|
||||||
|
|
||||||
host('prod')
|
host('prod')
|
||||||
->setHostname('185.243.135.29')
|
->setHostname('dedi10193.your-server.de')
|
||||||
->setRemoteUser('p704161')
|
->setDeployPath('/usr/www/users/myepsf/prod')
|
||||||
|
->setRemoteUser('myepsf')
|
||||||
->setForwardAgent(true)
|
->setForwardAgent(true)
|
||||||
->setSshMultiplexing(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')
|
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
|
||||||
;
|
;
|
||||||
|
|
||||||
host('staging')
|
host('staging')
|
||||||
->setHostname('185.243.135.29')
|
->setHostname('dedi10193.your-server.de')
|
||||||
->setRemoteUser('p704161')
|
->setDeployPath('/usr/www/users/myepsf/staging')
|
||||||
|
->setRemoteUser('myepsf')
|
||||||
->setForwardAgent(true)
|
->setForwardAgent(true)
|
||||||
->setSshMultiplexing(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')
|
->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', [
|
->add('shared_files', [
|
||||||
'public/.htpasswd',
|
'public/.htaccess',
|
||||||
])
|
|
||||||
;
|
|
||||||
|
|
||||||
host('develop')
|
|
||||||
->setHostname('185.243.135.29')
|
|
||||||
->setRemoteUser('p704161')
|
|
||||||
->setForwardAgent(true)
|
|
||||||
->setSshMultiplexing(true)
|
|
||||||
->setDeployPath('/home/www/p704161/html/myep-develop')
|
|
||||||
->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://mydev.ep-reisen.net --web-basic-auth=myep:develop')
|
|
||||||
->add('shared_files', [
|
|
||||||
'public/.htpasswd',
|
'public/.htpasswd',
|
||||||
])
|
])
|
||||||
;
|
;
|
||||||
|
|
||||||
task('deploy', [
|
task('deploy', [
|
||||||
'deploy:info',
|
'deploy:info',
|
||||||
|
'deploy:assets',
|
||||||
'deploy:setup',
|
'deploy:setup',
|
||||||
'deploy:lock',
|
'deploy:lock',
|
||||||
'deploy:release',
|
'deploy:release',
|
||||||
'deploy:assets',
|
|
||||||
'rsync',
|
'rsync',
|
||||||
'deploy:shared',
|
'deploy:shared',
|
||||||
'deploy:writable',
|
'deploy:writable',
|
||||||
'deploy:cache:clear',
|
'deploy:cache:warmup',
|
||||||
'database:migrate',
|
'database:migrate',
|
||||||
|
// Must run before the symlink flip: the cachetool probe file is only reachable through
|
||||||
|
// the release the docroot currently resolves to. It still pays off, because it drops the
|
||||||
|
// opcache entries keyed under current/public/*.
|
||||||
'cachetool:clear:opcache',
|
'cachetool:clear:opcache',
|
||||||
'deploy:publish',
|
'deploy:publish',
|
||||||
'deploy:stop-workers',
|
'deploy:stop-workers',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Purely local, so it runs before anything is created on the remote. once(), so
|
||||||
|
// deploying multiple hosts at the same time builds the bundle only once.
|
||||||
task('deploy:assets', function () {
|
task('deploy:assets', function () {
|
||||||
runLocally('ddev exec npm ci');
|
runLocally('ddev exec npm ci');
|
||||||
runLocally('ddev exec npm run build');
|
runLocally('ddev exec npm run build');
|
||||||
|
})->once();
|
||||||
|
|
||||||
|
// The recipe's deploy:cache:clear only does something when composer ran with
|
||||||
|
// --no-scripts, which never happens here: vendor/ is rsynced, composer never runs
|
||||||
|
// remotely. A fresh release has no var/cache to clear, so warm it up directly.
|
||||||
|
// Runs after deploy:writable so the default ACLs are already in place.
|
||||||
|
task('deploy:cache:warmup', function () {
|
||||||
|
run('{{bin/console}} cache:warmup {{console_options}}');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Workers keep running the previous release's code until they are told to stop.
|
||||||
task('deploy:stop-workers', function () {
|
task('deploy:stop-workers', function () {
|
||||||
run('{{bin/console}} messenger:stop-workers');
|
run('{{bin/console}} messenger:stop-workers');
|
||||||
});
|
});
|
||||||
|
|||||||
+165
-9
@@ -10,7 +10,10 @@ A Symfony application exposing a read-mostly JSON API under the `/api` prefix. A
|
|||||||
|
|
||||||
- Token endpoint: `POST /token` (unauthenticated).
|
- Token endpoint: `POST /token` (unauthenticated).
|
||||||
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
|
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
|
||||||
- Access token TTL: **10 minutes** — clients must refresh/re-request frequently.
|
- Access token TTL is **10 minutes** for every grant.
|
||||||
|
Do not hardcode it — read `expires_in` from the token response, it is authoritative and may be tuned.
|
||||||
|
A token can therefore expire part-way through a long page walk of `GET /api/exports/travels`; re-request one on
|
||||||
|
401 and resume at the page you were on. At the default `perPage=500` the whole export is a single page today.
|
||||||
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
|
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
|
||||||
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
|
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
|
||||||
- Send the token as `Authorization: Bearer <access_token>`.
|
- Send the token as `Authorization: Bearer <access_token>`.
|
||||||
@@ -47,7 +50,9 @@ There is no single error envelope; three shapes occur:
|
|||||||
- `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code.
|
- `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code.
|
||||||
- `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter).
|
- `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter).
|
||||||
|
|
||||||
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
|
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/exports/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
|
||||||
|
|
||||||
|
`GET /api/exports/travels` deliberately uses real status codes throughout and never the 200-with-an-`error`-key shape: a failure there is a 5xx, so retry logic can key on the status code alone.
|
||||||
|
|
||||||
The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them.
|
The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them.
|
||||||
|
|
||||||
@@ -59,11 +64,23 @@ The HTTP methods listed below are the intended ones. Several read routes do not
|
|||||||
|
|
||||||
#### `GET /api/hotels` — scope `api`
|
#### `GET /api/hotels` — scope `api`
|
||||||
|
|
||||||
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`.
|
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`,
|
||||||
|
`costUnit`, `countryCode`.
|
||||||
|
|
||||||
|
- `country` is the **raw** upstream token, unnormalised, and is the join key into `GET /api/countries[].token`.
|
||||||
|
It mixes conventions: ISO-2 (`CH`, `NL`), European vehicle registration codes (`D`, `A`, `F`, `S`, `FL`) and
|
||||||
|
ISO-3 (`AUT`, `PRT`, `HRV`), sometimes several for the same country.
|
||||||
|
- `countryCode` (`string|null`) is that token mapped to a lowercase ISO 3166-1 alpha-2 code — use this one to pick
|
||||||
|
a flag. A token with no confident mapping yields **`null`**, never a guess and never `""`, so treat null as
|
||||||
|
"unknown" rather than as an error. New tokens appear upstream from time to time; they surface as null here until
|
||||||
|
the mapping table is extended.
|
||||||
|
- `costUnit` (`string|null`) is the raw upstream cost centre. It is a **string, not a number**: most hotels have
|
||||||
|
none, and some carry a space-separated list such as `"84 85"`. Store it as text and do not parse it.
|
||||||
|
|
||||||
#### `GET /api/hotels/{id}` — scope `api`
|
#### `GET /api/hotels/{id}` — scope `api`
|
||||||
|
|
||||||
Single hotel by numeric id, same field set as the list. `404 {"message":"Not found"}` if unknown.
|
Single hotel by numeric id, same field set as the list, including `costUnit` and `countryCode`.
|
||||||
|
`404 {"message":"Not found"}` if unknown.
|
||||||
|
|
||||||
#### `GET /api/countries` — scope `api`
|
#### `GET /api/countries` — scope `api`
|
||||||
|
|
||||||
@@ -122,6 +139,107 @@ This is a full overwrite, not a merge.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Bulk exports
|
||||||
|
|
||||||
|
#### `GET /api/exports/travels` — scope `api`
|
||||||
|
|
||||||
|
Bulk export of every travel date × hotel pair with its boarding points resolved, for clients that mirror the
|
||||||
|
disposition locally. One request replaces a walk over `GET /api/travels` plus a per-pair detail call.
|
||||||
|
|
||||||
|
This is the only paginated endpoint and the only one with a `meta` envelope.
|
||||||
|
|
||||||
|
It sits under `/api/exports`, not `/api/travels`, on purpose: `/api/travels/{dateCode}` accepts an
|
||||||
|
arbitrary string, so an export hung off that collection would share a namespace with every date code.
|
||||||
|
`/api/exports` never takes a parameterised segment, so there is nothing for it to collide with.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `page` | int ≥ 1 | `1` | Out of range is **not** clamped — see below |
|
||||||
|
| `perPage` | int 1–1000 | `500` | |
|
||||||
|
| `changedSince` | — | — | **Reserved, not implemented.** Sending it today is ignored; it is named here only so it is not claimed for anything else. |
|
||||||
|
|
||||||
|
A `page`/`perPage` outside those bounds returns **400** with Symfony's constraint-violation body (send
|
||||||
|
`Accept: application/json` to get it as JSON rather than an HTML error page). A `page` past the last one is *not*
|
||||||
|
an error: it returns `200` with an empty `data` array and the usual `meta`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"busProId": 12794,
|
||||||
|
"code": "PCJSER261226",
|
||||||
|
"product": "Semesterende - Portes du Soleil - Joliment",
|
||||||
|
"dateFrom": "2026-12-26",
|
||||||
|
"dateTo": "2026-12-30",
|
||||||
|
"hotel": {
|
||||||
|
"busProId": 225444,
|
||||||
|
"code": "PCJSER1",
|
||||||
|
"name": "NEU SER Jolimont 1",
|
||||||
|
"costUnit": "863",
|
||||||
|
"country": "CH",
|
||||||
|
"countryCode": "ch"
|
||||||
|
},
|
||||||
|
"pickups": [
|
||||||
|
{
|
||||||
|
"busProId": 18,
|
||||||
|
"code": "ESS-Route",
|
||||||
|
"city": "Essen",
|
||||||
|
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
|
||||||
|
"time": "20:15",
|
||||||
|
"datetime": "2026-12-25T20:15:00+01:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"page": 1,
|
||||||
|
"perPage": 500,
|
||||||
|
"total": 415,
|
||||||
|
"totalPages": 1,
|
||||||
|
"skipped": 0,
|
||||||
|
"pickupsWithoutMasterData": 0,
|
||||||
|
"generatedAt": "2026-09-29T12:10:06+02:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Row identity.** `(busProId, hotel.busProId)` is unique across the whole export — upsert on that pair. `busProId`
|
||||||
|
is always an int and never null; `hotel` is always an object and never null; `pickups` is always an array, possibly
|
||||||
|
empty, never null.
|
||||||
|
|
||||||
|
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`
|
||||||
|
and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the
|
||||||
|
rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total / perPage)`.
|
||||||
|
|
||||||
|
- `skipped` — (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent
|
||||||
|
from the hotel master data. Expect `0`. A non-zero value means upstream data is inconsistent, not that you have
|
||||||
|
lost rows you previously had.
|
||||||
|
- `pickupsWithoutMasterData` — count of **distinct** pickup ids referenced by a travel but absent from
|
||||||
|
`GET /api/pickups`. Those entries are still emitted, with `code`, `city` and `street` null: a departure point
|
||||||
|
with no name is more useful to a disposition view than one that silently vanished.
|
||||||
|
|
||||||
|
**Pickup times.** `pickups` is in **source order** — the order the boarding list is driven in — not sorted by time.
|
||||||
|
|
||||||
|
- `time` is `"HH:MM"` for display; `datetime` is the same moment as a full ISO-8601 string with a
|
||||||
|
`Europe/Berlin` offset (`+01:00` / `+02:00` depending on DST). **Prefer `datetime`.**
|
||||||
|
- Upstream states a boarding time either as a full date and time or as a bare time. A bare time is resolved
|
||||||
|
against the departure date and, where the sequence demands it, rolled onto the following calendar day — so an
|
||||||
|
`02:15` listed after a `23:59` comes back on the next date, not the same one. `datetime` is emitted for
|
||||||
|
bare-time sources too, and carries that resolution.
|
||||||
|
- A boarding point with no time upstream comes back with **both** `time` and `datetime` null. They are always
|
||||||
|
null together.
|
||||||
|
- Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some
|
||||||
|
lists date every stop to the evening before departure, including stops after midnight, so an entry can appear a
|
||||||
|
day earlier than the running order implies. This is upstream data, not a transformation applied here.
|
||||||
|
|
||||||
|
**Consistency.** The export is built from the same cached master data as `GET /api/hotels` and `GET /api/pickups`,
|
||||||
|
under one cache tag, so all three move together on an upstream sync rather than drifting between your calls.
|
||||||
|
It is cached for 3 hours; `generatedAt` is when the cached copy was assembled, not when you requested it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Travels
|
### Travels
|
||||||
|
|
||||||
#### `GET /api/travels` — scope `api`
|
#### `GET /api/travels` — scope `api`
|
||||||
@@ -276,6 +394,8 @@ Single booking by UUID. `404 {"message":"Not found"}` if unknown.
|
|||||||
"accommodationDiscount": null,
|
"accommodationDiscount": null,
|
||||||
"boardServiceDiscount": null,
|
"boardServiceDiscount": null,
|
||||||
"additionalServicesDiscount": null,
|
"additionalServicesDiscount": null,
|
||||||
|
"totalDiscountAmount": null,
|
||||||
|
"totalDiscountLabel": null,
|
||||||
"totalPrice": 123456,
|
"totalPrice": 123456,
|
||||||
"pricingCurrency": "EUR",
|
"pricingCurrency": "EUR",
|
||||||
"pricingVersion": 3,
|
"pricingVersion": 3,
|
||||||
@@ -283,7 +403,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`
|
#### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api`
|
||||||
|
|
||||||
@@ -364,9 +497,9 @@ Responses:
|
|||||||
|
|
||||||
OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes:
|
OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes:
|
||||||
|
|
||||||
- always: `email`
|
- always: `sub` (UUID string, see below), `email`
|
||||||
- scope `id`: `person_id`, `address_id`
|
- scope `id`: `person_id`, `address_id`
|
||||||
- scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/user-roles.md`)
|
- scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`)
|
||||||
- scope `profile`: `profile` object:
|
- scope `profile`: `profile` object:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -379,7 +512,30 @@ OIDC-style claims for the authenticated user. The response contains only the cla
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: `profile.communication.email` comes from the CRM address record and is **not necessarily** the login email — a single address can hold several contacts. Use the top-level `email` claim for identity.
|
##### Identifying the account: match on `sub`
|
||||||
|
|
||||||
|
A BusPro address record holds several email addresses (private and business, say), and BusPro
|
||||||
|
accepts **any** of them as a login. Each one is a **separate MyE&P account with its own roles** —
|
||||||
|
that is intended, not a duplicate: administrative roles are reserved for the company address, so
|
||||||
|
the same person signing in privately is a Teamer:in or Kund:in and signing in with their
|
||||||
|
`@ep-reisen.de` address is an administrator.
|
||||||
|
|
||||||
|
All of those accounts report the **same** `person_id` and `address_id`, and the same
|
||||||
|
`profile.communication.email` — that value is the first contact address on the BusPro record and
|
||||||
|
is **not necessarily** the one signed in with. So:
|
||||||
|
|
||||||
|
- **`sub`** — a **UUID (v4, RFC 4122, 36 characters)**, stable for the life of the account and
|
||||||
|
unique across accounts. The only claim that identifies an account. Match your local user on it
|
||||||
|
and store it. Size the column at 36 characters and treat the value as opaque: it is random, it
|
||||||
|
carries no timestamp and no ordering, and nothing about it should be parsed or derived from.
|
||||||
|
- **`email`** — the address this session actually authenticated with. Distinct per account, but
|
||||||
|
treat it as a display and contact value; it is not the account key.
|
||||||
|
- **`person_id` / `address_id`** — the BusPro person behind the account. Shared between that
|
||||||
|
person's accounts, so they identify a *human*, never an account. Do not match on them.
|
||||||
|
|
||||||
|
Do not derive staff status from an email domain. MyE&P decides who is a member of staff and
|
||||||
|
exports the result as `ROLE_EMPLOYEE` in the `roles` claim; re-deriving it from `email` or from
|
||||||
|
`profile.communication.email` will disagree with MyE&P in both directions.
|
||||||
|
|
||||||
`400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials.
|
`400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials.
|
||||||
|
|
||||||
@@ -391,7 +547,7 @@ CRM selection attributes/segments for the authenticated user. `400 {"message": "
|
|||||||
|
|
||||||
## Client implementation notes
|
## Client implementation notes
|
||||||
|
|
||||||
1. **Token lifetime is 10 minutes** — implement refresh/re-fetch with a safety margin and retry once on 401.
|
1. **Token lifetime comes from `expires_in`** (currently 1 hour for client_credentials, 10 minutes for authorization_code) — cache the token for that long, refresh with a safety margin, and retry once on 401.
|
||||||
2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint.
|
2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint.
|
||||||
3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary.
|
3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary.
|
||||||
4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries.
|
4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries.
|
||||||
|
|||||||
@@ -577,6 +577,10 @@ Known role mapping from the XML parser:
|
|||||||
- admin users also receive default hotel code `SSL`
|
- admin users also receive default hotel code `SSL`
|
||||||
- no matched role means `ROLE_CUSTOMER`
|
- no matched role means `ROLE_CUSTOMER`
|
||||||
|
|
||||||
|
These are the CRM's claims. The portal filters them before storing anything (staff-only
|
||||||
|
roles, approval markers), so a proxy must return the claims unfiltered — see
|
||||||
|
`docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`.
|
||||||
|
|
||||||
Candidate SQL for selected customer selections:
|
Candidate SQL for selected customer selections:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
# BusProNet XML API — working reference
|
||||||
|
|
||||||
|
Condensed from the vendor PDF `docs/bpn_docs.pdf` (41 pages, German), keeping what this project
|
||||||
|
uses or plausibly could. The PDF stays authoritative; a few of its sentences are mangled by a
|
||||||
|
broken font encoding, so anything unclear there is flagged rather than guessed at.
|
||||||
|
|
||||||
|
> **The PDF lags the live API.** The vendor has shipped elements since it was written —
|
||||||
|
> `<ausstiege>` (return boarding points) is one confirmed example. So *absent from the PDF* means
|
||||||
|
> "not in this snapshot", never "unsupported". Treat the document as a floor on what the API
|
||||||
|
> accepts, not a ceiling, and prefer a live `buchungsart=Anfrage` probe over an argument from
|
||||||
|
> silence.
|
||||||
|
|
||||||
|
Read this alongside `buspro-database-reference.md` (what BusPro actually stores) — the two answer
|
||||||
|
different questions, and where they disagree the schema is the record of what happened.
|
||||||
|
|
||||||
|
**Use it for:** what an element is called and what it may contain; which codes a failure can
|
||||||
|
return; which documented capabilities we are not using; and which elements we send that the
|
||||||
|
vendor never documented — those are the ones that can break without warning.
|
||||||
|
|
||||||
|
## Envelope
|
||||||
|
|
||||||
|
Every request is `<anfrage>` with `<user>`, `<key>` and `<satz typ="…"/>`.
|
||||||
|
|
||||||
|
```
|
||||||
|
key = md5( user + password + date("YYYYMMDD") + satz-typ )
|
||||||
|
```
|
||||||
|
|
||||||
|
Per satz type and per day. `ApiClient::createKey()` implements it, and `sendRawXml()` regenerates
|
||||||
|
the key before sending, so a hand-built payload can carry a placeholder.
|
||||||
|
|
||||||
|
## Satz types
|
||||||
|
|
||||||
|
**Documented:** `ADRESSFELDER`, `ADRESSFELDERGUTSCHEIN`, `ADRESSFELDERKATALOGANFRAGE`,
|
||||||
|
`AGENTUREN`, `AGENTURKONTO`, `AKTIONSGUTSCHEIN`, `BUCHUNG`, `BUCHUNGAENDERUNG`, `CRM`,
|
||||||
|
`CRMKATALOGANFRAGE`, `GUTSCHEINBUCHUNG`, `GUTSCHEINDRUCK`, `GUTSCHEINPRUEFUNGEINLOESUNG`,
|
||||||
|
`GUTSCHEINVORLAGEN`, `GUTSCHEINVORSCHAU`, `GUTSCHEINZAHLUNGSARTEN`, `KATALOGANFRAGE`,
|
||||||
|
`KUNDENKONTO`, `KUNDENLISTE`, `MOEGLICHEAENDERUNGEN`, `PRODUKTDATEN`, `PRODUKTE`, `SITZPLATZ`,
|
||||||
|
`STAMMHOTELS`, `STAMMLAENDER`, `STAMMVERSICHERUNGEN`, `STAMMZUSTIEGE`, `VERFUEGBARKEIT`,
|
||||||
|
`VERFUEGBARKEITHOTEL`, `ZAHLUNG`, `ZAHLUNG2`, `ZAHLUNGSARTEN`, `ZAHLUNGSARTEN2`.
|
||||||
|
`SITZPLATZAUSWAHL` and `ZAHLUNG4` are named in prose without their own section.
|
||||||
|
|
||||||
|
**Used here but undocumented:** `VERFUEGBARKEIT2` (`ApiClient::TYPE_AVAILABILITY_EXTENDED`) — the
|
||||||
|
extended availability call returning `buchungstatusmoeglich` and per-service `pflicht`/`frei`.
|
||||||
|
Its contract is observed, not specified.
|
||||||
|
|
||||||
|
**Documented but unused here:** `SITZPLATZ`/`SITZPLATZAUSWAHL`, `KUNDENLISTE`, `ZAHLUNG2`,
|
||||||
|
`ADRESSFELDER`, `STAMMHOTELS`, `STAMMVERSICHERUNGEN`, `STAMMZUSTIEGE`, `KATALOGANFRAGE`.
|
||||||
|
|
||||||
|
## BUCHUNG — the create contract
|
||||||
|
|
||||||
|
`<buchungsart>` decides what the call does:
|
||||||
|
|
||||||
|
| Value | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `Anfrage` | Enquiry. Validates and **returns the full price breakdown**. Nothing is stored. |
|
||||||
|
| `Buchung` | Stores the booking. |
|
||||||
|
|
||||||
|
`<status>` at the root: `F` Festbuchung · `O` Optionsbuchung · `A` Anfragebuchung. Gated by
|
||||||
|
error `962` ("Buchungsstatus #1# kann nicht verarbeitet werden, nur Status #2# möglich"), and by
|
||||||
|
`982`/`983` when the travel forbids options or enquiries outright.
|
||||||
|
|
||||||
|
### Two accommodation models
|
||||||
|
|
||||||
|
The base payload books accommodation as an ordinary service:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<unterbringungen>
|
||||||
|
<unterbringung idleistung="22341" anzahl="1" zuordnung="1,2"/>
|
||||||
|
</unterbringungen>
|
||||||
|
```
|
||||||
|
|
||||||
|
For the **Ferienziel** domain — what this app books — `unterbringungen` is *replaced* by
|
||||||
|
`ferienzielunterbringungen`, **and** a root `<idpartner>` added:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ferienzielunterbringungen>
|
||||||
|
<ferienzielunterbringung idzimmer="2" kategorie="Ik" idverpflegung="2"
|
||||||
|
anreise="30.01.2018" abreise="06.02.2018"
|
||||||
|
anzahl="1" zuordnung="1,2"/>
|
||||||
|
</ferienzielunterbringungen>
|
||||||
|
…
|
||||||
|
<idpartner>78687</idpartner>
|
||||||
|
```
|
||||||
|
|
||||||
|
> `<idpartner>` is introduced as the element you add when switching to the Ferienziel model, and
|
||||||
|
> `914 Partner-ID fehlt` guards it. Note it is **not** confined to bookings that have a room: a
|
||||||
|
> teamer booking with zero `BuchungTNFerienziel` rows still returns `<idpartner>222925</idpartner>`
|
||||||
|
> from `Vorgang_Details`. Where that comes from is **unresolved** — `Buchung` has no partner FK, so
|
||||||
|
> either it is stored on `Buchung.IDAdresse_FS_ExtraFerienzielPartner`, or it is derived from the
|
||||||
|
> travel. That booking's product has exactly one hotel, so the response cannot distinguish the two;
|
||||||
|
> one read-only query settles it.
|
||||||
|
|
||||||
|
Accommodation is **mandatory** — `913 Unterbringungen fehlen` / `916 Ferienziel-Unterbringungen
|
||||||
|
fehlen`. Confirmed empirically on the test system 2026-09-02, not just documented: see
|
||||||
|
"Validation gate order" below. Room dates are cross-checked against the transport legs (`921`,
|
||||||
|
`923`).
|
||||||
|
|
||||||
|
### Full documented request shape
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<anfrage>
|
||||||
|
<user/><key/><satz typ="BUCHUNG"/>
|
||||||
|
<buchungsart>Anfrage|Buchung</buchungsart>
|
||||||
|
<status>F|O|A</status>
|
||||||
|
<idagentur/> <idcrm/> <idreise/> <idpartner/>
|
||||||
|
<fremdvorgang/> <!-- your own reference, stored as Buchung.FremdVorgang -->
|
||||||
|
<datenschutztfmp/> <!-- ad consent, combinable: T Telefon, F Telefax, M EMail, P Post -->
|
||||||
|
<anmelder>
|
||||||
|
name, vorname, anrede, titel, geschlecht, nationalitaet, geburtsdatum,
|
||||||
|
<anschrift>strasse, plz, ort, ortsteil, land</anschrift>
|
||||||
|
<kommunikation>telefonmobil, email, telefonprivat</kommunikation>
|
||||||
|
<bemerkung/>
|
||||||
|
</anmelder>
|
||||||
|
<teilnehmerliste>
|
||||||
|
<teilnehmer id="1">
|
||||||
|
…same personal fields…
|
||||||
|
<wünsche><unterbringungswunsch/><beförderungswunsch/></wünsche>
|
||||||
|
<aktionscode/>
|
||||||
|
</teilnehmer>
|
||||||
|
</teilnehmerliste>
|
||||||
|
<beförderungen><beförderung idleistung anzahl zuordnung/></beförderungen>
|
||||||
|
<ferienzielunterbringungen>…</ferienzielunterbringungen>
|
||||||
|
<zusatzleistungen><zusatzleistung idleistung anzahl zuordnung/></zusatzleistungen>
|
||||||
|
<zustiege><zustieg idzustieg anzahl zuordnung/></zustiege>
|
||||||
|
<sitzpläne><sitzplan idleistung idsitzplan sitzplätze="1B;1A"/></sitzpläne>
|
||||||
|
<versicherungen><versicherung idversicherung anzahl zuordnung/></versicherungen>
|
||||||
|
<gutscheine><gutschein gutscheinnr einloesecode/></gutscheine>
|
||||||
|
<zuschlaege>
|
||||||
|
<zuschlag text betrag anzahl zuordnung
|
||||||
|
provision altersrabatt gruppenrabatt frühbucherrabatt bzberechnung/>
|
||||||
|
</zuschlaege>
|
||||||
|
<zahlung idzahlungsart bezeichnung art>
|
||||||
|
<bankverbindung kreditinstitut bic iban kontoinhaber/>
|
||||||
|
</zahlung>
|
||||||
|
</anfrage>
|
||||||
|
```
|
||||||
|
|
||||||
|
`zuordnung` is a comma-joined list of 1-based `teilnehmer/@id` values throughout.
|
||||||
|
|
||||||
|
### Responses
|
||||||
|
|
||||||
|
`buchungsart=Anfrage` → `<buchung>möglich</buchung>` plus `<preise>`: one `<preis>` per line with
|
||||||
|
`position`, `art`, `unterart`, `bezeichnung`, `terminvon`/`terminbis`, `anzahl`, `zuordnung`,
|
||||||
|
`preis`, `gesamtpreis`, `id`; then `<gesamtpreis>` and `<zahlungsbedingungen>` with `<anzahlung>`
|
||||||
|
and `<restzahlung>` (each `betrag` + `termin`). `art` values seen: `BEF` transport, `UNT`
|
||||||
|
accommodation, `SON` other, `ERM` discount, `VER` insurance.
|
||||||
|
|
||||||
|
`buchungsart=Buchung` → `<buchung>erfolgt</buchung>`, `<vorgang>`, `<rechnung>`, `<idbuchung>`,
|
||||||
|
`<email>`, `<pdfpasswort>`.
|
||||||
|
|
||||||
|
### Validation gate order (measured)
|
||||||
|
|
||||||
|
Established with `buchungsart=Anfrage` against the test system on 2026-09-02, by mirroring a real
|
||||||
|
room-less teamer booking (Vorgang 110628) element for element:
|
||||||
|
|
||||||
|
1. **Accommodation.** No `ferienzielunterbringungen` → `916`, regardless of everything else being
|
||||||
|
valid. Nothing further is evaluated.
|
||||||
|
2. **Personal data**, once a room is present → `650` with the field list *"Fehlende
|
||||||
|
Anmelder/Teilnehmer Daten … Nachname, Vorname, Strasse, Plz, Ort, Telefon-Mobil, Mail-Adresse,
|
||||||
|
Geburtsdatum, Nationalität"*, for the Anmelder **and every participant**.
|
||||||
|
|
||||||
|
So **sending `idadresse`/`idadresseperson` alone is not enough on create** — BusPro validates the
|
||||||
|
personal fields whether or not it could resolve them from the ids. Echoing the record back
|
||||||
|
(`idadresse` + `idadresseperson` + full data) narrows the complaint to whatever that record is
|
||||||
|
genuinely missing, which is a useful way to audit an address: the placeholder account 19465:41003
|
||||||
|
fails on `Telefon-Mobil` and `Nationalität` because its `Vorgang_Details` record has only
|
||||||
|
`telefonprivat` and no `nationalitaet`.
|
||||||
|
|
||||||
|
**Participants are held to less than the Anmelder** (measured 2026-09-16 on 2036, travel 12500, from a
|
||||||
|
real step-3 `Anfrage`). Once a room is present, every `teilnehmer` needs a `Mail-Adresse`, children
|
||||||
|
included: without `kommunikation` the answer is `650 … 1.Teilnehmer-Mail-Adresse, 2.Teilnehmer-Mail-Adresse`.
|
||||||
|
`telefonmobil` and an empty `anschrift` pass for participants. An email identical to the Anmelder's is
|
||||||
|
accepted. The field list above (Strasse, Plz, Ort, Telefon-Mobil for every participant) was measured on
|
||||||
|
the placeholder records and is stricter than what BusPro applies to ordinary participants.
|
||||||
|
|
||||||
|
**The Anmelder does not have to travel.** An `<anmelder>` that matches no `teilnehmer` (a parent booking
|
||||||
|
for their children) returns `möglich` with the same price as the control where the Anmelder is
|
||||||
|
`teilnehmer 1`. Whether the committed booking binds to the Anmelder's existing customer record has not
|
||||||
|
been measured yet.
|
||||||
|
|
||||||
|
Note `650` again: both of those are hard rejections delivered under the code
|
||||||
|
`Notification::isError()` treats as success.
|
||||||
|
|
||||||
|
## BUCHUNGAENDERUNG — the edit contract
|
||||||
|
|
||||||
|
Structurally identical to `BUCHUNG` plus `<idbuchung>`, and the responses follow `buchungsart`
|
||||||
|
the same way. Ask `MOEGLICHEAENDERUNGEN` first (`<idreise>`), which answers:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<änderungen>
|
||||||
|
<änderung art="anzahl_teilnehmer" möglich="False"/>
|
||||||
|
<änderung art="beförderung" möglich="False"/>
|
||||||
|
<änderung art="zustieg" möglich="False"/>
|
||||||
|
<änderung art="sitztplatz" möglich="False"/> <!-- vendor's typo -->
|
||||||
|
<änderung art="unterbringung" möglich="False"/>
|
||||||
|
<änderung art="zusatzleistung" möglich="True" möglichbiszum="23.02.2020"/>
|
||||||
|
<änderung art="teilnehmerdaten" möglich="False"/>
|
||||||
|
</änderungen>
|
||||||
|
```
|
||||||
|
|
||||||
|
## How this app maps onto the contract
|
||||||
|
|
||||||
|
`BookingPayloadBuilder::buildCreatePayload()` builds create; `buildBasePayload()` + the
|
||||||
|
`addTo…Payload()` helpers build edit; `ApiClient::createBooking()`/`updateBooking()` wrap them.
|
||||||
|
|
||||||
|
| Documented element | Sent on create | Sent on edit | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`ApiClient.php:233`) |
|
||||||
|
| `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below |
|
||||||
|
| `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room |
|
||||||
|
| `anmelder` | yes | yes | Create builds it from participant[0]; edit uses `PersonalData::toPayload()`. BusPro accepts an Anmelder who is not a participant |
|
||||||
|
| `anmelder/bemerkung` | — | yes | Via `PersonalData::toPayload()` |
|
||||||
|
| `teilnehmerliste` | yes | yes | |
|
||||||
|
| `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | |
|
||||||
|
| `aktionscode` | yes | yes | Promo code, paired with `AKTIONSGUTSCHEIN` |
|
||||||
|
| `beförderungen`, `zusatzleistungen`, `zustiege` | yes | yes | Built by `ServiceMappingCollector` |
|
||||||
|
| `ferienzielunterbringungen` | yes | yes | Create omits the node entirely when no room maps; edit always emits the empty skeleton |
|
||||||
|
| `versicherungen` | yes | yes | |
|
||||||
|
| `gutscheine` | yes | yes | Paired with `GUTSCHEINPRUEFUNGEINLOESUNG` |
|
||||||
|
| `zahlung` + `bankverbindung` | yes | yes | |
|
||||||
|
| `idcrm` | no | no | |
|
||||||
|
| `fremdvorgang` | no | no | Would give our own bookings a stable external reference |
|
||||||
|
| `datenschutztfmp` | no | no | Advertising consent — we track newsletter consent locally instead |
|
||||||
|
| `sitzpläne` | no | no | See Seats |
|
||||||
|
| `zuschlaege` | no | no | Free-text manual surcharge or discount with its own amount |
|
||||||
|
|
||||||
|
### Newer than the PDF
|
||||||
|
|
||||||
|
These are absent from the document but work against the live API — vendor additions that
|
||||||
|
post-date it. Listed so nobody mistakes the PDF's silence for a contract violation, and so the
|
||||||
|
gaps in this reference are known.
|
||||||
|
|
||||||
|
| Element | Where | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `<ausstiege><ausstieg idzustieg …/></ausstiege>` | create and edit | Return boarding points. Confirmed as a recent vendor addition; the PDF documents only `<zustiege>`. |
|
||||||
|
| `<status>` inside `<teilnehmer>` | create (hardcoded `F`) and edit | Not in the PDF in either direction, though `Vorgang_Details` returns `status_teilnehmer`. |
|
||||||
|
| `<login>J</login>` on `KUNDENKONTO` | probe command | Passwordless read escape hatch; see `bpn-login-j-passwordless-reads`. |
|
||||||
|
| `VERFUEGBARKEIT2` | availability path | Whole satz type post-dates the PDF. Returns `buchungstatusmoeglich` and per-service `pflicht`. |
|
||||||
|
|
||||||
|
When one of these needs pinning down, the authoritative move is a live `Anfrage` call, not a
|
||||||
|
reading of this file.
|
||||||
|
|
||||||
|
### Caveat: the edit flow replays a derived status
|
||||||
|
|
||||||
|
`buildBasePayload()` sends `'status' => $bookingData->status`, and `Booking::$status` is parsed
|
||||||
|
from the response's root `<status>` (`BookingParser.php:38`). But `Buchung` has **no status
|
||||||
|
column** — the root value is an aggregate over `BuchungTN.Status`, the only real store
|
||||||
|
(`<status>F</status>` beside `<status_teilnehmer>F/F/…/F</status_teilnehmer>` in
|
||||||
|
`tests/Resources/booking_data.xml`). So on a booking whose participants do *not* all share one
|
||||||
|
status, the edit flow sends an aggregate back as a directive. Worth confirming against a mixed
|
||||||
|
booking before relying on it.
|
||||||
|
|
||||||
|
### Gap: edit never dry-runs
|
||||||
|
|
||||||
|
Create is two-phase — `Anfrage` to validate and price, then `Buchung`. Edit sends
|
||||||
|
`buchungsart=Buchung` directly (`ApiClient.php:233`), even though `BUCHUNGAENDERUNG` supports
|
||||||
|
`Anfrage` identically. An enquiry pass would surface `913`/`924`/`962` and return the recalculated
|
||||||
|
price *before* mutating a live booking.
|
||||||
|
|
||||||
|
## Seats
|
||||||
|
|
||||||
|
`SITZPLATZ` asks BusPro to pick seats:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<satz typ="SITZPLATZ"/>
|
||||||
|
<idreise/><idleistung_hin/><idleistung_rueck/><anzahl_personen/>
|
||||||
|
<wunsch_vornemittehinten>V|M|H</wunsch_vornemittehinten>
|
||||||
|
<wunsch_linksrechts>L|R</wunsch_linksrechts>
|
||||||
|
<wunsch_fenstergang>F|G</wunsch_fenstergang>
|
||||||
|
```
|
||||||
|
|
||||||
|
Response: one `<sitzplan>` per direction with `sitzplaetze="0D;1D;1C"`, `idsitzplan`,
|
||||||
|
`richtung="H|R"`, `bezeichnung`, `idleistung`, and `grafik` (base64 PNG of the seat map). Where
|
||||||
|
seats carry surcharges the response adds `<zuschläge>` (`id`, `bezeichnung`, `preis`, colours) and
|
||||||
|
the request must then carry `idzuschlag`. `SITZPLATZAUSWAHL` returns the raw plan structure
|
||||||
|
instead of a picture.
|
||||||
|
|
||||||
|
Chosen seats go back into a booking via `<sitzpläne>`, landing in
|
||||||
|
`BuchungTNLeistungZusatz.Sitzplatz` / `IDSitzplan_FS`. Guarded by `933 Sitzplan mit ID #1# bei
|
||||||
|
Leistung #2# nicht freigegeben` and `304 Es konnte nicht für alle Teilnehmer ein Sitzplatz
|
||||||
|
ermittelt werden`.
|
||||||
|
|
||||||
|
## KUNDENKONTO
|
||||||
|
|
||||||
|
`<art>`: `Adressdaten`, `Adressdaten_Ändern`, `Adresse_Neu`, `SelektionCRM`,
|
||||||
|
`SelektionCRM_Ändern`, `Vorgänge`, `Vorgangdruck`, `Voucherdruck`, `Dokumentdruck`,
|
||||||
|
`Passwort_Anfrage`, `Newsletter`, `Adressdokument`, `Vorgang_Details`, `Prüfung`, `PrüfungEmail`,
|
||||||
|
`Ausweisdaten_Ändern`, `Stornoanfrage`, `Storno`, `Versicherungspolice`.
|
||||||
|
|
||||||
|
`Stornoanfrage` answers `stornierung_moeglich` + `stornopreis`; `Storno` answers `storniert` —
|
||||||
|
the documented way to cancel, including cleaning up a test booking. `Ausweisdaten_Ändern` takes a
|
||||||
|
`<personen>` list with `<ausweis>` data and answers `änderung`. `Versicherungspolice` returns
|
||||||
|
per-policy document URLs.
|
||||||
|
|
||||||
|
## AGENTURKONTO
|
||||||
|
|
||||||
|
`<art>`: `Vorgänge`, `Vorgangdruck`, `Rechnungdruck`, `Hotelgutscheine`, `Hotelgutscheindruck`,
|
||||||
|
`OptionInFest` — the last converts a held option into a firm booking, answering
|
||||||
|
`<optioninfestumgewandelt>true</optioninfestumgewandelt>`. `Vorgangdruck` takes `idbuchung`,
|
||||||
|
`Rechnungdruck` takes `idrechnung`, `Hotelgutscheindruck` takes `idgutschein`; print arts return
|
||||||
|
base64 PDFs.
|
||||||
|
|
||||||
|
## Availability and payment
|
||||||
|
|
||||||
|
`VERFUEGBARKEIT` takes `<idreise>` plus a `<leistungen><leistung id="…"/></leistungen>` filter and
|
||||||
|
answers `<reise id status>` plus per-service `status` ("buchbar"), `frei`, `preis`,
|
||||||
|
`preisfaellig`. `VERFUEGBARKEIT2`, which we actually use, additionally reports
|
||||||
|
`buchungstatusmoeglich` and `pflicht` but is undocumented.
|
||||||
|
|
||||||
|
`ZAHLUNGSARTEN2` lists payment methods; `ZAHLUNG2` records a payment against a booking
|
||||||
|
(`vorgang`, `idbuchung`, `datum`, `betrag`, `idzahlungsart`, `zahlungstext` — all mandatory).
|
||||||
|
`ZAHLUNG` reports a credit-card authorisation after booking; the amount must equal the confirmed
|
||||||
|
`anzahlung` or errors `1011`-`1018` follow.
|
||||||
|
|
||||||
|
## Error codes
|
||||||
|
|
||||||
|
Returned as `<satz typ="HINWEIS"><nr>…</nr><text>…</text></satz>`. `#1#`/`#2#` are substituted at
|
||||||
|
runtime.
|
||||||
|
|
||||||
|
> **`650` is not a success code.** Its documented text is a bare `#1#` — a generic message
|
||||||
|
> carrier whose entire content is substituted. `Notification::isError()`
|
||||||
|
> (`src/BusProNet/Model/Notification.php:33-36`) treats it as success, which happens to hold on
|
||||||
|
> the `KUNDENKONTO` paths but not on `BUCHUNG`/`BUCHUNGAENDERUNG`, where the same code delivers
|
||||||
|
> real rejections such as *"Es wurde nicht jedem Teilnehmer eine Pflichtleistung zugeordnet"*.
|
||||||
|
> `BookingEditSubmitter.php:81` therefore reports some genuine failures as INFO.
|
||||||
|
|
||||||
|
**Envelope / auth** — 100 Anfrageknoten fehlt · 101 Satzknoten fehlt · 102 Ungültiger Satztyp ·
|
||||||
|
103/104 User fehlt/fehlerhaft · 105/106 Key fehlt/fehlerhaft · 999 Systemfehler
|
||||||
|
|
||||||
|
**Lookup** — 200 Keine Einträge · 201/202 Partner · 203 Ungültiger Termin · 300 IDReise fehlt ·
|
||||||
|
301 Reise nicht gefunden · 302 IDLeistung (Hin & Rück) fehlt · 303 IDLeistung passt nicht zu
|
||||||
|
IDReise · 304 Kein Sitzplatz für alle Teilnehmer ermittelbar · 305 Anzahl Personen fehlt ·
|
||||||
|
306/307 Leistungen · 308 Keine Zahlungsarten · 400 IDPartner fehlt · 401 Partner nicht gefunden ·
|
||||||
|
402 Bis-Termin fehlt · 403 Keine Unterbringungsleistungen gefunden
|
||||||
|
|
||||||
|
**Customer / address** — 500 Art fehlt · 502 Keine Kunden · 840-842 Personen · 850 IDAdresse und
|
||||||
|
EMail fehlen · 851 Passwort fehlt · 852 Art fehlt · 853 ID, EMail oder Passwort falsch · 855 Keine
|
||||||
|
Daten für Adresse · 860 Adressdaten fehlen · 862-867 Selektionsmerkmale / CRM-Aktionen ·
|
||||||
|
870-875 Newsletter · 880-882 Dokumente · 885-891 Pflichtfelder (Name, Vorname, Geschlecht, EMail,
|
||||||
|
PLZ, Straße) · 889 Kunde bereits angelegt (ID #1#)
|
||||||
|
|
||||||
|
**Booking (create and edit)** — 900 Buchungsart fehlt · 901 Buchungsart falsch · 902 Status
|
||||||
|
fehlt · 903 Status falsch · 904/905 Agentur · 906 Reise-ID fehlt · 907 Reise nicht gefunden ·
|
||||||
|
908 Reise ist storniert · **909 Beförderungen fehlen** · 910 Leistung gehört nicht zur Reise ·
|
||||||
|
911 Leistung gehört nicht zum Produkt · 912 Beförderungsleistung fehlt · **913 Unterbringungen
|
||||||
|
fehlen** · **914 Partner-ID fehlt** · 915 Partner nicht gefunden · **916 Ferienziel-Unterbringungen
|
||||||
|
fehlen** · 917 Zimmer-IDZ fehlt · 918 Kategorie fehlt · 919 Verpflegungs-ID fehlt · 920 Anreise
|
||||||
|
fehlt · **921 Anreise passt nicht zur Beförderungsleistung (Hinfahrt)** · 922 Abreise fehlt ·
|
||||||
|
**923 Abreise passt nicht zur Beförderungsleistung (Rückfahrt)** · **924 Ferienziel: Keine Preise
|
||||||
|
zu den Daten gefunden** · 925 Preis zu den Daten nicht gefunden · 930 Zustiege fehlen ·
|
||||||
|
931 Zustieg nicht gefunden · 932 Zustieg bei Leistung nicht freigegeben · 933 Sitzplan bei
|
||||||
|
Leistung nicht freigegeben · 935-938 Versicherungen · 940-942 Zahlungsart · 950 Anmelder fehlt ·
|
||||||
|
951 Teilnehmerliste fehlt · 952 Teilnehmer-ID fehlt · 953 Keine Teilnehmer übergeben ·
|
||||||
|
960 Preisfehler · 961 Buchung konnte nicht gespeichert werden · **962 Buchungsstatus #1# kann
|
||||||
|
nicht verarbeitet werden, nur Status #2# möglich** · 970 CRM-Merkmal nicht gültig · 980 Reise ist
|
||||||
|
fürs Internet gesperrt · 981 Reise ist nicht mehr buchbar (#1#) · **982 Optionsbuchungen nicht
|
||||||
|
zugelassen** · 983 Anfragebuchung nicht zugelassen · 984 Leistung nicht fürs Internet buchbar ·
|
||||||
|
985 Zustieg nicht fürs Internet buchbar · 986/987 Rechnung · 990-994 Agenturkonto ·
|
||||||
|
995/996 Gutschein · 997/998 Buchung-ID · 1001 Reisedatum fehlerhaft · 1002 Buchungsdatum
|
||||||
|
fehlerhaft · 1003 Status im Vorgang nicht korrekt · 1011-1018 Zahlungsabgleich
|
||||||
|
|
||||||
|
**Vouchers** — 600-608 Gutscheine · 610/611 Buchungsart · 612-621 Gutscheinart / Stamm /
|
||||||
|
Kapazität · 622 Einlösecode fehlt · 630-632 Aktionsgutschein
|
||||||
|
|
||||||
|
## Applying this: the teamer placeholder booking
|
||||||
|
|
||||||
|
**Answered 2026-09-02: `BUCHUNG` cannot create it.** A payload mirroring the real teamer booking
|
||||||
|
(travel 12429, hotel 222925, agency 77876, both bus legs, ski pass, Ortstaxe, Keycard-Pfand,
|
||||||
|
pickup and drop-off, root and participant status `O`, ids-only identity, no room) returns
|
||||||
|
`916 Ferienziel-Unterbringungen fehlen`. Adding a room to the identical payload clears that gate
|
||||||
|
and moves on to `650`, which proves accommodation was the only structural objection.
|
||||||
|
|
||||||
|
Two blockers that were *not* the problem:
|
||||||
|
|
||||||
|
- **Status `O` is fine here.** Travel 12429 reports `buchungstatusmoeglich="F, O, A"`. The `982`
|
||||||
|
refusal seen earlier is per travel, not a global setting.
|
||||||
|
- **`NurFahrt` never came up** — the request never got far enough for it to matter.
|
||||||
|
|
||||||
|
What the real booking shows about how the office builds one, worth reproducing if the work moves
|
||||||
|
to a direct write:
|
||||||
|
|
||||||
|
- `<idpartner>` **is** present despite there being no accommodation — 222925 is the ordinary
|
||||||
|
hotel of the travel (`Adresse.IDAdresse`, "NEU Sportclub Weissfluh Midweeks und Wochenreisen",
|
||||||
|
the sole hotel of product 1429, with real bookable rooms), not a special no-accommodation
|
||||||
|
partner. Whether the booking stores it or the response derives it is still open.
|
||||||
|
- Each teamer seat is its **own `AdressePerson`** (407725, 407726) under the shared address 41003,
|
||||||
|
with an empty `<anschrift/>`.
|
||||||
|
- `<wünsche><beförderungswunsch>` carries the role label — "Reiseleitung + Busbegleitung".
|
||||||
|
- Per participant, `<optionsdatum>` sets the option expiry.
|
||||||
|
- The total is driven to zero with a **manual `<zuschlag>`**: Ortstaxe 44,00 + Keycard-Pfand 10,80
|
||||||
|
+ Abfahrtsortzuschlag 25,80 = 80,60, offset by an unnamed −80,60 line. `<zuschlaege>` is part of
|
||||||
|
the documented `BUCHUNG` payload, so this part *is* expressible over XML.
|
||||||
|
|
||||||
|
See `.ddev/plans/teamer-placeholder-booking-plan.md`.
|
||||||
@@ -225,6 +225,17 @@ JOIN dbo.ZustiegPreis zp ON zp.IDArtZustiegPreis_FS=lb.IDArtZustiegPreis_FS
|
|||||||
WHERE l.IDLeistung IN (@OutboundServiceId,@ReturnServiceId);
|
WHERE l.IDLeistung IN (@OutboundServiceId,@ReturnServiceId);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**V 2026-09-28 — the XML export can omit the return-leg price.** Since one price row is
|
||||||
|
surfaced twice, an asymmetric pair is an export defect: on products 701 and 2435 a
|
||||||
|
`<zustieg_rueck>` carries no `preis` although the engine still bills the return
|
||||||
|
`Abfahrtsortzuschlag`, so reading the export verbatim undercharges by one leg. Measured over
|
||||||
|
all exports, a stated return price never differs from its outbound counterpart (510 pairs,
|
||||||
|
zero counter-examples). `TravelParser` therefore levels a boarding point's price across both
|
||||||
|
directions, switchable through `APP_BPN_LEVEL_BOARDING_POINT_PRICES` while the defect is open
|
||||||
|
with the vendor. Flipping the flag needs `app:bpn:xml-cache-invalidate` and
|
||||||
|
`app:bpn:refresh-travel-snapshot`. A point appearing **only** in `zustiege_rueck` without a
|
||||||
|
price cannot be priced from our side — that is a catalog job.
|
||||||
|
|
||||||
**N — the departure surcharge is computed.** Booking responses carry a position
|
**N — the departure surcharge is computed.** Booking responses carry a position
|
||||||
`art="SON" unterart="TRA"` (*Abfahrtsortzuschlag*) with no matching row type: `ArtZuschlag`
|
`art="SON" unterart="TRA"` (*Abfahrtsortzuschlag*) with no matching row type: `ArtZuschlag`
|
||||||
— the lookup behind `BuchungTNZuschlag` — holds only discounts and fees
|
— the lookup behind `BuchungTNZuschlag` — holds only discounts and fees
|
||||||
|
|||||||
@@ -18,16 +18,47 @@ Symfony roles:
|
|||||||
| `IDSelektionsstamm` | Role |
|
| `IDSelektionsstamm` | Role |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `1070` | `ROLE_TEAMER` |
|
| `1070` | `ROLE_TEAMER` |
|
||||||
| `1292` | `ROLE_ADMIN` (+ `ROLE_HOUSE_MANAGER`, + hotel code `SSL`) |
|
| `1292` | `ROLE_ADMIN` |
|
||||||
| `1293` | `ROLE_MANAGER` |
|
| `1293` | `ROLE_MANAGER` |
|
||||||
| `1477` | `ROLE_GROUPS_MANAGER` |
|
| `1477` | `ROLE_GROUPS_MANAGER` |
|
||||||
| `1478` | `ROLE_GROUPS_ADMIN` |
|
| `1478` | `ROLE_GROUPS_ADMIN` |
|
||||||
|
| `1483` | `ROLE_CUSTOMER_EXPERT` |
|
||||||
|
| `1484` | `ROLE_TEAM_ADMIN` (not consumed here — exported through `/api/userinfo` for myep-team) |
|
||||||
| label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` |
|
| label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` |
|
||||||
| nothing matched | `ROLE_CUSTOMER` |
|
| nothing matched | `ROLE_CUSTOMER` |
|
||||||
|
|
||||||
Only selections with `auswahl="True"` count. Every `Admin/*` controller is gated on
|
Only selections with `auswahl="True"` count. Every `Admin/*` controller is gated on
|
||||||
one of these roles.
|
one of these roles.
|
||||||
|
|
||||||
|
These are *claims*, not grants. What an account ends up holding is decided by
|
||||||
|
`Role::sync()` (`src/Security/Role.php`) on every login — see
|
||||||
|
[From claim to role](#from-claim-to-role).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## From claim to role
|
||||||
|
|
||||||
|
BusPro backend users can edit their own CRM selections, so the claims above are
|
||||||
|
filtered before anything is stored:
|
||||||
|
|
||||||
|
| Role | Claimed without a staff address | Claimed with a staff address |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ROLE_TEAMER` | granted | granted |
|
||||||
|
| `ROLE_HOUSE_MANAGER` | nominated | nominated |
|
||||||
|
| `ROLE_ADMIN`, `ROLE_TEAM_ADMIN`, `ROLE_MANAGER`, `ROLE_GROUPS_ADMIN`, `ROLE_GROUPS_MANAGER`, `ROLE_CUSTOMER_EXPERT` (`Role::EMPLOYEE_ONLY`) | **ignored** | nominated |
|
||||||
|
|
||||||
|
- **Staff address**: the login email's domain is listed in `employee_email_domains`
|
||||||
|
(`config/services.yaml`, exact match, never a suffix). Such an account also gets
|
||||||
|
`ROLE_EMPLOYEE`, which is derived from the address and not from the CRM.
|
||||||
|
- **Nominated**: stored as the marker `ROLE_X_PENDING`, which grants nothing until an
|
||||||
|
administrator approves it in `/admin/user`. Nominations are announced to
|
||||||
|
`role_nomination_notification_emails`.
|
||||||
|
- **Ignored**: treated exactly like a selection that is not set. No marker is stored,
|
||||||
|
and a role approved earlier is **revoked** on the next login. A leftover marker from
|
||||||
|
before the rule cannot be approved and disappears on that login too.
|
||||||
|
- Anything the CRM stops claiming is revoked, approved or not.
|
||||||
|
- An account left without any effective role gets `ROLE_CUSTOMER`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The four tables
|
## The four tables
|
||||||
@@ -397,11 +428,11 @@ BusProNet host is the fix, and no schema change is needed.
|
|||||||
|
|
||||||
## Portal side: a correct XML is not enough
|
## Portal side: a correct XML is not enough
|
||||||
|
|
||||||
`BpnAuthenticator::createOrUpdateLocalUser()` snapshots the roles onto the local `User`
|
`BpnAuthenticator::syncFromCrm()` writes the roles onto the local `User` entity at login,
|
||||||
entity at login (`setRoles($crmAttributes->roles)` /
|
through `Role::sync()` (see [From claim to role](#from-claim-to-role)), and the hotel codes
|
||||||
`setHotelCodes($crmAttributes->hotelCodes)`). Roles are **not** re-read per request. So
|
verbatim. Roles are **not** re-read per request. So after fixing BusPro the affected user
|
||||||
after fixing BusPro the affected user must **log out and log in again** before
|
must **log out and log in again**, and even then `ROLE_GROUPS_ADMIN` only takes effect once
|
||||||
`ROLE_GROUPS_ADMIN` takes effect — an active session keeps the stale role set.
|
the account has a staff address and an administrator has approved the nomination.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260903153641 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE accommodation ADD product_code VARCHAR(16) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE accommodation DROP product_code');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the per-client role requirement that App\EventListener\AuthorizationCodeListener
|
||||||
|
* gates the authorization code grant on.
|
||||||
|
*
|
||||||
|
* The column defaults to an empty list and an empty list denies, so every client is
|
||||||
|
* refused until its roles are set. The two clients in actual use are therefore set here
|
||||||
|
* rather than by hand after the deploy: it closes the window in which both staff tools
|
||||||
|
* would be live but unable to authorize anyone, and it keeps the role strings somewhere
|
||||||
|
* review can see them.
|
||||||
|
*
|
||||||
|
* Matched on name, not identifier: client identifiers are generated per environment and
|
||||||
|
* differ between local, staging and production, while the names do not.
|
||||||
|
*
|
||||||
|
* Deliberately left empty, and so denied: myepapp (the mobile app) and myep (the Keycloak
|
||||||
|
* broker), both R&D only and not in use. The client_credentials clients never reach the
|
||||||
|
* authorization endpoint and are unaffected either way.
|
||||||
|
*/
|
||||||
|
final class Version20260923120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Adds oauth2_client.required_roles and sets it for the two staff clients.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Added nullable and backfilled before being tightened: a NOT NULL column with no
|
||||||
|
// default leaves existing rows holding an empty string, which is not valid JSON and
|
||||||
|
// fails to hydrate -- the row cannot even reach the listener that would deny it.
|
||||||
|
$this->addSql("ALTER TABLE oauth2_client ADD required_roles JSON DEFAULT NULL COMMENT '(DC2Type:json)'");
|
||||||
|
$this->addSql("UPDATE oauth2_client SET required_roles = '[]'");
|
||||||
|
$this->addSql("ALTER TABLE oauth2_client MODIFY required_roles JSON NOT NULL COMMENT '(DC2Type:json)'");
|
||||||
|
|
||||||
|
// mirrors ephub's App\Security\Role::ELIGIBLE
|
||||||
|
$this->addSql('UPDATE oauth2_client SET required_roles = :roles WHERE name = :name', [
|
||||||
|
'roles' => json_encode(['ROLE_EMPLOYEE', 'ROLE_ADMIN'], JSON_THROW_ON_ERROR),
|
||||||
|
'name' => 'EPHub',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// mirrors myep-team's App\Security\MyEpAuthenticator::ELIGIBLE_ROLES
|
||||||
|
$this->addSql('UPDATE oauth2_client SET required_roles = :roles WHERE name = :name', [
|
||||||
|
'roles' => json_encode(['ROLE_TEAM_ADMIN', 'ROLE_TEAMER', 'ROLE_MANAGER', 'ROLE_HOUSE_MANAGER'], JSON_THROW_ON_ERROR),
|
||||||
|
'name' => 'MyEPTeam',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE oauth2_client DROP required_roles');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives every account a uuid, which replaces the primary key as the OAuth2 `sub` claim.
|
||||||
|
*
|
||||||
|
* The UUIDs are generated here in PHP rather than with MariaDB's UUID(): that function
|
||||||
|
* returns v1, which encodes a timestamp and the server's node id, so a whole backfill
|
||||||
|
* would come out time-ordered and share a suffix. Guessing one account's identifier from
|
||||||
|
* another's is the property this column exists to remove, so the backfill has to be as
|
||||||
|
* random as the ones App\Entity\User generates from now on.
|
||||||
|
*
|
||||||
|
* One statement per row, which is why it is emitted rather than written out: the values
|
||||||
|
* cannot exist before the migration runs. Added nullable and tightened afterwards, since
|
||||||
|
* there is no single default that could satisfy a unique column.
|
||||||
|
*/
|
||||||
|
final class Version20260923150000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Adds user.uuid and backfills it; the OAuth2 sub claim moves off the primary key.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE user ADD uuid VARCHAR(36) DEFAULT NULL');
|
||||||
|
|
||||||
|
foreach ($this->connection->fetchFirstColumn('SELECT id FROM user') as $id) {
|
||||||
|
$this->addSql('UPDATE user SET uuid = :uuid WHERE id = :id', [
|
||||||
|
'uuid' => Uuid::v4()->toRfc4122(),
|
||||||
|
'id' => $id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE user MODIFY uuid VARCHAR(36) NOT NULL');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649D17F50A6 ON user (uuid)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP INDEX UNIQ_8D93D649D17F50A6 ON user');
|
||||||
|
$this->addSql('ALTER TABLE user DROP uuid');
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-10
@@ -1,20 +1,20 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
|
<!-- https://docs.phpunit.de/en/12.5/configuration.html -->
|
||||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
backupGlobals="false"
|
backupGlobals="false"
|
||||||
colors="true"
|
colors="true"
|
||||||
bootstrap="tests/bootstrap.php"
|
bootstrap="tests/bootstrap.php"
|
||||||
convertDeprecationsToExceptions="false"
|
cacheDirectory=".phpunit.cache"
|
||||||
|
displayDetailsOnTestsThatTriggerDeprecations="true"
|
||||||
|
displayDetailsOnPhpunitDeprecations="true"
|
||||||
>
|
>
|
||||||
<php>
|
<php>
|
||||||
<ini name="display_errors" value="1" />
|
<ini name="display_errors" value="1" />
|
||||||
<ini name="error_reporting" value="-1" />
|
<ini name="error_reporting" value="-1" />
|
||||||
<server name="APP_ENV" value="test" force="true" />
|
<server name="APP_ENV" value="test" force="true" />
|
||||||
<server name="SHELL_VERBOSITY" value="-1" />
|
<server name="SHELL_VERBOSITY" value="-1" />
|
||||||
<server name="SYMFONY_PHPUNIT_REMOVE" value="" />
|
|
||||||
<server name="SYMFONY_PHPUNIT_VERSION" value="9.5" />
|
|
||||||
</php>
|
</php>
|
||||||
|
|
||||||
<testsuites>
|
<testsuites>
|
||||||
@@ -23,16 +23,13 @@
|
|||||||
</testsuite>
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
|
|
||||||
<coverage processUncoveredFiles="true">
|
<source>
|
||||||
<include>
|
<include>
|
||||||
<directory suffix=".php">src</directory>
|
<directory suffix=".php">src</directory>
|
||||||
</include>
|
</include>
|
||||||
</coverage>
|
</source>
|
||||||
|
|
||||||
<listeners>
|
|
||||||
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
|
|
||||||
</listeners>
|
|
||||||
|
|
||||||
<extensions>
|
<extensions>
|
||||||
|
<bootstrap class="Symfony\Bridge\PhpUnit\SymfonyExtension" />
|
||||||
</extensions>
|
</extensions>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
+73
-24
@@ -602,10 +602,12 @@ class ApiClient
|
|||||||
'port' => $this->selectedPort,
|
'port' => $this->selectedPort,
|
||||||
]);
|
]);
|
||||||
$this->send($socket, $body);
|
$this->send($socket, $body);
|
||||||
$response = $this->receive($socket);
|
|
||||||
$this->disconnect($socket);
|
|
||||||
|
|
||||||
$responseXml = substr($response, 10);
|
try {
|
||||||
|
$responseXml = $this->receive($socket);
|
||||||
|
} finally {
|
||||||
|
$this->disconnect($socket);
|
||||||
|
}
|
||||||
|
|
||||||
if (true === $debug || true === $this->config['debug']) {
|
if (true === $debug || true === $this->config['debug']) {
|
||||||
$this->dumpXmlToFile('response', $requestId, $responseXml);
|
$this->dumpXmlToFile('response', $requestId, $responseXml);
|
||||||
@@ -714,18 +716,12 @@ class ApiClient
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->send($socket, $body);
|
$this->send($socket, $body);
|
||||||
$response = $this->receive($socket);
|
|
||||||
$this->disconnect($socket);
|
|
||||||
|
|
||||||
// message length (10 bytes) is prepended to actual message
|
try {
|
||||||
$xml = substr($response, 10);
|
// the length header is consumed and verified by receive(), this is the payload
|
||||||
|
$xml = $this->receive($socket);
|
||||||
if ('' === $xml) {
|
} finally {
|
||||||
$this->logger->error('Empty response body received from API (header-only response)', [
|
$this->disconnect($socket);
|
||||||
'request_id' => $requestId,
|
|
||||||
'raw_length' => strlen($response),
|
|
||||||
]);
|
|
||||||
throw new ApiClientException('Empty response body received from API');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (true === $debug || true === $this->config['debug']) {
|
if (true === $debug || true === $this->config['debug']) {
|
||||||
@@ -735,14 +731,19 @@ class ApiClient
|
|||||||
try {
|
try {
|
||||||
return $this->responseParser->parseXmlString($type, $xml, $additionalArgs);
|
return $this->responseParser->parseXmlString($type, $xml, $additionalArgs);
|
||||||
} catch (ResponseParserException $e) {
|
} catch (ResponseParserException $e) {
|
||||||
|
// Keep the evidence even outside debug mode: without the raw response a parse
|
||||||
|
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
|
||||||
$this->dumpXmlToFile('response', $requestId, $xml);
|
$this->dumpXmlToFile('response', $requestId, $xml);
|
||||||
|
|
||||||
|
$this->logger->error('Unable to parse response received from API', [
|
||||||
|
'request_id' => $requestId,
|
||||||
|
'type' => $type,
|
||||||
|
'response_length' => strlen($xml),
|
||||||
|
'error_message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new ApiClientException('Unable to parse response received from API: '.$e->getMessage(), 0, $e);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logger->error('Unexpected response received from API', [
|
|
||||||
'request_id' => $requestId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw new ApiClientException('Unexpected response received from API');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -773,10 +774,12 @@ class ApiClient
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->send($socket, $body);
|
$this->send($socket, $body);
|
||||||
$response = $this->receive($socket);
|
|
||||||
$this->disconnect($socket);
|
|
||||||
|
|
||||||
$xml = substr($response, 10);
|
try {
|
||||||
|
$xml = $this->receive($socket);
|
||||||
|
} finally {
|
||||||
|
$this->disconnect($socket);
|
||||||
|
}
|
||||||
|
|
||||||
if (true === $this->config['debug']) {
|
if (true === $this->config['debug']) {
|
||||||
$this->dumpXmlToFile('response', $requestId, $xml);
|
$this->dumpXmlToFile('response', $requestId, $xml);
|
||||||
@@ -898,8 +901,14 @@ class ApiClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Reads a single response message and returns its payload. The protocol prepends the
|
||||||
|
* payload length as a 10 byte header, so the announced length is compared against what
|
||||||
|
* actually arrived: a peer closing mid-stream would otherwise yield a silently
|
||||||
|
* truncated body that only surfaces later as an unexplained parse failure.
|
||||||
|
*
|
||||||
* @param resource $socket
|
* @param resource $socket
|
||||||
*
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
* @throws TimeoutException
|
* @throws TimeoutException
|
||||||
* @throws ImmediateConnectionCloseException
|
* @throws ImmediateConnectionCloseException
|
||||||
*/
|
*/
|
||||||
@@ -963,7 +972,47 @@ class ApiClient
|
|||||||
throw new ImmediateConnectionCloseException('Server closed connection without sending data');
|
throw new ImmediateConnectionCloseException('Server closed connection without sending data');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $response;
|
return $this->extractPayload($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
private function extractPayload(string $response): string
|
||||||
|
{
|
||||||
|
if (10 > strlen($response)) {
|
||||||
|
$this->logger->error('Incomplete response header received from API', [
|
||||||
|
'bytes_received' => strlen($response),
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new ApiClientException(sprintf('Incomplete response header, got %d of 10 bytes', strlen($response)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = substr($response, 0, 10);
|
||||||
|
$announcedLength = (int) trim($header);
|
||||||
|
$payload = substr($response, 10);
|
||||||
|
|
||||||
|
if (1 > $announcedLength) {
|
||||||
|
$this->logger->error('Empty response body announced by API', [
|
||||||
|
'header' => trim($header),
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new ApiClientException(sprintf('Response announced an empty body (header "%s")', trim($header)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($payload) !== $announcedLength) {
|
||||||
|
$this->logger->error('Truncated response received from API', [
|
||||||
|
'bytes_received' => strlen($payload),
|
||||||
|
'announced_length' => $announcedLength,
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new ApiClientException(sprintf('Truncated response, got %d of %d announced bytes', strlen($payload), $announcedLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param resource $socket */
|
/** @param resource $socket */
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ class BookingPayloadBuilder
|
|||||||
foreach ($bookingData->participants as $index => $participant) {
|
foreach ($bookingData->participants as $index => $participant) {
|
||||||
$participantPayload = [
|
$participantPayload = [
|
||||||
'@id' => $index + 1,
|
'@id' => $index + 1,
|
||||||
'status' => $bookingData->participantsStatus[$index],
|
'status' => $bookingData->participantsStatus[$index] ?? null,
|
||||||
...$participant->toPayload(),
|
...$participant->toPayload(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\BusProNet\Model;
|
namespace App\BusProNet\Model;
|
||||||
|
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
use function Symfony\Component\String\u;
|
use function Symfony\Component\String\u;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -143,6 +143,31 @@ class Booking
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a participant already holds a service in this booking.
|
||||||
|
*
|
||||||
|
* This is the BusPro-side baseline, not the working edit selection: it answers
|
||||||
|
* "did the participant have this service when the booking was last read back".
|
||||||
|
* Services already held may be kept regardless of their current status or
|
||||||
|
* contingent - withdrawing one produces "Anzahl Leistung stimmt nicht mit
|
||||||
|
* Teilnehmerzuordnung überein" on the next update.
|
||||||
|
*
|
||||||
|
* @param int $participantIndex The participant index to search for
|
||||||
|
* @param int $serviceId The service ID to look for
|
||||||
|
*
|
||||||
|
* @return bool True if the participant already holds the service
|
||||||
|
*/
|
||||||
|
public function hasServiceForParticipant(int $participantIndex, int $serviceId): bool
|
||||||
|
{
|
||||||
|
foreach ([...$this->additionalServices, ...$this->transportationServices] as $service) {
|
||||||
|
if ($service->id === $serviceId && in_array($participantIndex, $service->mapping)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves transportation service for a specific participant and direction.
|
* Retrieves transportation service for a specific participant and direction.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -35,4 +35,15 @@ class Hotel
|
|||||||
|
|
||||||
#[Groups(['api:list', 'api:single', 'snapshot'])]
|
#[Groups(['api:list', 'api:single', 'snapshot'])]
|
||||||
public ?string $country = null;
|
public ?string $country = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw `kostenstelle`, deliberately a string: coverage is thin and some hotels carry a
|
||||||
|
* space-separated list such as "84 85", so it is an opaque label, not a number.
|
||||||
|
*/
|
||||||
|
#[Groups(['api:list', 'api:single', 'snapshot'])]
|
||||||
|
public ?string $costUnit = null;
|
||||||
|
|
||||||
|
/** {@see \App\BusProNet\Utility\CountryCodeMapper} — lowercase ISO-2, null when unmappable. */
|
||||||
|
#[Groups(['api:list', 'api:single', 'snapshot'])]
|
||||||
|
public ?string $countryCode = null;
|
||||||
}
|
}
|
||||||
@@ -61,6 +61,13 @@ class PersonalData
|
|||||||
/** @var list<string> */
|
/** @var list<string> */
|
||||||
public array $hotelCodes = [];
|
public array $hotelCodes = [];
|
||||||
|
|
||||||
|
// The authenticated account, not BusPro's idea of this person. One BusPro person may sign in
|
||||||
|
// under any of the addresses on its record, and each of those is a separate local account with
|
||||||
|
// its own roles, so neither the BusPro ids nor the communication email identifies one. Patched
|
||||||
|
// on by the caller in the same way as the roles and hotel codes above.
|
||||||
|
public ?string $subject = null;
|
||||||
|
public ?string $loginEmail = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->address = new Address();
|
$this->address = new Address();
|
||||||
@@ -132,14 +139,21 @@ class PersonalData
|
|||||||
* Creates a structured array containing user profile information
|
* Creates a structured array containing user profile information
|
||||||
* suitable for JWT claims or user session data.
|
* suitable for JWT claims or user session data.
|
||||||
*
|
*
|
||||||
|
* `sub` and `email` describe the account that authenticated and are read from the patched-on
|
||||||
|
* fields, never from BusPro: `person_id`/`address_id` are shared by every account of the same
|
||||||
|
* person, and `profile.communication.email` is the first contact address on the BusPro record,
|
||||||
|
* which is not necessarily the one signed in with. Deliberately without a fallback to that
|
||||||
|
* address — a caller that forgets to patch them gets null rather than a wrong identity.
|
||||||
|
*
|
||||||
* @return array<string, mixed> The claims array with user profile data
|
* @return array<string, mixed> The claims array with user profile data
|
||||||
*/
|
*/
|
||||||
public function getClaims(): array
|
public function getClaims(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'sub' => $this->subject,
|
||||||
'person_id' => $this->personId,
|
'person_id' => $this->personId,
|
||||||
'address_id' => $this->addressId,
|
'address_id' => $this->addressId,
|
||||||
'email' => $this->communication->email,
|
'email' => $this->loginEmail,
|
||||||
'roles' => $this->roles,
|
'roles' => $this->roles,
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'first_name' => $this->firstName,
|
'first_name' => $this->firstName,
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\Utility;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps BusPro's `hotel/land` token onto a lowercase ISO 3166-1 alpha-2 code.
|
||||||
|
*
|
||||||
|
* BusPro has no country vocabulary: the field is free text that has accumulated three
|
||||||
|
* conventions side by side — ISO-2 (`CH`, `NL`), the European vehicle registration codes
|
||||||
|
* (`D`, `A`, `F`, `S`, `FL`) and ISO-3 (`AUT`, `PRT`, `HRV`), with the same country often
|
||||||
|
* under several tokens. The table below is therefore part of the API contract rather than
|
||||||
|
* a derivation: consumers render a flag from it, and a wrong entry mislabels a destination
|
||||||
|
* silently, which is worse than no flag at all. An unknown token maps to null — never to a
|
||||||
|
* guess, never to an empty string.
|
||||||
|
*/
|
||||||
|
class CountryCodeMapper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Keys are upper-cased tokens, values lowercase ISO-2.
|
||||||
|
*
|
||||||
|
* Entries worth their explanation:
|
||||||
|
* - `IR` is Ireland, not Iran. Every row carrying it is Dublin or Carlow.
|
||||||
|
* - `SLO` is Slovakia here, not Slovenia: its only row is a Slovak company, and
|
||||||
|
* Slovenia already arrives under its own `SI`.
|
||||||
|
* - `S`, `N`, `FL` are vehicle codes (Sweden, Norway, Liechtenstein), consistent with
|
||||||
|
* the `D`/`A`/`F`/`I`/`E`/`B` in the same column.
|
||||||
|
* - `UK` is the colloquial form of the ISO `GB`.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private const MAP = [
|
||||||
|
'A' => 'at',
|
||||||
|
'AT' => 'at',
|
||||||
|
'AUT' => 'at',
|
||||||
|
'B' => 'be',
|
||||||
|
'CA' => 'ca',
|
||||||
|
'CH' => 'ch',
|
||||||
|
'CZ' => 'cz',
|
||||||
|
'D' => 'de',
|
||||||
|
'DEU' => 'de',
|
||||||
|
'DK' => 'dk',
|
||||||
|
'E' => 'es',
|
||||||
|
'ES' => 'es',
|
||||||
|
'F' => 'fr',
|
||||||
|
'FI' => 'fi',
|
||||||
|
'FL' => 'li',
|
||||||
|
'FR' => 'fr',
|
||||||
|
'GR' => 'gr',
|
||||||
|
'GRE' => 'gr',
|
||||||
|
'HR' => 'hr',
|
||||||
|
'HRV' => 'hr',
|
||||||
|
'HU' => 'hu',
|
||||||
|
'I' => 'it',
|
||||||
|
'IL' => 'il',
|
||||||
|
'IR' => 'ie',
|
||||||
|
'IS' => 'is',
|
||||||
|
'IT' => 'it',
|
||||||
|
'LT' => 'lt',
|
||||||
|
'LV' => 'lv',
|
||||||
|
'MT' => 'mt',
|
||||||
|
'N' => 'no',
|
||||||
|
'NL' => 'nl',
|
||||||
|
'PL' => 'pl',
|
||||||
|
'PRT' => 'pt',
|
||||||
|
'PT' => 'pt',
|
||||||
|
'RUS' => 'ru',
|
||||||
|
'S' => 'se',
|
||||||
|
'SI' => 'si',
|
||||||
|
'SLO' => 'sk',
|
||||||
|
'UK' => 'gb',
|
||||||
|
'USA' => 'us',
|
||||||
|
'ZA' => 'za',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens already reported in this process, so a master data file full of the same
|
||||||
|
* unknown token produces one log line rather than one per hotel.
|
||||||
|
*
|
||||||
|
* @var array<string, true>
|
||||||
|
*/
|
||||||
|
private array $reported = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $token the raw `hotel/land` value
|
||||||
|
*
|
||||||
|
* @return string|null lowercase ISO-2, or null when absent, empty or unmappable
|
||||||
|
*/
|
||||||
|
public function map(?string $token): ?string
|
||||||
|
{
|
||||||
|
if (null === $token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = strtoupper(trim($token));
|
||||||
|
|
||||||
|
if ('' === $normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset(self::MAP[$normalized])) {
|
||||||
|
return self::MAP[$normalized];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === isset($this->reported[$normalized])) {
|
||||||
|
$this->reported[$normalized] = true;
|
||||||
|
$this->logger->warning('Unmapped BusPro country token', ['token' => $token]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\BusProNet;
|
||||||
|
|
||||||
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a Crawler over BusPro's XML payloads.
|
||||||
|
*
|
||||||
|
* The Crawler constructor parses its content as HTML5, which flattens the
|
||||||
|
* nesting BusPro's exports and API responses rely on, so XML has to be added
|
||||||
|
* through addXmlContent() instead.
|
||||||
|
*/
|
||||||
|
final class XmlCrawlerFactory
|
||||||
|
{
|
||||||
|
public static function create(string $xml): Crawler
|
||||||
|
{
|
||||||
|
$crawler = new Crawler();
|
||||||
|
$crawler->addXmlContent($xml);
|
||||||
|
|
||||||
|
return $crawler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explains why create() returned an empty Crawler. addXmlContent() discards the
|
||||||
|
* libxml errors, so an empty, a malformed and a truncated payload are
|
||||||
|
* indistinguishable afterwards; this re-parses to recover the reason. Only worth
|
||||||
|
* calling on the failure path.
|
||||||
|
*/
|
||||||
|
public static function diagnose(string $xml): string
|
||||||
|
{
|
||||||
|
if ('' === trim($xml)) {
|
||||||
|
return sprintf('empty response (%d bytes)', strlen($xml));
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousUseErrors = libxml_use_internal_errors(true);
|
||||||
|
libxml_clear_errors();
|
||||||
|
|
||||||
|
try {
|
||||||
|
simplexml_load_string($xml);
|
||||||
|
|
||||||
|
$messages = [];
|
||||||
|
foreach (libxml_get_errors() as $error) {
|
||||||
|
$messages[] = sprintf('%s (line %d, column %d)', trim($error->message), $error->line, $error->column);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] === $messages) {
|
||||||
|
return sprintf('unknown XML error (%d bytes)', strlen($xml));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%s (%d bytes)', implode('; ', array_unique($messages)), strlen($xml));
|
||||||
|
} finally {
|
||||||
|
libxml_clear_errors();
|
||||||
|
libxml_use_internal_errors($previousUseErrors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,25 @@ namespace App\BusProNet\XmlLoader;
|
|||||||
|
|
||||||
use App\BusProNet\Model\Hotel;
|
use App\BusProNet\Model\Hotel;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\BusProNet\Utility\CountryCodeMapper;
|
||||||
|
use App\BusProNet\XmlCrawlerFactory;
|
||||||
use App\Exception\HotelNotFoundException;
|
use App\Exception\HotelNotFoundException;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Symfony\Component\DomCrawler\Crawler;
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
use Symfony\Contracts\Cache\ItemInterface;
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
|
|
||||||
class HotelLoader extends AbstractLoader
|
class HotelLoader extends AbstractLoader
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CountryCodeMapper $countryCodeMapper,
|
||||||
|
CacheInterface $cache,
|
||||||
|
FilesystemOperator $xmlExport,
|
||||||
|
) {
|
||||||
|
parent::__construct($cache, $xmlExport);
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<int, Hotel> */
|
/** @return array<int, Hotel> */
|
||||||
public function loadAll(?string $filename = 'hotel.xml'): array
|
public function loadAll(?string $filename = 'hotel.xml'): array
|
||||||
{
|
{
|
||||||
@@ -73,7 +85,7 @@ class HotelLoader extends AbstractLoader
|
|||||||
{
|
{
|
||||||
$xml = $this->xmlExport->read($filename);
|
$xml = $this->xmlExport->read($filename);
|
||||||
|
|
||||||
return new Crawler($xml);
|
return XmlCrawlerFactory::create($xml);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function parseXml(Crawler $node): Hotel
|
public function parseXml(Crawler $node): Hotel
|
||||||
@@ -86,6 +98,10 @@ class HotelLoader extends AbstractLoader
|
|||||||
$hotel->country = $this->getStringOrNullValue($node->filterXPath('//land'));
|
$hotel->country = $this->getStringOrNullValue($node->filterXPath('//land'));
|
||||||
$hotel->street = $this->getStringOrNullValue($node->filterXPath('//strasse'));
|
$hotel->street = $this->getStringOrNullValue($node->filterXPath('//strasse'));
|
||||||
$hotel->phone = $this->getStringOrNullValue($node->filterXPath('//telefon'));
|
$hotel->phone = $this->getStringOrNullValue($node->filterXPath('//telefon'));
|
||||||
|
// String, never int: see Hotel::$costUnit.
|
||||||
|
$hotel->costUnit = $this->getStringOrNullValue($node->filterXPath('//kostenstelle'));
|
||||||
|
// `country` itself stays the raw token — it is the join key into /api/countries[].token.
|
||||||
|
$hotel->countryCode = $this->countryCodeMapper->map($hotel->country);
|
||||||
|
|
||||||
return $hotel;
|
return $hotel;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\BusProNet\XmlLoader;
|
namespace App\BusProNet\XmlLoader;
|
||||||
|
|
||||||
use App\BusProNet\Model\Insurance;
|
use App\BusProNet\Model\Insurance;
|
||||||
|
use App\BusProNet\XmlCrawlerFactory;
|
||||||
use App\BusProNet\XmlParser\InsuranceParser;
|
use App\BusProNet\XmlParser\InsuranceParser;
|
||||||
use League\Flysystem\FilesystemOperator;
|
use League\Flysystem\FilesystemOperator;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
@@ -50,6 +51,6 @@ class InsuranceLoader extends AbstractLoader
|
|||||||
{
|
{
|
||||||
$xml = $this->xmlExport->read($filename);
|
$xml = $this->xmlExport->read($filename);
|
||||||
|
|
||||||
return new Crawler($xml);
|
return XmlCrawlerFactory::create($xml);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\BusProNet\XmlLoader;
|
|||||||
|
|
||||||
use App\BusProNet\Model\Pickup;
|
use App\BusProNet\Model\Pickup;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\BusProNet\XmlCrawlerFactory;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Symfony\Component\DomCrawler\Crawler;
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
use Symfony\Contracts\Cache\ItemInterface;
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
@@ -46,7 +47,7 @@ class PickupLoader extends AbstractLoader
|
|||||||
{
|
{
|
||||||
$xml = $this->xmlExport->read($filename);
|
$xml = $this->xmlExport->read($filename);
|
||||||
|
|
||||||
return new Crawler($xml);
|
return XmlCrawlerFactory::create($xml);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function parseXml(Crawler $node): Pickup
|
public function parseXml(Crawler $node): Pickup
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\BusProNet\Model\MutableData;
|
|||||||
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\BusProNet\Utility\DateCodeUtility;
|
use App\BusProNet\Utility\DateCodeUtility;
|
||||||
|
use App\BusProNet\XmlCrawlerFactory;
|
||||||
use App\BusProNet\XmlParser\TravelParser;
|
use App\BusProNet\XmlParser\TravelParser;
|
||||||
use App\Exception\HotelNotInTravelException;
|
use App\Exception\HotelNotInTravelException;
|
||||||
use App\Exception\TravelNotFoundException;
|
use App\Exception\TravelNotFoundException;
|
||||||
@@ -71,7 +72,7 @@ class TravelLoader extends AbstractLoader
|
|||||||
foreach ($xmlFiles as $file) {
|
foreach ($xmlFiles as $file) {
|
||||||
$xml = $this->xmlExport->read($file->path());
|
$xml = $this->xmlExport->read($file->path());
|
||||||
|
|
||||||
$crawler = new Crawler($xml);
|
$crawler = XmlCrawlerFactory::create($xml);
|
||||||
$travelDataNodes = $crawler->filterXPath('//reisen/reise/termin');
|
$travelDataNodes = $crawler->filterXPath('//reisen/reise/termin');
|
||||||
|
|
||||||
$travelDataNodes->each(function (Crawler $node) use (&$mapping, $hotels, $file) {
|
$travelDataNodes->each(function (Crawler $node) use (&$mapping, $hotels, $file) {
|
||||||
@@ -91,6 +92,13 @@ class TravelLoader extends AbstractLoader
|
|||||||
->each(function (Crawler $hotelNode) use (&$mapping, $hotels, $travelId) {
|
->each(function (Crawler $hotelNode) use (&$mapping, $hotels, $travelId) {
|
||||||
$hotelId = (int) $hotelNode->attr('idbuspro');
|
$hotelId = (int) $hotelNode->attr('idbuspro');
|
||||||
|
|
||||||
|
// A travel may reference a hotel that hotel.xml does not carry.
|
||||||
|
// Reading ->name off the miss would fatal the whole mapping, not
|
||||||
|
// just this entry, and take /api/travels down with it.
|
||||||
|
if (false === isset($hotels[$hotelId])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$mapping[$travelId]['hotels'][$hotelId] = [
|
$mapping[$travelId]['hotels'][$hotelId] = [
|
||||||
'id' => $hotelId,
|
'id' => $hotelId,
|
||||||
'code' => $hotelNode->attr('code'),
|
'code' => $hotelNode->attr('code'),
|
||||||
@@ -218,7 +226,7 @@ class TravelLoader extends AbstractLoader
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$xml = $this->xmlExport->read($filename);
|
$xml = $this->xmlExport->read($filename);
|
||||||
$crawler = new Crawler($xml);
|
$crawler = XmlCrawlerFactory::create($xml);
|
||||||
|
|
||||||
$travelNode = $crawler->filterXPath(sprintf('//reise/termin[@idbuspro="%d"]', $dateId));
|
$travelNode = $crawler->filterXPath(sprintf('//reise/termin[@idbuspro="%d"]', $dateId));
|
||||||
|
|
||||||
@@ -280,9 +288,9 @@ class TravelLoader extends AbstractLoader
|
|||||||
/**
|
/**
|
||||||
* Apply availability data to travel services.
|
* Apply availability data to travel services.
|
||||||
*
|
*
|
||||||
* Updates the availability status of additional and transportation
|
* Updates the remaining contingent and the live status of additional and
|
||||||
* services, and the allowed booking status, based on the provided
|
* transportation services, and the allowed booking status, based on the
|
||||||
* availability data.
|
* provided availability data.
|
||||||
*
|
*
|
||||||
* @param Travel $travel The travel object to update
|
* @param Travel $travel The travel object to update
|
||||||
* @param ServiceAvailabilityResponse $availabilities The availability data for services
|
* @param ServiceAvailabilityResponse $availabilities The availability data for services
|
||||||
@@ -292,8 +300,19 @@ class TravelLoader extends AbstractLoader
|
|||||||
$serviceAvailabilities = $availabilities->getServices();
|
$serviceAvailabilities = $availabilities->getServices();
|
||||||
|
|
||||||
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
|
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
|
||||||
if (array_key_exists($service->id, $serviceAvailabilities)) {
|
if (false === array_key_exists($service->id, $serviceAvailabilities)) {
|
||||||
$service->available = $serviceAvailabilities[$service->id]->available;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$availability = $serviceAvailabilities[$service->id];
|
||||||
|
$service->available = $availability->available;
|
||||||
|
|
||||||
|
// The live status is the only signal that a contingent has run out: BusPro moves a
|
||||||
|
// Leistung to 'Anfrage' without necessarily reporting frei="0". Only overwrite when the
|
||||||
|
// response actually carries one, so a response that omits the attribute does not wipe
|
||||||
|
// the value parsed from the XML export. Mirrors TravelSnapshotManager::applyExtendedToService().
|
||||||
|
if (null !== $availability->status && '' !== trim($availability->status)) {
|
||||||
|
$service->status = $availability->status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,6 @@ abstract class AbstractParser
|
|||||||
{
|
{
|
||||||
use TypeConversionTrait;
|
use TypeConversionTrait;
|
||||||
|
|
||||||
protected function getIntOrNullAttribute(?string $value): ?int
|
|
||||||
{
|
|
||||||
if (null === $value || '' === $value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getStringOrNullValue(Crawler $node): ?string
|
protected function getStringOrNullValue(Crawler $node): ?string
|
||||||
{
|
{
|
||||||
return 0 < $node->count() ? $node->text() : null;
|
return 0 < $node->count() ? $node->text() : null;
|
||||||
@@ -64,7 +55,27 @@ abstract class AbstractParser
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $node->attr($attribute);
|
$value = $node->attr($attribute);
|
||||||
|
if (null !== $value) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BusPro mixes casing (MinPax in the travel export, minpax in booking
|
||||||
|
// responses) and addXmlContent() makes attr() case-sensitive.
|
||||||
|
foreach ($node->getNode(0)->attributes ?? [] as $candidate) {
|
||||||
|
if (0 === strcasecmp($candidate->name, $attribute)) {
|
||||||
|
return $candidate->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getIntAttrOrNullValue(Crawler $node, string $attribute): ?int
|
||||||
|
{
|
||||||
|
$value = $this->getAttrOrNullValue($node, $attribute);
|
||||||
|
|
||||||
|
return null !== $value && '' !== $value ? (int) $value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getRequiredAttrValue(Crawler $node, string $attribute, string $context): string
|
protected function getRequiredAttrValue(Crawler $node, string $attribute, string $context): string
|
||||||
|
|||||||
@@ -4,20 +4,25 @@ namespace App\BusProNet\XmlParser;
|
|||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\ApiClient;
|
||||||
use App\BusProNet\Exception\ResponseParserException;
|
use App\BusProNet\Exception\ResponseParserException;
|
||||||
use Symfony\Component\DomCrawler\Crawler;
|
use App\BusProNet\XmlCrawlerFactory;
|
||||||
|
|
||||||
class ApiResponseParser extends AbstractParser
|
class ApiResponseParser extends AbstractParser
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TravelParser $travelParser,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws ResponseParserException
|
* @throws ResponseParserException
|
||||||
*/
|
*/
|
||||||
/** @param array<string, mixed> $additionalArgs */
|
/** @param array<string, mixed> $additionalArgs */
|
||||||
public function parseXmlString(string $type, string $xml, array $additionalArgs = []): mixed
|
public function parseXmlString(string $type, string $xml, array $additionalArgs = []): mixed
|
||||||
{
|
{
|
||||||
$crawler = new Crawler($xml);
|
$crawler = XmlCrawlerFactory::create($xml);
|
||||||
|
|
||||||
if (0 === $crawler->count()) {
|
if (0 === $crawler->count()) {
|
||||||
throw new ResponseParserException('Empty response received from server');
|
throw new ResponseParserException('Unable to parse XML response: '.XmlCrawlerFactory::diagnose($xml));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override type unless present in XML to catch error responses
|
// Override type unless present in XML to catch error responses
|
||||||
@@ -56,7 +61,8 @@ class ApiResponseParser extends AbstractParser
|
|||||||
case 'Vorgangdruck':
|
case 'Vorgangdruck':
|
||||||
return (new DocumentsParser())->parseConfirmation($resultNode);
|
return (new DocumentsParser())->parseConfirmation($resultNode);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
|
throw new ResponseParserException(sprintf('Unrecognised customer data subtype "%s"', $subType));
|
||||||
case ApiClient::TYPE_BASE_DATA_COUNTRIES:
|
case ApiClient::TYPE_BASE_DATA_COUNTRIES:
|
||||||
return (new CountriesParser())->parse($resultNode);
|
return (new CountriesParser())->parse($resultNode);
|
||||||
case ApiClient::TYPE_MUTABLE_DATA:
|
case ApiClient::TYPE_MUTABLE_DATA:
|
||||||
@@ -80,7 +86,7 @@ class ApiResponseParser extends AbstractParser
|
|||||||
throw new ResponseParserException('No travel data node found in product data response');
|
throw new ResponseParserException('No travel data node found in product data response');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (new TravelParser())->parse($travelNode->first(), ...$additionalArgs);
|
return $this->travelParser->parse($travelNode->first(), ...$additionalArgs);
|
||||||
case ApiClient::TYPE_AGENCIES:
|
case ApiClient::TYPE_AGENCIES:
|
||||||
return (new AgencyParser())->parse($resultNode);
|
return (new AgencyParser())->parse($resultNode);
|
||||||
case ApiClient::TYPE_PURCHASE_VOUCHER:
|
case ApiClient::TYPE_PURCHASE_VOUCHER:
|
||||||
@@ -92,6 +98,6 @@ class ApiResponseParser extends AbstractParser
|
|||||||
throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e);
|
throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ResponseParserException('Unable to parse XML response');
|
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,11 +49,13 @@ class BookingParser extends AbstractParser
|
|||||||
|
|
||||||
$booking->applicant = $this->parsePersonalData($node->filterXPath('//anmelder'));
|
$booking->applicant = $this->parsePersonalData($node->filterXPath('//anmelder'));
|
||||||
|
|
||||||
$participantsStatus = $this->getArrayValue($node->filterXPath('//status_teilnehmer'), '/');
|
|
||||||
$booking->participantsStatus = array_combine(range(0, count($participantsStatus) - 1), $participantsStatus);
|
|
||||||
|
|
||||||
$booking->participants = $this->parseParticipants($node->filterXPath('//teilnehmerliste/teilnehmer'));
|
$booking->participants = $this->parseParticipants($node->filterXPath('//teilnehmerliste/teilnehmer'));
|
||||||
|
|
||||||
|
$booking->participantsStatus = $this->mapParticipantsStatus(
|
||||||
|
array_keys($booking->participants),
|
||||||
|
$this->getArrayValue($node->filterXPath('//status_teilnehmer'), '/'),
|
||||||
|
);
|
||||||
|
|
||||||
$paymentData = $node->filterXPath('//zahlung');
|
$paymentData = $node->filterXPath('//zahlung');
|
||||||
$booking->paymentId = $this->getAttrOrNullValue($paymentData, 'idzahlungsart');
|
$booking->paymentId = $this->getAttrOrNullValue($paymentData, 'idzahlungsart');
|
||||||
$booking->paymentLabel = $this->getAttrOrNullValue($paymentData, 'bezeichnung');
|
$booking->paymentLabel = $this->getAttrOrNullValue($paymentData, 'bezeichnung');
|
||||||
@@ -129,6 +131,38 @@ class BookingParser extends AbstractParser
|
|||||||
return $participants;
|
return $participants;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the positional status_teilnehmer list onto the participant keys.
|
||||||
|
*
|
||||||
|
* BusPro sends the statuses as a slash-separated list in teilnehmerliste order and
|
||||||
|
* without ids, while participants are keyed by "BusPro participant id - 1" (see
|
||||||
|
* parseParticipants()). Zipping the two in document order keeps both arrays on the same
|
||||||
|
* keys even when the ids are not contiguous from 1, which BookingPayloadBuilder relies
|
||||||
|
* on when it reads participantsStatus[$index] while iterating participants, and which
|
||||||
|
* gates editing in ParticipantController::isParticipantCanceled().
|
||||||
|
*
|
||||||
|
* Surplus entries on either side are dropped rather than shifting the remaining ones
|
||||||
|
* onto the wrong participant.
|
||||||
|
*
|
||||||
|
* @param list<int> $participantKeys Participant array keys, in document order
|
||||||
|
* @param list<string> $statusValues Status codes, in document order
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function mapParticipantsStatus(array $participantKeys, array $statusValues): array
|
||||||
|
{
|
||||||
|
$count = min(count($participantKeys), count($statusValues));
|
||||||
|
|
||||||
|
if (0 === $count) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_combine(
|
||||||
|
array_slice($participantKeys, 0, $count),
|
||||||
|
array_slice($statusValues, 0, $count),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function parsePersonalData(Crawler $node): PersonalData
|
private function parsePersonalData(Crawler $node): PersonalData
|
||||||
{
|
{
|
||||||
$personalData = new PersonalData();
|
$personalData = new PersonalData();
|
||||||
|
|||||||
@@ -26,11 +26,22 @@ class CrmAttributesResponseParser
|
|||||||
{
|
{
|
||||||
use TypeConversionTrait;
|
use TypeConversionTrait;
|
||||||
|
|
||||||
private const BPN_CRM_ID_ADMIN = 1292;
|
/**
|
||||||
private const BPN_CRM_ID_MANAGER = 1293;
|
* The CRM selections that stand for a role, by selection id. "Hausleitung" is deliberately not
|
||||||
private const BPN_CRM_ID_TEAMER = 1070;
|
* in here: its ids are deployment configuration (see $houseManagerIds) and carry a hotel code
|
||||||
private const BPN_CRM_ID_GROUPS_MANAGER = 1477;
|
* on top of the role.
|
||||||
private const BPN_CRM_ID_GROUPS_ADMIN = 1478;
|
*
|
||||||
|
* @var array<int, string> selection id => role
|
||||||
|
*/
|
||||||
|
private const ROLE_BY_CRM_ID = [
|
||||||
|
1070 => Role::TEAMER,
|
||||||
|
1292 => Role::ADMIN,
|
||||||
|
1293 => Role::MANAGER,
|
||||||
|
1477 => Role::GROUPS_MANAGER,
|
||||||
|
1478 => Role::GROUPS_ADMIN,
|
||||||
|
1483 => Role::CUSTOMER_EXPERT,
|
||||||
|
1484 => Role::TEAM_ADMIN,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int|string, string> $houseManagerIds "Hausleitung" selection id => hotel code
|
* @param array<int|string, string> $houseManagerIds "Hausleitung" selection id => hotel code
|
||||||
@@ -71,20 +82,11 @@ class CrmAttributesResponseParser
|
|||||||
$roles[] = Role::HOUSE_MANAGER;
|
$roles[] = Role::HOUSE_MANAGER;
|
||||||
$hotelCodes[] = $hotelCode;
|
$hotelCodes[] = $hotelCode;
|
||||||
}
|
}
|
||||||
if (self::BPN_CRM_ID_ADMIN === $attribute->id) {
|
|
||||||
$roles[] = Role::ADMIN;
|
$role = self::ROLE_BY_CRM_ID[$attribute->id] ?? null;
|
||||||
}
|
|
||||||
if (self::BPN_CRM_ID_MANAGER === $attribute->id) {
|
if (null !== $role) {
|
||||||
$roles[] = Role::MANAGER;
|
$roles[] = $role;
|
||||||
}
|
|
||||||
if (self::BPN_CRM_ID_TEAMER === $attribute->id) {
|
|
||||||
$roles[] = Role::TEAMER;
|
|
||||||
}
|
|
||||||
if (self::BPN_CRM_ID_GROUPS_MANAGER === $attribute->id) {
|
|
||||||
$roles[] = Role::GROUPS_MANAGER;
|
|
||||||
}
|
|
||||||
if (self::BPN_CRM_ID_GROUPS_ADMIN === $attribute->id) {
|
|
||||||
$roles[] = Role::GROUPS_ADMIN;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,20 @@ class TravelParser extends AbstractParser
|
|||||||
Constants::PSEUDO_PRICE_ROOM_CODE,
|
Constants::PSEUDO_PRICE_ROOM_CODE,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `$levelBoardingPointPrices` carries no default on purpose: a hand-built parser would
|
||||||
|
* otherwise silently ignore APP_BPN_LEVEL_BOARDING_POINT_PRICES and price one travel tier
|
||||||
|
* differently from the others. The registry gave up its own default along with it, so that
|
||||||
|
* there is one way to build this class — through the container, or explicitly in a test.
|
||||||
|
*
|
||||||
|
* @param bool $levelBoardingPointPrices Whether to compensate the BusPro export defect that
|
||||||
|
* omits a boarding point's return-leg price. The
|
||||||
|
* container wires it from
|
||||||
|
* APP_BPN_LEVEL_BOARDING_POINT_PRICES.
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AgeConstraintParserRegistry $ageConstraintRegistry = new AgeConstraintParserRegistry(),
|
private readonly AgeConstraintParserRegistry $ageConstraintRegistry,
|
||||||
|
private readonly bool $levelBoardingPointPrices,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +99,10 @@ class TravelParser extends AbstractParser
|
|||||||
$travel->pickups = $this->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true);
|
$travel->pickups = $this->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true);
|
||||||
$travel->dropOffs = $this->getPickups($node->filterXPath('.//zustiege_rueck/zustieg_rueck'), $dateTo, false);
|
$travel->dropOffs = $this->getPickups($node->filterXPath('.//zustiege_rueck/zustieg_rueck'), $dateTo, false);
|
||||||
|
|
||||||
// Merge drop-off prices into pickups for split pricing support
|
// A boarding point costs the same in both directions
|
||||||
$this->mergeDropOffPricesIntoPickups($travel->pickups, $travel->dropOffs);
|
if ($this->levelBoardingPointPrices) {
|
||||||
|
$this->applyBoardingPointPriceLevelling($travel->pickups, $travel->dropOffs);
|
||||||
|
}
|
||||||
|
|
||||||
$travel->guide = $this->getGuide($node);
|
$travel->guide = $this->getGuide($node);
|
||||||
|
|
||||||
@@ -381,8 +395,8 @@ class TravelParser extends AbstractParser
|
|||||||
$room->category = $this->getAttrOrNullValue($roomNode, 'kat');
|
$room->category = $this->getAttrOrNullValue($roomNode, 'kat');
|
||||||
$room->boardId = (int) $this->getAttrOrNullValue($roomNode, 'idbuspro_vp');
|
$room->boardId = (int) $this->getAttrOrNullValue($roomNode, 'idbuspro_vp');
|
||||||
$room->label = $this->getAttrOrNullValue($roomNode, 'zimmertext');
|
$room->label = $this->getAttrOrNullValue($roomNode, 'zimmertext');
|
||||||
$room->minPax = (int) $this->getAttrOrNullValue($roomNode, 'minpax');
|
$room->minPax = $this->getIntAttrOrNullValue($roomNode, 'minpax');
|
||||||
$room->maxPax = (int) $this->getAttrOrNullValue($roomNode, 'maxpax');
|
$room->maxPax = $this->getIntAttrOrNullValue($roomNode, 'maxpax');
|
||||||
$room->nights = (int) $this->getAttrOrNullValue($roomNode, 'naechte');
|
$room->nights = (int) $this->getAttrOrNullValue($roomNode, 'naechte');
|
||||||
$roomPrice = $this->getAttrOrNullValue($roomNode, 'preis');
|
$roomPrice = $this->getAttrOrNullValue($roomNode, 'preis');
|
||||||
$room->price = null !== $roomPrice && '' !== $roomPrice
|
$room->price = null !== $roomPrice && '' !== $roomPrice
|
||||||
@@ -453,22 +467,43 @@ class TravelParser extends AbstractParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges drop-off prices into pickups for split pricing support.
|
* Levels the price of a boarding point across both travel directions.
|
||||||
*
|
*
|
||||||
* For each pickup, finds the matching drop-off by ID and copies
|
* A boarding point is either free or costs the same in both directions:
|
||||||
* the inbound price to the pickup's priceInbound property. This enables
|
* `ZustiegPreis` is keyed by (point, tariff scheme) and holds no per-leg price, so
|
||||||
* split pricing calculations where costs are distributed between outbound
|
* BusPro bills an `Abfahrtsortzuschlag` per leg at one and the same rate. An
|
||||||
* and inbound transportation.
|
* asymmetric pair is therefore always an export defect — on some products a
|
||||||
|
* `<zustieg_rueck>` carries no `preis` although the engine still charges the return
|
||||||
|
* leg — and the stated price is the one that applies to both legs.
|
||||||
|
*
|
||||||
|
* Where both directions state a price they agree in practice; the outbound one wins
|
||||||
|
* to keep the outcome deterministic. Points without a counterpart are left as they are.
|
||||||
|
*
|
||||||
|
* Compensating the defect locally is provisional, so it is switchable: with
|
||||||
|
* `$levelBoardingPointPrices` off the export is reported verbatim and the affected
|
||||||
|
* travels undercharge the return leg again. See
|
||||||
|
* `docs/buspro-database-reference.md` section A7.
|
||||||
*
|
*
|
||||||
* @param array<int, Pickup> $pickups Pickups indexed by ID (modified in place)
|
* @param array<int, Pickup> $pickups Pickups indexed by ID (modified in place)
|
||||||
* @param array<int, Pickup> $dropOffs Drop-offs indexed by ID
|
* @param array<int, Pickup> $dropOffs Drop-offs indexed by ID (modified in place)
|
||||||
*/
|
*/
|
||||||
private function mergeDropOffPricesIntoPickups(array &$pickups, array $dropOffs): void
|
private function applyBoardingPointPriceLevelling(array $pickups, array $dropOffs): void
|
||||||
{
|
{
|
||||||
foreach ($pickups as $pickupId => $pickup) {
|
foreach ($dropOffs as $pickupId => $dropOff) {
|
||||||
if (isset($dropOffs[$pickupId])) {
|
if (false === isset($pickups[$pickupId])) {
|
||||||
$pickup->priceInbound = $dropOffs[$pickupId]->priceInbound;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$pickup = $pickups[$pickupId];
|
||||||
|
$price = $pickup->price ?? $dropOff->price;
|
||||||
|
|
||||||
|
$pickup->price = $price;
|
||||||
|
$pickup->priceOutbound = $price;
|
||||||
|
$pickup->priceInbound = $price;
|
||||||
|
|
||||||
|
$dropOff->price = $price;
|
||||||
|
$dropOff->priceOutbound = $price;
|
||||||
|
$dropOff->priceInbound = $price;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,489 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\BusProNet\ApiClient;
|
||||||
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||||
|
use App\BusProNet\Model\Booking;
|
||||||
|
use App\BusProNet\Model\Notification;
|
||||||
|
use App\BusProNet\Model\Service;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\Entity\BookingEditDraft;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\BookingEditDraftRepository;
|
||||||
|
use App\Security\Crypt;
|
||||||
|
use App\Service\BookingChangeTracker;
|
||||||
|
use App\Service\BookingEditDraftMerger;
|
||||||
|
use App\Service\TravelDataProvider;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repairs a booking edit draft that BusPro keeps rejecting.
|
||||||
|
*
|
||||||
|
* A draft survives every failed submission and is replayed on each re-entry into the edit flow.
|
||||||
|
* When one selection inside it has become unacceptable to BusPro - a Leistung whose status has
|
||||||
|
* drifted to "Anfrage" because its contingent ran out, say - the whole change is refused, nothing
|
||||||
|
* persists, and the customer is stuck in a loop they cannot edit their way out of. Booking 98787
|
||||||
|
* accumulated 37 such failures over two months.
|
||||||
|
*
|
||||||
|
* The valuable half of that draft is the personal data: names, dates of birth, contact details,
|
||||||
|
* addresses, room remarks. Re-entering it by hand for eighty participants is not a reasonable ask.
|
||||||
|
* So this command rebuilds the draft on top of the booking as BusPro currently holds it, keeps
|
||||||
|
* every field the customer typed, and reverts only the service selections that BusPro will not
|
||||||
|
* accept - naming each one, so the office can tell the customer what to pick again.
|
||||||
|
*
|
||||||
|
* Dry run by default. --apply writes a JSON backup of the original form data first.
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:booking:repair-draft',
|
||||||
|
description: 'Rebuild a rejected booking edit draft, keeping typed data and reverting unacceptable service selections',
|
||||||
|
)]
|
||||||
|
class BookingRepairDraftCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Draft `services` keys holding a single service id.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Date of birth the office writes into the empty slots of a template booking.
|
||||||
|
*/
|
||||||
|
private const PLACEHOLDER_DATE_OF_BIRTH = '2000-01-01';
|
||||||
|
|
||||||
|
private const SINGLE_SERVICE_KEYS = ['skiPass', 'veg', 'insurance', 'rentalInsurance'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draft `services` keys holding a list of service ids.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const LIST_SERVICE_KEYS = ['courses', 'board', 'rentals', 'additionalServices'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transport is reverted as a unit: a participant put back on their own arrival must lose the
|
||||||
|
* pickup and drop-off that only make sense on a coach.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const TRANSPORT_KEYS = ['transportationOutbound', 'transportationInbound', 'pickup', 'dropOff'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly BookingEditDraftRepository $draftRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly ApiClient $apiClient,
|
||||||
|
private readonly Crypt $crypt,
|
||||||
|
private readonly TravelDataProvider $travelDataProvider,
|
||||||
|
private readonly BookingDataProcessor $bookingDataProcessor,
|
||||||
|
private readonly BookingEditDraftMerger $draftMerger,
|
||||||
|
private readonly BookingChangeTracker $changeTracker,
|
||||||
|
#[Autowire('%kernel.project_dir%')]
|
||||||
|
private readonly string $projectDir,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('booking', 'b', InputOption::VALUE_REQUIRED, 'idbuchung whose draft should be repaired')
|
||||||
|
->addOption('apply', null, InputOption::VALUE_NONE, 'Write the repaired draft. Without this the command only reports')
|
||||||
|
->addOption('backup-dir', null, InputOption::VALUE_REQUIRED, 'Where to write the backup of the original form data', 'var/draft-backups')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$bookingId = $input->getOption('booking');
|
||||||
|
if (null === $bookingId) {
|
||||||
|
$io->error('--booking is required.');
|
||||||
|
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
$draft = $this->findSingleDraft($io, (int) $bookingId);
|
||||||
|
if (null === $draft) {
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $draft->getUser();
|
||||||
|
$booking = $this->fetchLiveBooking($io, $user, (int) $bookingId);
|
||||||
|
if (null === $booking) {
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$travel = $this->travelDataProvider->getTravelData((int) $booking->dateId, $booking->hotelId, true);
|
||||||
|
if (null === $travel) {
|
||||||
|
$io->error(sprintf('No travel data for date id %d.', (int) $booking->dateId));
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$formData = $draft->getFormData();
|
||||||
|
$draftParticipants = $formData['participants'] ?? [];
|
||||||
|
|
||||||
|
if (false === $this->assertAlignment($io, $booking, $draftParticipants)) {
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$blocked = $this->collectUnbookableServices($io, $travel);
|
||||||
|
|
||||||
|
// The booking as BusPro currently holds it is the only state known to be acceptable, so
|
||||||
|
// it is the base everything is rebuilt on.
|
||||||
|
$repairedDto = $this->bookingDataProcessor->createBookingDtoFromBooking(
|
||||||
|
$booking,
|
||||||
|
$travel,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$liveSelections = $this->changeTracker->extractUserData($repairedDto)['participants'] ?? [];
|
||||||
|
|
||||||
|
$reverted = [];
|
||||||
|
foreach ($draftParticipants as $index => $participantData) {
|
||||||
|
if (false === isset($repairedDto->participants[$index])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filtered = $this->filterServiceSelections(
|
||||||
|
$participantData,
|
||||||
|
$liveSelections[$index]['services'] ?? [],
|
||||||
|
$blocked,
|
||||||
|
$index,
|
||||||
|
$reverted,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->draftMerger->apply($repairedDto, $index, $repairedDto->participants[$index], $filtered, $travel);
|
||||||
|
}
|
||||||
|
|
||||||
|
$repaired = $this->changeTracker->extractUserData($repairedDto);
|
||||||
|
|
||||||
|
$this->report($io, $formData, $repaired, $reverted, $blocked);
|
||||||
|
|
||||||
|
if (false === $input->getOption('apply')) {
|
||||||
|
$io->note('Dry run. Re-run with --apply to write the repaired draft.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupPath = $this->writeBackup($draft, (string) $input->getOption('backup-dir'));
|
||||||
|
$io->success(sprintf('Original form data backed up to %s', $backupPath));
|
||||||
|
|
||||||
|
$draft->setFormData($repaired);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$io->success(sprintf('Draft %d repaired.', (int) $draft->getId()));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findSingleDraft(SymfonyStyle $io, int $bookingId): ?BookingEditDraft
|
||||||
|
{
|
||||||
|
$drafts = $this->draftRepository->findBy(['bookingId' => $bookingId]);
|
||||||
|
|
||||||
|
if ([] === $drafts) {
|
||||||
|
$io->error(sprintf('No draft found for booking %d.', $bookingId));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($drafts) > 1) {
|
||||||
|
$io->error(sprintf('Booking %d has %d drafts; resolve by hand.', $bookingId, count($drafts)));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$draft = $drafts[0];
|
||||||
|
$io->definitionList(
|
||||||
|
['Draft' => sprintf('%d (user %d)', (int) $draft->getId(), (int) $draft->getUser()->getId())],
|
||||||
|
['Created' => $draft->getCreatedAt()->format('Y-m-d H:i')],
|
||||||
|
['Last saved' => $draft->getUpdatedAt()->format('Y-m-d H:i')],
|
||||||
|
['Participants' => (string) count($draft->getFormData()['participants'] ?? [])],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchLiveBooking(SymfonyStyle $io, User $user, int $bookingId): ?Booking
|
||||||
|
{
|
||||||
|
$result = $this->apiClient->getBooking(
|
||||||
|
(string) $user->getEmail(),
|
||||||
|
$this->crypt->decrypt((string) $user->getPassword()),
|
||||||
|
$bookingId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result instanceof Notification) {
|
||||||
|
$io->error(sprintf('Vorgang_Details failed: %d %s', (int) $result->code, (string) $result->message));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses to repair a draft whose positions no longer line up with the live booking.
|
||||||
|
*
|
||||||
|
* Drafts are merged positionally, by array index, with no identity check. Empty template
|
||||||
|
* slots are interchangeable, so they prove nothing; the participants BusPro already knows by
|
||||||
|
* date of birth are the only anchors available. If one of those has moved, every later
|
||||||
|
* position is suspect and rebuilding would quietly graft data onto the wrong people.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $draftParticipants
|
||||||
|
*/
|
||||||
|
private function assertAlignment(SymfonyStyle $io, Booking $booking, array $draftParticipants): bool
|
||||||
|
{
|
||||||
|
$liveCount = count($booking->participants);
|
||||||
|
$draftCount = count($draftParticipants);
|
||||||
|
|
||||||
|
if ($liveCount !== $draftCount) {
|
||||||
|
$io->warning(sprintf(
|
||||||
|
'Live booking has %d participants, the draft has %d. Seats were added or removed since the draft was written.',
|
||||||
|
$liveCount,
|
||||||
|
$draftCount,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$anchors = 0;
|
||||||
|
$mismatches = [];
|
||||||
|
|
||||||
|
foreach (array_values($booking->participants) as $index => $participant) {
|
||||||
|
$liveDob = $participant->dateOfBirth?->format('Y-m-d');
|
||||||
|
if (null === $liveDob || self::PLACEHOLDER_DATE_OF_BIRTH === $liveDob) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
++$anchors;
|
||||||
|
$draftDob = $draftParticipants[$index]['personalData']['dateOfBirth'] ?? null;
|
||||||
|
|
||||||
|
if (null !== $draftDob && $draftDob !== $liveDob) {
|
||||||
|
$mismatches[] = sprintf('position %d: live %s, draft %s', $index + 1, $liveDob, $draftDob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] !== $mismatches) {
|
||||||
|
$io->error('Draft positions no longer match the live booking:');
|
||||||
|
$io->listing($mismatches);
|
||||||
|
$io->comment('Repairing would move typed data onto the wrong participants. Resolve by hand.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->text(sprintf('Alignment verified against %d participant(s) BusPro already knows.', $anchors));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects the services BusPro will not accept as an addition.
|
||||||
|
*
|
||||||
|
* A Leistung only takes new participants while its own status is "Frei"; anything else is the
|
||||||
|
* condition behind "Status der Leistung (A) ist unterschiedlich zum Status des Teilnehmers".
|
||||||
|
* Live availability is preferred over the travel data, which lags behind it.
|
||||||
|
*
|
||||||
|
* @return array<int, string> service id => status
|
||||||
|
*/
|
||||||
|
private function collectUnbookableServices(SymfonyStyle $io, Travel $travel): array
|
||||||
|
{
|
||||||
|
$statuses = [];
|
||||||
|
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
|
||||||
|
if (null !== $service->id && null !== $service->status) {
|
||||||
|
$statuses[$service->id] = $service->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$live = $this->apiClient->getAvailabilitiesExtended((int) $travel->id);
|
||||||
|
if ($live instanceof Notification) {
|
||||||
|
$io->warning(sprintf(
|
||||||
|
'VERFUEGBARKEIT2 failed (%d %s); falling back to travel data, which may be stale.',
|
||||||
|
(int) $live->code,
|
||||||
|
(string) $live->message,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
foreach ($live->getServices() as $availability) {
|
||||||
|
if (null !== $availability->serviceId && null !== $availability->status && '' !== trim($availability->status)) {
|
||||||
|
$statuses[$availability->serviceId] = $availability->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$blocked = array_filter($statuses, static fn (string $status): bool => Constants::STATUS_AVAILABLE !== $status);
|
||||||
|
|
||||||
|
if ([] === $blocked) {
|
||||||
|
$io->text('Every service on this travel is currently "Frei".');
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($blocked as $serviceId => $status) {
|
||||||
|
$service = $travel->additionalServices[$serviceId] ?? $travel->transportationServices[$serviceId] ?? null;
|
||||||
|
$label = $service instanceof Service ? (string) $service->label : '?';
|
||||||
|
$rows[] = [$serviceId, $label, $status];
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->section('Services that cannot take new participants');
|
||||||
|
$io->table(['id', 'Leistung', 'status'], $rows);
|
||||||
|
|
||||||
|
return $blocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the draft's selections that would add a participant to a service BusPro has closed.
|
||||||
|
*
|
||||||
|
* Removing a key leaves the live booking's own value in place, because the merger applies
|
||||||
|
* only the keys it is given. Selections BusPro still accepts - a different ski pass, a meal
|
||||||
|
* preference - are kept, so the customer loses as little as possible.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $participantData
|
||||||
|
* @param array<string, mixed> $liveServices
|
||||||
|
* @param array<int, string> $blocked
|
||||||
|
* @param list<string> $reverted
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function filterServiceSelections(
|
||||||
|
array $participantData,
|
||||||
|
array $liveServices,
|
||||||
|
array $blocked,
|
||||||
|
int $index,
|
||||||
|
array &$reverted,
|
||||||
|
): array {
|
||||||
|
if (false === isset($participantData['services']) || [] === $blocked) {
|
||||||
|
return $participantData;
|
||||||
|
}
|
||||||
|
|
||||||
|
$services = $participantData['services'];
|
||||||
|
$position = $index + 1;
|
||||||
|
|
||||||
|
foreach (self::SINGLE_SERVICE_KEYS as $key) {
|
||||||
|
$selected = $services[$key] ?? null;
|
||||||
|
if (null !== $selected && isset($blocked[$selected]) && ($liveServices[$key] ?? null) !== $selected) {
|
||||||
|
unset($services[$key]);
|
||||||
|
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $selected, $blocked[$selected]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::LIST_SERVICE_KEYS as $key) {
|
||||||
|
$selected = $services[$key] ?? null;
|
||||||
|
if (false === is_array($selected)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$liveList = $liveServices[$key] ?? [];
|
||||||
|
$kept = [];
|
||||||
|
foreach ($selected as $serviceId) {
|
||||||
|
if (isset($blocked[$serviceId]) && false === in_array($serviceId, $liveList, true)) {
|
||||||
|
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $serviceId, $blocked[$serviceId]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$kept[] = $serviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$services[$key] = $kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['transportationOutbound', 'transportationInbound'] as $key) {
|
||||||
|
$selected = $services[$key] ?? null;
|
||||||
|
if (null === $selected || false === isset($blocked[$selected]) || ($liveServices[$key] ?? null) === $selected) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $selected, $blocked[$selected]);
|
||||||
|
|
||||||
|
// Transport reverts as a unit, pickup and drop-off included.
|
||||||
|
foreach (self::TRANSPORT_KEYS as $transportKey) {
|
||||||
|
unset($services[$transportKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$participantData['services'] = $services;
|
||||||
|
|
||||||
|
return $participantData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $original
|
||||||
|
* @param array<string, mixed> $repaired
|
||||||
|
* @param list<string> $reverted
|
||||||
|
* @param array<int, string> $blocked
|
||||||
|
*/
|
||||||
|
private function report(SymfonyStyle $io, array $original, array $repaired, array $reverted, array $blocked): void
|
||||||
|
{
|
||||||
|
$io->section('Typed data preserved');
|
||||||
|
|
||||||
|
$fields = ['firstName', 'lastName', 'dateOfBirth', 'email', 'mobile'];
|
||||||
|
$kept = 0;
|
||||||
|
$lost = [];
|
||||||
|
|
||||||
|
foreach ($original['participants'] ?? [] as $index => $participantData) {
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
$before = $participantData['personalData'][$field] ?? null;
|
||||||
|
if (null === $before || '' === $before) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$after = $repaired['participants'][$index]['personalData'][$field] ?? null;
|
||||||
|
if ($before === $after) {
|
||||||
|
++$kept;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lost[] = sprintf('participant %d: %s', $index + 1, $field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->text(sprintf('<info>%d</info> personal-data field(s) carried over.', $kept));
|
||||||
|
|
||||||
|
if ([] !== $lost) {
|
||||||
|
$io->warning(sprintf('%d field(s) could NOT be carried over:', count($lost)));
|
||||||
|
$io->listing(array_slice($lost, 0, 25));
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->section('Service selections reverted');
|
||||||
|
|
||||||
|
if ([] === $reverted) {
|
||||||
|
$io->text([] === $blocked
|
||||||
|
? 'None - no service on this travel is closed.'
|
||||||
|
: 'None - the draft selects no closed service.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->warning(sprintf('%d selection(s) reverted to the booked state. The customer must choose again:', count($reverted)));
|
||||||
|
$io->listing($reverted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeBackup(BookingEditDraft $draft, string $backupDir): string
|
||||||
|
{
|
||||||
|
$directory = $this->projectDir.'/'.trim($backupDir, '/');
|
||||||
|
if (false === is_dir($directory)) {
|
||||||
|
mkdir($directory, 0o775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = sprintf(
|
||||||
|
'%s/draft-%d-booking-%d-%s.json',
|
||||||
|
$directory,
|
||||||
|
(int) $draft->getId(),
|
||||||
|
$draft->getBookingId(),
|
||||||
|
(new \DateTimeImmutable())->format('Ymd-His'),
|
||||||
|
);
|
||||||
|
|
||||||
|
file_put_contents($path, json_encode($draft->getFormData(), \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,12 +114,13 @@ class BpnSyncContingentsCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
$io->writeln(sprintf(
|
$io->writeln(sprintf(
|
||||||
'<info>%s</info>: %s (+%d ~%d -%d)',
|
'<info>%s</info>: %s (+%d ~%d -%d, %d beyond the previous horizon)',
|
||||||
(string) $accommodation->getCalendarCode(),
|
(string) $accommodation->getCalendarCode(),
|
||||||
$result->changed ? 'changed' : 'unchanged',
|
$result->changed ? 'changed' : 'unchanged',
|
||||||
$result->added,
|
$result->added,
|
||||||
$result->updated,
|
$result->updated,
|
||||||
$result->removed,
|
$result->removed,
|
||||||
|
$result->extended,
|
||||||
), OutputInterface::VERBOSITY_VERBOSE);
|
), OutputInterface::VERBOSITY_VERBOSE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,11 +50,12 @@ final class DbAnonymizeCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
$io->success(sprintf(
|
$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['users'],
|
||||||
$report['newsletterConsents'],
|
$report['newsletterConsents'],
|
||||||
$report['newsletterOptInRequests'],
|
$report['newsletterOptInRequests'],
|
||||||
$report['bookingEditDrafts'],
|
$report['bookingEditDrafts'],
|
||||||
|
$report['accommodationBookings'],
|
||||||
));
|
));
|
||||||
|
|
||||||
return Command::SUCCESS;
|
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 array<int, Accommodation> $accommodationByUid
|
||||||
* @param list<int> $knownUids
|
* @param list<int> $knownUids
|
||||||
* @param list<array{season: Season, dateFrom: \DateTimeImmutable, dateTo: \DateTimeImmutable}> $seasonRanges
|
* @param list<array{season: Season, dateFrom: \DateTimeImmutable, dateTo: \DateTimeImmutable}> $seasonRanges
|
||||||
*
|
*
|
||||||
* @return array{0: int, 1: list<int>, 2: int}
|
* @return array{0: int, 1: list<int>, 2: int}
|
||||||
*/
|
*/
|
||||||
@@ -526,11 +526,11 @@ class ImportGroupsLegacyDataCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, Accommodation> $accommodationByUid
|
* @param array<int, Accommodation> $accommodationByUid
|
||||||
* @param list<AdditionalService> $additionalServicesNeedingDefaultRange only these
|
* @param list<AdditionalService> $additionalServicesNeedingDefaultRange only these
|
||||||
* (titles without a leading year) get the accommodation-
|
* (titles without a leading year) get the accommodation-
|
||||||
* wide fallback range; year-prefixed ones already have
|
* wide fallback range; year-prefixed ones already have
|
||||||
* their scoped range set in importAdditionalServices().
|
* their scoped range set in importAdditionalServices()
|
||||||
*/
|
*/
|
||||||
private function applyDateRangeToServices(array $accommodationByUid, array $additionalServicesNeedingDefaultRange): void
|
private function applyDateRangeToServices(array $accommodationByUid, array $additionalServicesNeedingDefaultRange): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use App\BusProNet\ApiClient;
|
|||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
use App\BusProNet\Model\PersonalData;
|
use App\BusProNet\Model\PersonalData;
|
||||||
use App\BusProNet\Model\PersonalDataUpdateResponse;
|
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Exception\NewsletterProviderException;
|
use App\Exception\NewsletterProviderException;
|
||||||
use App\Form\PersonalDataType;
|
use App\Form\PersonalDataType;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class CalendarController extends AbstractController
|
|||||||
{
|
{
|
||||||
return $this->render('admin/accommodation/_calendar_section.html.twig', [
|
return $this->render('admin/accommodation/_calendar_section.html.twig', [
|
||||||
'accommodation' => $accommodation,
|
'accommodation' => $accommodation,
|
||||||
'startMonth' => $request->query->get('month'),
|
'startMonth' => $request->query->get('month'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,11 +6,13 @@ namespace App\Controller\Admin\AccommodationBooking;
|
|||||||
|
|
||||||
use App\Entity\Groups\Accommodation;
|
use App\Entity\Groups\Accommodation;
|
||||||
use App\Entity\Groups\AccommodationBooking;
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Exception\ImplausibleBookingPriceException;
|
||||||
use App\Form\Admin\Groups\AccommodationBookingChangeAccommodationType;
|
use App\Form\Admin\Groups\AccommodationBookingChangeAccommodationType;
|
||||||
use App\Htmx\HxTrait;
|
use App\Htmx\HxTrait;
|
||||||
use App\Service\AccommodationBookingService;
|
use App\Service\AccommodationBookingService;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\Form\FormError;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -49,7 +51,24 @@ class ChangeAccommodationController extends AbstractController
|
|||||||
$selected = $form->get('accommodation')->getData();
|
$selected = $form->get('accommodation')->getData();
|
||||||
|
|
||||||
if ($selected instanceof Accommodation && $selected !== $booking->getAccommodation()) {
|
if ($selected instanceof Accommodation && $selected !== $booking->getAccommodation()) {
|
||||||
$this->bookingService->changeAccommodation($booking, $selected);
|
try {
|
||||||
|
$this->bookingService->changeAccommodation($booking, $selected);
|
||||||
|
} catch (ImplausibleBookingPriceException $e) {
|
||||||
|
// The house that was picked prices this stay implausibly high, so the change
|
||||||
|
// did not happen. Reported in the modal the office is standing in front of —
|
||||||
|
// the same treatment the create and edit forms give it.
|
||||||
|
$this->logger->error('Refused to change accommodation over an implausible booking total', [
|
||||||
|
'exception' => $e,
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
'accommodation' => $selected->getCalendarCode(),
|
||||||
|
]);
|
||||||
|
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
|
||||||
|
|
||||||
|
return $this->render('admin/accommodation_booking/modal_change_accommodation.html.twig', [
|
||||||
|
'booking' => $booking,
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$this->addFlash('success', 'Das Gruppenhaus wurde geändert. Bitte wähle die Leistungen neu aus.');
|
$this->addFlash('success', 'Das Gruppenhaus wurde geändert. Bitte wähle die Leistungen neu aus.');
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ namespace App\Controller\Admin\AccommodationBooking;
|
|||||||
|
|
||||||
use App\Entity\Groups\AccommodationBooking;
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Exception\ImplausibleBookingPriceException;
|
||||||
use App\Form\Admin\Groups\AccommodationBookingCreateType;
|
use App\Form\Admin\Groups\AccommodationBookingCreateType;
|
||||||
use App\Htmx\HxRedirectResponse;
|
use App\Htmx\HxRedirectResponse;
|
||||||
use App\Service\AccommodationBookingService;
|
use App\Service\AccommodationBookingService;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\Form\FormError;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -45,8 +48,7 @@ class CreateController extends AbstractController
|
|||||||
]);
|
]);
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid() && $this->refreshPriceSnapshot($form, $booking)) {
|
||||||
$this->bookingService->refreshPriceSnapshot($booking);
|
|
||||||
$this->entityManager->persist($booking);
|
$this->entityManager->persist($booking);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
@@ -65,4 +67,31 @@ class CreateController extends AbstractController
|
|||||||
'form' => $form,
|
'form' => $form,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The price is computed after validation, so an implausible total cannot be a constraint on
|
||||||
|
* the form — it is reported as one anyway, because the office's next move is to go and fix
|
||||||
|
* the house's prices, not to read an error page.
|
||||||
|
*
|
||||||
|
* @param FormInterface<AccommodationBooking> $form
|
||||||
|
*/
|
||||||
|
private function refreshPriceSnapshot(FormInterface $form, AccommodationBooking $booking): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->bookingService->refreshPriceSnapshot($booking);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (ImplausibleBookingPriceException $e) {
|
||||||
|
$this->logger->error('Refused to store an implausible accommodation booking total', [
|
||||||
|
'exception' => $e,
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
'accommodation' => $booking->getAccommodation()?->getCalendarCode(),
|
||||||
|
'paxCount' => $booking->getPaxCount(),
|
||||||
|
'nights' => $booking->getNights(),
|
||||||
|
]);
|
||||||
|
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\AccommodationBooking;
|
||||||
|
|
||||||
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Htmx\HxRedirectResponse;
|
||||||
|
use App\Service\AccommodationBookingService;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copying is its own named action rather than a flag on the edit route the way it is for the
|
||||||
|
* catalog entities (see Admin\AdditionalService\EditController): a booking carries a lifecycle,
|
||||||
|
* so the copy has to be reset and written before anybody edits it, and the edit form is built
|
||||||
|
* from a persisted record's house and service catalog.
|
||||||
|
*
|
||||||
|
* Unlike absagen and löschen this has no status guard — the original is not touched, so a
|
||||||
|
* confirmed or discarded booking may be copied just as well as a draft.
|
||||||
|
*/
|
||||||
|
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||||
|
class DuplicateController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AccommodationBookingService $bookingService,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/accommodation-booking/{id}/duplicate', name: 'app_admin_accommodationbooking_duplicate')]
|
||||||
|
public function index(AccommodationBooking $booking, Request $request): Response
|
||||||
|
{
|
||||||
|
if ($request->isMethod(Request::METHOD_POST)) {
|
||||||
|
if (!$this->isCsrfTokenValid('duplicate_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$copy = $this->bookingService->duplicate($booking);
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Die Kopie wurde als neuer Entwurf angelegt.');
|
||||||
|
|
||||||
|
$this->logger->info('Duplicated accommodation booking', [
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
'copyId' => $copy->getId(),
|
||||||
|
'groupName' => $copy->getGroupName(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Straight into the copy's edit form — the copy exists to be changed, and that is
|
||||||
|
// the only reason it was made. The return url is forwarded still encoded, the way
|
||||||
|
// return_url() handed it over, so "zurück" still leads to the list this started from.
|
||||||
|
$parameters = ['id' => $copy->getId()];
|
||||||
|
$returnUrl = $request->query->getString('r');
|
||||||
|
if ('' !== $returnUrl) {
|
||||||
|
$parameters['r'] = $returnUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_edit', $parameters));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('admin/accommodation_booking/modal_duplicate.html.twig', [
|
||||||
|
'booking' => $booking,
|
||||||
|
'csrf_token_id' => 'duplicate_accommodation_booking_'.$booking->getId(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace App\Controller\Admin\AccommodationBooking;
|
|||||||
use App\Entity\Groups\AccommodationBooking;
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
use App\Entity\Groups\AdditionalService;
|
use App\Entity\Groups\AdditionalService;
|
||||||
use App\Entity\Groups\BoardService;
|
use App\Entity\Groups\BoardService;
|
||||||
|
use App\Exception\ImplausibleBookingPriceException;
|
||||||
use App\Form\Admin\Groups\AccommodationBookingType;
|
use App\Form\Admin\Groups\AccommodationBookingType;
|
||||||
use App\Repository\Groups\AdditionalServiceRepository;
|
use App\Repository\Groups\AdditionalServiceRepository;
|
||||||
use App\Repository\Groups\BoardServiceRepository;
|
use App\Repository\Groups\BoardServiceRepository;
|
||||||
@@ -15,6 +16,8 @@ use App\Service\AccommodationBookingService;
|
|||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\Form\FormError;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -91,6 +94,7 @@ class EditController extends AbstractController
|
|||||||
'current_board_service' => $currentBoardService,
|
'current_board_service' => $currentBoardService,
|
||||||
'current_additional_services' => $currentAdditionalServices,
|
'current_additional_services' => $currentAdditionalServices,
|
||||||
'assignable_managers' => $assignableManagers,
|
'assignable_managers' => $assignableManagers,
|
||||||
|
'currency' => $booking->getPricingCurrency() ?? $accommodation?->getCurrency() ?? 'EUR',
|
||||||
]);
|
]);
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
@@ -120,7 +124,15 @@ class EditController extends AbstractController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->bookingService->refreshPriceSnapshot($booking);
|
if (!$this->refreshPriceSnapshot($form, $booking)) {
|
||||||
|
// The submitted changes stay on the entity but are never flushed, so the form
|
||||||
|
// comes back filled in with the error on it. Logging is safe here: the database
|
||||||
|
// handler writes through DBAL, not the EntityManager.
|
||||||
|
return $this->render('admin/accommodation_booking/edit.html.twig', [
|
||||||
|
'booking' => $booking,
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// Editing never moves the booking on and never mails the customer: every status
|
// Editing never moves the booking on and never mails the customer: every status
|
||||||
// change is its own named action (Angebot senden, bestätigen, absagen), so the
|
// change is its own named action (Angebot senden, bestätigen, absagen), so the
|
||||||
@@ -153,4 +165,31 @@ class EditController extends AbstractController
|
|||||||
'form' => $form,
|
'form' => $form,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The price is computed after validation, so an implausible total cannot be a constraint on
|
||||||
|
* the form — it is reported as one anyway, because the office's next move is to go and fix
|
||||||
|
* the house's prices, not to read an error page.
|
||||||
|
*
|
||||||
|
* @param FormInterface<AccommodationBooking> $form
|
||||||
|
*/
|
||||||
|
private function refreshPriceSnapshot(FormInterface $form, AccommodationBooking $booking): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->bookingService->refreshPriceSnapshot($booking);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (ImplausibleBookingPriceException $e) {
|
||||||
|
$this->logger->error('Refused to store an implausible accommodation booking total', [
|
||||||
|
'exception' => $e,
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
'accommodation' => $booking->getAccommodation()?->getCalendarCode(),
|
||||||
|
'paxCount' => $booking->getPaxCount(),
|
||||||
|
'nights' => $booking->getNights(),
|
||||||
|
]);
|
||||||
|
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ class CreateController extends AbstractController
|
|||||||
'date_range' => [
|
'date_range' => [
|
||||||
'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'),
|
'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'),
|
||||||
'to' => $accommodationPrice->getDateTo()->format('Y-m-d'),
|
'to' => $accommodationPrice->getDateTo()->format('Y-m-d'),
|
||||||
]
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
|
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class EditController extends AbstractController
|
|||||||
'date_range' => [
|
'date_range' => [
|
||||||
'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'),
|
'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'),
|
||||||
'to' => $accommodationPrice->getDateTo()->format('Y-m-d'),
|
'to' => $accommodationPrice->getDateTo()->format('Y-m-d'),
|
||||||
]
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
|
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
|
||||||
|
|||||||
@@ -4,14 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controller\Admin\AdditionalService;
|
namespace App\Controller\Admin\AdditionalService;
|
||||||
|
|
||||||
use App\Entity\Groups\Accommodation;
|
|
||||||
use App\Entity\Groups\AdditionalService;
|
use App\Entity\Groups\AdditionalService;
|
||||||
use App\Form\Admin\Groups\AdditionalServiceType;
|
use App\Form\Admin\Groups\AdditionalServiceType;
|
||||||
use App\Htmx\HxRedirectResponse;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\Form\FormInterface;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class DeleteController extends AbstractController
|
class DeleteController extends AbstractController
|
||||||
{
|
{
|
||||||
use ReturnUrlTrait;
|
use ReturnUrlTrait;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class ExportController extends AbstractController
|
class ExportController extends AbstractController
|
||||||
{
|
{
|
||||||
use ReturnUrlTrait;
|
use ReturnUrlTrait;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class FilterController extends AbstractController
|
class FilterController extends AbstractController
|
||||||
{
|
{
|
||||||
use ListFilterTrait;
|
use ListFilterTrait;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class IndexController extends AbstractController
|
class IndexController extends AbstractController
|
||||||
{
|
{
|
||||||
use ListFilterTrait;
|
use ListFilterTrait;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class ShowController extends AbstractController
|
class ShowController extends AbstractController
|
||||||
{
|
{
|
||||||
use ReturnUrlTrait;
|
use ReturnUrlTrait;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class DownloadController extends AbstractController
|
class DownloadController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
|
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class FilterController extends AbstractController
|
class FilterController extends AbstractController
|
||||||
{
|
{
|
||||||
use ListFilterTrait;
|
use ListFilterTrait;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class IndexController extends AbstractController
|
class IndexController extends AbstractController
|
||||||
{
|
{
|
||||||
use ListFilterTrait;
|
use ListFilterTrait;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
|
||||||
class XmlDumpController extends AbstractController
|
class XmlDumpController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
|
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
|
use App\Model\TravelExportQuery;
|
||||||
|
use App\Service\TravelExportBuilder;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk exports, for consumers that mirror our data rather than query it per row.
|
||||||
|
*
|
||||||
|
* Deliberately its own resource rather than a verb hung off the collection it exports.
|
||||||
|
* `/api/travels/{dateCode}` accepts an arbitrary string, so an `/api/travels/export` would
|
||||||
|
* share a namespace with every date code and could only be kept apart from one by a
|
||||||
|
* negative lookahead on the sibling route -- a constraint nothing in the URL announces and
|
||||||
|
* which silently mis-routes when it is wrong. Under `/api/exports` the collision does not
|
||||||
|
* exist: this prefix never takes a parameterised segment, exactly like `/api/contingents`.
|
||||||
|
*/
|
||||||
|
#[Route('/api/exports')]
|
||||||
|
#[IsGranted('ROLE_OAUTH2_API')]
|
||||||
|
class ExportController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TravelExportBuilder $travelExportBuilder,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every travel date x hotel pair with its pickups resolved, paginated.
|
||||||
|
*
|
||||||
|
* The counters in meta describe the whole export, not the page, so they repeat
|
||||||
|
* unchanged on every page and a consumer can abort after the first one.
|
||||||
|
*/
|
||||||
|
#[Route(path: '/travels', name: 'api_exports_travels', methods: ['GET'])]
|
||||||
|
public function travels(
|
||||||
|
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
||||||
|
TravelExportQuery $query = new TravelExportQuery(),
|
||||||
|
): JsonResponse {
|
||||||
|
// Deliberately not wrapped in a catch that answers 200 with an error body, the way
|
||||||
|
// TravelController::hotelAvailability() does: the consumer's retry logic keys on
|
||||||
|
// status codes, so a failure here has to surface as a 5xx.
|
||||||
|
$export = $this->travelExportBuilder->build();
|
||||||
|
|
||||||
|
$total = count($export->rows);
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'data' => array_slice($export->rows, ($query->page - 1) * $query->perPage, $query->perPage),
|
||||||
|
'meta' => [
|
||||||
|
'page' => $query->page,
|
||||||
|
'perPage' => $query->perPage,
|
||||||
|
'total' => $total,
|
||||||
|
'totalPages' => (int) ceil($total / $query->perPage),
|
||||||
|
'skipped' => $export->skipped,
|
||||||
|
'pickupsWithoutMasterData' => $export->pickupsWithoutMasterData,
|
||||||
|
'generatedAt' => $export->generatedAt->format('c'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,8 +26,9 @@ class UserinfoController extends AbstractController
|
|||||||
#[Route('/userinfo', name: 'api_userinfo', methods: ['GET'])]
|
#[Route('/userinfo', name: 'api_userinfo', methods: ['GET'])]
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
// basic scopes applicable to all authenticated users
|
// basic scopes applicable to all authenticated users. `sub` is not gated on a scope of its
|
||||||
$scopes = ['email'];
|
// own: it identifies the account every other claim describes, so it is always exported.
|
||||||
|
$scopes = ['sub', 'email'];
|
||||||
|
|
||||||
// extend scopes depending on granted permissions
|
// extend scopes depending on granted permissions
|
||||||
if ($this->isGranted('ROLE_OAUTH2_ID')) {
|
if ($this->isGranted('ROLE_OAUTH2_ID')) {
|
||||||
@@ -53,6 +54,17 @@ class UserinfoController extends AbstractController
|
|||||||
return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST);
|
return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Patch the identity of the account that authenticated. BusPro accepts any of the
|
||||||
|
// addresses on a person's record as a login and answers all of them with the same
|
||||||
|
// ids and the same first contact address, so only the local account tells the staff
|
||||||
|
// account and the private one apart — and they hold different roles.
|
||||||
|
//
|
||||||
|
// The uuid, not the primary key: consumers store this value and match their own
|
||||||
|
// accounts on it, so it must not disclose how many accounts exist or let one
|
||||||
|
// account's identifier be guessed from another's.
|
||||||
|
$data->subject = $user->getUuid();
|
||||||
|
$data->loginEmail = $user->getEmail();
|
||||||
|
|
||||||
// Patch current user's roles. The implicit ROLE_USER says nothing about the
|
// Patch current user's roles. The implicit ROLE_USER says nothing about the
|
||||||
// account — every authenticated user holds it — and is not exported.
|
// account — every authenticated user holds it — and is not exported.
|
||||||
$data->roles = Role::effectiveOnly($user->getRoles());
|
$data->roles = Role::effectiveOnly($user->getRoles());
|
||||||
|
|||||||
@@ -13,12 +13,16 @@ use App\Form\BookingCreateStep2Type;
|
|||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Form\Model\RoomSelectionDto;
|
use App\Form\Model\RoomSelectionDto;
|
||||||
|
use App\Model\RoomSelectionAdjustment;
|
||||||
use App\Service\BookingConfigurator;
|
use App\Service\BookingConfigurator;
|
||||||
use App\Service\BookingCreateContextFactory;
|
use App\Service\BookingCreateContextFactory;
|
||||||
use App\Service\BookingSessionManager;
|
use App\Service\BookingSessionManager;
|
||||||
use App\Service\ParticipantDataPrefiller;
|
use App\Service\ParticipantDataPrefiller;
|
||||||
use App\Service\RoomAssigner;
|
use App\Service\RoomAssigner;
|
||||||
use App\Service\TravelDataProvider;
|
use App\Service\TravelDataProvider;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -38,6 +42,8 @@ class Step2Controller extends AbstractBookingCreateController
|
|||||||
private readonly TravelDataProvider $travelDataService,
|
private readonly TravelDataProvider $travelDataService,
|
||||||
private readonly RoomAssigner $roomAssignmentService,
|
private readonly RoomAssigner $roomAssignmentService,
|
||||||
private readonly ParticipantDataPrefiller $prepopulationService,
|
private readonly ParticipantDataPrefiller $prepopulationService,
|
||||||
|
#[Autowire(service: 'monolog.logger.bpn')]
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +67,17 @@ class Step2Controller extends AbstractBookingCreateController
|
|||||||
// Enrich with fresh availability data
|
// Enrich with fresh availability data
|
||||||
$this->travelDataService->enrichWithFreshAvailabilities($bookingCreateDto->travel);
|
$this->travelDataService->enrichWithFreshAvailabilities($bookingCreateDto->travel);
|
||||||
|
|
||||||
|
// Resync the room selections with the refreshed travel before any participant count is derived
|
||||||
|
// from them, and send the customer back to step 1 when a room they picked is gone.
|
||||||
|
if (null !== $redirect = $this->guardAgainstUnavailableRoomSelection($request, $bookingCreateDto)) {
|
||||||
|
return $redirect;
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure correct number of participants first, then prepopulate the applicant if needed.
|
// Ensure correct number of participants first, then prepopulate the applicant if needed.
|
||||||
|
if (null !== $redirect = $this->guardAgainstEmptyParticipantList($bookingCreateDto)) {
|
||||||
|
return $redirect;
|
||||||
|
}
|
||||||
|
|
||||||
$this->ensureCorrectNumberOfParticipants($bookingCreateDto);
|
$this->ensureCorrectNumberOfParticipants($bookingCreateDto);
|
||||||
|
|
||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
@@ -111,6 +127,122 @@ class Step2Controller extends AbstractBookingCreateController
|
|||||||
return $this->render('booking/create/step_2.html.twig', $templateData);
|
return $this->render('booking/create/step_2.html.twig', $templateData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the customer back to step 1 when the refreshed travel data no longer covers their rooms.
|
||||||
|
*
|
||||||
|
* Deriving the participant count from a room that has disappeared would silently fall back to a
|
||||||
|
* capacity of one and render too few participants, and a quantity the contingent no longer covers
|
||||||
|
* reaches the BusPro submit unchecked, because no later step re-reads room availability. The
|
||||||
|
* selections are resynced and saved before every exit, so step 1 and the neighbouring guard both
|
||||||
|
* see a collection that matches the rooms the template renders: leaving a stale entry behind would
|
||||||
|
* fail step 1's own validation on a field it never shows.
|
||||||
|
*/
|
||||||
|
private function guardAgainstUnavailableRoomSelection(Request $request, BookingDto $bookingCreateDto): ?RedirectResponse
|
||||||
|
{
|
||||||
|
$adjustments = $this->bookingService->reconcileRoomSelections($bookingCreateDto);
|
||||||
|
|
||||||
|
$this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||||
|
|
||||||
|
if ([] === $bookingCreateDto->travel->getAvailableRooms()) {
|
||||||
|
$this->handleApiError(
|
||||||
|
$this->logger,
|
||||||
|
'Travel has no bookable rooms left while a booking is in progress.',
|
||||||
|
$this->createRoomContext($bookingCreateDto, $adjustments),
|
||||||
|
'Für diese Reise sind leider keine Zimmer mehr verfügbar.'
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_booking_create_error');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] === $adjustments) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasDroppedRoom = false;
|
||||||
|
foreach ($adjustments as $adjustment) {
|
||||||
|
$hasDroppedRoom = $hasDroppedRoom || $adjustment->isDropped();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->handleApiError(
|
||||||
|
$this->logger,
|
||||||
|
'Room selection exceeds what the current travel data still offers.',
|
||||||
|
$this->createRoomContext($bookingCreateDto, $adjustments),
|
||||||
|
true === $hasDroppedRoom
|
||||||
|
? 'Dein gewähltes Zimmer ist leider nicht mehr verfügbar. Bitte wähle dein Zimmer erneut.'
|
||||||
|
: 'Für dein gewähltes Zimmer sind nicht mehr genügend Plätze verfügbar. Wir haben deine Auswahl angepasst, bitte überprüfe sie.'
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_booking_create_step_1');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<RoomSelectionAdjustment> $adjustments
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function createRoomContext(BookingDto $bookingCreateDto, array $adjustments): array
|
||||||
|
{
|
||||||
|
$rooms = [];
|
||||||
|
foreach ($adjustments as $adjustment) {
|
||||||
|
$rooms[] = [
|
||||||
|
'roomId' => $adjustment->roomId,
|
||||||
|
'label' => $adjustment->label,
|
||||||
|
'requestedQuantity' => $adjustment->requestedQuantity,
|
||||||
|
'bookableQuantity' => $adjustment->bookableQuantity,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'travelId' => $bookingCreateDto->travel->id,
|
||||||
|
'travelCode' => $bookingCreateDto->travel->code,
|
||||||
|
'hotelId' => $bookingCreateDto->hotelId,
|
||||||
|
'rooms' => $rooms,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects a room selection that resolves to no participants at all.
|
||||||
|
*
|
||||||
|
* A selected room with a quantity always yields at least one participant, so a zero count means the
|
||||||
|
* travel data carries no room occupancy. Rendering step 2 anyway would show an empty participant list
|
||||||
|
* that still validates and lets the booking continue to step 3.
|
||||||
|
*/
|
||||||
|
private function guardAgainstEmptyParticipantList(BookingDto $bookingCreateDto): ?RedirectResponse
|
||||||
|
{
|
||||||
|
$selectedRooms = $bookingCreateDto->getSelectedRooms();
|
||||||
|
|
||||||
|
if ([] === $selectedRooms) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (0 < $this->calculateParticipantsCount($bookingCreateDto->roomSelections, $bookingCreateDto->travel)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rooms = [];
|
||||||
|
foreach ($selectedRooms as $roomSelection) {
|
||||||
|
$rooms[] = [
|
||||||
|
'roomId' => $roomSelection->id,
|
||||||
|
'quantity' => $roomSelection->quantity,
|
||||||
|
'capacity' => $roomSelection->capacity,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->handleApiError(
|
||||||
|
$this->logger,
|
||||||
|
'Room selection resolves to zero participants; travel data carries no room occupancy.',
|
||||||
|
[
|
||||||
|
'travelId' => $bookingCreateDto->travel->id,
|
||||||
|
'travelCode' => $bookingCreateDto->travel->code,
|
||||||
|
'hotelId' => $bookingCreateDto->hotelId,
|
||||||
|
'rooms' => $rooms,
|
||||||
|
],
|
||||||
|
'Die Zimmerdaten dieser Reise sind unvollständig. Bitte wähle dein Zimmer erneut oder kontaktiere uns.'
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_booking_create_step_1');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensures the booking DTO has the expected number of participant objects based on room selections.
|
* Ensures the booking DTO has the expected number of participant objects based on room selections.
|
||||||
*/
|
*/
|
||||||
@@ -138,8 +270,15 @@ class Step2Controller extends AbstractBookingCreateController
|
|||||||
$rooms = $travelData->getAvailableRooms();
|
$rooms = $travelData->getAvailableRooms();
|
||||||
|
|
||||||
foreach ($roomSelections as $roomSelection) {
|
foreach ($roomSelections as $roomSelection) {
|
||||||
$room = $rooms[$roomSelection->id];
|
$room = $rooms[$roomSelection->id] ?? null;
|
||||||
$participantsCount += $room->minPax * $roomSelection->quantity;
|
|
||||||
|
if (null === $room) {
|
||||||
|
// Unreachable from index(), which reconciles the selections first. Kept so a future
|
||||||
|
// caller cannot silently fall back to a capacity of one and book too few people.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$participantsCount += ($room->minPax ?? 1) * $roomSelection->quantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $participantsCount;
|
return $participantsCount;
|
||||||
|
|||||||
@@ -10,16 +10,14 @@ use App\BusProNet\Model\Notification;
|
|||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Exception\BookingSessionNotFoundException;
|
use App\Exception\BookingSessionNotFoundException;
|
||||||
use App\Exception\HotelNotInTravelException;
|
use App\Exception\HotelNotInTravelException;
|
||||||
use App\Exception\NewsletterProviderException;
|
|
||||||
use App\Exception\TravelNotFoundException;
|
use App\Exception\TravelNotFoundException;
|
||||||
use App\Form\BookingCreateStep4Type;
|
use App\Form\BookingCreateStep4Type;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
|
||||||
use App\Htmx\HxTrait;
|
use App\Htmx\HxTrait;
|
||||||
use App\Service\BookingConfigurator;
|
use App\Service\BookingConfigurator;
|
||||||
use App\Service\BookingCreateContextFactory;
|
use App\Service\BookingCreateContextFactory;
|
||||||
|
use App\Service\BookingNewsletterOptIn;
|
||||||
use App\Service\BookingSessionManager;
|
use App\Service\BookingSessionManager;
|
||||||
use App\Service\NewsletterManager;
|
|
||||||
use App\Service\ParticipantFormSupport;
|
use App\Service\ParticipantFormSupport;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\Form\FormInterface;
|
use Symfony\Component\Form\FormInterface;
|
||||||
@@ -41,7 +39,7 @@ class Step4Controller extends AbstractBookingCreateController
|
|||||||
private readonly BookingCreateContextFactory $createContextFactory,
|
private readonly BookingCreateContextFactory $createContextFactory,
|
||||||
private readonly ApiClient $apiClient,
|
private readonly ApiClient $apiClient,
|
||||||
private readonly CacheInterface $cache,
|
private readonly CacheInterface $cache,
|
||||||
private readonly NewsletterManager $doubleOptInService,
|
private readonly BookingNewsletterOptIn $bookingNewsletterOptIn,
|
||||||
private readonly ParticipantFormSupport $participantFormSupportService,
|
private readonly ParticipantFormSupport $participantFormSupportService,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
@@ -64,11 +62,15 @@ class Step4Controller extends AbstractBookingCreateController
|
|||||||
return $redirect;
|
return $redirect;
|
||||||
}
|
}
|
||||||
|
|
||||||
$newsletterTargetEmail = $this->resolveNewsletterTargetEmail($bookingCreateDto);
|
$user = $this->getUser();
|
||||||
|
$newsletterTargetEmail = $this->bookingNewsletterOptIn->resolveApplicantEmail($bookingCreateDto, $user instanceof User ? $user : null);
|
||||||
$newsletterOptInVisible = null !== $newsletterTargetEmail;
|
$newsletterOptInVisible = null !== $newsletterTargetEmail;
|
||||||
|
$companions = $this->bookingNewsletterOptIn->resolveCompanions($bookingCreateDto, $newsletterTargetEmail);
|
||||||
|
$companionNewsletterOptInVisible = [] !== $companions;
|
||||||
|
|
||||||
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [
|
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [
|
||||||
'show_newsletter_opt_in' => $newsletterOptInVisible,
|
'show_newsletter_opt_in' => $newsletterOptInVisible,
|
||||||
|
'show_companion_newsletter_opt_in' => $companionNewsletterOptInVisible,
|
||||||
'newsletter_target_email' => $newsletterTargetEmail,
|
'newsletter_target_email' => $newsletterTargetEmail,
|
||||||
]);
|
]);
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
@@ -94,7 +96,7 @@ class Step4Controller extends AbstractBookingCreateController
|
|||||||
$bookingResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen es erneut.',
|
$bookingResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen es erneut.',
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail);
|
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail, $companionNewsletterOptInVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (false === $bookingResponse->isBookingSuccessful()) {
|
if (false === $bookingResponse->isBookingSuccessful()) {
|
||||||
@@ -110,7 +112,7 @@ class Step4Controller extends AbstractBookingCreateController
|
|||||||
$errorMessage,
|
$errorMessage,
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail);
|
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail, $companionNewsletterOptInVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
$newsletterOptInSelected = $newsletterOptInVisible
|
$newsletterOptInSelected = $newsletterOptInVisible
|
||||||
@@ -118,19 +120,21 @@ class Step4Controller extends AbstractBookingCreateController
|
|||||||
&& true === $form->get('newsletterOptIn')->getData();
|
&& true === $form->get('newsletterOptIn')->getData();
|
||||||
|
|
||||||
if (true === $newsletterOptInSelected) {
|
if (true === $newsletterOptInSelected) {
|
||||||
try {
|
$this->bookingNewsletterOptIn->subscribeApplicant(
|
||||||
$targetParticipant = $this->resolveNewsletterTargetParticipant($bookingCreateDto);
|
$newsletterTargetEmail,
|
||||||
$this->doubleOptInService->requestConfirmation(
|
$this->bookingNewsletterOptIn->resolveApplicantParticipant($bookingCreateDto),
|
||||||
$newsletterTargetEmail,
|
);
|
||||||
$targetParticipant?->firstName,
|
}
|
||||||
$targetParticipant?->lastName,
|
|
||||||
);
|
$companionOptInSelected = $companionNewsletterOptInVisible
|
||||||
} catch (NewsletterProviderException|\InvalidArgumentException $e) {
|
&& $form->has('companionNewsletterOptIn')
|
||||||
$this->logger->warning('Newsletter confirmation request failed after booking', [
|
&& true === $form->get('companionNewsletterOptIn')->getData();
|
||||||
'email' => $newsletterTargetEmail,
|
|
||||||
'error' => $e->getMessage(),
|
if (true === $companionOptInSelected) {
|
||||||
]);
|
$this->bookingNewsletterOptIn->subscribeCompanions(
|
||||||
}
|
$companions,
|
||||||
|
$this->bookingNewsletterOptIn->resolveRequestedByName($bookingCreateDto),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success: Store booking data in flash for conversion tracking
|
// Success: Store booking data in flash for conversion tracking
|
||||||
@@ -171,7 +175,7 @@ class Step4Controller extends AbstractBookingCreateController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail);
|
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail, $companionNewsletterOptInVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -185,6 +189,7 @@ class Step4Controller extends AbstractBookingCreateController
|
|||||||
FormInterface $form,
|
FormInterface $form,
|
||||||
bool $newsletterOptInVisible,
|
bool $newsletterOptInVisible,
|
||||||
?string $newsletterTargetEmail,
|
?string $newsletterTargetEmail,
|
||||||
|
bool $companionNewsletterOptInVisible,
|
||||||
): Response {
|
): Response {
|
||||||
$bookingCreateContext = $this->createContextFactory->createWithParticipantPrices($bookingCreateDto);
|
$bookingCreateContext = $this->createContextFactory->createWithParticipantPrices($bookingCreateDto);
|
||||||
|
|
||||||
@@ -193,45 +198,10 @@ class Step4Controller extends AbstractBookingCreateController
|
|||||||
'form' => $form,
|
'form' => $form,
|
||||||
'newsletterOptInVisible' => $newsletterOptInVisible,
|
'newsletterOptInVisible' => $newsletterOptInVisible,
|
||||||
'newsletterTargetEmail' => $newsletterTargetEmail,
|
'newsletterTargetEmail' => $newsletterTargetEmail,
|
||||||
|
'companionNewsletterOptInVisible' => $companionNewsletterOptInVisible,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveNewsletterTargetEmail(BookingDto $bookingDto): ?string
|
|
||||||
{
|
|
||||||
$currentUser = $this->getUser();
|
|
||||||
$email = null;
|
|
||||||
|
|
||||||
if ($currentUser instanceof User) {
|
|
||||||
$email = $currentUser->getEmail();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((null === $email || '' === trim((string) $email)) && isset($bookingDto->participants[0])) {
|
|
||||||
$email = $bookingDto->participants[0]->email;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null === $email) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalizedEmail = mb_strtolower(trim($email));
|
|
||||||
|
|
||||||
return false !== filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) ? $normalizedEmail : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveNewsletterTargetParticipant(BookingDto $bookingDto): ?ParticipantDto
|
|
||||||
{
|
|
||||||
$participant = $bookingDto->participants[0] ?? null;
|
|
||||||
if (null === $participant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null === $participant->firstName && null === $participant->lastName) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $participant;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears travel data and availability cache after successful booking.
|
* Clears travel data and availability cache after successful booking.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ class IndexController extends AbstractController
|
|||||||
$this->addFlash('info', 'Dein zuvor gespeicherter Entwurf wurde wiederhergestellt.');
|
$this->addFlash('info', 'Dein zuvor gespeicherter Entwurf wurde wiederhergestellt.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$droppedDraftServiceLabels = $this->dataLoader->getDroppedDraftServiceLabels();
|
||||||
|
if ([] !== $droppedDraftServiceLabels) {
|
||||||
|
$this->addFlash('info', sprintf(
|
||||||
|
'Folgende Leistungen aus deinem gespeicherten Entwurf sind inzwischen ausgebucht oder nur auf Anfrage verfügbar und konnten nicht übernommen werden: %s. An deiner bestehenden Buchung ändert sich dadurch nichts. Bitte kontaktiere uns, wenn du sie trotzdem benötigst.',
|
||||||
|
implode(', ', $droppedDraftServiceLabels)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch booking data for display (surcharges, canceled status, etc.)
|
// Fetch booking data for display (surcharges, canceled status, etc.)
|
||||||
$bookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
|
$bookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
|
||||||
if (null === $bookingData || $bookingData instanceof Notification) {
|
if (null === $bookingData || $bookingData instanceof Notification) {
|
||||||
@@ -185,6 +193,13 @@ class IndexController extends AbstractController
|
|||||||
$this->addFlash('info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.');
|
$this->addFlash('info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([] !== $result->unbookableServicesReverted) {
|
||||||
|
$this->addFlash('info', sprintf(
|
||||||
|
'Folgende Leistungen konnten nicht hinzugebucht werden, da sie inzwischen ausgebucht oder nur auf Anfrage verfügbar sind: %s. An deiner bestehenden Buchung ändert sich dadurch nichts. Bitte kontaktiere uns, wenn du sie trotzdem benötigst.',
|
||||||
|
implode(', ', $result->unbookableServicesReverted)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
switch ($result->status) {
|
switch ($result->status) {
|
||||||
case BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED:
|
case BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED:
|
||||||
$this->addFlash('error', 'Buchungsdaten konnten vor dem Speichern nicht neu geladen werden');
|
$this->addFlash('error', 'Buchungsdaten konnten vor dem Speichern nicht neu geladen werden');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Entity\User;
|
|||||||
use App\Form\BookingParticipantType;
|
use App\Form\BookingParticipantType;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Htmx\HxTrait;
|
use App\Htmx\HxTrait;
|
||||||
|
use App\Service\BookingChangeTracker;
|
||||||
use App\Service\BookingConfigurator;
|
use App\Service\BookingConfigurator;
|
||||||
use App\Service\BookingEditContextFactory;
|
use App\Service\BookingEditContextFactory;
|
||||||
use App\Service\BookingEditDataLoader;
|
use App\Service\BookingEditDataLoader;
|
||||||
@@ -37,6 +38,7 @@ class ParticipantController extends AbstractController
|
|||||||
private readonly BookingEditDraftManager $draftService,
|
private readonly BookingEditDraftManager $draftService,
|
||||||
private readonly BookingEditContextFactory $editContextFactory,
|
private readonly BookingEditContextFactory $editContextFactory,
|
||||||
private readonly BookingConfigurator $bookingConfigurator,
|
private readonly BookingConfigurator $bookingConfigurator,
|
||||||
|
private readonly BookingChangeTracker $changeTracker,
|
||||||
private readonly BookingSessionManager $bookingSessionService,
|
private readonly BookingSessionManager $bookingSessionService,
|
||||||
private readonly ParticipantDataPrefiller $prepopulationService,
|
private readonly ParticipantDataPrefiller $prepopulationService,
|
||||||
private readonly ParticipantFormSupport $participantFormSupportService,
|
private readonly ParticipantFormSupport $participantFormSupportService,
|
||||||
@@ -99,7 +101,7 @@ class ParticipantController extends AbstractController
|
|||||||
$this->prepopulationService->fillDummyParticipant($bookingDto->participants[$index], $index);
|
$this->prepopulationService->fillDummyParticipant($bookingDto->participants[$index], $index);
|
||||||
|
|
||||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
||||||
$this->draftService->saveDraft($user, $bookingId, $bookingDto);
|
$this->persistDraftState($user, $bookingId, $bookingDto);
|
||||||
|
|
||||||
$form = $this->createParticipantForm($bookingDto, $index);
|
$form = $this->createParticipantForm($bookingDto, $index);
|
||||||
|
|
||||||
@@ -110,7 +112,7 @@ class ParticipantController extends AbstractController
|
|||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
||||||
$this->draftService->saveDraft($user, $bookingId, $bookingDto);
|
$this->persistDraftState($user, $bookingId, $bookingDto);
|
||||||
$this->addNotificationsAsFlashMessages($notifications);
|
$this->addNotificationsAsFlashMessages($notifications);
|
||||||
|
|
||||||
return $this->redirectToRoute('app_booking_edit', ['bookingId' => $bookingId]);
|
return $this->redirectToRoute('app_booking_edit', ['bookingId' => $bookingId]);
|
||||||
@@ -196,6 +198,24 @@ class ParticipantController extends AbstractController
|
|||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the draft only while there is something to draft.
|
||||||
|
*
|
||||||
|
* Saving a draft that reproduces the API state exactly would make the next
|
||||||
|
* overview load report a restored draft for edits the user never made.
|
||||||
|
* Such a draft is obsolete, so it is removed instead.
|
||||||
|
*/
|
||||||
|
private function persistDraftState(User $user, int $bookingId, BookingDto $bookingDto): void
|
||||||
|
{
|
||||||
|
if (true === $this->changeTracker->isDirty($bookingDto)) {
|
||||||
|
$this->draftService->saveDraft($user, $bookingId, $bookingDto);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->draftService->deleteDraft($user, $bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<array{type: string, message: string}> $notifications
|
* @param array<array{type: string, message: string}> $notifications
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ class Step2Controller extends AbstractController
|
|||||||
$selectedAdditionalServices = $this->resolveSelectedAdditionalServices($dto, $services['additionalServices']);
|
$selectedAdditionalServices = $this->resolveSelectedAdditionalServices($dto, $services['additionalServices']);
|
||||||
|
|
||||||
$priceBreakdown = null;
|
$priceBreakdown = null;
|
||||||
if ($dto->dateFrom !== null && $dto->dateTo !== null) {
|
if (null !== $dto->dateFrom && null !== $dto->dateTo) {
|
||||||
$priceBreakdown = $this->priceCalculator->calculate(
|
$priceBreakdown = $this->priceCalculator->calculate(
|
||||||
$dto->paxCount,
|
$dto->paxCount,
|
||||||
$dto->minorsCount,
|
$dto->minorsCount,
|
||||||
@@ -168,7 +168,7 @@ class Step2Controller extends AbstractController
|
|||||||
|
|
||||||
return array_values(array_filter(
|
return array_values(array_filter(
|
||||||
$additionalServices,
|
$additionalServices,
|
||||||
fn(AdditionalService $s) => isset($selectedIds[$s->getId()]),
|
fn (AdditionalService $s) => isset($selectedIds[$s->getId()]),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Entity\Groups\Accommodation;
|
|||||||
use App\Entity\Groups\AdditionalService;
|
use App\Entity\Groups\AdditionalService;
|
||||||
use App\Entity\Groups\BoardService;
|
use App\Entity\Groups\BoardService;
|
||||||
use App\Exception\AccommodationSessionNotFoundException;
|
use App\Exception\AccommodationSessionNotFoundException;
|
||||||
|
use App\Exception\ImplausibleBookingPriceException;
|
||||||
use App\Form\AccommodationBookingConfirmationType;
|
use App\Form\AccommodationBookingConfirmationType;
|
||||||
use App\Form\AccommodationInquiryConfirmationType;
|
use App\Form\AccommodationInquiryConfirmationType;
|
||||||
use App\Form\Model\AccommodationBookingDto;
|
use App\Form\Model\AccommodationBookingDto;
|
||||||
@@ -16,6 +17,7 @@ use App\Model\AccommodationBookingContext;
|
|||||||
use App\Service\AccommodationBookingService;
|
use App\Service\AccommodationBookingService;
|
||||||
use App\Service\AccommodationSessionManager;
|
use App\Service\AccommodationSessionManager;
|
||||||
use App\Service\AccommodationTermsUrlProvider;
|
use App\Service\AccommodationTermsUrlProvider;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -28,6 +30,7 @@ class Step4Controller extends AbstractController
|
|||||||
private readonly AccommodationBookingService $bookingService,
|
private readonly AccommodationBookingService $bookingService,
|
||||||
private readonly AccommodationSessionManager $sessionManager,
|
private readonly AccommodationSessionManager $sessionManager,
|
||||||
private readonly AccommodationTermsUrlProvider $termsUrlProvider,
|
private readonly AccommodationTermsUrlProvider $termsUrlProvider,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +54,9 @@ class Step4Controller extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$dto->forceInquiry = true;
|
$dto->forceInquiry = true;
|
||||||
$this->persistBooking($request, $dto, $accommodation, $services);
|
if (null !== $failure = $this->persistBooking($request, $dto, $accommodation, $services)) {
|
||||||
|
return $failure;
|
||||||
|
}
|
||||||
|
|
||||||
return $this->redirectToRoute('app_groups_booking_success');
|
return $this->redirectToRoute('app_groups_booking_success');
|
||||||
}
|
}
|
||||||
@@ -102,7 +107,9 @@ class Step4Controller extends AbstractController
|
|||||||
|
|
||||||
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
|
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
|
||||||
$services = $this->bookingService->loadAvailableServices($dto, $accommodation);
|
$services = $this->bookingService->loadAvailableServices($dto, $accommodation);
|
||||||
$this->persistBooking($request, $dto, $accommodation, $services);
|
if (null !== $failure = $this->persistBooking($request, $dto, $accommodation, $services)) {
|
||||||
|
return $failure;
|
||||||
|
}
|
||||||
|
|
||||||
return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_success'));
|
return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_success'));
|
||||||
}
|
}
|
||||||
@@ -148,12 +155,35 @@ class Step4Controller extends AbstractController
|
|||||||
* groupedAdditionalServices: array<string, AdditionalService[]>,
|
* groupedAdditionalServices: array<string, AdditionalService[]>,
|
||||||
* ungroupedAdditionalServices: AdditionalService[]
|
* ungroupedAdditionalServices: AdditionalService[]
|
||||||
* } $services
|
* } $services
|
||||||
|
*
|
||||||
|
* @return Response|null a response to return instead of the success redirect, or null when
|
||||||
|
* the booking was stored
|
||||||
*/
|
*/
|
||||||
private function persistBooking(Request $request, AccommodationBookingDto $dto, Accommodation $accommodation, array $services): void
|
private function persistBooking(Request $request, AccommodationBookingDto $dto, Accommodation $accommodation, array $services): ?Response
|
||||||
{
|
{
|
||||||
$prices = $this->bookingService->loadPrices($dto, $accommodation);
|
$prices = $this->bookingService->loadPrices($dto, $accommodation);
|
||||||
$booking = $this->bookingService->finalizeBooking($dto, $accommodation, $prices, $services);
|
|
||||||
|
try {
|
||||||
|
$booking = $this->bookingService->finalizeBooking($dto, $accommodation, $prices, $services);
|
||||||
|
} catch (ImplausibleBookingPriceException $e) {
|
||||||
|
// The bounded inputs are already rejected in step 2, so what is left is a mistyped
|
||||||
|
// catalog price for this house — nothing the customer can act on and nothing to show
|
||||||
|
// them an error page over. The session survives so the desk can pick the inquiry up
|
||||||
|
// by phone with the data still in hand.
|
||||||
|
$this->logger->error('Refused to store an implausible accommodation booking total', [
|
||||||
|
'exception' => $e,
|
||||||
|
'accommodation' => $accommodation->getCalendarCode(),
|
||||||
|
'paxCount' => $dto->paxCount,
|
||||||
|
'nights' => $dto->getNights(),
|
||||||
|
]);
|
||||||
|
$this->addFlash('error', 'Die Anfrage konnte nicht abgeschlossen werden. Bitte wende dich direkt an uns — wir kümmern uns darum.');
|
||||||
|
|
||||||
|
return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_error'));
|
||||||
|
}
|
||||||
|
|
||||||
$this->addFlash('groups_booking_result', $booking->getOrigin()->value);
|
$this->addFlash('groups_booking_result', $booking->getOrigin()->value);
|
||||||
$this->sessionManager->clear($request);
|
$this->sessionManager->clear($request);
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Groups\Offer;
|
||||||
|
|
||||||
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Htmx\HxTrait;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as SymfonyAbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
abstract class AbstractController extends SymfonyAbstractController
|
||||||
|
{
|
||||||
|
use HxTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A draft and an inquiry the office has not worked through yet are not ready to be
|
||||||
|
* shown, and a discarded booking must not be viewable any more — in all three cases
|
||||||
|
* the link behaves as if it had expired.
|
||||||
|
*/
|
||||||
|
protected function isCustomerVisible(AccommodationBooking $booking): bool
|
||||||
|
{
|
||||||
|
return $booking->isCustomerAccessible() && !$booking->isRequested() && !$booking->isDiscarded();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the browser to a full reload of the (non-modal) offer page — this is
|
||||||
|
* triggered from an htmx-loaded modal, so a plain render/redirect here would
|
||||||
|
* get appended as an inert HTML fragment instead of actually navigating.
|
||||||
|
*/
|
||||||
|
protected function redirectToOfferPage(Request $request, string $uuid): Response
|
||||||
|
{
|
||||||
|
$url = $this->generateUrl('app_groups_offer_view', ['uuid' => $uuid]);
|
||||||
|
|
||||||
|
return $this->htmxRedirect($request, $url);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Groups\Offer;
|
||||||
|
|
||||||
|
use App\Form\Model\OfferAcceptDto;
|
||||||
|
use App\Form\OfferAcceptConfirmationType;
|
||||||
|
use App\Repository\Groups\AccommodationBookingRepository;
|
||||||
|
use App\Service\AccommodationBookingLinkSigner;
|
||||||
|
use App\Service\AccommodationBookingService;
|
||||||
|
use App\Service\AccommodationTermsUrlProvider;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class ConfirmController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AccommodationBookingRepository $bookingRepository,
|
||||||
|
private readonly AccommodationBookingLinkSigner $linkSigner,
|
||||||
|
private readonly AccommodationBookingService $bookingService,
|
||||||
|
private readonly AccommodationTermsUrlProvider $termsUrlProvider,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/groups/booking/offer/{uuid}/confirm', name: 'app_groups_offer_confirm', methods: ['GET', 'POST'])]
|
||||||
|
public function index(string $uuid, Request $request): Response
|
||||||
|
{
|
||||||
|
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
|
||||||
|
|
||||||
|
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
|
||||||
|
return $this->redirectToOfferPage($request, $uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offen is the one status that means an offer is out and awaiting acceptance, so it
|
||||||
|
// is the whole guard — mirroring acceptBooking(), which no-ops on anything else.
|
||||||
|
if (!$booking->isOpen()) {
|
||||||
|
return $this->redirectToOfferPage($request, $uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checked for POST as well, so the modal cannot be submitted around the contact page.
|
||||||
|
if (!$this->bookingService->hasCompleteContactData($booking)) {
|
||||||
|
return $this->htmxRedirect($request, $this->generateUrl('app_groups_offer_contact', ['uuid' => $uuid]));
|
||||||
|
}
|
||||||
|
|
||||||
|
$dto = new OfferAcceptDto(remarks: $booking->getRemarks());
|
||||||
|
$confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, $dto, [
|
||||||
|
'terms_url' => $this->termsUrlProvider->forAccommodation($booking->getAccommodation()),
|
||||||
|
]);
|
||||||
|
$confirmationForm->handleRequest($request);
|
||||||
|
|
||||||
|
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
|
||||||
|
// The textarea is prefilled, so the submitted value is the complete remark —
|
||||||
|
// an emptied field arrives as null and must clear the stored one, not keep it.
|
||||||
|
$this->bookingService->acceptBooking($booking, $dto->remarks ?? '');
|
||||||
|
$this->addFlash('success', 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.');
|
||||||
|
|
||||||
|
return $this->redirectToOfferPage($request, $uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('groups/offer/modal_accept_confirmation.html.twig', [
|
||||||
|
'booking' => $booking,
|
||||||
|
'confirmationForm' => $confirmationForm,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Groups\Offer;
|
||||||
|
|
||||||
|
use App\Form\AccommodationContactType;
|
||||||
|
use App\Repository\Groups\AccommodationBookingRepository;
|
||||||
|
use App\Service\AccommodationBookingLinkSigner;
|
||||||
|
use App\Service\AccommodationBookingService;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class ContactController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AccommodationBookingRepository $bookingRepository,
|
||||||
|
private readonly AccommodationBookingLinkSigner $linkSigner,
|
||||||
|
private readonly AccommodationBookingService $bookingService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full page rather than modal: the form is as large as step 3 of a self-service booking,
|
||||||
|
* whose fields and constraints it shares. Once saved, the customer is back on the offer
|
||||||
|
* and accepts it from there as usual.
|
||||||
|
*/
|
||||||
|
#[Route(path: '/groups/booking/offer/{uuid}/contact', name: 'app_groups_offer_contact', methods: ['GET', 'POST'])]
|
||||||
|
public function index(string $uuid, Request $request): Response
|
||||||
|
{
|
||||||
|
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
|
||||||
|
|
||||||
|
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
|
||||||
|
return $this->redirectToOfferPage($request, $uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$booking->isOpen()) {
|
||||||
|
return $this->redirectToOfferPage($request, $uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dto = $this->bookingService->contactDataFromBooking($booking);
|
||||||
|
$form = $this->createForm(AccommodationContactType::class, $dto, [
|
||||||
|
'email_readonly' => true,
|
||||||
|
]);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$this->bookingService->applyContactData($booking, $dto);
|
||||||
|
$this->addFlash('success', 'Deine Kontaktdaten wurden gespeichert. Du kannst die Buchung jetzt abschließen.');
|
||||||
|
|
||||||
|
return $this->redirectToOfferPage($request, $uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
|
||||||
|
$ctx = $this->bookingService->createOfferContext($booking, $accommodation);
|
||||||
|
|
||||||
|
return $this->render('groups/offer/contact.html.twig', [
|
||||||
|
'booking' => $booking,
|
||||||
|
'priceBreakdown' => $ctx->priceBreakdown,
|
||||||
|
'ctx' => $ctx,
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,17 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controller\Groups\Offer;
|
namespace App\Controller\Groups\Offer;
|
||||||
|
|
||||||
use App\Entity\Groups\AccommodationBooking;
|
|
||||||
use App\Form\Model\OfferAcceptDto;
|
|
||||||
use App\Form\OfferAcceptConfirmationType;
|
|
||||||
use App\Htmx\HxTrait;
|
|
||||||
use App\Model\AccommodationBookingContext;
|
|
||||||
use App\Repository\Groups\AccommodationBookingRepository;
|
use App\Repository\Groups\AccommodationBookingRepository;
|
||||||
use App\Service\AccommodationBookingBreakdownCalculator;
|
|
||||||
use App\Service\AccommodationBookingLinkSigner;
|
use App\Service\AccommodationBookingLinkSigner;
|
||||||
use App\Service\AccommodationBookingService;
|
|
||||||
use App\Service\AccommodationTermsUrlProvider;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -22,14 +13,9 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||||||
|
|
||||||
class IndexController extends AbstractController
|
class IndexController extends AbstractController
|
||||||
{
|
{
|
||||||
use HxTrait;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AccommodationBookingRepository $bookingRepository,
|
private readonly AccommodationBookingRepository $bookingRepository,
|
||||||
private readonly AccommodationBookingLinkSigner $linkSigner,
|
private readonly AccommodationBookingLinkSigner $linkSigner,
|
||||||
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
|
|
||||||
private readonly AccommodationBookingService $bookingService,
|
|
||||||
private readonly AccommodationTermsUrlProvider $termsUrlProvider,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +25,7 @@ class IndexController extends AbstractController
|
|||||||
* (session-gated) offer page — nothing downstream needs the signature again.
|
* (session-gated) offer page — nothing downstream needs the signature again.
|
||||||
*/
|
*/
|
||||||
#[Route(path: '/groups/booking/offer/{uuid}', name: 'app_groups_offer', methods: ['GET'])]
|
#[Route(path: '/groups/booking/offer/{uuid}', name: 'app_groups_offer', methods: ['GET'])]
|
||||||
public function access(string $uuid, Request $request): Response
|
public function index(string $uuid, Request $request): Response
|
||||||
{
|
{
|
||||||
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
|
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
|
||||||
|
|
||||||
@@ -55,91 +41,4 @@ class IndexController extends AbstractController
|
|||||||
|
|
||||||
return new RedirectResponse($this->generateUrl('app_groups_offer_view', ['uuid' => $uuid]));
|
return new RedirectResponse($this->generateUrl('app_groups_offer_view', ['uuid' => $uuid]));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route(path: '/groups/booking/offer/{uuid}/view', name: 'app_groups_offer_view', methods: ['GET'])]
|
|
||||||
public function view(string $uuid, Request $request): Response
|
|
||||||
{
|
|
||||||
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
|
|
||||||
|
|
||||||
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
|
|
||||||
return $this->render('groups/offer/unavailable.html.twig');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$this->isCustomerVisible($booking)) {
|
|
||||||
return $this->render('groups/offer/unavailable.html.twig');
|
|
||||||
}
|
|
||||||
|
|
||||||
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
|
|
||||||
$priceBreakdown = $this->breakdownCalculator->compute($booking);
|
|
||||||
|
|
||||||
$ctx = new AccommodationBookingContext(
|
|
||||||
accommodation: $accommodation,
|
|
||||||
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
|
|
||||||
priceBreakdown: $priceBreakdown,
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->render('groups/offer/view.html.twig', [
|
|
||||||
'booking' => $booking,
|
|
||||||
'priceBreakdown' => $priceBreakdown,
|
|
||||||
'ctx' => $ctx,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route(path: '/groups/booking/offer/{uuid}/confirm', name: 'app_groups_offer_confirm', methods: ['GET', 'POST'])]
|
|
||||||
public function confirm(string $uuid, Request $request): Response
|
|
||||||
{
|
|
||||||
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
|
|
||||||
|
|
||||||
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
|
|
||||||
return $this->redirectToOfferPage($request, $uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offen is the one status that means an offer is out and awaiting acceptance, so it
|
|
||||||
// is the whole guard — mirroring acceptBooking(), which no-ops on anything else.
|
|
||||||
if (!$booking->isOpen()) {
|
|
||||||
return $this->redirectToOfferPage($request, $uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
$dto = new OfferAcceptDto(remarks: $booking->getRemarks());
|
|
||||||
$confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, $dto, [
|
|
||||||
'terms_url' => $this->termsUrlProvider->forAccommodation($booking->getAccommodation()),
|
|
||||||
]);
|
|
||||||
$confirmationForm->handleRequest($request);
|
|
||||||
|
|
||||||
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
|
|
||||||
// The textarea is prefilled, so the submitted value is the complete remark —
|
|
||||||
// an emptied field arrives as null and must clear the stored one, not keep it.
|
|
||||||
$this->bookingService->acceptBooking($booking, $dto->remarks ?? '');
|
|
||||||
$this->addFlash('success', 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.');
|
|
||||||
|
|
||||||
return $this->redirectToOfferPage($request, $uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->render('groups/offer/modal_accept_confirmation.html.twig', [
|
|
||||||
'booking' => $booking,
|
|
||||||
'confirmationForm' => $confirmationForm,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A draft and an inquiry the office has not worked through yet are not ready to be
|
|
||||||
* shown, and a discarded booking must not be viewable any more — in all three cases
|
|
||||||
* the link behaves as if it had expired.
|
|
||||||
*/
|
|
||||||
private function isCustomerVisible(AccommodationBooking $booking): bool
|
|
||||||
{
|
|
||||||
return $booking->isCustomerAccessible() && !$booking->isRequested() && !$booking->isDiscarded();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends the browser to a full reload of the (non-modal) offer page — this is
|
|
||||||
* triggered from an htmx-loaded modal, so a plain render/redirect here would
|
|
||||||
* get appended as an inert HTML fragment instead of actually navigating.
|
|
||||||
*/
|
|
||||||
private function redirectToOfferPage(Request $request, string $uuid): Response
|
|
||||||
{
|
|
||||||
$url = $this->generateUrl('app_groups_offer_view', ['uuid' => $uuid]);
|
|
||||||
|
|
||||||
return $this->htmxRedirect($request, $url);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Groups\Offer;
|
||||||
|
|
||||||
|
use App\Repository\Groups\AccommodationBookingRepository;
|
||||||
|
use App\Service\AccommodationBookingLinkSigner;
|
||||||
|
use App\Service\AccommodationBookingService;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class ViewController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AccommodationBookingRepository $bookingRepository,
|
||||||
|
private readonly AccommodationBookingLinkSigner $linkSigner,
|
||||||
|
private readonly AccommodationBookingService $bookingService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/groups/booking/offer/{uuid}/view', name: 'app_groups_offer_view', methods: ['GET'])]
|
||||||
|
public function index(string $uuid, Request $request): Response
|
||||||
|
{
|
||||||
|
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
|
||||||
|
|
||||||
|
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
|
||||||
|
return $this->render('groups/offer/unavailable.html.twig');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isCustomerVisible($booking)) {
|
||||||
|
return $this->render('groups/offer/unavailable.html.twig');
|
||||||
|
}
|
||||||
|
|
||||||
|
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
|
||||||
|
$ctx = $this->bookingService->createOfferContext($booking, $accommodation);
|
||||||
|
|
||||||
|
return $this->render('groups/offer/view.html.twig', [
|
||||||
|
'booking' => $booking,
|
||||||
|
'priceBreakdown' => $ctx->priceBreakdown,
|
||||||
|
'ctx' => $ctx,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Controller;
|
|||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Service\BookingSessionManager;
|
use App\Service\BookingSessionManager;
|
||||||
use App\Service\BookingSummaryAssembler;
|
use App\Service\BookingSummaryAssembler;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -21,13 +22,28 @@ class SecurityController extends AbstractController
|
|||||||
Request $request,
|
Request $request,
|
||||||
BookingSessionManager $bookingSessionService,
|
BookingSessionManager $bookingSessionService,
|
||||||
BookingSummaryAssembler $summaryDataService,
|
BookingSummaryAssembler $summaryDataService,
|
||||||
|
ClientManagerInterface $clientManager,
|
||||||
): Response {
|
): Response {
|
||||||
|
// flag this request in the session as external OAuth2 request if
|
||||||
|
// applicable to immediately logout the current user after successful
|
||||||
|
// authentication (see App\EventListener\AuthorizationCodeListener).
|
||||||
|
// Read before the booking flow is decided, because it overrules it.
|
||||||
|
$session = $request->getSession();
|
||||||
|
$targetPath = $session->get('_security.main.target_path');
|
||||||
|
$isOauth2 = \is_string($targetPath) && str_contains($targetPath, '/authorize');
|
||||||
|
$session->set('_oauth2', $isOauth2);
|
||||||
|
|
||||||
// Check if this is a booking flow (BookingDto exists in session)
|
// Check if this is a booking flow (BookingDto exists in session)
|
||||||
$bookingDto = $bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE);
|
$bookingDto = $bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE);
|
||||||
$isBookingFlow = null !== $bookingDto;
|
$hasPendingBooking = null !== $bookingDto;
|
||||||
|
|
||||||
|
// A pending authorization wins over a half-finished booking. Otherwise the target path
|
||||||
|
// below is overwritten with booking step 1, the user is sent there after logging in, and
|
||||||
|
// the application that asked for the authorization never receives its code.
|
||||||
|
$isBookingFlow = $hasPendingBooking && false === $isOauth2;
|
||||||
|
|
||||||
// If authenticated and in booking flow, proceed to Step 1
|
// If authenticated and in booking flow, proceed to Step 1
|
||||||
if (null !== $this->getUser() && true === $isBookingFlow) {
|
if (null !== $this->getUser() && true === $hasPendingBooking) {
|
||||||
return $this->redirectToRoute('app_booking_create_step_1');
|
return $this->redirectToRoute('app_booking_create_step_1');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,21 +55,18 @@ class SecurityController extends AbstractController
|
|||||||
$error = $authenticationUtils->getLastAuthenticationError();
|
$error = $authenticationUtils->getLastAuthenticationError();
|
||||||
$lastUsername = $authenticationUtils->getLastUsername();
|
$lastUsername = $authenticationUtils->getLastUsername();
|
||||||
|
|
||||||
// flag this request in the session as external OAuth2 request if
|
|
||||||
// applicable to immediately logout the current user after successful
|
|
||||||
// authentication (see App\EventListener\AuthorizationCodeListener).
|
|
||||||
$session = $request->getSession();
|
|
||||||
$targetPath = $session->get('_security.main.target_path');
|
|
||||||
$isOauth2 = null !== $targetPath && str_contains($targetPath, '/authorize');
|
|
||||||
$session->set('_oauth2', $isOauth2);
|
|
||||||
|
|
||||||
// For booking flow, set target path to Step 1 (after successful auth, redirect there)
|
// For booking flow, set target path to Step 1 (after successful auth, redirect there)
|
||||||
if (true === $isBookingFlow) {
|
if (true === $isBookingFlow) {
|
||||||
$session->set('_security.main.target_path', $this->generateUrl('app_booking_create_step_1'));
|
$session->set('_security.main.target_path', $this->generateUrl('app_booking_create_step_1'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render booking login template if in booking flow, otherwise standard login
|
// Render booking login template if in booking flow, the self-explaining one when another
|
||||||
$template = true === $isBookingFlow ? 'booking/create/authenticate.html.twig' : 'security/login.html.twig';
|
// application sent them here to authorize it, otherwise standard login
|
||||||
|
$template = match (true) {
|
||||||
|
$isBookingFlow => 'booking/create/authenticate.html.twig',
|
||||||
|
$isOauth2 => 'security/login_oauth2.html.twig',
|
||||||
|
default => 'security/login.html.twig',
|
||||||
|
};
|
||||||
|
|
||||||
// Fetch CMS data for booking flow (uses cache warmed in IndexController)
|
// Fetch CMS data for booking flow (uses cache warmed in IndexController)
|
||||||
$cmsData = null;
|
$cmsData = null;
|
||||||
@@ -72,9 +85,37 @@ class SecurityController extends AbstractController
|
|||||||
'travel_date_to' => $bookingDto?->travel->dateTo,
|
'travel_date_to' => $bookingDto?->travel->dateTo,
|
||||||
'cmsData' => $cmsData,
|
'cmsData' => $cmsData,
|
||||||
'oauth2' => $isOauth2,
|
'oauth2' => $isOauth2,
|
||||||
|
'client_name' => $isOauth2 ? $this->authorizingClientName($clientManager, $targetPath) : null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display name of the application that sent the user here to authorize it, so the login page
|
||||||
|
* can name it instead of leaving the user wondering why the site changed.
|
||||||
|
*
|
||||||
|
* Derived from the pending target path on every render rather than stashed on the session: a
|
||||||
|
* failed login redirects back to this route and the target path survives, so the name comes back
|
||||||
|
* with it. Returns null when the client cannot be resolved, and the page falls back to neutral
|
||||||
|
* wording rather than failing -- this runs before authentication.
|
||||||
|
*/
|
||||||
|
private function authorizingClientName(ClientManagerInterface $clientManager, ?string $targetPath): ?string
|
||||||
|
{
|
||||||
|
$query = null !== $targetPath ? parse_url($targetPath, \PHP_URL_QUERY) : null;
|
||||||
|
if (!\is_string($query) || '' === $query) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_str($query, $params);
|
||||||
|
$clientId = $params['client_id'] ?? null;
|
||||||
|
if (!\is_string($clientId) || '' === $clientId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $clientManager->find($clientId)?->getName() ?? '';
|
||||||
|
|
||||||
|
return '' !== $name ? $name : null;
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/logout', name: 'app_logout')]
|
#[Route('/logout', name: 'app_logout')]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('ROLE_USER')]
|
||||||
public function logout(): void
|
public function logout(): void
|
||||||
|
|||||||
@@ -6,19 +6,20 @@ namespace App\Dashboard\Widget;
|
|||||||
|
|
||||||
use App\Dashboard\Contract\DashboardWidgetProviderInterface;
|
use App\Dashboard\Contract\DashboardWidgetProviderInterface;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Form\Model\Filter\AbstractListFilterDto;
|
|
||||||
use App\Model\DashboardWidget;
|
use App\Model\DashboardWidget;
|
||||||
use App\Model\DashboardWidgetEntry;
|
use App\Model\DashboardWidgetEntry;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
use App\Security\Role;
|
use App\Security\Role;
|
||||||
|
use App\Service\RoleApprovalUrlGenerator;
|
||||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The accounts waiting on an administrator to act.
|
* The accounts waiting on an administrator to act.
|
||||||
*
|
*
|
||||||
* A nomination grants nothing until it is approved, and nothing else in the application
|
* A nomination grants nothing until it is approved. RoleNominationHandler emails the
|
||||||
* announces that one is waiting — without this widget an administrator only finds out by
|
* administrators the moment one appears, but that fires once, on the login that produced it —
|
||||||
* opening the user list.
|
* this widget is the standing list, and the only thing that still shows a nomination somebody
|
||||||
|
* has left sitting.
|
||||||
*/
|
*/
|
||||||
class PendingRoleApprovalsWidgetProvider implements DashboardWidgetProviderInterface
|
class PendingRoleApprovalsWidgetProvider implements DashboardWidgetProviderInterface
|
||||||
{
|
{
|
||||||
@@ -27,6 +28,7 @@ class PendingRoleApprovalsWidgetProvider implements DashboardWidgetProviderInter
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserRepository $userRepository,
|
private readonly UserRepository $userRepository,
|
||||||
private readonly UrlGeneratorInterface $urlGenerator,
|
private readonly UrlGeneratorInterface $urlGenerator,
|
||||||
|
private readonly RoleApprovalUrlGenerator $approvalUrlGenerator,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +48,7 @@ class PendingRoleApprovalsWidgetProvider implements DashboardWidgetProviderInter
|
|||||||
'Offene Rollenfreigaben',
|
'Offene Rollenfreigaben',
|
||||||
array_map(fn (User $user): DashboardWidgetEntry => new DashboardWidgetEntry(
|
array_map(fn (User $user): DashboardWidgetEntry => new DashboardWidgetEntry(
|
||||||
$this->label($user),
|
$this->label($user),
|
||||||
$this->approvalUrl($user),
|
$this->approvalUrlGenerator->forUser($user),
|
||||||
'user',
|
'user',
|
||||||
), $this->userRepository->findWithPendingRoles(self::LIMIT)),
|
), $this->userRepository->findWithPendingRoles(self::LIMIT)),
|
||||||
'Keine offenen Rollenfreigaben.',
|
'Keine offenen Rollenfreigaben.',
|
||||||
@@ -65,20 +67,4 @@ class PendingRoleApprovalsWidgetProvider implements DashboardWidgetProviderInter
|
|||||||
|
|
||||||
return sprintf('%s: %s', $user->getDisplayName(), implode(', ', $nominated));
|
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(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ class RecentLogEntriesWidgetProvider implements DashboardWidgetProviderInterface
|
|||||||
|
|
||||||
public function getRequiredRole(): string
|
public function getRequiredRole(): string
|
||||||
{
|
{
|
||||||
return Role::ADMIN;
|
return Role::CUSTOMER_EXPERT;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPriority(): int
|
public function getPriority(): int
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dashboard\Widget;
|
||||||
|
|
||||||
|
use App\Dashboard\Contract\DashboardWidgetProviderInterface;
|
||||||
|
use App\Entity\BookingEditDraft;
|
||||||
|
use App\Model\DashboardWidget;
|
||||||
|
use App\Model\DashboardWidgetEntry;
|
||||||
|
use App\Repository\BookingEditDraftRepository;
|
||||||
|
use App\Security\Role;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customers whose changes cannot be saved.
|
||||||
|
*
|
||||||
|
* A draft is deleted as soon as an update succeeds, so one that has been around for days belongs to
|
||||||
|
* somebody BusPro keeps refusing — and nothing else tells anyone. One booking accumulated
|
||||||
|
* thirty-seven failed attempts across two months before it was noticed by accident; the customer
|
||||||
|
* had entered seventy-nine participants, none of which ever reached BusPro. The point of this card
|
||||||
|
* is that the next one gets noticed in the first week instead.
|
||||||
|
*
|
||||||
|
* Each entry links to the draft, since the age alone does not say what is going wrong.
|
||||||
|
*/
|
||||||
|
class StuckBookingDraftsWidgetProvider implements DashboardWidgetProviderInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* How long a draft must have survived to be worth reporting.
|
||||||
|
*
|
||||||
|
* Short enough to catch a customer inside their first week of trying, long enough that an edit
|
||||||
|
* somebody merely abandoned over a weekend does not fill the card.
|
||||||
|
*/
|
||||||
|
private const MIN_AGE_DAYS = 7;
|
||||||
|
|
||||||
|
private const LIMIT = 10;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly BookingEditDraftRepository $draftRepository,
|
||||||
|
private readonly UrlGeneratorInterface $urlGenerator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequiredRole(): string
|
||||||
|
{
|
||||||
|
return Role::CUSTOMER_EXPERT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriority(): int
|
||||||
|
{
|
||||||
|
return 95;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): ?DashboardWidget
|
||||||
|
{
|
||||||
|
$drafts = $this->draftRepository->findStuck(self::MIN_AGE_DAYS, self::LIMIT);
|
||||||
|
|
||||||
|
return new DashboardWidget(
|
||||||
|
'Festhängende Buchungsänderungen',
|
||||||
|
array_map(fn (BookingEditDraft $draft): DashboardWidgetEntry => new DashboardWidgetEntry(
|
||||||
|
$this->label($draft),
|
||||||
|
$this->urlGenerator->generate('app_admin_bookingeditdraft_show', ['id' => $draft->getId()]),
|
||||||
|
'edit',
|
||||||
|
), $drafts),
|
||||||
|
'Keine festhängenden Buchungsänderungen.',
|
||||||
|
$this->urlGenerator->generate('app_admin_bookingeditdraft'),
|
||||||
|
'Alle Buchungsentwürfe',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names the booking, how long it has been stuck, and when the customer last tried.
|
||||||
|
*
|
||||||
|
* The gap between the two is what distinguishes an abandoned edit from somebody still trying
|
||||||
|
* every few days and getting nowhere.
|
||||||
|
*/
|
||||||
|
private function label(BookingEditDraft $draft): string
|
||||||
|
{
|
||||||
|
$days = $draft->getCreatedAt()->diff(new \DateTimeImmutable())->days ?? 0;
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'Vorgang %s (%s) — seit %d Tagen, zuletzt %s',
|
||||||
|
$draft->getBookingNumber() ?? $draft->getBookingId(),
|
||||||
|
(string) $draft->getUser()->getEmail(),
|
||||||
|
$days,
|
||||||
|
$draft->getUpdatedAt()->format('d.m.Y'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,9 @@ class Accommodation implements BlameableEntityInterface, TimestampableEntityInte
|
|||||||
#[ORM\Column(length: 16, nullable: true)]
|
#[ORM\Column(length: 16, nullable: true)]
|
||||||
private ?string $cmsCode = null;
|
private ?string $cmsCode = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 16, nullable: true)]
|
||||||
|
private ?string $productCode = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Collection<int, AccommodationPrice>
|
* @var Collection<int, AccommodationPrice>
|
||||||
*/
|
*/
|
||||||
@@ -122,6 +125,18 @@ class Accommodation implements BlameableEntityInterface, TimestampableEntityInte
|
|||||||
return $this->cmsCode ?? $this->calendarCode;
|
return $this->cmsCode ?? $this->calendarCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getProductCode(): ?string
|
||||||
|
{
|
||||||
|
return $this->productCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setProductCode(?string $productCode): self
|
||||||
|
{
|
||||||
|
$this->productCode = $productCode;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Collection<int, AccommodationPrice>
|
* @return Collection<int, AccommodationPrice>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ use App\Entity\User;
|
|||||||
use App\Enum\Groups\AccommodationBookingOrigin;
|
use App\Enum\Groups\AccommodationBookingOrigin;
|
||||||
use App\Enum\Groups\AccommodationBookingStatus;
|
use App\Enum\Groups\AccommodationBookingStatus;
|
||||||
use App\Enum\Groups\AdditionalServiceType;
|
use App\Enum\Groups\AdditionalServiceType;
|
||||||
|
use App\Exception\ImplausibleBookingPriceException;
|
||||||
use App\Repository\Groups\AccommodationBookingRepository;
|
use App\Repository\Groups\AccommodationBookingRepository;
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Uid\Uuid;
|
use Symfony\Component\Uid\Uuid;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
|
||||||
#[ORM\Entity(repositoryClass: AccommodationBookingRepository::class)]
|
#[ORM\Entity(repositoryClass: AccommodationBookingRepository::class)]
|
||||||
class AccommodationBooking implements BlameableEntityInterface, TimestampableEntityInterface
|
class AccommodationBooking implements BlameableEntityInterface, TimestampableEntityInterface
|
||||||
@@ -24,6 +26,24 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
|||||||
use BlameableEntity;
|
use BlameableEntity;
|
||||||
use TimestampableEntity;
|
use TimestampableEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plausibility ceilings for the two figures every price in the breakdown is multiplied by.
|
||||||
|
* Both sit above anything the group houses actually see — the largest booking on record is
|
||||||
|
* 150 people for 18 nights — and exist so that a mistyped year or pax count is answered with
|
||||||
|
* a form error rather than a price nobody meant.
|
||||||
|
*/
|
||||||
|
public const int MAX_PAX = 250;
|
||||||
|
public const int MAX_NIGHTS = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* And the ceiling on the product of the two, in minor units: a million euros against a
|
||||||
|
* largest-ever booking of some 42,000. The inputs above cannot reach it, so what it catches
|
||||||
|
* is a mistyped catalog price, the one factor of the total that is not bounded per booking.
|
||||||
|
* Deliberately well inside what the INT column holds — the column's range is not a price
|
||||||
|
* limit, and a total that needs more room is a mistake rather than a wider column.
|
||||||
|
*/
|
||||||
|
public const int MAX_TOTAL_PRICE = 100_000_000;
|
||||||
|
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
@@ -40,9 +60,11 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
|||||||
private ?\DateTimeImmutable $dateFrom = null;
|
private ?\DateTimeImmutable $dateFrom = null;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||||
|
#[Assert\GreaterThan(propertyPath: 'dateFrom', message: 'Das Abreisedatum muss nach dem Anreisedatum liegen.')]
|
||||||
private ?\DateTimeImmutable $dateTo = null;
|
private ?\DateTimeImmutable $dateTo = null;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
|
#[Assert\Range(min: 0, max: self::MAX_PAX)]
|
||||||
private int $paxCount = 0;
|
private int $paxCount = 0;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
@@ -68,6 +90,7 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
|||||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||||
private ?array $priceBreakdown = null;
|
private ?array $priceBreakdown = null;
|
||||||
|
|
||||||
|
/** In minor units, like every other price here, and never above self::MAX_TOTAL_PRICE. */
|
||||||
#[ORM\Column(nullable: true)]
|
#[ORM\Column(nullable: true)]
|
||||||
private ?int $totalPrice = null;
|
private ?int $totalPrice = null;
|
||||||
|
|
||||||
@@ -156,6 +179,18 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
|||||||
#[Assert\Range(min: 1, max: 100)]
|
#[Assert\Range(min: 1, max: 100)]
|
||||||
private ?int $additionalServicesDiscount = null;
|
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\ManyToOne]
|
||||||
#[ORM\JoinColumn(onDelete: 'SET NULL')]
|
#[ORM\JoinColumn(onDelete: 'SET NULL')]
|
||||||
private ?User $managedBy = null;
|
private ?User $managedBy = null;
|
||||||
@@ -165,6 +200,34 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
|||||||
$this->uuid = Uuid::v4()->toRfc4122();
|
$this->uuid = Uuid::v4()->toRfc4122();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A copy is a new record, not a second view of the original: it gets its own identity and
|
||||||
|
* its own access link token — sharing that token would put two records behind one customer
|
||||||
|
* link — and every mark the lifecycle left is wiped, so the copy starts as an Entwurf that
|
||||||
|
* has never been in front of a customer. The origin is rewritten too: however the original
|
||||||
|
* came about, the copy comes about as an offer the office prepared, which is what keeps the
|
||||||
|
* field truthful.
|
||||||
|
*
|
||||||
|
* The price snapshot, the service snapshots and all four discounts are deliberately carried
|
||||||
|
* over untouched. A copy is made to vary an offer that was already quoted, so it has to show
|
||||||
|
* the same numbers as the record it came from rather than a re-quote at today's catalog
|
||||||
|
* prices; saving it through the edit form recomputes it.
|
||||||
|
*/
|
||||||
|
public function __clone()
|
||||||
|
{
|
||||||
|
$this->id = null;
|
||||||
|
$this->uuid = Uuid::v4()->toRfc4122();
|
||||||
|
$this->status = AccommodationBookingStatus::Draft;
|
||||||
|
$this->origin = AccommodationBookingOrigin::Offer;
|
||||||
|
$this->accessLinkIssuedAt = null;
|
||||||
|
$this->acceptedAt = null;
|
||||||
|
$this->confirmedAt = null;
|
||||||
|
$this->createdAt = null;
|
||||||
|
$this->updatedAt = null;
|
||||||
|
$this->createdBy = null;
|
||||||
|
$this->updatedBy = null;
|
||||||
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -220,6 +283,25 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
|||||||
return $this->dateFrom->diff($this->dateTo)->days;
|
return $this->dateFrom->diff($this->dateTo)->days;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reported on dateTo, the field a mistyped year is corrected in — a stay of 200 years is
|
||||||
|
* always the departure date being wrong, never the arrival.
|
||||||
|
*/
|
||||||
|
#[Assert\Callback]
|
||||||
|
public function validateStayLength(ExecutionContextInterface $context): void
|
||||||
|
{
|
||||||
|
if (null === $this->dateFrom || null === $this->dateTo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->getNights() > self::MAX_NIGHTS) {
|
||||||
|
$context->buildViolation('Der Aufenthalt darf höchstens {{ max }} Nächte umfassen — bitte Anreise und Abreise prüfen.')
|
||||||
|
->setParameter('{{ max }}', (string) self::MAX_NIGHTS)
|
||||||
|
->atPath('dateTo')
|
||||||
|
->addViolation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function getPaxCount(): int
|
public function getPaxCount(): int
|
||||||
{
|
{
|
||||||
return $this->paxCount;
|
return $this->paxCount;
|
||||||
@@ -324,9 +406,17 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
|||||||
return $this->priceBreakdown;
|
return $this->priceBreakdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $priceBreakdown */
|
/**
|
||||||
|
* @param array<string, mixed> $priceBreakdown
|
||||||
|
*
|
||||||
|
* @throws ImplausibleBookingPriceException if the total is too large to be a real price
|
||||||
|
*/
|
||||||
public function setPriceSnapshot(array $priceBreakdown, int $totalPrice, string $currency, int $version): self
|
public function setPriceSnapshot(array $priceBreakdown, int $totalPrice, string $currency, int $version): self
|
||||||
{
|
{
|
||||||
|
if ($totalPrice > self::MAX_TOTAL_PRICE) {
|
||||||
|
throw new ImplausibleBookingPriceException($totalPrice, $this->paxCount, $this->getNights());
|
||||||
|
}
|
||||||
|
|
||||||
$this->priceBreakdown = $priceBreakdown;
|
$this->priceBreakdown = $priceBreakdown;
|
||||||
$this->totalPrice = $totalPrice;
|
$this->totalPrice = $totalPrice;
|
||||||
$this->pricingCurrency = $currency;
|
$this->pricingCurrency = $currency;
|
||||||
@@ -638,6 +728,30 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
|||||||
return $this;
|
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
|
public function getManagedBy(): ?User
|
||||||
{
|
{
|
||||||
return $this->managedBy;
|
return $this->managedBy;
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ use Doctrine\ORM\Mapping as ORM;
|
|||||||
/**
|
/**
|
||||||
* Bookkeeping for the scheduled contingent sync of a single accommodation.
|
* Bookkeeping for the scheduled contingent sync of a single accommodation.
|
||||||
*
|
*
|
||||||
* Holds the fingerprint of the stored contingent days, which is how the sync decides whether
|
* `changedAt` records when the sync last saw a day appear, vanish or switch status within the
|
||||||
* anything actually changed, and `changedAt` records when it last did.
|
* horizon it had already reached; `horizonTo` is how far that reach extends.
|
||||||
*/
|
*/
|
||||||
#[ORM\Entity(repositoryClass: ContingentSyncStateRepository::class)]
|
#[ORM\Entity(repositoryClass: ContingentSyncStateRepository::class)]
|
||||||
#[ORM\Table(name: 'contingent_sync_state')]
|
#[ORM\Table(name: 'contingent_sync_state')]
|
||||||
@@ -28,13 +28,7 @@ class ContingentSyncState
|
|||||||
private ?Accommodation $accommodation = null;
|
private ?Accommodation $accommodation = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* sha256 over the stored contingent days, contingent status only.
|
* Last time the stored snapshot actually changed. Kept for operators, not exposed via the API.
|
||||||
*/
|
|
||||||
#[ORM\Column(length: 64)]
|
|
||||||
private string $contentHash = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Last time the fingerprint actually changed. Kept for operators, not exposed via the API.
|
|
||||||
*/
|
*/
|
||||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
private ?\DateTimeImmutable $changedAt = null;
|
private ?\DateTimeImmutable $changedAt = null;
|
||||||
@@ -76,18 +70,6 @@ class ContingentSyncState
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getContentHash(): string
|
|
||||||
{
|
|
||||||
return $this->contentHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setContentHash(string $contentHash): self
|
|
||||||
{
|
|
||||||
$this->contentHash = $contentHash;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getChangedAt(): ?\DateTimeImmutable
|
public function getChangedAt(): ?\DateTimeImmutable
|
||||||
{
|
{
|
||||||
return $this->changedAt;
|
return $this->changedAt;
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\AbstractClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An OAuth2 client, extended with the roles an account must hold to authorize it.
|
||||||
|
*
|
||||||
|
* The bundle ships its own Client model and maps it for us. Replacing it (via
|
||||||
|
* league_oauth2_server.client.classname) is the supported way to add a column: the bundle
|
||||||
|
* maps AbstractClient as a mapped superclass and carries name, secret, redirectUris,
|
||||||
|
* grants, scopes, active and allowPlainTextPkce, then stops mapping its own Client, so
|
||||||
|
* this class only has to declare the table, the primary key and what it adds.
|
||||||
|
*
|
||||||
|
* Why the requirement lives here rather than in configuration: the alternative was a map
|
||||||
|
* in services.yaml keyed on the client identifier, and identifiers differ per environment,
|
||||||
|
* so that map would have needed an env var per client and would have drifted from the
|
||||||
|
* client table it describes. A column cannot drift from its own row.
|
||||||
|
*
|
||||||
|
* Nothing validates the contents. A misspelled role is not an error, it is a client that
|
||||||
|
* authorizes nobody, so AuthorizationCodeListener logs the requirement whenever it
|
||||||
|
* refuses — that is what makes the mistake visible without a database query.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'oauth2_client')]
|
||||||
|
class OAuth2Client extends AbstractClient
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Declared protected on AbstractClient and mapped by the bundle only for its own
|
||||||
|
* Client, so the custom class has to map it itself. The type restates the parent's
|
||||||
|
* contract, which redeclaring the property would otherwise widen.
|
||||||
|
*
|
||||||
|
* @var non-empty-string
|
||||||
|
*/
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\Column(type: 'string', length: 32)]
|
||||||
|
protected string $identifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roles that entitle an account to authorize this client. An empty list denies
|
||||||
|
* everyone: a row nobody has configured and a client that needs no roles are the same
|
||||||
|
* stored value, and of the two readings only "deny" is safe.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $requiredRoles = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function getRequiredRoles(): array
|
||||||
|
{
|
||||||
|
return $this->requiredRoles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $requiredRoles
|
||||||
|
*/
|
||||||
|
public function setRequiredRoles(array $requiredRoles): self
|
||||||
|
{
|
||||||
|
$this->requiredRoles = $requiredRoles;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Repository\UserRepository;
|
|||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
@@ -15,6 +16,22 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[ORM\Column(type: 'integer')]
|
#[ORM\Column(type: 'integer')]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account's external identifier, exported as the OAuth2 `sub` claim.
|
||||||
|
*
|
||||||
|
* Separate from the primary key on purpose: `sub` is handed to other applications, which
|
||||||
|
* store it and match their own accounts on it, so it has to be stable, unique and free of
|
||||||
|
* anything the row's position in the table implies. The auto-increment id satisfied the
|
||||||
|
* first two and neither of the last: it disclosed how many accounts exist and made one
|
||||||
|
* account's identifier a guess away from the next.
|
||||||
|
*
|
||||||
|
* v4 rather than the framework's default v7 (see config/packages/uid.yaml): v7 embeds its
|
||||||
|
* creation timestamp and sorts, which is useful for a key and is exactly the leak this
|
||||||
|
* column exists to avoid. Matches Groups\AccommodationBooking::$uuid in shape and version.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||||
|
private string $uuid;
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 180, unique: true)]
|
#[ORM\Column(type: 'string', length: 180, unique: true)]
|
||||||
private ?string $email;
|
private ?string $email;
|
||||||
|
|
||||||
@@ -54,6 +71,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
public function __construct(string $email)
|
public function __construct(string $email)
|
||||||
{
|
{
|
||||||
$this->email = $email;
|
$this->email = $email;
|
||||||
|
$this->uuid = Uuid::v4()->toRfc4122();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
@@ -61,6 +79,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
return $this->id;
|
return $this->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getUuid(): string
|
||||||
|
{
|
||||||
|
return $this->uuid;
|
||||||
|
}
|
||||||
|
|
||||||
public function getEmail(): ?string
|
public function getEmail(): ?string
|
||||||
{
|
{
|
||||||
return $this->email;
|
return $this->email;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ enum PriceType: string
|
|||||||
|
|
||||||
public function priority(): int
|
public function priority(): int
|
||||||
{
|
{
|
||||||
return match($this) {
|
return match ($this) {
|
||||||
self::OVERRIDE => 1,
|
self::OVERRIDE => 1,
|
||||||
self::DISCOUNT => 2,
|
self::DISCOUNT => 2,
|
||||||
};
|
};
|
||||||
@@ -17,7 +17,7 @@ enum PriceType: string
|
|||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
return match($this) {
|
return match ($this) {
|
||||||
self::OVERRIDE => 'Override',
|
self::OVERRIDE => 'Override',
|
||||||
self::DISCOUNT => 'Rabatt',
|
self::DISCOUNT => 'Rabatt',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who asked for a double opt-in mail — the recipient themselves, or the applicant of a
|
||||||
|
* booking they are travelling on. Only the wording of the confirmation mail depends on
|
||||||
|
* this: a third-party sign-up has to name who requested it and make clear that nothing
|
||||||
|
* is subscribed without the recipient's own click. Confirmation itself stays
|
||||||
|
* context-agnostic, so the value never has to be persisted.
|
||||||
|
*/
|
||||||
|
enum NewsletterOptInContext: string
|
||||||
|
{
|
||||||
|
case Own = 'own';
|
||||||
|
case Companion = 'companion';
|
||||||
|
|
||||||
|
public function subject(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Own => 'Deine Anmeldung zum E&P Newsletter',
|
||||||
|
self::Companion => 'Möchtest du den E&P Newsletter erhalten?',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function template(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Own => 'email/newsletter_opt_in.html.twig',
|
||||||
|
self::Companion => 'email/newsletter_opt_in_companion.html.twig',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\EventListener;
|
namespace App\EventListener;
|
||||||
|
|
||||||
|
use App\Entity\OAuth2Client;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Security\Role;
|
||||||
use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent;
|
use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent;
|
||||||
use League\Bundle\OAuth2ServerBundle\OAuth2Events;
|
use League\Bundle\OAuth2ServerBundle\OAuth2Events;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -14,14 +16,21 @@ use Symfony\Component\HttpFoundation\RedirectResponse;
|
|||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles OAuth2 authorization code grant flow.
|
* Handles OAuth2 authorization code grant flow.
|
||||||
*
|
*
|
||||||
* Automatically approves authorization requests for authenticated users without
|
* Redirects anonymous users to login, then decides whether the account may authorize the
|
||||||
* consent prompts. Redirects anonymous users to login. For external OAuth2
|
* client that asked. Consent is deliberately not part of that decision: every client is
|
||||||
* requests (flagged via session), logs out the user after authorization to
|
* one of our own applications, so there is nothing for the user to weigh up. What the user
|
||||||
* prevent session hijacking.
|
* cannot be asked, though, is whether they are entitled to the application at all, and
|
||||||
|
* that is what this listener answers — a client declares the roles it requires in
|
||||||
|
* OAuth2Client::$requiredRoles and an account holding none of them is refused here rather
|
||||||
|
* than handed a token the client will reject for reasons it cannot explain.
|
||||||
|
*
|
||||||
|
* For external OAuth2 requests (flagged via session), logs out the user after a successful
|
||||||
|
* authorization to prevent session hijacking.
|
||||||
*/
|
*/
|
||||||
#[AsEventListener(event: OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, method: 'onAuthorizationRequestResolve')]
|
#[AsEventListener(event: OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, method: 'onAuthorizationRequestResolve')]
|
||||||
class AuthorizationCodeListener
|
class AuthorizationCodeListener
|
||||||
@@ -31,6 +40,7 @@ class AuthorizationCodeListener
|
|||||||
private readonly RequestStack $requestStack,
|
private readonly RequestStack $requestStack,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
private readonly LoggerInterface $authenticationLogger,
|
private readonly LoggerInterface $authenticationLogger,
|
||||||
|
private readonly Environment $twig,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,13 +63,81 @@ class AuthorizationCodeListener
|
|||||||
);
|
);
|
||||||
$event->setResponse($response);
|
$event->setResponse($response);
|
||||||
$this->authenticationLogger->info('Authorization request without session');
|
$this->authenticationLogger->info('Authorization request without session');
|
||||||
} else {
|
|
||||||
$event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED);
|
return;
|
||||||
// in case this authorization request has been flagged as external in login controller,
|
}
|
||||||
// immediately logout the current user (see App\Controller\Core\Security\LoginController).
|
|
||||||
if (true === $request->getSession()->get('_oauth2', false)) {
|
$client = $event->getClient();
|
||||||
$this->security->logout(false);
|
|
||||||
}
|
// the event is typed to the bundle's ClientInterface, which knows nothing about the
|
||||||
|
// column below: anything that is not our entity is a client we cannot have configured
|
||||||
|
if (false === $client instanceof OAuth2Client) {
|
||||||
|
$this->deny($event, $user, $client->getIdentifier(), [], 'unexpected client class');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$required = $client->getRequiredRoles();
|
||||||
|
|
||||||
|
// An empty requirement is not "no requirement": it is the state a client row starts
|
||||||
|
// in, so it means nobody has said who may use this client yet.
|
||||||
|
if ([] === $required) {
|
||||||
|
$this->deny($event, $user, $client->getName(), $required, 'client has no roles configured');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectiveOnly(), never getRoles(): a *_PENDING marker is a CRM nomination that
|
||||||
|
// grants nothing until an administrator approves it, and matching the raw role set
|
||||||
|
// would let a nomination open a staff application on its own.
|
||||||
|
if ([] === array_intersect($required, Role::effectiveOnly($user->getRoles()))) {
|
||||||
|
$this->deny($event, $user, $client->getName(), $required, 'insufficient roles');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED);
|
||||||
|
|
||||||
|
// in case this authorization request has been flagged as external in login controller,
|
||||||
|
// immediately logout the current user (see App\Controller\SecurityController).
|
||||||
|
if (true === $request->getSession()->get('_oauth2', false)) {
|
||||||
|
$this->security->logout(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses the request and explains it on our own page.
|
||||||
|
*
|
||||||
|
* The resolution is left at its AUTHORIZATION_DENIED default; setting a response is what
|
||||||
|
* keeps the user here instead of bouncing them back to a client that would only be able
|
||||||
|
* to report that no authorization code arrived. The session is deliberately left intact
|
||||||
|
* — the user has to still be signed in to read the page and navigate away from it.
|
||||||
|
*
|
||||||
|
* Logged with the client's requirement but never with the user's roles: the requirement
|
||||||
|
* is configuration, and it is what makes a misspelled entry diagnosable from the log,
|
||||||
|
* while the account's roles are personal data and the auth channel is database-backed.
|
||||||
|
*
|
||||||
|
* @param list<string> $required
|
||||||
|
*/
|
||||||
|
private function deny(
|
||||||
|
AuthorizationRequestResolveEvent $event,
|
||||||
|
User $user,
|
||||||
|
string $client,
|
||||||
|
array $required,
|
||||||
|
string $reason,
|
||||||
|
): void {
|
||||||
|
$this->authenticationLogger->info('Authorization request denied', [
|
||||||
|
'client' => $client,
|
||||||
|
'user_id' => $user->getId(),
|
||||||
|
'required_roles' => $required,
|
||||||
|
'reason' => $reason,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$event->setResponse(new Response(
|
||||||
|
// oauth2: true drops hx-boost in base.html.twig -- this page is rendered
|
||||||
|
// mid-redirect-chain, where boosting breaks the navigation
|
||||||
|
$this->twig->render('security/oauth2_denied.html.twig', ['client' => $client, 'oauth2' => true]),
|
||||||
|
Response::HTTP_FORBIDDEN
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\EventListener;
|
||||||
|
|
||||||
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
|
use Symfony\Component\HttpFoundation\IpUtils;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
use Symfony\Component\HttpKernel\KernelEvents;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
#[AsEventListener(event: KernelEvents::REQUEST, method: 'onKernelRequest')]
|
||||||
|
class MaintenanceModeListener
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
private array $config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $options
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly Environment $twig, array $options)
|
||||||
|
{
|
||||||
|
$this->config = $this->resolveConfig($options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onKernelRequest(RequestEvent $event): void
|
||||||
|
{
|
||||||
|
if (false === $event->isMainRequest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === $this->config['enabled']) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $event->getRequest();
|
||||||
|
|
||||||
|
// Check requested path against whitelist
|
||||||
|
foreach ($this->config['whitelisted_paths'] as $whitelistedPath) {
|
||||||
|
$prefix = rtrim((string) $whitelistedPath, '/');
|
||||||
|
|
||||||
|
if ($request->getPathInfo() === $prefix || str_starts_with($request->getPathInfo(), $prefix.'/')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user's IP against whitelist
|
||||||
|
if (true === IpUtils::checkIp($request->getClientIp(), $this->config['whitelisted_ips'])) {
|
||||||
|
$request->attributes->set('_maintenance_mode_allowed', true);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check requested route against whitelist
|
||||||
|
if (true === in_array($request->attributes->get('_route'), $this->config['whitelisted_routes'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = $this->twig->render('maintenance/index.html.twig');
|
||||||
|
|
||||||
|
$event->setResponse(new Response($content, Response::HTTP_SERVICE_UNAVAILABLE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $options
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function resolveConfig(array $options): array
|
||||||
|
{
|
||||||
|
$optionsResolver = new OptionsResolver();
|
||||||
|
$optionsResolver
|
||||||
|
->setDefaults([
|
||||||
|
'enabled' => false,
|
||||||
|
'whitelisted_ips' => [],
|
||||||
|
'whitelisted_routes' => [],
|
||||||
|
'whitelisted_paths' => [],
|
||||||
|
])
|
||||||
|
;
|
||||||
|
|
||||||
|
return $optionsResolver->resolve($options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exception;
|
||||||
|
|
||||||
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a computed accommodation booking total is too large to be a real price.
|
||||||
|
*
|
||||||
|
* The total is a product of pax count, nights and catalog prices, so a single mistyped figure
|
||||||
|
* anywhere in that chain can produce a number nobody could owe. Storing it is pointless — it
|
||||||
|
* would go out on an offer — and a signed INT column answers it with SQLSTATE 22003, which says
|
||||||
|
* nothing about where the mistake is. Failing here instead names the price and the inputs it
|
||||||
|
* came from.
|
||||||
|
*/
|
||||||
|
class ImplausibleBookingPriceException extends \RuntimeException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* What the backoffice is told. The total itself is in the log; the actionable part is the
|
||||||
|
* house's price rows, which is where the stray digit is.
|
||||||
|
*/
|
||||||
|
public const string FORM_ERROR_MESSAGE = 'Der berechnete Gesamtpreis ist unplausibel hoch. Bitte die Preise und Leistungen des Gruppenhauses prüfen.';
|
||||||
|
|
||||||
|
public function __construct(int $totalPrice, int $paxCount, int $nights, ?\Throwable $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct(
|
||||||
|
sprintf(
|
||||||
|
'Refusing to store an implausible booking total of %d (ceiling %d, in minor units) for %d people over %d nights — check the catalog prices for this house.',
|
||||||
|
$totalPrice,
|
||||||
|
AccommodationBooking::MAX_TOTAL_PRICE,
|
||||||
|
$paxCount,
|
||||||
|
$nights,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
$previous,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\Form\Model\AccommodationBookingDto;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contact data of a group booking — shared by step 3 of the self-service flow and by
|
||||||
|
* the page that completes an office-made offer before it can be accepted, so both ask
|
||||||
|
* for the same fields under the same constraints.
|
||||||
|
*
|
||||||
|
* @extends AbstractType<AccommodationBookingDto>
|
||||||
|
*/
|
||||||
|
class AccommodationContactType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('groupName', TextType::class, [
|
||||||
|
'label' => 'Name der Gruppe',
|
||||||
|
])
|
||||||
|
->add('salutation', ChoiceType::class, [
|
||||||
|
'label' => 'Anrede',
|
||||||
|
'choices' => [
|
||||||
|
'Herr' => 'Herr',
|
||||||
|
'Frau' => 'Frau',
|
||||||
|
'divers' => 'divers',
|
||||||
|
],
|
||||||
|
'expanded' => false,
|
||||||
|
'multiple' => false,
|
||||||
|
'placeholder' => 'Bitte wählen',
|
||||||
|
])
|
||||||
|
->add('firstName', TextType::class, [
|
||||||
|
'label' => 'Vorname',
|
||||||
|
])
|
||||||
|
->add('lastName', TextType::class, [
|
||||||
|
'label' => 'Nachname',
|
||||||
|
])
|
||||||
|
->add('email', EmailType::class, [
|
||||||
|
'label' => 'E-Mail-Adresse',
|
||||||
|
// An offer's access link and every notification already go to this address.
|
||||||
|
'disabled' => $options['email_readonly'],
|
||||||
|
])
|
||||||
|
->add('phone', TextType::class, [
|
||||||
|
'label' => 'Telefon',
|
||||||
|
])
|
||||||
|
->add('street', TextType::class, [
|
||||||
|
'label' => 'Straße und Hausnummer',
|
||||||
|
])
|
||||||
|
->add('zip', TextType::class, [
|
||||||
|
'label' => 'Postleitzahl',
|
||||||
|
])
|
||||||
|
->add('city', TextType::class, [
|
||||||
|
'label' => 'Ort',
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => AccommodationBookingDto::class,
|
||||||
|
'validation_groups' => ['contact'],
|
||||||
|
'email_readonly' => false,
|
||||||
|
]);
|
||||||
|
$resolver->setAllowedTypes('email_readonly', 'bool');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,9 @@ use App\Service\AdditionalServiceExclusionResolver;
|
|||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
use Symfony\Component\Form\FormEvent;
|
use Symfony\Component\Form\FormEvent;
|
||||||
use Symfony\Component\Form\FormEvents;
|
use Symfony\Component\Form\FormEvents;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,6 +96,6 @@ class AccommodationStep2Type extends AbstractType
|
|||||||
'max_adolescent_age' => 0,
|
'max_adolescent_age' => 0,
|
||||||
]);
|
]);
|
||||||
$resolver->setAllowedTypes('max_adolescent_age', 'int');
|
$resolver->setAllowedTypes('max_adolescent_age', 'int');
|
||||||
$resolver->setAllowedTypes('additional_services', AdditionalService::class . '[]');
|
$resolver->setAllowedTypes('additional_services', AdditionalService::class.'[]');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,10 +6,7 @@ namespace App\Form;
|
|||||||
|
|
||||||
use App\Form\Model\AccommodationBookingDto;
|
use App\Form\Model\AccommodationBookingDto;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
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\FormBuilderInterface;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
@@ -21,41 +18,6 @@ class AccommodationStep3Type extends AbstractType
|
|||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
{
|
{
|
||||||
$builder
|
$builder
|
||||||
->add('groupName', TextType::class, [
|
|
||||||
'label' => 'Name der Gruppe',
|
|
||||||
])
|
|
||||||
->add('salutation', ChoiceType::class, [
|
|
||||||
'label' => 'Anrede',
|
|
||||||
'choices' => [
|
|
||||||
'Herr' => 'Herr',
|
|
||||||
'Frau' => 'Frau',
|
|
||||||
'divers' => 'divers',
|
|
||||||
],
|
|
||||||
'expanded' => false,
|
|
||||||
'multiple' => false,
|
|
||||||
'placeholder' => 'Bitte wählen',
|
|
||||||
])
|
|
||||||
->add('firstName', TextType::class, [
|
|
||||||
'label' => 'Vorname',
|
|
||||||
])
|
|
||||||
->add('lastName', TextType::class, [
|
|
||||||
'label' => 'Nachname',
|
|
||||||
])
|
|
||||||
->add('email', EmailType::class, [
|
|
||||||
'label' => 'E-Mail-Adresse',
|
|
||||||
])
|
|
||||||
->add('phone', TextType::class, [
|
|
||||||
'label' => 'Telefon',
|
|
||||||
])
|
|
||||||
->add('street', TextType::class, [
|
|
||||||
'label' => 'Straße und Hausnummer',
|
|
||||||
])
|
|
||||||
->add('zip', TextType::class, [
|
|
||||||
'label' => 'Postleitzahl',
|
|
||||||
])
|
|
||||||
->add('city', TextType::class, [
|
|
||||||
'label' => 'Ort',
|
|
||||||
])
|
|
||||||
->add('remarks', TextareaType::class, [
|
->add('remarks', TextareaType::class, [
|
||||||
'label' => false,
|
'label' => false,
|
||||||
'required' => false,
|
'required' => false,
|
||||||
@@ -71,4 +33,9 @@ class AccommodationStep3Type extends AbstractType
|
|||||||
'validation_groups' => ['step_3'],
|
'validation_groups' => ['step_3'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getParent(): string
|
||||||
|
{
|
||||||
|
return AccommodationContactType::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\DateType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
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\TextareaType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
use Symfony\Component\Form\FormInterface;
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints\Positive;
|
||||||
use Symfony\Component\Validator\Constraints\Range;
|
use Symfony\Component\Validator\Constraints\Range;
|
||||||
|
|
||||||
/** @extends AbstractType<AccommodationBooking> */
|
/** @extends AbstractType<AccommodationBooking> */
|
||||||
@@ -158,6 +160,23 @@ class AccommodationBookingType extends AbstractType
|
|||||||
new Range(min: 1, max: 100),
|
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, [
|
->add('remarks', TextareaType::class, [
|
||||||
'label' => 'Bemerkungen',
|
'label' => 'Bemerkungen',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
@@ -187,6 +206,7 @@ class AccommodationBookingType extends AbstractType
|
|||||||
'current_board_service' => null,
|
'current_board_service' => null,
|
||||||
'current_additional_services' => [],
|
'current_additional_services' => [],
|
||||||
'assignable_managers' => [],
|
'assignable_managers' => [],
|
||||||
|
'currency' => 'EUR',
|
||||||
]);
|
]);
|
||||||
$resolver->setAllowedTypes('max_adolescent_age', 'int');
|
$resolver->setAllowedTypes('max_adolescent_age', 'int');
|
||||||
$resolver->setAllowedTypes('board_services', 'array');
|
$resolver->setAllowedTypes('board_services', 'array');
|
||||||
@@ -194,5 +214,6 @@ class AccommodationBookingType extends AbstractType
|
|||||||
$resolver->setAllowedTypes('current_board_service', ['null', BoardService::class]);
|
$resolver->setAllowedTypes('current_board_service', ['null', BoardService::class]);
|
||||||
$resolver->setAllowedTypes('current_additional_services', 'array');
|
$resolver->setAllowedTypes('current_additional_services', 'array');
|
||||||
$resolver->setAllowedTypes('assignable_managers', 'array');
|
$resolver->setAllowedTypes('assignable_managers', 'array');
|
||||||
|
$resolver->setAllowedTypes('currency', 'string');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,12 +37,12 @@ class AccommodationPriceType extends AbstractType
|
|||||||
'class' => PriceType::class,
|
'class' => PriceType::class,
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'placeholder' => 'Standard',
|
'placeholder' => 'Standard',
|
||||||
'choice_label' => fn($item) => $item->label(),
|
'choice_label' => fn ($item) => $item->label(),
|
||||||
])
|
])
|
||||||
->add('season', EnumType::class, [
|
->add('season', EnumType::class, [
|
||||||
'label' => 'Saison',
|
'label' => 'Saison',
|
||||||
'class' => Season::class,
|
'class' => Season::class,
|
||||||
'choice_label' => fn($item) => $item->token(),
|
'choice_label' => fn ($item) => $item->token(),
|
||||||
])
|
])
|
||||||
->add('includedPax', IntegerType::class, [
|
->add('includedPax', IntegerType::class, [
|
||||||
'label' => 'Inklusiv-Personen',
|
'label' => 'Inklusiv-Personen',
|
||||||
|
|||||||
Loaded 100 of 325 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user