Compare commits
33
Commits
1d3263373e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+46
-33
@@ -1,21 +1,24 @@
|
|||||||
|
name: myep
|
||||||
type: php
|
type: php
|
||||||
docroot: public
|
docroot: public
|
||||||
php_version: "8.4"
|
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 ###
|
||||||
|
|
||||||
@@ -96,6 +96,8 @@ APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
|
|||||||
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
|
||||||
|
|||||||
Generated
+300
-441
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -15,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:
|
||||||
@@ -31,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
|
||||||
@@ -260,6 +267,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: ~
|
||||||
@@ -268,6 +276,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'
|
||||||
@@ -299,6 +308,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%'
|
||||||
|
|||||||
+30
-33
@@ -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,74 +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')
|
|
||||||
->setRemoteUser('p704161')
|
|
||||||
->setForwardAgent(true)
|
|
||||||
->setSshMultiplexing(true)
|
|
||||||
->setDeployPath('/home/www/p704161/html/myep-staging')
|
|
||||||
->set('writable_mode', 'chmod')
|
|
||||||
->set('http_user', 'p704161')
|
|
||||||
->set('bin/php', '/usr/local/bin/php')
|
|
||||||
->set('rsync_src', __DIR__)
|
|
||||||
->set('rsync', $rsyncOptions)
|
|
||||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging')
|
|
||||||
->add('shared_files', [
|
|
||||||
'public/.htpasswd',
|
|
||||||
])
|
|
||||||
;
|
|
||||||
|
|
||||||
host('hetzner')
|
|
||||||
->setHostname('dedi10193.your-server.de')
|
->setHostname('dedi10193.your-server.de')
|
||||||
|
->setDeployPath('/usr/www/users/myepsf/staging')
|
||||||
->setRemoteUser('myepsf')
|
->setRemoteUser('myepsf')
|
||||||
->setForwardAgent(true)
|
->setForwardAgent(true)
|
||||||
->setSshMultiplexing(true)
|
->setSshMultiplexing(true)
|
||||||
->setDeployPath('/usr/home/myepsf/public_html/myep')
|
|
||||||
->set('bin/php', '/usr/bin/php')
|
|
||||||
->set('http_user', 'myepsf')
|
|
||||||
->set('rsync_src', __DIR__)
|
|
||||||
->set('rsync', $rsyncOptions)
|
|
||||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging')
|
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging')
|
||||||
->add('shared_files', [
|
->add('shared_files', [
|
||||||
|
'public/.htaccess',
|
||||||
'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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -379,9 +379,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` (string), `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
|
||||||
@@ -394,7 +394,28 @@ 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`** — opaque, stable, unique per MyE&P account. The only claim that identifies an account.
|
||||||
|
Match your local user on it and store it.
|
||||||
|
- **`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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -166,6 +166,18 @@ genuinely missing, which is a useful way to audit an address: the placeholder ac
|
|||||||
fails on `Telefon-Mobil` and `Nationalität` because its `Vorgang_Details` record has only
|
fails on `Telefon-Mobil` and `Nationalität` because its `Vorgang_Details` record has only
|
||||||
`telefonprivat` and no `nationalitaet`.
|
`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
|
Note `650` again: both of those are hard rejections delivered under the code
|
||||||
`Notification::isError()` treats as success.
|
`Notification::isError()` treats as success.
|
||||||
|
|
||||||
@@ -196,7 +208,7 @@ the same way. Ask `MOEGLICHEAENDERUNGEN` first (`<idreise>`), which answers:
|
|||||||
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`ApiClient.php:233`) |
|
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`ApiClient.php:233`) |
|
||||||
| `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below |
|
| `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 |
|
| `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()` |
|
| `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()` |
|
| `anmelder/bemerkung` | — | yes | Via `PersonalData::toPayload()` |
|
||||||
| `teilnehmerliste` | yes | yes | |
|
| `teilnehmerliste` | yes | yes | |
|
||||||
| `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | |
|
| `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | |
|
||||||
|
|||||||
@@ -22,12 +22,42 @@ Symfony roles:
|
|||||||
| `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` |
|
||||||
| 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 +427,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,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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -20,4 +20,38 @@ final class XmlCrawlerFactory
|
|||||||
|
|
||||||
return $crawler;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,9 +281,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
|
||||||
@@ -293,8 +293,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class ApiResponseParser extends AbstractParser
|
|||||||
$crawler = XmlCrawlerFactory::create($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 +56,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:
|
||||||
@@ -92,6 +93,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,21 @@ 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,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int|string, string> $houseManagerIds "Hausleitung" selection id => hotel code
|
* @param array<int|string, string> $houseManagerIds "Hausleitung" selection id => hotel code
|
||||||
@@ -71,20 +81,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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,13 @@ 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.
|
||||||
|
$data->subject = (string) $user->getId();
|
||||||
|
$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());
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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()]),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class AdditionalServiceType extends AbstractType
|
|||||||
->add('type', EnumType::class, [
|
->add('type', EnumType::class, [
|
||||||
'label' => 'Preistyp',
|
'label' => 'Preistyp',
|
||||||
'class' => AdditionalServiceTypeEnum::class,
|
'class' => AdditionalServiceTypeEnum::class,
|
||||||
'choice_label' => fn($item) => $item->label(),
|
'choice_label' => fn ($item) => $item->label(),
|
||||||
])
|
])
|
||||||
->add('price', MoneyType::class, [
|
->add('price', MoneyType::class, [
|
||||||
'label' => 'Preis',
|
'label' => 'Preis',
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class AccommodationBookingDto
|
|||||||
#[Assert\NotNull(message: 'required', groups: ['step_2'])]
|
#[Assert\NotNull(message: 'required', groups: ['step_2'])]
|
||||||
public ?int $selectedBoardServiceId = null;
|
public ?int $selectedBoardServiceId = null;
|
||||||
|
|
||||||
/** @var list<int>*/
|
/** @var list<int> */
|
||||||
public array $selectedAdditionalServiceIds = [];
|
public array $selectedAdditionalServiceIds = [];
|
||||||
|
|
||||||
public bool $isInquiry = false;
|
public bool $isInquiry = false;
|
||||||
@@ -43,33 +43,35 @@ class AccommodationBookingDto
|
|||||||
/** @var array<string, mixed> */
|
/** @var array<string, mixed> */
|
||||||
public array $priceBreakdown = [];
|
public array $priceBreakdown = [];
|
||||||
|
|
||||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
// `contact` is the subset an office-made offer is checked against before the customer
|
||||||
|
// may accept it — the same constraints the customer meets in step 3 of a self-service booking.
|
||||||
|
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||||
public ?string $groupName = null;
|
public ?string $groupName = null;
|
||||||
|
|
||||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||||
#[Assert\Choice(choices: ['Herr', 'Frau', 'divers'], groups: ['step_3'])]
|
#[Assert\Choice(choices: ['Herr', 'Frau', 'divers'], groups: ['step_3', 'contact'])]
|
||||||
public ?string $salutation = null;
|
public ?string $salutation = null;
|
||||||
|
|
||||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||||
public ?string $firstName = null;
|
public ?string $firstName = null;
|
||||||
|
|
||||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||||
public ?string $lastName = null;
|
public ?string $lastName = null;
|
||||||
|
|
||||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||||
#[Assert\Email(message: 'invalid', groups: ['step_3'])]
|
#[Assert\Email(message: 'invalid', groups: ['step_3', 'contact'])]
|
||||||
public ?string $email = null;
|
public ?string $email = null;
|
||||||
|
|
||||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||||
public ?string $phone = null;
|
public ?string $phone = null;
|
||||||
|
|
||||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||||
public ?string $street = null;
|
public ?string $street = null;
|
||||||
|
|
||||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||||
public ?string $zip = null;
|
public ?string $zip = null;
|
||||||
|
|
||||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||||
public ?string $city = null;
|
public ?string $city = null;
|
||||||
|
|
||||||
public ?string $remarks = null;
|
public ?string $remarks = null;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class BookingEditContext
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param array<int, ParticipantCardDataDto>|null $cardsData
|
* @param array<int, ParticipantCardDataDto>|null $cardsData
|
||||||
* @param array<int, list<string>> $missingValueLabelsByParticipantIndex
|
* @param array<int, list<string>> $missingValueLabelsByParticipantIndex
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly BookingDto $bookingDto,
|
public readonly BookingDto $bookingDto,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Form\Model;
|
|||||||
use App\Validator\Constraints as AppAssert;
|
use App\Validator\Constraints as AppAssert;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
|
||||||
use function Symfony\Component\String\u;
|
use function Symfony\Component\String\u;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -154,10 +154,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
$attributes['data-description'] = $service->description;
|
$attributes['data-description'] = $service->description;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make readonly if service is unavailable (intelligently handles edit mode)
|
// Make readonly if the service is not bookable for this participant
|
||||||
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'courses')) {
|
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -213,9 +213,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Make readonly if service is unavailable (only if not already mandatory)
|
// Make readonly if service is unavailable (only if not already mandatory)
|
||||||
if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'additionalServices')) {
|
if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -270,9 +270,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
|
|
||||||
// Make readonly if service is unavailable (only if not already mandatory)
|
// Make readonly if service is unavailable (only if not already mandatory)
|
||||||
if (false === $service->mandatory
|
if (false === $service->mandatory
|
||||||
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'board')) {
|
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -330,10 +330,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
return $attributes;
|
return $attributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make readonly if service is unavailable (intelligently handles edit mode)
|
// Make readonly if the service is not bookable for this participant
|
||||||
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'veg')) {
|
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -392,9 +392,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
|
|
||||||
// Make readonly if service is unavailable (only if not already mandatory)
|
// Make readonly if service is unavailable (only if not already mandatory)
|
||||||
if (false === $service->mandatory
|
if (false === $service->mandatory
|
||||||
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'rentals')) {
|
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -527,10 +527,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
$attributes['data-description'] = $service->description;
|
$attributes['data-description'] = $service->description;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make readonly if service is unavailable (intelligently handles edit mode)
|
// Make readonly if the service is not bookable for this participant
|
||||||
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationOutbound')) {
|
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -567,10 +567,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
$attributes['data-description'] = $service->description;
|
$attributes['data-description'] = $service->description;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make readonly if service is unavailable (intelligently handles edit mode)
|
// Make readonly if the service is not bookable for this participant
|
||||||
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationInbound')) {
|
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -661,9 +661,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
$fieldOptions['attr']['data-description'] = $parkingService->description;
|
$fieldOptions['attr']['data-description'] = $parkingService->description;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->shouldMakeServiceReadonly($parkingService, $bookingDto, $participantIndex, 'parking')) {
|
if ($this->shouldMakeServiceReadonly($parkingService, $bookingDto, $participantIndex)) {
|
||||||
$fieldOptions['attr']['readonly'] = true;
|
$fieldOptions['attr']['readonly'] = true;
|
||||||
$fieldOptions['attr']['data-tooltip'] = 'ausgebucht';
|
$fieldOptions['attr']['data-tooltip'] = $this->getUnavailabilityTooltip($parkingService, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $fieldOptions;
|
return $fieldOptions;
|
||||||
@@ -863,66 +863,25 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
*/
|
*/
|
||||||
private function isServiceUnavailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
|
private function isServiceUnavailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
|
||||||
{
|
{
|
||||||
if (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
|
|
||||||
// For non-create workflows, don't apply availability restrictions
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
|
return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if a service should be rendered as read-only.
|
* Determines if a service should be rendered as read-only.
|
||||||
*
|
*
|
||||||
* This method intelligently handles readonly state for services in both create and edit modes:
|
* Both modes share one rule set. The already-held carve-out inside
|
||||||
*
|
* ServiceAvailabilityCalculator::isServiceUnavailable() is what lets a participant keep a
|
||||||
* - CREATE MODE: Uses existing availability calculator logic
|
* service that is now sold out or on request, so no mode-specific branch is needed here.
|
||||||
* - EDIT MODE: Services unavailable (available <= 0) are readonly ONLY if participant doesn't already have them
|
|
||||||
*
|
|
||||||
* This prevents fingerprint false positives in edit mode by allowing participants to keep
|
|
||||||
* services they already have, even if those services are now fully booked.
|
|
||||||
*
|
*
|
||||||
* @param Service $service The service to check
|
* @param Service $service The service to check
|
||||||
* @param BookingDto $bookingDto The booking DTO containing participant data
|
* @param BookingDto $bookingDto The booking DTO containing participant data
|
||||||
* @param int $participantIndex Index of the participant currently selecting services
|
* @param int $participantIndex Index of the participant currently selecting services
|
||||||
* @param string $fieldName Name of the service field (e.g., 'courses', 'board', 'rentals')
|
|
||||||
*
|
*
|
||||||
* @return bool True if the service should be read-only
|
* @return bool True if the service should be read-only
|
||||||
*/
|
*/
|
||||||
private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex, string $fieldName): bool
|
private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex): bool
|
||||||
{
|
{
|
||||||
// In CREATE mode, use existing availability logic
|
return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex);
|
||||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
|
||||||
return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In EDIT mode, apply intelligent readonly logic
|
|
||||||
// If service is available (available > 0), it's never readonly
|
|
||||||
if (null !== $service->available && $service->available > 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service is unavailable - check if participant already has it
|
|
||||||
$participant = $bookingDto->getParticipant($participantIndex);
|
|
||||||
if (null === $participant) {
|
|
||||||
return true; // Readonly if no participant data
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if participant has this service based on field type
|
|
||||||
$participantHasService = match ($fieldName) {
|
|
||||||
'courses' => $this->hasServiceById($participant->courses, $service->id),
|
|
||||||
'additionalServices' => $this->hasServiceById($participant->additionalServices, $service->id),
|
|
||||||
'board' => $this->hasServiceById($participant->board, $service->id),
|
|
||||||
'veg' => $participant->veg?->id === $service->id,
|
|
||||||
'rentals' => $this->hasServiceById($participant->rentals, $service->id),
|
|
||||||
'skiPass' => $participant->skiPass?->id === $service->id,
|
|
||||||
'transportationOutbound' => $participant->transportationOutbound?->id === $service->id,
|
|
||||||
'transportationInbound' => $participant->transportationInbound?->id === $service->id,
|
|
||||||
default => false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Make readonly only if participant doesn't have it
|
|
||||||
return false === $participantHasService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -944,6 +903,23 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the tooltip explaining why a service is rendered read-only.
|
||||||
|
*
|
||||||
|
* Only called for a service that is already known to be blocked, so the status alone
|
||||||
|
* identifies the reason. Create keeps the pre-existing wording, since the on-request rule
|
||||||
|
* never fires there.
|
||||||
|
*/
|
||||||
|
private function getUnavailabilityTooltip(Service $service, BookingDto $bookingDto): string
|
||||||
|
{
|
||||||
|
if (BookingDto::MODE_EDIT === $bookingDto->getMode()
|
||||||
|
&& Constants::STATUS_ON_REQUEST === $service->status) {
|
||||||
|
return 'Diese Leistung ist derzeit nur auf Anfrage buchbar. Bitte kontaktiere uns.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ausgebucht';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters services based on participant's age constraints.
|
* Filters services based on participant's age constraints.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ class ParticipantTransportationDiscountReplacementFieldHandler extends AbstractP
|
|||||||
$services[$oldService->id]->mapping = array_values(
|
$services[$oldService->id]->mapping = array_values(
|
||||||
array_filter(
|
array_filter(
|
||||||
$services[$oldService->id]->mapping,
|
$services[$oldService->id]->mapping,
|
||||||
fn(int $idx): bool => $idx !== $participantIndex,
|
fn (int $idx): bool => $idx !== $participantIndex,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Menu;
|
namespace App\Menu;
|
||||||
|
|
||||||
|
use App\Security\Voter\AdministrativeAccessVoter;
|
||||||
use Knp\Menu\FactoryInterface;
|
use Knp\Menu\FactoryInterface;
|
||||||
use Knp\Menu\ItemInterface;
|
use Knp\Menu\ItemInterface;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
@@ -39,7 +40,7 @@ abstract class AbstractMenuBuilder
|
|||||||
|
|
||||||
protected function addAdminItem(ItemInterface $menu): void
|
protected function addAdminItem(ItemInterface $menu): void
|
||||||
{
|
{
|
||||||
if ($this->security->isGranted('ROLE_ADMIN')) {
|
if ($this->security->isGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)) {
|
||||||
$this->addDivider($menu);
|
$this->addDivider($menu);
|
||||||
$menu->addChild('zum Adminbereich', [
|
$menu->addChild('zum Adminbereich', [
|
||||||
'route' => 'app_admin_index',
|
'route' => 'app_admin_index',
|
||||||
|
|||||||
@@ -22,16 +22,18 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
|||||||
'icon' => 'chart',
|
'icon' => 'chart',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
$menu->addChild('Buchungsentwürfe', [
|
if ($this->security->isGranted('ROLE_CUSTOMER_EXPERT')) {
|
||||||
'route' => 'app_admin_bookingeditdraft',
|
$menu->addChild('Buchungsentwürfe', [
|
||||||
'linkAttributes' => [
|
'route' => 'app_admin_bookingeditdraft',
|
||||||
'title' => 'Buchungsentwürfe',
|
'linkAttributes' => [
|
||||||
],
|
'title' => 'Buchungsentwürfe',
|
||||||
'extras' => [
|
],
|
||||||
'icon' => 'edit',
|
'extras' => [
|
||||||
'routes' => [['pattern' => '/^app_admin_bookingeditdraft/']],
|
'icon' => 'edit',
|
||||||
],
|
'routes' => [['pattern' => '/^app_admin_bookingeditdraft/']],
|
||||||
]);
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->security->isGranted('ROLE_GROUPS_ADMIN')) {
|
if ($this->security->isGranted('ROLE_GROUPS_ADMIN')) {
|
||||||
$menu->addChild('Gruppenbuchungen', [
|
$menu->addChild('Gruppenbuchungen', [
|
||||||
@@ -56,25 +58,29 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$menu->addChild('Benutzer', [
|
if ($this->security->isGranted('ROLE_ADMIN')) {
|
||||||
'route' => 'app_admin_user',
|
$menu->addChild('Benutzer', [
|
||||||
'linkAttributes' => [
|
'route' => 'app_admin_user',
|
||||||
'title' => 'Benutzer',
|
'linkAttributes' => [
|
||||||
],
|
'title' => 'Benutzer',
|
||||||
'extras' => [
|
],
|
||||||
'icon' => 'users',
|
'extras' => [
|
||||||
],
|
'icon' => 'users',
|
||||||
]);
|
],
|
||||||
$menu->addChild('Logs', [
|
]);
|
||||||
'route' => 'app_admin_log',
|
}
|
||||||
'linkAttributes' => [
|
if ($this->security->isGranted('ROLE_CUSTOMER_EXPERT')) {
|
||||||
'title' => 'Logs',
|
$menu->addChild('Logs', [
|
||||||
],
|
'route' => 'app_admin_log',
|
||||||
'extras' => [
|
'linkAttributes' => [
|
||||||
'icon' => 'list',
|
'title' => 'Logs',
|
||||||
'routes' => [['pattern' => '/^app_admin_log/']],
|
],
|
||||||
],
|
'extras' => [
|
||||||
]);
|
'icon' => 'list',
|
||||||
|
'routes' => [['pattern' => '/^app_admin_log/']],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$this->addLogoutItem($menu);
|
$this->addLogoutItem($menu);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Message;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched when the BusPro CRM newly nominates an account for one or more administrative roles,
|
||||||
|
* so that the administrators who can approve it are told there is something waiting in
|
||||||
|
* /admin/user.
|
||||||
|
*
|
||||||
|
* Carries the ids only, never the User: the handler runs in a separate process, where a
|
||||||
|
* serialized entity would be stale by the time it is read.
|
||||||
|
*/
|
||||||
|
final class RoleNominationMessage
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string[] $roles the nominated roles themselves, not their _PENDING markers
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $userId,
|
||||||
|
public readonly array $roles,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\MessageHandler;
|
||||||
|
|
||||||
|
use App\Email\Mailer;
|
||||||
|
use App\Message\RoleNominationMessage;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use App\Security\Role;
|
||||||
|
use App\Service\RoleApprovalUrlGenerator;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the configured recipients that an account is waiting for a role to be approved.
|
||||||
|
*
|
||||||
|
* Runs off the request: mail is routed sync in this application, so sending it inline would put
|
||||||
|
* SMTP latency and SMTP failures into the login path.
|
||||||
|
*/
|
||||||
|
#[AsMessageHandler]
|
||||||
|
final class RoleNominationHandler
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string[] $notificationRecipients
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserRepository $userRepository,
|
||||||
|
private readonly Mailer $mailer,
|
||||||
|
private readonly RoleApprovalUrlGenerator $approvalUrlGenerator,
|
||||||
|
private readonly LoggerInterface $authLogger,
|
||||||
|
private readonly array $notificationRecipients,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(RoleNominationMessage $message): void
|
||||||
|
{
|
||||||
|
$user = $this->userRepository->find($message->userId);
|
||||||
|
|
||||||
|
if (null === $user) {
|
||||||
|
// The account is gone — a retry cannot bring it back.
|
||||||
|
$this->authLogger->warning('Nominated user not found, skipping the role nomination email', [
|
||||||
|
'userId' => $message->userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$recipients = $this->notificationRecipients;
|
||||||
|
|
||||||
|
if ([] === $recipients) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$labels = Role::labels();
|
||||||
|
|
||||||
|
$this->mailer->createAndSendEmail(
|
||||||
|
[
|
||||||
|
'user' => $user,
|
||||||
|
'roles' => array_values(array_map(
|
||||||
|
static fn (string $role): string => $labels[$role] ?? $role,
|
||||||
|
$message->roles,
|
||||||
|
)),
|
||||||
|
// Absolute: generated in a worker, where there is no request to borrow a host
|
||||||
|
// from (see framework.router.default_uri).
|
||||||
|
'approvalUrl' => $this->approvalUrlGenerator->forUser($user, UrlGeneratorInterface::ABSOLUTE_URL),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'template' => 'email/role_nomination.html.twig',
|
||||||
|
'subject' => 'Neue Rollen-Freischaltung angefordert',
|
||||||
|
'to' => $recipients,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,10 +14,17 @@ final readonly class BookingEditSubmissionResult
|
|||||||
public const string STATUS_TIMEOUT = 'timeout';
|
public const string STATUS_TIMEOUT = 'timeout';
|
||||||
public const string STATUS_API_CLIENT_ERROR = 'api_client_error';
|
public const string STATUS_API_CLIENT_ERROR = 'api_client_error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $unbookableServicesReverted Labels of services dropped because they are
|
||||||
|
* no longer bookable, reported separately from
|
||||||
|
* the mutability revert: the category is still
|
||||||
|
* mutable, the service is not bookable
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $status,
|
public string $status,
|
||||||
public ?string $message = null,
|
public ?string $message = null,
|
||||||
public bool $immutableChangesReverted = false,
|
public bool $immutableChangesReverted = false,
|
||||||
|
public array $unbookableServicesReverted = [],
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,19 +13,24 @@ readonly class ContingentSyncResult
|
|||||||
public bool $successful,
|
public bool $successful,
|
||||||
public bool $changed,
|
public bool $changed,
|
||||||
public int $added,
|
public int $added,
|
||||||
|
public int $extended,
|
||||||
public int $updated,
|
public int $updated,
|
||||||
public int $removed,
|
public int $removed,
|
||||||
public ?string $error = null,
|
public ?string $error = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function synced(bool $changed, int $added, int $updated, int $removed): self
|
/**
|
||||||
|
* $added counts days newly stored within the horizon the previous run already reached;
|
||||||
|
* $extended counts days beyond it, which is the rolling window growing rather than a change.
|
||||||
|
*/
|
||||||
|
public static function synced(bool $changed, int $added, int $extended, int $updated, int $removed): self
|
||||||
{
|
{
|
||||||
return new self(true, $changed, $added, $updated, $removed);
|
return new self(true, $changed, $added, $extended, $updated, $removed);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function failed(string $error): self
|
public static function failed(string $error): self
|
||||||
{
|
{
|
||||||
return new self(false, false, 0, 0, 0, $error);
|
return new self(false, false, 0, 0, 0, 0, $error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,4 +123,35 @@ class BookingEditDraftRepository extends ServiceEntityRepository
|
|||||||
->getQuery()
|
->getQuery()
|
||||||
->execute();
|
->execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds drafts whose owner has been unable to save for a while.
|
||||||
|
*
|
||||||
|
* A draft is deleted the moment an update succeeds (see BookingEditSubmitter), so its mere age
|
||||||
|
* is the signal: one that has survived for days belongs to somebody whose changes BusPro keeps
|
||||||
|
* refusing. Drafts for departed travels are excluded - nothing can be done about those, and the
|
||||||
|
* nightly cleanup removes them anyway.
|
||||||
|
*
|
||||||
|
* Ordered by the most recent attempt rather than by age: somebody who tried again yesterday is
|
||||||
|
* still stuck and still waiting, while the oldest drafts are mostly edits abandoned months ago.
|
||||||
|
* Sorting by age alone fills the list with the latter and buries the people to help.
|
||||||
|
*
|
||||||
|
* @param int $minAgeDays How long a draft must have existed to count as stuck
|
||||||
|
*
|
||||||
|
* @return BookingEditDraft[] Most recently attempted first
|
||||||
|
*/
|
||||||
|
public function findStuck(int $minAgeDays = 7, int $limit = 10): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('d')
|
||||||
|
->join('d.user', 'u')
|
||||||
|
->addSelect('u')
|
||||||
|
->where('d.createdAt < :cutoff')
|
||||||
|
->andWhere('d.travelDate >= :today')
|
||||||
|
->setParameter('cutoff', new \DateTimeImmutable(sprintf('-%d days', $minAgeDays)))
|
||||||
|
->setParameter('today', new \DateTimeImmutable('today'))
|
||||||
|
->orderBy('d.updatedAt', 'DESC')
|
||||||
|
->setMaxResults($limit)
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,30 +71,6 @@ class ContingentDayRepository extends ServiceEntityRepository
|
|||||||
return $this->indexByDate($days);
|
return $this->indexByDate($days);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the accommodation's complete stored snapshot as an ordered "Y-m-d:STATUS" list.
|
|
||||||
*
|
|
||||||
* Scalar hydration keeps the fingerprint cheap: the entities themselves are of no interest here.
|
|
||||||
*
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function findStatusFingerprintParts(Accommodation $accommodation): array
|
|
||||||
{
|
|
||||||
/** @var array<int, array{date: \DateTimeImmutable, status: \App\BpnConnect\Model\ContingentStatus}> $rows */
|
|
||||||
$rows = $this->createQueryBuilder('cd')
|
|
||||||
->select('cd.date', 'cd.status')
|
|
||||||
->where('cd.accommodation = :accommodation')
|
|
||||||
->setParameter('accommodation', $accommodation)
|
|
||||||
->orderBy('cd.date', 'ASC')
|
|
||||||
->getQuery()
|
|
||||||
->getArrayResult();
|
|
||||||
|
|
||||||
return array_map(
|
|
||||||
static fn (array $row) => $row['date']->format('Y-m-d').':'.$row['status']->value,
|
|
||||||
$rows,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retention: drops snapshot days that are in the past and can no longer be requested.
|
* Retention: drops snapshot days that are in the past and can no longer be requested.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ use App\BusProNet\Model\CrmAttributes;
|
|||||||
use App\BusProNet\Model\PersonalData;
|
use App\BusProNet\Model\PersonalData;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Htmx\HxRedirectResponse;
|
use App\Htmx\HxRedirectResponse;
|
||||||
|
use App\Message\RoleNominationMessage;
|
||||||
use App\Service\ProfileCompletenessChecker;
|
use App\Service\ProfileCompletenessChecker;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
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;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||||
@@ -34,8 +36,10 @@ use Symfony\Component\Security\Http\Util\TargetPathTrait;
|
|||||||
*
|
*
|
||||||
* The password is kept, RSA-encrypted, because every later BPN call needs it again.
|
* The password is kept, RSA-encrypted, because every later BPN call needs it again.
|
||||||
*
|
*
|
||||||
* BusPro owns the whole role set and the hotel codes: both are synced on every login, in both
|
* BusPro owns the whole role set bar one, and the hotel codes: both are synced on every login, in
|
||||||
* directions, so anything the CRM no longer reports is withdrawn here. What the CRM claims is
|
* both directions, so anything the CRM no longer reports is withdrawn here. The exception is
|
||||||
|
* ROLE_EMPLOYEE, which BusPro has no selection for and which is derived from the account's email
|
||||||
|
* domain — passed to Role::sync() as a claim, so it is granted and revoked by the same machinery. What the CRM claims is
|
||||||
* not automatically granted, though — Role::sync() turns an administrative claim into a
|
* not automatically granted, though — Role::sync() turns an administrative claim into a
|
||||||
* nomination that an administrator has to approve in /admin/user, because BusPro backend users
|
* nomination that an administrator has to approve in /admin/user, because BusPro backend users
|
||||||
* can edit their own CRM selections and would otherwise make themselves administrators.
|
* can edit their own CRM selections and would otherwise make themselves administrators.
|
||||||
@@ -51,6 +55,8 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
|||||||
private readonly Crypt $crypt,
|
private readonly Crypt $crypt,
|
||||||
private readonly ProfileCompletenessChecker $completenessChecker,
|
private readonly ProfileCompletenessChecker $completenessChecker,
|
||||||
private readonly LoggerInterface $authLogger,
|
private readonly LoggerInterface $authLogger,
|
||||||
|
private readonly EmployeeDomainMatcher $employeeDomainMatcher,
|
||||||
|
private readonly MessageBusInterface $messageBus,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +67,10 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
|||||||
|
|
||||||
public function authenticate(Request $request): Passport
|
public function authenticate(Request $request): Passport
|
||||||
{
|
{
|
||||||
$email = trim($request->request->getString('_username'));
|
// One canonical casing per account. BusPro matches an address case-insensitively and so
|
||||||
|
// does the utf8mb4_unicode_ci column, so the casing somebody happens to type must not
|
||||||
|
// become the casing every later BusPro call re-sends.
|
||||||
|
$email = mb_strtolower(trim($request->request->getString('_username')));
|
||||||
$passwordPlain = trim($request->request->getString('_password'));
|
$passwordPlain = trim($request->request->getString('_password'));
|
||||||
|
|
||||||
// BPN requires md5, not a real hash
|
// BPN requires md5, not a real hash
|
||||||
@@ -108,6 +117,10 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
|||||||
$user = $userRepository->findOneBy(['email' => $email]) ?? new User($email);
|
$user = $userRepository->findOneBy(['email' => $email]) ?? new User($email);
|
||||||
|
|
||||||
$user
|
$user
|
||||||
|
// Not redundant next to the constructor: an account created before the address was
|
||||||
|
// normalized still carries the casing of its very first login, and everything
|
||||||
|
// downstream re-sends what is stored rather than what was just typed.
|
||||||
|
->setEmail($email)
|
||||||
->setPassword($encryptedPassword)
|
->setPassword($encryptedPassword)
|
||||||
->setPersonId($personalData->personId)
|
->setPersonId($personalData->personId)
|
||||||
->setAddressId($personalData->addressId)
|
->setAddressId($personalData->addressId)
|
||||||
@@ -117,7 +130,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
|||||||
->setProfileComplete($this->completenessChecker->isComplete($personalData))
|
->setProfileComplete($this->completenessChecker->isComplete($personalData))
|
||||||
;
|
;
|
||||||
|
|
||||||
$this->syncFromCrm($user, $crmAttributes);
|
$nominated = $this->syncFromCrm($user, $crmAttributes);
|
||||||
|
|
||||||
// Registered only once it is fully populated: syncFromCrm() logs on a channel that writes
|
// Registered only once it is fully populated: syncFromCrm() logs on a channel that writes
|
||||||
// to the database, and an account already managed at that point would be flushed
|
// to the database, and an account already managed at that point would be flushed
|
||||||
@@ -126,14 +139,24 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
|||||||
$this->entityManager->persist($user);
|
$this->entityManager->persist($user);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
// After the flush, deliberately: a first login has no id before it, and the transport is
|
||||||
|
// Doctrine-backed, so a message queued ahead of a failing flush would announce a
|
||||||
|
// nomination that was never stored.
|
||||||
|
if ([] !== $nominated) {
|
||||||
|
$this->messageBus->dispatch(new RoleNominationMessage((int) $user->getId(), $nominated));
|
||||||
|
}
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes back what the CRM currently claims: the roles per Role::sync() and the hotel codes
|
* Writes back what the CRM currently claims: the roles per Role::sync() and the hotel codes
|
||||||
* verbatim. Both replace what is stored, which is what makes BusPro the source of truth.
|
* verbatim. Both replace what is stored, which is what makes BusPro the source of truth.
|
||||||
|
*
|
||||||
|
* @return string[] the roles this login newly nominated the account for — the roles
|
||||||
|
* themselves, not their markers, and empty whenever nothing changed
|
||||||
*/
|
*/
|
||||||
private function syncFromCrm(User $user, CrmAttributes $crmAttributes): void
|
private function syncFromCrm(User $user, CrmAttributes $crmAttributes): array
|
||||||
{
|
{
|
||||||
$previousRoles = $user->getRoles();
|
$previousRoles = $user->getRoles();
|
||||||
|
|
||||||
@@ -148,25 +171,45 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
|||||||
// An existing account keeps everything it has. A brand new one still needs a role,
|
// An existing account keeps everything it has. A brand new one still needs a role,
|
||||||
// and an empty claim set is exactly what Role::sync() answers with the fallback.
|
// and an empty claim set is exactly what Role::sync() answers with the fallback.
|
||||||
if ([] !== Role::assignedOnly($previousRoles)) {
|
if ([] !== Role::assignedOnly($previousRoles)) {
|
||||||
return;
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$claimedRoles = $crmAttributes->roles;
|
||||||
|
|
||||||
|
// Not a CRM claim: BusPro has no selection for it, so the account's own address decides.
|
||||||
|
// Passing it in as a claim rather than setting it afterwards is what makes it revocable —
|
||||||
|
// Role::sync() strips every stored role the claim set does not contain.
|
||||||
|
if ($this->employeeDomainMatcher->isEmployee($user->getEmail())) {
|
||||||
|
$claimedRoles[] = Role::EMPLOYEE;
|
||||||
|
}
|
||||||
|
|
||||||
$user
|
$user
|
||||||
->setRoles(Role::sync($previousRoles, $crmAttributes->roles))
|
->setRoles(Role::sync($previousRoles, $claimedRoles))
|
||||||
->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes)))
|
->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes)))
|
||||||
;
|
;
|
||||||
|
|
||||||
$nominated = array_diff(Role::pendingOnly($user->getRoles()), Role::pendingOnly($previousRoles));
|
// Compared on the full role sets rather than on the markers alone: nominatedFrom() needs
|
||||||
|
// to see ROLE_EMPLOYEE to know whether an EMPLOYEE_ONLY marker counts.
|
||||||
|
$nominated = array_values(array_diff(
|
||||||
|
array_keys(Role::nominatedFrom($user->getRoles())),
|
||||||
|
array_keys(Role::nominatedFrom($previousRoles)),
|
||||||
|
));
|
||||||
|
|
||||||
if ([] !== $nominated) {
|
if ([] === $nominated) {
|
||||||
// The CRM claims an administrative role for somebody who does not hold it. It grants
|
return [];
|
||||||
// nothing until an administrator approves it in /admin/user.
|
|
||||||
$this->authLogger->info('Nominated for administrative roles by the BPN CRM', [
|
|
||||||
'email' => $user->getEmail(),
|
|
||||||
'roles' => array_values($nominated),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The CRM claims an administrative role for somebody who does not hold it. It grants
|
||||||
|
// nothing until an administrator approves it in /admin/user.
|
||||||
|
$this->authLogger->info('Nominated for administrative roles by the BPN CRM', [
|
||||||
|
'email' => $user->getEmail(),
|
||||||
|
'roles' => $nominated,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Only the newly appeared nominations reach this point, so a repeat login with a
|
||||||
|
// nomination still standing announces nothing. That difference is the whole de-duplication.
|
||||||
|
return $nominated;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether an account belongs to a member of staff, by its email domain.
|
||||||
|
*
|
||||||
|
* BusPro has no CRM selection expressing "works here", so the email address is the only signal
|
||||||
|
* available. The domains are configuration (%employee_email_domains%) rather than a constant
|
||||||
|
* because they are deployment-specific, in the same way the brand hosts and the CRM selection ids
|
||||||
|
* are.
|
||||||
|
*
|
||||||
|
* Matching is exact on the domain part and never on a suffix: "mail.ep-reisen.de" and
|
||||||
|
* "notep-reisen.de" are not "ep-reisen.de". A suffix match here would hand ROLE_EMPLOYEE to
|
||||||
|
* anybody able to register a domain ending in the configured one.
|
||||||
|
*/
|
||||||
|
final class EmployeeDomainMatcher
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
private readonly array $domains;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $domains
|
||||||
|
*/
|
||||||
|
public function __construct(array $domains)
|
||||||
|
{
|
||||||
|
$this->domains = array_values(array_filter(array_map(
|
||||||
|
static fn (string $domain): string => strtolower(trim($domain, " \t\n\r\0\x0B.@")),
|
||||||
|
$domains,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A malformed or missing identifier is simply not an employee. It must not throw: this runs
|
||||||
|
* inside the authentication path, where an exception would turn a bad address into a failed
|
||||||
|
* login rather than a login without the role.
|
||||||
|
*/
|
||||||
|
public function isEmployee(?string $email): bool
|
||||||
|
{
|
||||||
|
if (null === $email || [] === $this->domains) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$position = strrpos($email, '@');
|
||||||
|
|
||||||
|
if (false === $position) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$domain = strtolower(substr($email, $position + 1));
|
||||||
|
|
||||||
|
return \in_array($domain, $this->domains, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
-10
@@ -16,6 +16,17 @@ namespace App\Security;
|
|||||||
* nothing until an administrator approves it in /admin/user. The CRM's word alone is enough to
|
* nothing until an administrator approves it in /admin/user. The CRM's word alone is enough to
|
||||||
* take a role away, never to hand it out, and an administrator's word alone is enough for
|
* take a role away, never to hand it out, and an administrator's word alone is enough for
|
||||||
* neither.
|
* neither.
|
||||||
|
*
|
||||||
|
* One role is not claimed by the CRM at all: ROLE_EMPLOYEE is derived from the account's own
|
||||||
|
* email domain, because BusPro has no selection expressing "works here". It is passed to sync()
|
||||||
|
* as a claim alongside the CRM's, so it is granted and revoked by exactly the same machinery.
|
||||||
|
* That widens a claim from "what the CRM reports" to "what the CRM reports plus what the account
|
||||||
|
* itself implies", and nothing more: ROLE_EMPLOYEE is not administrative, so it cannot reach the
|
||||||
|
* nomination path, and the CRM remains the only source for every role that grants privileges.
|
||||||
|
*
|
||||||
|
* ROLE_EMPLOYEE does gate a few administrative roles, though (see EMPLOYEE_ONLY): for those, a
|
||||||
|
* CRM claim only counts when the account is staff. It narrows what the CRM can nominate and
|
||||||
|
* never widens it — the approval step still applies on top.
|
||||||
*/
|
*/
|
||||||
final class Role
|
final class Role
|
||||||
{
|
{
|
||||||
@@ -26,12 +37,15 @@ final class Role
|
|||||||
public const USER = 'ROLE_USER';
|
public const USER = 'ROLE_USER';
|
||||||
|
|
||||||
public const ADMIN = 'ROLE_ADMIN';
|
public const ADMIN = 'ROLE_ADMIN';
|
||||||
|
public const TEAM_ADMIN = 'ROLE_TEAM_ADMIN';
|
||||||
public const MANAGER = 'ROLE_MANAGER';
|
public const MANAGER = 'ROLE_MANAGER';
|
||||||
public const TEAMER = 'ROLE_TEAMER';
|
public const TEAMER = 'ROLE_TEAMER';
|
||||||
public const CUSTOMER = 'ROLE_CUSTOMER';
|
public const CUSTOMER = 'ROLE_CUSTOMER';
|
||||||
public const HOUSE_MANAGER = 'ROLE_HOUSE_MANAGER';
|
public const HOUSE_MANAGER = 'ROLE_HOUSE_MANAGER';
|
||||||
public const GROUPS_ADMIN = 'ROLE_GROUPS_ADMIN';
|
public const GROUPS_ADMIN = 'ROLE_GROUPS_ADMIN';
|
||||||
public const GROUPS_MANAGER = 'ROLE_GROUPS_MANAGER';
|
public const GROUPS_MANAGER = 'ROLE_GROUPS_MANAGER';
|
||||||
|
public const CUSTOMER_EXPERT = 'ROLE_CUSTOMER_EXPERT';
|
||||||
|
public const EMPLOYEE = 'ROLE_EMPLOYEE';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Appended to an administrative role to mark it as claimed by the CRM but not yet approved.
|
* Appended to an administrative role to mark it as claimed by the CRM but not yet approved.
|
||||||
@@ -49,23 +63,29 @@ final class Role
|
|||||||
*/
|
*/
|
||||||
public const ALL = [
|
public const ALL = [
|
||||||
self::ADMIN,
|
self::ADMIN,
|
||||||
|
self::TEAM_ADMIN,
|
||||||
self::MANAGER,
|
self::MANAGER,
|
||||||
self::TEAMER,
|
self::TEAMER,
|
||||||
self::CUSTOMER,
|
self::CUSTOMER,
|
||||||
self::HOUSE_MANAGER,
|
self::HOUSE_MANAGER,
|
||||||
self::GROUPS_ADMIN,
|
self::GROUPS_ADMIN,
|
||||||
self::GROUPS_MANAGER,
|
self::GROUPS_MANAGER,
|
||||||
|
self::CUSTOMER_EXPERT,
|
||||||
|
self::EMPLOYEE,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Roles the CRM grants outright. ROLE_CUSTOMER is never claimed by BusPro — it is the
|
* Roles granted outright, without an approval step. ROLE_CUSTOMER is never claimed by BusPro —
|
||||||
* fallback for an account left without any effective role, and exclusive with the others.
|
* it is the fallback for an account left without any effective role, and exclusive with the
|
||||||
|
* others. ROLE_EMPLOYEE is not claimed by BusPro either: it is derived from the account's own
|
||||||
|
* email domain (see EmployeeDomainMatcher) and, being effective, displaces that fallback.
|
||||||
*
|
*
|
||||||
* @var string[]
|
* @var string[]
|
||||||
*/
|
*/
|
||||||
public const UNCONDITIONAL = [
|
public const UNCONDITIONAL = [
|
||||||
self::TEAMER,
|
self::TEAMER,
|
||||||
self::CUSTOMER,
|
self::CUSTOMER,
|
||||||
|
self::EMPLOYEE,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,10 +96,31 @@ final class Role
|
|||||||
*/
|
*/
|
||||||
public const ADMINISTRATIVE = [
|
public const ADMINISTRATIVE = [
|
||||||
self::ADMIN,
|
self::ADMIN,
|
||||||
|
self::TEAM_ADMIN,
|
||||||
self::MANAGER,
|
self::MANAGER,
|
||||||
self::HOUSE_MANAGER,
|
self::HOUSE_MANAGER,
|
||||||
self::GROUPS_ADMIN,
|
self::GROUPS_ADMIN,
|
||||||
self::GROUPS_MANAGER,
|
self::GROUPS_MANAGER,
|
||||||
|
self::CUSTOMER_EXPERT,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Administrative roles reserved for staff accounts. A CRM claim for one of these is ignored
|
||||||
|
* unless the account is also claimed as ROLE_EMPLOYEE, so it neither nominates nor keeps a
|
||||||
|
* role approved earlier. BusPro backend users outside the company can edit their own CRM
|
||||||
|
* selections too, and these roles reach far enough that the email domain has to agree.
|
||||||
|
* ROLE_HOUSE_MANAGER is the one administrative role left out: a Hausleitung signs in with
|
||||||
|
* the hotel's own address.
|
||||||
|
*
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
public const EMPLOYEE_ONLY = [
|
||||||
|
self::ADMIN,
|
||||||
|
self::TEAM_ADMIN,
|
||||||
|
self::MANAGER,
|
||||||
|
self::GROUPS_ADMIN,
|
||||||
|
self::GROUPS_MANAGER,
|
||||||
|
self::CUSTOMER_EXPERT,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,7 +185,9 @@ final class Role
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The roles behind those markers, labelled — what an approver acts on.
|
* The roles behind those markers, labelled — what an approver acts on. A marker for an
|
||||||
|
* EMPLOYEE_ONLY role on an account that is not staff is left out: it predates the rule and
|
||||||
|
* goes on that account's next login, and until then it must not be approvable.
|
||||||
*
|
*
|
||||||
* @param string[] $roles
|
* @param string[] $roles
|
||||||
*
|
*
|
||||||
@@ -155,9 +198,15 @@ final class Role
|
|||||||
$labels = self::labels();
|
$labels = self::labels();
|
||||||
$nominated = [];
|
$nominated = [];
|
||||||
|
|
||||||
|
$isEmployee = \in_array(self::EMPLOYEE, $roles, true);
|
||||||
|
|
||||||
foreach (self::pendingOnly($roles) as $marker) {
|
foreach (self::pendingOnly($roles) as $marker) {
|
||||||
$role = self::realRole($marker);
|
$role = self::realRole($marker);
|
||||||
|
|
||||||
|
if (false === $isEmployee && \in_array($role, self::EMPLOYEE_ONLY, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (\in_array($role, self::ADMINISTRATIVE, true)) {
|
if (\in_array($role, self::ADMINISTRATIVE, true)) {
|
||||||
$nominated[$role] = $labels[$role];
|
$nominated[$role] = $labels[$role];
|
||||||
}
|
}
|
||||||
@@ -169,6 +218,8 @@ final class Role
|
|||||||
/**
|
/**
|
||||||
* The whole policy, applied on every login.
|
* The whole policy, applied on every login.
|
||||||
*
|
*
|
||||||
|
* 0. drop the claims for EMPLOYEE_ONLY roles unless ROLE_EMPLOYEE is claimed as well — they
|
||||||
|
* then count as not made, so the steps below revoke and never mark them;
|
||||||
* 1. revoke everything the CRM no longer claims — granted roles and markers alike, which is
|
* 1. revoke everything the CRM no longer claims — granted roles and markers alike, which is
|
||||||
* what makes BusPro the source of truth;
|
* what makes BusPro the source of truth;
|
||||||
* 2. grant the unconditional roles it claims;
|
* 2. grant the unconditional roles it claims;
|
||||||
@@ -180,7 +231,8 @@ final class Role
|
|||||||
* Nothing here can raise a privilege: step 3 only ever produces markers.
|
* Nothing here can raise a privilege: step 3 only ever produces markers.
|
||||||
*
|
*
|
||||||
* @param string[] $storedRoles
|
* @param string[] $storedRoles
|
||||||
* @param string[] $claimedRoles what the CRM reports
|
* @param string[] $claimedRoles what the CRM reports, plus the roles derived from the account
|
||||||
|
* itself (ROLE_EMPLOYEE); anything outside self::ALL is ignored
|
||||||
*
|
*
|
||||||
* @return string[]
|
* @return string[]
|
||||||
*/
|
*/
|
||||||
@@ -188,6 +240,10 @@ final class Role
|
|||||||
{
|
{
|
||||||
$claimed = array_values(array_intersect(self::ALL, array_unique($claimedRoles)));
|
$claimed = array_values(array_intersect(self::ALL, array_unique($claimedRoles)));
|
||||||
|
|
||||||
|
if (false === \in_array(self::EMPLOYEE, $claimed, true)) {
|
||||||
|
$claimed = array_values(array_diff($claimed, self::EMPLOYEE_ONLY));
|
||||||
|
}
|
||||||
|
|
||||||
$roles = array_values(array_filter(
|
$roles = array_values(array_filter(
|
||||||
self::assignedOnly($storedRoles),
|
self::assignedOnly($storedRoles),
|
||||||
static fn (string $role): bool => \in_array(self::realRole($role), $claimed, true),
|
static fn (string $role): bool => \in_array(self::realRole($role), $claimed, true),
|
||||||
@@ -208,8 +264,8 @@ final class Role
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns a marker into the role it stands for. Refuses anything the account is not nominated
|
* Turns a marker into the role it stands for. Refuses anything the account is not nominated
|
||||||
* for, so neither a hand-crafted request nor a claim revoked while the confirmation dialog
|
* for (see nominatedFrom()), so neither a hand-crafted request nor a claim revoked while the
|
||||||
* was open can grant a role the CRM never reported.
|
* confirmation dialog was open can grant a role the CRM never reported.
|
||||||
*
|
*
|
||||||
* @param string[] $storedRoles
|
* @param string[] $storedRoles
|
||||||
*
|
*
|
||||||
@@ -221,7 +277,7 @@ final class Role
|
|||||||
{
|
{
|
||||||
$roles = self::assignedOnly($storedRoles);
|
$roles = self::assignedOnly($storedRoles);
|
||||||
|
|
||||||
if (false === \in_array(self::pending($role), $roles, true)) {
|
if (false === \array_key_exists($role, self::nominatedFrom($roles))) {
|
||||||
throw new \InvalidArgumentException(sprintf('The role "%s" is not pending approval.', $role));
|
throw new \InvalidArgumentException(sprintf('The role "%s" is not pending approval.', $role));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,12 +324,15 @@ final class Role
|
|||||||
{
|
{
|
||||||
$labels = [
|
$labels = [
|
||||||
self::ADMIN => 'Administration',
|
self::ADMIN => 'Administration',
|
||||||
self::MANAGER => 'Manager:in',
|
self::TEAM_ADMIN => 'Team Administration',
|
||||||
|
self::MANAGER => 'Reisemanager:in',
|
||||||
self::TEAMER => 'Teamer:in',
|
self::TEAMER => 'Teamer:in',
|
||||||
self::CUSTOMER => 'Kund:in',
|
self::CUSTOMER => 'Kund:in',
|
||||||
self::HOUSE_MANAGER => 'Hausleitung',
|
self::HOUSE_MANAGER => 'Hausleitung',
|
||||||
self::GROUPS_ADMIN => 'Preisrechner Admin',
|
self::GROUPS_ADMIN => 'GRO Admin',
|
||||||
self::GROUPS_MANAGER => 'Preisrechner',
|
self::GROUPS_MANAGER => 'GRO-Expert:in',
|
||||||
|
self::CUSTOMER_EXPERT => 'KO-Expert:in',
|
||||||
|
self::EMPLOYEE => 'Mitarbeiter:in',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach (self::ADMINISTRATIVE as $role) {
|
foreach (self::ADMINISTRATIVE as $role) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class AdministrativeAccessVoter extends Voter
|
|||||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||||
{
|
{
|
||||||
return $this->accessDecisionManager->decide($token, ['ROLE_ADMIN'])
|
return $this->accessDecisionManager->decide($token, ['ROLE_ADMIN'])
|
||||||
|| $this->accessDecisionManager->decide($token, ['ROLE_GROUPS_MANAGER']);
|
|| $this->accessDecisionManager->decide($token, ['ROLE_GROUPS_MANAGER'])
|
||||||
|
|| $this->accessDecisionManager->decide($token, ['ROLE_CUSTOMER_EXPERT']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Entity\Groups\BoardService;
|
|||||||
use App\Enum\Groups\AccommodationBookingOrigin;
|
use App\Enum\Groups\AccommodationBookingOrigin;
|
||||||
use App\Enum\Groups\AccommodationBookingStatus;
|
use App\Enum\Groups\AccommodationBookingStatus;
|
||||||
use App\Form\Model\AccommodationBookingDto;
|
use App\Form\Model\AccommodationBookingDto;
|
||||||
|
use App\Model\AccommodationBookingContext;
|
||||||
use App\Model\AccommodationBookingQueryParams;
|
use App\Model\AccommodationBookingQueryParams;
|
||||||
use App\Model\CmsHotelData;
|
use App\Model\CmsHotelData;
|
||||||
use App\Model\InquiryStatus;
|
use App\Model\InquiryStatus;
|
||||||
@@ -23,6 +24,7 @@ use App\Repository\Groups\AdditionalServiceRepository;
|
|||||||
use App\Repository\Groups\BoardServiceRepository;
|
use App\Repository\Groups\BoardServiceRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||||
|
|
||||||
class AccommodationBookingService
|
class AccommodationBookingService
|
||||||
{
|
{
|
||||||
@@ -41,6 +43,7 @@ class AccommodationBookingService
|
|||||||
private readonly AccommodationBookingLinkSigner $linkSigner,
|
private readonly AccommodationBookingLinkSigner $linkSigner,
|
||||||
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
|
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
|
||||||
private readonly AccommodationBookingPdfGenerator $pdfGenerator,
|
private readonly AccommodationBookingPdfGenerator $pdfGenerator,
|
||||||
|
private readonly ValidatorInterface $validator,
|
||||||
private readonly string $accommodationEmail,
|
private readonly string $accommodationEmail,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -115,6 +118,19 @@ class AccommodationBookingService
|
|||||||
return $this->cmsDataProvider->getHotelDetails($accommodation->getEffectiveCmsCode());
|
return $this->cmsDataProvider->getHotelDetails($accommodation->getEffectiveCmsCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the customer-facing offer pages show around a stored booking: the hotel's CMS
|
||||||
|
* content and the price breakdown computed from the booking's frozen snapshot.
|
||||||
|
*/
|
||||||
|
public function createOfferContext(AccommodationBooking $booking, Accommodation $accommodation): AccommodationBookingContext
|
||||||
|
{
|
||||||
|
return new AccommodationBookingContext(
|
||||||
|
accommodation: $accommodation,
|
||||||
|
hotelCmsData: $this->loadHotelCmsData($accommodation),
|
||||||
|
priceBreakdown: $this->breakdownCalculator->compute($booking),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return AccommodationPrice[]
|
* @return AccommodationPrice[]
|
||||||
*/
|
*/
|
||||||
@@ -262,7 +278,6 @@ class AccommodationBookingService
|
|||||||
): AccommodationBooking {
|
): AccommodationBooking {
|
||||||
$booking = new AccommodationBooking();
|
$booking = new AccommodationBooking();
|
||||||
$booking->setAccommodation($accommodation);
|
$booking->setAccommodation($accommodation);
|
||||||
$booking->setGroupName($dto->groupName);
|
|
||||||
$booking->setDateFrom($dto->dateFrom);
|
$booking->setDateFrom($dto->dateFrom);
|
||||||
$booking->setDateTo($dto->dateTo);
|
$booking->setDateTo($dto->dateTo);
|
||||||
$booking->setPaxCount($dto->paxCount);
|
$booking->setPaxCount($dto->paxCount);
|
||||||
@@ -305,15 +320,7 @@ class AccommodationBookingService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Personal data
|
$this->copyContactData($dto, $booking);
|
||||||
$booking->setSalutation($dto->salutation);
|
|
||||||
$booking->setFirstName($dto->firstName);
|
|
||||||
$booking->setLastName($dto->lastName);
|
|
||||||
$booking->setEmail($dto->email);
|
|
||||||
$booking->setPhone($dto->phone);
|
|
||||||
$booking->setStreet($dto->street);
|
|
||||||
$booking->setZip($dto->zip);
|
|
||||||
$booking->setCity($dto->city);
|
|
||||||
$booking->setRemarks($dto->remarks);
|
$booking->setRemarks($dto->remarks);
|
||||||
|
|
||||||
$this->refreshPriceSnapshot($booking);
|
$this->refreshPriceSnapshot($booking);
|
||||||
@@ -627,6 +634,51 @@ class AccommodationBookingService
|
|||||||
$this->sendOfferAcceptedNotificationEmail($booking);
|
$this->sendOfferAcceptedNotificationEmail($booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offers are put together by the office, which only has to supply name and email, so
|
||||||
|
* the rest of the contact data is often missing. A customer may accept an offer once it
|
||||||
|
* meets the constraints a self-service booking meets in step 3.
|
||||||
|
*/
|
||||||
|
public function hasCompleteContactData(AccommodationBooking $booking): bool
|
||||||
|
{
|
||||||
|
return 0 === $this->validator->validate($this->contactDataFromBooking($booking), null, ['contact'])->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function contactDataFromBooking(AccommodationBooking $booking): AccommodationBookingDto
|
||||||
|
{
|
||||||
|
$dto = new AccommodationBookingDto();
|
||||||
|
$dto->groupName = $booking->getGroupName();
|
||||||
|
$dto->salutation = $booking->getSalutation();
|
||||||
|
$dto->firstName = $booking->getFirstName();
|
||||||
|
$dto->lastName = $booking->getLastName();
|
||||||
|
$dto->email = $booking->getEmail();
|
||||||
|
$dto->phone = $booking->getPhone();
|
||||||
|
$dto->street = $booking->getStreet();
|
||||||
|
$dto->zip = $booking->getZip();
|
||||||
|
$dto->city = $booking->getCity();
|
||||||
|
|
||||||
|
return $dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyContactData(AccommodationBooking $booking, AccommodationBookingDto $dto): void
|
||||||
|
{
|
||||||
|
$this->copyContactData($dto, $booking);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function copyContactData(AccommodationBookingDto $dto, AccommodationBooking $booking): void
|
||||||
|
{
|
||||||
|
$booking->setGroupName($dto->groupName);
|
||||||
|
$booking->setSalutation($dto->salutation);
|
||||||
|
$booking->setFirstName($dto->firstName);
|
||||||
|
$booking->setLastName($dto->lastName);
|
||||||
|
$booking->setEmail($dto->email);
|
||||||
|
$booking->setPhone($dto->phone);
|
||||||
|
$booking->setStreet($dto->street);
|
||||||
|
$booking->setZip($dto->zip);
|
||||||
|
$booking->setCity($dto->city);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Explicit office action after the requested services and capacities have been validated:
|
* Explicit office action after the requested services and capacities have been validated:
|
||||||
* this is the moment the booking becomes binding for the customer, and the only place the
|
* this is the moment the booking becomes binding for the customer, and the only place the
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class AdditionalServiceExclusionResolver
|
|||||||
|
|
||||||
$submittedIds = array_filter(
|
$submittedIds = array_filter(
|
||||||
$submittedIds,
|
$submittedIds,
|
||||||
fn(int $id) => !$this->conflicts($rules, $addedId, $id),
|
fn (int $id) => !$this->conflicts($rules, $addedId, $id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ class BookingEditDataLoader
|
|||||||
|
|
||||||
private bool $draftRestored = false;
|
private bool $draftRestored = false;
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $droppedDraftServiceLabels = [];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ApiClient $apiClient,
|
private readonly ApiClient $apiClient,
|
||||||
private readonly BookingDataProcessor $bookingDataProcessor,
|
private readonly BookingDataProcessor $bookingDataProcessor,
|
||||||
@@ -53,6 +56,17 @@ class BookingEditDataLoader
|
|||||||
return $this->draftRestored;
|
return $this->draftRestored;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the drafted services dropped during the last load because they are no longer
|
||||||
|
* bookable. Reset on each call to loadFormData(), like isDraftRestored().
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function getDroppedDraftServiceLabels(): array
|
||||||
|
{
|
||||||
|
return $this->droppedDraftServiceLabels;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads booking data from session or initializes from API.
|
* Loads booking data from session or initializes from API.
|
||||||
*
|
*
|
||||||
@@ -72,6 +86,7 @@ class BookingEditDataLoader
|
|||||||
public function loadFormData(Request $request, int $bookingId, User $user): ?BookingDto
|
public function loadFormData(Request $request, int $bookingId, User $user): ?BookingDto
|
||||||
{
|
{
|
||||||
$this->draftRestored = false;
|
$this->draftRestored = false;
|
||||||
|
$this->droppedDraftServiceLabels = [];
|
||||||
|
|
||||||
$formData = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT);
|
$formData = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT);
|
||||||
|
|
||||||
@@ -177,6 +192,10 @@ class BookingEditDataLoader
|
|||||||
// it would tell the user their draft was restored for edits they never made.
|
// it would tell the user their draft was restored for edits they never made.
|
||||||
$this->draftRestored = true === $applied
|
$this->draftRestored = true === $applied
|
||||||
&& $formData->originalFingerprint !== $this->fingerprintService->generateFingerprint($formData);
|
&& $formData->originalFingerprint !== $this->fingerprintService->generateFingerprint($formData);
|
||||||
|
|
||||||
|
if (true === $applied) {
|
||||||
|
$this->droppedDraftServiceLabels = $this->draftService->getDroppedServiceLabels();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->bookingSessionService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT);
|
$this->bookingSessionService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT);
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ use Psr\Log\LoggerInterface;
|
|||||||
*/
|
*/
|
||||||
class BookingEditDraftManager
|
class BookingEditDraftManager
|
||||||
{
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $droppedServiceLabels = [];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BookingEditDraftRepository $draftRepository,
|
private readonly BookingEditDraftRepository $draftRepository,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
@@ -32,6 +35,19 @@ class BookingEditDraftManager
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the services dropped during the last applyDraftToDto() call.
|
||||||
|
*
|
||||||
|
* Reset on each call, so the caller can report them to the user. Mirrors
|
||||||
|
* BookingEditDataLoader::isDraftRestored().
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function getDroppedServiceLabels(): array
|
||||||
|
{
|
||||||
|
return $this->droppedServiceLabels;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds an existing draft for a user and booking combination.
|
* Finds an existing draft for a user and booking combination.
|
||||||
*
|
*
|
||||||
@@ -129,6 +145,9 @@ class BookingEditDraftManager
|
|||||||
*/
|
*/
|
||||||
public function applyDraftToDto(BookingEditDraft $draft, BookingDto $dto, Travel $travel): bool
|
public function applyDraftToDto(BookingEditDraft $draft, BookingDto $dto, Travel $travel): bool
|
||||||
{
|
{
|
||||||
|
$this->droppedServiceLabels = [];
|
||||||
|
$droppedServiceLabels = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$formData = $draft->getFormData();
|
$formData = $draft->getFormData();
|
||||||
|
|
||||||
@@ -149,16 +168,25 @@ class BookingEditDraftManager
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->participantApplier->apply(
|
$droppedServiceLabels = [...$droppedServiceLabels, ...$this->participantApplier->apply(
|
||||||
$dto,
|
$dto,
|
||||||
$index,
|
$index,
|
||||||
$dto->participants[$index],
|
$dto->participants[$index],
|
||||||
$participantData,
|
$participantData,
|
||||||
$travel,
|
$travel,
|
||||||
);
|
)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->droppedServiceLabels = array_values(array_unique($droppedServiceLabels));
|
||||||
|
|
||||||
|
if ([] !== $this->droppedServiceLabels) {
|
||||||
|
$this->logger->info('Dropped drafted services that are no longer bookable', [
|
||||||
|
'booking_id' => $draft->getBookingId(),
|
||||||
|
'services' => $this->droppedServiceLabels,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$this->logger->info('Applied draft to booking DTO', [
|
$this->logger->info('Applied draft to booking DTO', [
|
||||||
'booking_id' => $draft->getBookingId(),
|
'booking_id' => $draft->getBookingId(),
|
||||||
'draft_created_at' => $draft->getCreatedAt()->format('Y-m-d H:i:s'),
|
'draft_created_at' => $draft->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||||
|
|||||||
@@ -18,19 +18,30 @@ use App\Form\Model\ParticipantDto;
|
|||||||
*
|
*
|
||||||
* Service selections are gated by travel-level mutability flags (additionalServicesMutable,
|
* Service selections are gated by travel-level mutability flags (additionalServicesMutable,
|
||||||
* transportationServicesMutable, pickupsMutable). Insurance is always applied regardless
|
* transportationServicesMutable, pickupsMutable). Insurance is always applied regardless
|
||||||
* of mutability. Merge strategy (only apply if resolves to a valid service) is used for
|
* of mutability. Merge strategy (only apply if it resolves against the travel data) is used
|
||||||
* single-select fields; overwrite strategy is used for multi-select and boolean fields.
|
* for single-select fields, room assignment included; overwrite strategy is used for
|
||||||
|
* multi-select, boolean and free-text fields.
|
||||||
*/
|
*/
|
||||||
class BookingEditDraftMerger
|
class BookingEditDraftMerger
|
||||||
{
|
{
|
||||||
/** @param array<string, mixed> $data */
|
public function __construct(
|
||||||
|
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*
|
||||||
|
* @return list<string> Labels of drafted services dropped because they are no longer bookable
|
||||||
|
*/
|
||||||
public function apply(
|
public function apply(
|
||||||
BookingDto $bookingDto,
|
BookingDto $bookingDto,
|
||||||
int $participantIndex,
|
int $participantIndex,
|
||||||
ParticipantDto $participant,
|
ParticipantDto $participant,
|
||||||
array $data,
|
array $data,
|
||||||
Travel $travel,
|
Travel $travel,
|
||||||
): void {
|
): array {
|
||||||
|
$droppedServiceLabels = [];
|
||||||
$canApplyPersonalDataDraft = $this->canApplyPersonalDataDraft($bookingDto, $participantIndex, $participant);
|
$canApplyPersonalDataDraft = $this->canApplyPersonalDataDraft($bookingDto, $participantIndex, $participant);
|
||||||
|
|
||||||
// Personal data
|
// Personal data
|
||||||
@@ -52,7 +63,7 @@ class BookingEditDraftMerger
|
|||||||
|
|
||||||
// Room assignment
|
// Room assignment
|
||||||
if (true === isset($data['roomAssignment']) && true === is_array($data['roomAssignment'])) {
|
if (true === isset($data['roomAssignment']) && true === is_array($data['roomAssignment'])) {
|
||||||
$this->applyRoomAssignment($participant, $data['roomAssignment']);
|
$this->applyRoomAssignment($participant, $data['roomAssignment'], $travel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// License plate
|
// License plate
|
||||||
@@ -67,10 +78,19 @@ class BookingEditDraftMerger
|
|||||||
|
|
||||||
// Service selections (gated per-category by travel mutability flags)
|
// Service selections (gated per-category by travel mutability flags)
|
||||||
if (isset($data['services']) && true === is_array($data['services'])) {
|
if (isset($data['services']) && true === is_array($data['services'])) {
|
||||||
$this->applyServiceSelections($participant, $data['services'], $travel);
|
$this->applyServiceSelections(
|
||||||
|
$participant,
|
||||||
|
$data['services'],
|
||||||
|
$travel,
|
||||||
|
$bookingDto,
|
||||||
|
$participantIndex,
|
||||||
|
$droppedServiceLabels
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$participant->normalizeLoadedData();
|
$participant->normalizeLoadedData();
|
||||||
|
|
||||||
|
return array_values(array_unique($droppedServiceLabels));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool
|
private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool
|
||||||
@@ -156,12 +176,27 @@ class BookingEditDraftMerger
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $data */
|
/**
|
||||||
private function applyRoomAssignment(ParticipantDto $participant, array $data): void
|
* Applies the drafted room assignment to the participant.
|
||||||
|
*
|
||||||
|
* The room id uses the same merge strategy as single-select services: it is only
|
||||||
|
* applied when it resolves to a room of the current travel. A draft may therefore move
|
||||||
|
* a participant to another room, but never unassign one. Edit mode renders the room as
|
||||||
|
* static text and never submits assignedRoomId, so a null in the draft is only ever an
|
||||||
|
* artefact of a snapshot taken before BusPro reported the assignment - replaying it
|
||||||
|
* would drop the participant's room, both in the UI and in the outbound zuordnung.
|
||||||
|
*
|
||||||
|
* remarksRoom keeps overwrite semantics: it is free text the user can deliberately clear.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private function applyRoomAssignment(ParticipantDto $participant, array $data, Travel $travel): void
|
||||||
{
|
{
|
||||||
if (true === array_key_exists('assignedRoomId', $data)) {
|
$assignedRoomId = $data['assignedRoomId'] ?? null;
|
||||||
$participant->assignedRoomId = $data['assignedRoomId'];
|
if (null !== $assignedRoomId && null !== $travel->getRoomById((int) $assignedRoomId)) {
|
||||||
|
$participant->assignedRoomId = (int) $assignedRoomId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (true === array_key_exists('remarksRoom', $data)) {
|
if (true === array_key_exists('remarksRoom', $data)) {
|
||||||
$participant->remarksRoom = $data['remarksRoom'];
|
$participant->remarksRoom = $data['remarksRoom'];
|
||||||
}
|
}
|
||||||
@@ -189,16 +224,25 @@ class BookingEditDraftMerger
|
|||||||
* Multi-select fields (checkboxes) and booleans use overwrite strategy: draft values
|
* Multi-select fields (checkboxes) and booleans use overwrite strategy: draft values
|
||||||
* always replace API data, since users can intentionally clear these selections.
|
* always replace API data, since users can intentionally clear these selections.
|
||||||
*/
|
*/
|
||||||
/** @param array<string, mixed> $data */
|
/**
|
||||||
private function applyServiceSelections(ParticipantDto $participant, array $data, Travel $travel): void
|
* @param array<string, mixed> $data
|
||||||
{
|
* @param list<string> $droppedServiceLabels Collects what was dropped, by reference
|
||||||
|
*/
|
||||||
|
private function applyServiceSelections(
|
||||||
|
ParticipantDto $participant,
|
||||||
|
array $data,
|
||||||
|
Travel $travel,
|
||||||
|
BookingDto $bookingDto,
|
||||||
|
int $participantIndex,
|
||||||
|
array &$droppedServiceLabels,
|
||||||
|
): void {
|
||||||
// Additional services category — only apply draft data when services are mutable.
|
// Additional services category — only apply draft data when services are mutable.
|
||||||
// When immutable, the booking's current services must be preserved as-is to avoid
|
// When immutable, the booking's current services must be preserved as-is to avoid
|
||||||
// API rejection (stale draft data could differ from the locked booking state).
|
// API rejection (stale draft data could differ from the locked booking state).
|
||||||
if (true === $travel->additionalServicesMutable) {
|
if (true === $travel->additionalServicesMutable) {
|
||||||
// Ski pass (single service) - merge strategy: only apply if resolves to valid service
|
// Ski pass (single service) - merge strategy: only apply if resolves to valid service
|
||||||
if (true === array_key_exists('skiPass', $data) && null !== $data['skiPass']) {
|
if (true === array_key_exists('skiPass', $data) && null !== $data['skiPass']) {
|
||||||
$resolved = $this->resolveService($data['skiPass'], $travel->additionalServices);
|
$resolved = $this->resolveService($data['skiPass'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
if (null !== $resolved) {
|
if (null !== $resolved) {
|
||||||
$participant->skiPass = $resolved;
|
$participant->skiPass = $resolved;
|
||||||
}
|
}
|
||||||
@@ -206,17 +250,17 @@ class BookingEditDraftMerger
|
|||||||
|
|
||||||
// Courses (array) - overwrite strategy: user can deselect all
|
// Courses (array) - overwrite strategy: user can deselect all
|
||||||
if (true === array_key_exists('courses', $data) && true === is_array($data['courses'])) {
|
if (true === array_key_exists('courses', $data) && true === is_array($data['courses'])) {
|
||||||
$participant->courses = $this->resolveServiceArray($data['courses'], $travel->additionalServices);
|
$participant->courses = $this->resolveServiceArray($data['courses'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Board (array) - overwrite strategy: user can deselect all
|
// Board (array) - overwrite strategy: user can deselect all
|
||||||
if (true === array_key_exists('board', $data) && true === is_array($data['board'])) {
|
if (true === array_key_exists('board', $data) && true === is_array($data['board'])) {
|
||||||
$participant->board = $this->resolveServiceArray($data['board'], $travel->additionalServices);
|
$participant->board = $this->resolveServiceArray($data['board'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Veg (single service) - merge strategy: only apply if resolves to valid service
|
// Veg (single service) - merge strategy: only apply if resolves to valid service
|
||||||
if (true === array_key_exists('veg', $data) && null !== $data['veg']) {
|
if (true === array_key_exists('veg', $data) && null !== $data['veg']) {
|
||||||
$resolved = $this->resolveService($data['veg'], $travel->additionalServices);
|
$resolved = $this->resolveService($data['veg'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
if (null !== $resolved) {
|
if (null !== $resolved) {
|
||||||
$participant->veg = $resolved;
|
$participant->veg = $resolved;
|
||||||
}
|
}
|
||||||
@@ -224,12 +268,12 @@ class BookingEditDraftMerger
|
|||||||
|
|
||||||
// Rentals (array) - overwrite strategy: user can deselect all
|
// Rentals (array) - overwrite strategy: user can deselect all
|
||||||
if (true === array_key_exists('rentals', $data) && true === is_array($data['rentals'])) {
|
if (true === array_key_exists('rentals', $data) && true === is_array($data['rentals'])) {
|
||||||
$participant->rentals = $this->resolveServiceArray($data['rentals'], $travel->additionalServices);
|
$participant->rentals = $this->resolveServiceArray($data['rentals'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rental insurance (single) - merge strategy: only apply if resolves to valid service
|
// Rental insurance (single) - merge strategy: only apply if resolves to valid service
|
||||||
if (true === array_key_exists('rentalInsurance', $data) && null !== $data['rentalInsurance']) {
|
if (true === array_key_exists('rentalInsurance', $data) && null !== $data['rentalInsurance']) {
|
||||||
$resolved = $this->resolveService($data['rentalInsurance'], $travel->additionalServices);
|
$resolved = $this->resolveService($data['rentalInsurance'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
if (null !== $resolved) {
|
if (null !== $resolved) {
|
||||||
$participant->rentalInsurance = $resolved;
|
$participant->rentalInsurance = $resolved;
|
||||||
$participant->rentalInsuranceSelected = true;
|
$participant->rentalInsuranceSelected = true;
|
||||||
@@ -239,7 +283,7 @@ class BookingEditDraftMerger
|
|||||||
// Additional services (array) - overwrite strategy with mandatory service preservation
|
// Additional services (array) - overwrite strategy with mandatory service preservation
|
||||||
// User can deselect optional services, but mandatory services from API must be preserved
|
// User can deselect optional services, but mandatory services from API must be preserved
|
||||||
if (true === array_key_exists('additionalServices', $data) && true === is_array($data['additionalServices'])) {
|
if (true === array_key_exists('additionalServices', $data) && true === is_array($data['additionalServices'])) {
|
||||||
$resolvedFromDraft = $this->resolveServiceArray($data['additionalServices'], $travel->additionalServices);
|
$resolvedFromDraft = $this->resolveServiceArray($data['additionalServices'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
$participant->additionalServices = $this->preserveMandatoryServices(
|
$participant->additionalServices = $this->preserveMandatoryServices(
|
||||||
$resolvedFromDraft,
|
$resolvedFromDraft,
|
||||||
$participant->additionalServices,
|
$participant->additionalServices,
|
||||||
@@ -252,7 +296,7 @@ class BookingEditDraftMerger
|
|||||||
if (true === $travel->transportationServicesMutable) {
|
if (true === $travel->transportationServicesMutable) {
|
||||||
// Transportation outbound (single) - merge strategy: only apply if resolves to valid service
|
// Transportation outbound (single) - merge strategy: only apply if resolves to valid service
|
||||||
if (true === array_key_exists('transportationOutbound', $data) && null !== $data['transportationOutbound']) {
|
if (true === array_key_exists('transportationOutbound', $data) && null !== $data['transportationOutbound']) {
|
||||||
$resolved = $this->resolveService($data['transportationOutbound'], $travel->transportationServices);
|
$resolved = $this->resolveService($data['transportationOutbound'], $travel->transportationServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
if (null !== $resolved) {
|
if (null !== $resolved) {
|
||||||
$participant->transportationOutbound = $resolved;
|
$participant->transportationOutbound = $resolved;
|
||||||
}
|
}
|
||||||
@@ -260,7 +304,7 @@ class BookingEditDraftMerger
|
|||||||
|
|
||||||
// Transportation inbound (single) - merge strategy: only apply if resolves to valid service
|
// Transportation inbound (single) - merge strategy: only apply if resolves to valid service
|
||||||
if (true === array_key_exists('transportationInbound', $data) && null !== $data['transportationInbound']) {
|
if (true === array_key_exists('transportationInbound', $data) && null !== $data['transportationInbound']) {
|
||||||
$resolved = $this->resolveService($data['transportationInbound'], $travel->transportationServices);
|
$resolved = $this->resolveService($data['transportationInbound'], $travel->transportationServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
if (null !== $resolved) {
|
if (null !== $resolved) {
|
||||||
$participant->transportationInbound = $resolved;
|
$participant->transportationInbound = $resolved;
|
||||||
}
|
}
|
||||||
@@ -352,37 +396,84 @@ class BookingEditDraftMerger
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, object> $services
|
* @param array<int, object> $services
|
||||||
|
* @param list<string> $droppedServiceLabels
|
||||||
*/
|
*/
|
||||||
private function resolveService(?int $serviceId, array $services): ?object
|
private function resolveService(
|
||||||
{
|
?int $serviceId,
|
||||||
|
array $services,
|
||||||
|
BookingDto $bookingDto,
|
||||||
|
int $participantIndex,
|
||||||
|
array &$droppedServiceLabels,
|
||||||
|
): ?object {
|
||||||
if (null === $serviceId) {
|
if (null === $serviceId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $services[$serviceId] ?? null;
|
$service = $services[$serviceId] ?? null;
|
||||||
|
|
||||||
|
if (null !== $service && true === $this->isDroppedFromDraft($service, $bookingDto, $participantIndex)) {
|
||||||
|
$droppedServiceLabels[] = (string) $service->label;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $service;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, int> $serviceIds
|
* @param array<int, int> $serviceIds
|
||||||
* @param array<int, object> $services
|
* @param array<int, object> $services
|
||||||
|
* @param list<string> $droppedServiceLabels
|
||||||
*
|
*
|
||||||
* @return array<int, object>
|
* @return array<int, object>
|
||||||
*/
|
*/
|
||||||
private function resolveServiceArray(array $serviceIds, array $services): array
|
private function resolveServiceArray(
|
||||||
{
|
array $serviceIds,
|
||||||
|
array $services,
|
||||||
|
BookingDto $bookingDto,
|
||||||
|
int $participantIndex,
|
||||||
|
array &$droppedServiceLabels,
|
||||||
|
): array {
|
||||||
$resolved = [];
|
$resolved = [];
|
||||||
$addedIds = [];
|
$addedIds = [];
|
||||||
|
|
||||||
foreach ($serviceIds as $serviceId) {
|
foreach ($serviceIds as $serviceId) {
|
||||||
if (isset($services[$serviceId]) && false === isset($addedIds[$serviceId])) {
|
if (false === isset($services[$serviceId]) || true === isset($addedIds[$serviceId])) {
|
||||||
$resolved[] = $services[$serviceId];
|
continue;
|
||||||
$addedIds[$serviceId] = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$service = $services[$serviceId];
|
||||||
|
|
||||||
|
if (true === $this->isDroppedFromDraft($service, $bookingDto, $participantIndex)) {
|
||||||
|
$droppedServiceLabels[] = (string) $service->label;
|
||||||
|
$addedIds[$serviceId] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved[] = $service;
|
||||||
|
$addedIds[$serviceId] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $resolved;
|
return $resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a drafted selection must not be restored.
|
||||||
|
*
|
||||||
|
* A draft can be weeks old. Restoring a selection for a service that has since sold out or
|
||||||
|
* moved to 'Anfrage' is what makes a stuck booking stuck: the stale choice is re-applied on
|
||||||
|
* every re-entry and BusPro rejects the whole update again. Services the participant already
|
||||||
|
* holds are carved out by the availability rule itself, so they still restore.
|
||||||
|
*/
|
||||||
|
private function isDroppedFromDraft(object $service, BookingDto $bookingDto, int $participantIndex): bool
|
||||||
|
{
|
||||||
|
if (false === $service instanceof Service || null === $service->id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
|
||||||
|
}
|
||||||
|
|
||||||
private function resolvePickup(?int $pickupId, Travel $travel): ?object
|
private function resolvePickup(?int $pickupId, Travel $travel): ?object
|
||||||
{
|
{
|
||||||
if (null === $pickupId) {
|
if (null === $pickupId) {
|
||||||
|
|||||||
@@ -24,9 +24,72 @@ class BookingEditSubmitGuard
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BookingDataProcessor $bookingDataProcessor,
|
private readonly BookingDataProcessor $bookingDataProcessor,
|
||||||
|
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts selections of services that are no longer bookable.
|
||||||
|
*
|
||||||
|
* The read-only state in the participant form is presentation only, so a stale session or
|
||||||
|
* a replayed POST can still carry a service that has since sold out or moved to 'Anfrage'.
|
||||||
|
* Sending one makes BusPro reject the entire update - every participant's changes with it -
|
||||||
|
* so the selection is reverted to what the live booking already holds.
|
||||||
|
*
|
||||||
|
* Services the participant already holds are never touched: the availability rule carves
|
||||||
|
* them out, and withdrawing one would break the Leistung/Teilnehmer counts.
|
||||||
|
*
|
||||||
|
* Expects $workingDto->booking to be the fresh booking, as the availability rule reads its
|
||||||
|
* already-held carve-out from there.
|
||||||
|
*
|
||||||
|
* @return list<string> Labels of the services that were reverted, for user feedback
|
||||||
|
*/
|
||||||
|
public function revertUnbookableServiceAdditions(BookingDto $workingDto, Booking $freshBooking): array
|
||||||
|
{
|
||||||
|
$baselineDto = $this->bookingDataProcessor->createBookingDtoFromBooking($freshBooking, $workingDto->travel, $workingDto->isInternalAgencyBooking());
|
||||||
|
$revertedLabels = [];
|
||||||
|
|
||||||
|
foreach ($workingDto->participants as $index => $participant) {
|
||||||
|
$baseline = $baselineDto->participants[$index] ?? null;
|
||||||
|
if (null === $baseline) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isBlocked = fn (?Service $service): bool => null !== $service
|
||||||
|
&& null !== $service->id
|
||||||
|
&& $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $workingDto, $index);
|
||||||
|
|
||||||
|
// Multi-selection fields: drop the blocked additions, keep everything else
|
||||||
|
foreach (['courses', 'additionalServices', 'board', 'rentals'] as $field) {
|
||||||
|
$kept = [];
|
||||||
|
foreach ($participant->{$field} as $service) {
|
||||||
|
if (true === $isBlocked($service)) {
|
||||||
|
$revertedLabels[] = (string) $service->label;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$kept[] = $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($kept) !== count($participant->{$field})) {
|
||||||
|
$participant->{$field} = $kept;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-selection fields: fall back to what the booking already holds rather than
|
||||||
|
// clearing, so a required field does not end up empty
|
||||||
|
foreach (['skiPass', 'veg', 'transportationOutbound', 'transportationInbound', 'parkingService'] as $field) {
|
||||||
|
if (false === $isBlocked($participant->{$field})) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$revertedLabels[] = (string) $participant->{$field}->label;
|
||||||
|
$participant->{$field} = $baseline->{$field};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($revertedLabels));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reverts immutable category changes to fresh booking values.
|
* Reverts immutable category changes to fresh booking values.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -63,11 +63,32 @@ class BookingEditSubmitter
|
|||||||
$this->travelDataService->patchMutability($bookingDto->travel, $mutableData);
|
$this->travelDataService->patchMutability($bookingDto->travel, $mutableData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The guard checks service status and contingent, so it needs the live figures rather
|
||||||
|
// than whatever was cached when the edit session started.
|
||||||
|
$availabilities = $this->travelDataService->getAvailabilityData(
|
||||||
|
$freshBookingData->dateId,
|
||||||
|
cached: true,
|
||||||
|
forceRefresh: true
|
||||||
|
);
|
||||||
|
if (null !== $availabilities) {
|
||||||
|
$this->travelDataService->patchAvailabilities($bookingDto->travel, $availabilities);
|
||||||
|
}
|
||||||
|
|
||||||
$immutableChangesReverted = $this->submitGuard->reconcileImmutableCategories($bookingDto, $freshBookingData);
|
$immutableChangesReverted = $this->submitGuard->reconcileImmutableCategories($bookingDto, $freshBookingData);
|
||||||
if (true === $immutableChangesReverted) {
|
$unbookableServicesReverted = $this->submitGuard->revertUnbookableServiceAdditions($bookingDto, $freshBookingData);
|
||||||
|
|
||||||
|
if (true === $immutableChangesReverted || [] !== $unbookableServicesReverted) {
|
||||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([] !== $unbookableServicesReverted) {
|
||||||
|
$this->logger->warning('Reverted selections of services that are no longer bookable', [
|
||||||
|
'email' => $email,
|
||||||
|
'booking_id' => $bookingId,
|
||||||
|
'services' => $unbookableServicesReverted,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $this->apiClient->updateBooking($bookingDto, true);
|
$response = $this->apiClient->updateBooking($bookingDto, true);
|
||||||
if ($response instanceof Notification) {
|
if ($response instanceof Notification) {
|
||||||
@@ -83,6 +104,7 @@ class BookingEditSubmitter
|
|||||||
: BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO,
|
: BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO,
|
||||||
$response->message,
|
$response->message,
|
||||||
$immutableChangesReverted,
|
$immutableChangesReverted,
|
||||||
|
$unbookableServicesReverted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +121,7 @@ class BookingEditSubmitter
|
|||||||
BookingEditSubmissionResult::STATUS_SUCCESS,
|
BookingEditSubmissionResult::STATUS_SUCCESS,
|
||||||
null,
|
null,
|
||||||
$immutableChangesReverted,
|
$immutableChangesReverted,
|
||||||
|
$unbookableServicesReverted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +136,7 @@ class BookingEditSubmitter
|
|||||||
BookingEditSubmissionResult::STATUS_UNSUCCESSFUL,
|
BookingEditSubmissionResult::STATUS_UNSUCCESSFUL,
|
||||||
$response->status ?? 'Buchung konnte nicht aktualisiert werden',
|
$response->status ?? 'Buchung konnte nicht aktualisiert werden',
|
||||||
$immutableChangesReverted,
|
$immutableChangesReverted,
|
||||||
|
$unbookableServicesReverted,
|
||||||
);
|
);
|
||||||
} catch (TimeoutException) {
|
} catch (TimeoutException) {
|
||||||
$this->logger->error('Booking update timeout', [
|
$this->logger->error('Booking update timeout', [
|
||||||
@@ -124,12 +148,14 @@ class BookingEditSubmitter
|
|||||||
BookingEditSubmissionResult::STATUS_TIMEOUT,
|
BookingEditSubmissionResult::STATUS_TIMEOUT,
|
||||||
null,
|
null,
|
||||||
$immutableChangesReverted,
|
$immutableChangesReverted,
|
||||||
|
$unbookableServicesReverted,
|
||||||
);
|
);
|
||||||
} catch (ApiClientException) {
|
} catch (ApiClientException) {
|
||||||
return new BookingEditSubmissionResult(
|
return new BookingEditSubmissionResult(
|
||||||
BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR,
|
BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR,
|
||||||
null,
|
null,
|
||||||
$immutableChangesReverted,
|
$immutableChangesReverted,
|
||||||
|
$unbookableServicesReverted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ class BpnXmlAnonymizer
|
|||||||
{
|
{
|
||||||
private const SAFE_EMAIL_DOMAIN = 'example.com';
|
private const SAFE_EMAIL_DOMAIN = 'example.com';
|
||||||
|
|
||||||
|
/** Elements holding a person id, in the order resolvePersonId() prefers them. */
|
||||||
|
private const PERSON_ID_TAGS = ['personid', 'idperson', 'idadresseperson'];
|
||||||
|
|
||||||
|
private const ADDRESS_ID_TAG = 'idadresse';
|
||||||
|
|
||||||
private Generator $faker;
|
private Generator $faker;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,6 +25,24 @@ class BpnXmlAnonymizer
|
|||||||
|
|
||||||
private int $fallbackIdentityCounter = 0;
|
private int $fallbackIdentityCounter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a person id as it appears in the source to its replacement.
|
||||||
|
*
|
||||||
|
* Person ids show up in several places for the same person - the root <idperson>, the
|
||||||
|
* anmelder's <idadresseperson>, each participant's own - and referential integrity only
|
||||||
|
* survives if every occurrence of one source value maps to the same replacement.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private array $personIdReplacements = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps an address id as it appears in the source to its replacement.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private array $addressIdReplacements = [];
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->faker = Factory::create('de_DE');
|
$this->faker = Factory::create('de_DE');
|
||||||
@@ -46,14 +69,24 @@ class BpnXmlAnonymizer
|
|||||||
throw new \RuntimeException('Unable to query person nodes.');
|
throw new \RuntimeException('Unable to query person nodes.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolving runs to completion before anything is replaced. resolvePersonId() falls
|
||||||
|
// back to walking up the ancestors when a node carries no person id of its own, so a
|
||||||
|
// single interleaved pass could read an id that an earlier replacement had already
|
||||||
|
// faked and split one person into two identities.
|
||||||
|
$resolved = [];
|
||||||
|
|
||||||
foreach ($personNodes as $index => $personNode) {
|
foreach ($personNodes as $index => $personNode) {
|
||||||
if (false === $personNode instanceof \DOMElement) {
|
if (false === $personNode instanceof \DOMElement) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$personId = $this->resolvePersonId($personNode, (int) $index);
|
$identity = $this->getOrCreateIdentity($this->resolvePersonId($personNode, (int) $index));
|
||||||
$identity = $this->getOrCreateIdentity($personId);
|
$this->collectIdReplacements($personNode, $identity);
|
||||||
|
|
||||||
|
$resolved[] = [$personNode, $identity];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($resolved as [$personNode, $identity]) {
|
||||||
if ('anfrage' === $personNode->tagName) {
|
if ('anfrage' === $personNode->tagName) {
|
||||||
$this->replaceRequestFields($personNode, $identity);
|
$this->replaceRequestFields($personNode, $identity);
|
||||||
continue;
|
continue;
|
||||||
@@ -67,6 +100,10 @@ class BpnXmlAnonymizer
|
|||||||
$this->replaceMetadataFields($personNode, $identity);
|
$this->replaceMetadataFields($personNode, $identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Person and address ids are swept document-wide rather than per person node: the
|
||||||
|
// same ids also appear outside any of them, as direct children of <ergebnis>.
|
||||||
|
$this->replaceIdReferences($xpath);
|
||||||
|
|
||||||
$result = $document->saveXML();
|
$result = $document->saveXML();
|
||||||
if (false === $result) {
|
if (false === $result) {
|
||||||
throw new \RuntimeException('Unable to serialize anonymized XML.');
|
throw new \RuntimeException('Unable to serialize anonymized XML.');
|
||||||
@@ -84,6 +121,8 @@ class BpnXmlAnonymizer
|
|||||||
{
|
{
|
||||||
$this->identities = [];
|
$this->identities = [];
|
||||||
$this->fallbackIdentityCounter = 0;
|
$this->fallbackIdentityCounter = 0;
|
||||||
|
$this->personIdReplacements = [];
|
||||||
|
$this->addressIdReplacements = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolvePersonId(\DOMElement $personNode, int $nodeIndex): string
|
private function resolvePersonId(\DOMElement $personNode, int $nodeIndex): string
|
||||||
@@ -419,16 +458,22 @@ class BpnXmlAnonymizer
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Replaces identifying attributes on the person node.
|
||||||
|
*
|
||||||
|
* teilnehmer/@id is deliberately left alone: it is not an identifier but the
|
||||||
|
* participant's 1-based slot within the booking, and zuordnung, status_teilnehmer,
|
||||||
|
* einzelpreis and the outbound payload all index against it. Rewriting it makes the
|
||||||
|
* dump self-contradictory - room and service assignments then resolve to nobody - so
|
||||||
|
* anonymized dumps stop being usable for debugging.
|
||||||
|
*
|
||||||
|
* kunde/@id is a real customer id and is replaced.
|
||||||
|
*
|
||||||
* @param array<string, string> $identity
|
* @param array<string, string> $identity
|
||||||
*/
|
*/
|
||||||
private function replaceMetadataFields(\DOMElement $personNode, array $identity): void
|
private function replaceMetadataFields(\DOMElement $personNode, array $identity): void
|
||||||
{
|
{
|
||||||
if (true === $personNode->hasAttribute('id')) {
|
if ('kunde' === $personNode->tagName) {
|
||||||
if ('kunde' === $personNode->tagName) {
|
$this->replaceAttributeValue($personNode, 'id', $identity['customerId']);
|
||||||
$this->replaceAttributeValue($personNode, 'id', $identity['customerId']);
|
|
||||||
} elseif ('teilnehmer' === $personNode->tagName || 'anmelder' === $personNode->tagName) {
|
|
||||||
$this->replaceAttributeValue($personNode, 'id', $identity['personId']);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,15 +521,120 @@ class BpnXmlAnonymizer
|
|||||||
$node->setAttribute($attributeName, $replacement);
|
$node->setAttribute($attributeName, $replacement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records how this person's ids must be replaced wherever they occur.
|
||||||
|
*
|
||||||
|
* Every id the node carries itself is claimed. When it carries none, the nearest
|
||||||
|
* ancestor's is claimed instead - which is the same rule resolvePersonId() used to pick
|
||||||
|
* this node's identity in the first place, so the two can never disagree about who a
|
||||||
|
* given id belongs to. A response whose only person sits in <adressdaten> keeps its ids
|
||||||
|
* at the <ergebnis> level, and this is what ties them together.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $identity
|
||||||
|
*/
|
||||||
|
private function collectIdReplacements(\DOMElement $personNode, array $identity): void
|
||||||
|
{
|
||||||
|
$ownPersonIds = [];
|
||||||
|
|
||||||
|
foreach (self::PERSON_ID_TAGS as $tagName) {
|
||||||
|
$value = $this->getDirectChildValue($personNode, $tagName);
|
||||||
|
if (null !== $value) {
|
||||||
|
$ownPersonIds[] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] === $ownPersonIds) {
|
||||||
|
$inherited = $this->findPersonIdInAncestors($personNode);
|
||||||
|
if (null !== $inherited) {
|
||||||
|
$ownPersonIds[] = $inherited;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($ownPersonIds as $value) {
|
||||||
|
$this->personIdReplacements[$value] ??= $identity['personId'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$addressId = $this->getDirectChildValue($personNode, self::ADDRESS_ID_TAG)
|
||||||
|
?? $this->findIdInAncestors($personNode, [self::ADDRESS_ID_TAG]);
|
||||||
|
|
||||||
|
if (null !== $addressId) {
|
||||||
|
$this->addressIdReplacements[$addressId] ??= $identity['addressId'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces every person and address id in the document.
|
||||||
|
*
|
||||||
|
* Runs after all person nodes have been resolved, so the lookup keys are the untouched
|
||||||
|
* source values. An id belonging to nobody the person-node query reached still gets a
|
||||||
|
* replacement of its own rather than being left in place - it identifies a real person
|
||||||
|
* either way.
|
||||||
|
*/
|
||||||
|
private function replaceIdReferences(\DOMXPath $xpath): void
|
||||||
|
{
|
||||||
|
$tagNames = [...self::PERSON_ID_TAGS, self::ADDRESS_ID_TAG];
|
||||||
|
$nodes = $xpath->query('//'.implode(' | //', $tagNames));
|
||||||
|
|
||||||
|
if (false === $nodes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($nodes as $node) {
|
||||||
|
if (false === $node instanceof \DOMElement) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim($node->textContent);
|
||||||
|
if ('' === $value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isAddressId = self::ADDRESS_ID_TAG === $node->tagName;
|
||||||
|
$replacements = $isAddressId ? $this->addressIdReplacements : $this->personIdReplacements;
|
||||||
|
|
||||||
|
if (false === isset($replacements[$value])) {
|
||||||
|
$identity = $this->getOrCreateIdentity(sprintf('%s:%s', $node->tagName, $value));
|
||||||
|
$replacements[$value] = $isAddressId ? $identity['addressId'] : $identity['personId'];
|
||||||
|
|
||||||
|
if (true === $isAddressId) {
|
||||||
|
$this->addressIdReplacements[$value] = $replacements[$value];
|
||||||
|
} else {
|
||||||
|
$this->personIdReplacements[$value] = $replacements[$value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->replaceNodeValue($node, $replacements[$value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getDirectChildValue(\DOMElement $parent, string $name): ?string
|
||||||
|
{
|
||||||
|
$child = $this->getDirectChild($parent, $name);
|
||||||
|
if (null === $child) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim($child->textContent);
|
||||||
|
|
||||||
|
return '' === $value ? null : $value;
|
||||||
|
}
|
||||||
|
|
||||||
private function findPersonIdInAncestors(\DOMElement $node): ?string
|
private function findPersonIdInAncestors(\DOMElement $node): ?string
|
||||||
{
|
{
|
||||||
foreach (['personid', 'idperson', 'idadresseperson'] as $tagName) {
|
return $this->findIdInAncestors($node, self::PERSON_ID_TAGS);
|
||||||
$child = $this->getDirectChild($node, $tagName);
|
}
|
||||||
if (null !== $child) {
|
|
||||||
$value = trim($child->textContent);
|
/**
|
||||||
if ('' !== $value) {
|
* Finds the nearest id of the given kind on the node or one of its ancestors.
|
||||||
return $value;
|
*
|
||||||
}
|
* @param list<string> $tagNames
|
||||||
|
*/
|
||||||
|
private function findIdInAncestors(\DOMElement $node, array $tagNames): ?string
|
||||||
|
{
|
||||||
|
foreach ($tagNames as $tagName) {
|
||||||
|
$value = $this->getDirectChildValue($node, $tagName);
|
||||||
|
if (null !== $value) {
|
||||||
|
return $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,7 +643,7 @@ class BpnXmlAnonymizer
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->findPersonIdInAncestors($parent);
|
return $this->findIdInAncestors($parent, $tagNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getDirectChild(\DOMElement $parent, string $name): ?\DOMElement
|
private function getDirectChild(\DOMElement $parent, string $name): ?\DOMElement
|
||||||
@@ -510,5 +660,4 @@ class BpnXmlAnonymizer
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class CalendarGridBuilder
|
|||||||
$months = [];
|
$months = [];
|
||||||
$base = $from->modify('first day of this month')->setTime(0, 0, 0);
|
$base = $from->modify('first day of this month')->setTime(0, 0, 0);
|
||||||
|
|
||||||
for ($i = 0; $i < $count; $i++) {
|
for ($i = 0; $i < $count; ++$i) {
|
||||||
$monthStart = $base->modify("+{$i} months");
|
$monthStart = $base->modify("+{$i} months");
|
||||||
$months[] = [
|
$months[] = [
|
||||||
'month' => $monthStart,
|
'month' => $monthStart,
|
||||||
@@ -54,7 +54,7 @@ class CalendarGridBuilder
|
|||||||
|
|
||||||
while ($current <= $gridEnd) {
|
while ($current <= $gridEnd) {
|
||||||
$week = [];
|
$week = [];
|
||||||
for ($d = 0; $d < 7; $d++) {
|
for ($d = 0; $d < 7; ++$d) {
|
||||||
$week[] = [
|
$week[] = [
|
||||||
'date' => $current,
|
'date' => $current,
|
||||||
'inMonth' => $current->format('Y-m') === $monthKey,
|
'inMonth' => $current->format('Y-m') === $monthKey,
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ class ContingentSnapshotManager
|
|||||||
|
|
||||||
$state = $this->syncStateRepository->findOneByAccommodation($accommodation) ?? new ContingentSyncState($accommodation);
|
$state = $this->syncStateRepository->findOneByAccommodation($accommodation) ?? new ContingentSyncState($accommodation);
|
||||||
|
|
||||||
|
// Read before recordSuccess() moves it: days beyond the horizon the previous run reached
|
||||||
|
// are the window growing, not the contingent situation changing.
|
||||||
|
$previousHorizonTo = $state->getHorizonTo();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$calendar = $this->contingentsClient->getContingentCalendar(
|
$calendar = $this->contingentsClient->getContingentCalendar(
|
||||||
$hotelCode,
|
$hotelCode,
|
||||||
@@ -72,6 +76,7 @@ class ContingentSnapshotManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
$added = 0;
|
$added = 0;
|
||||||
|
$extended = 0;
|
||||||
$updated = 0;
|
$updated = 0;
|
||||||
$seen = [];
|
$seen = [];
|
||||||
|
|
||||||
@@ -102,7 +107,12 @@ class ContingentSnapshotManager
|
|||||||
->setDate($date);
|
->setDate($date);
|
||||||
|
|
||||||
$this->entityManager->persist($day);
|
$this->entityManager->persist($day);
|
||||||
++$added;
|
|
||||||
|
if (null !== $previousHorizonTo && $date > $previousHorizonTo) {
|
||||||
|
++$extended;
|
||||||
|
} else {
|
||||||
|
++$added;
|
||||||
|
}
|
||||||
} elseif ($day->getStatus() === $entry->status) {
|
} elseif ($day->getStatus() === $entry->status) {
|
||||||
continue;
|
continue;
|
||||||
} else {
|
} else {
|
||||||
@@ -124,26 +134,21 @@ class ContingentSnapshotManager
|
|||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
$now = CarbonImmutable::now()->toDateTimeImmutable();
|
$now = CarbonImmutable::now()->toDateTimeImmutable();
|
||||||
$hash = $this->fingerprint($accommodation);
|
|
||||||
$changed = $hash !== $state->getContentHash();
|
// The diff is the change signal. A digest of the stored snapshot cannot be one: the window
|
||||||
|
// rolls forward a day at a time, so every digest would cover a different span than the one
|
||||||
|
// it is compared against and every run would report a change.
|
||||||
|
$changed = $added > 0 || $updated > 0 || $removed > 0;
|
||||||
|
|
||||||
if ($changed) {
|
if ($changed) {
|
||||||
$state->setContentHash($hash)->setChangedAt($now);
|
$state->setChangedAt($now);
|
||||||
}
|
}
|
||||||
|
|
||||||
$state->recordSuccess($now, $dateTo);
|
$state->recordSuccess($now, $dateTo);
|
||||||
$this->entityManager->persist($state);
|
$this->entityManager->persist($state);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
return ContingentSyncResult::synced($changed, $added, $updated, $removed);
|
return ContingentSyncResult::synced($changed, $added, $extended, $updated, $removed);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* sha256 over the accommodation's complete stored snapshot, contingent status only.
|
|
||||||
*/
|
|
||||||
public function fingerprint(Accommodation $accommodation): string
|
|
||||||
{
|
|
||||||
return hash('sha256', implode('|', $this->dayRepository->findStatusFingerprintParts($accommodation)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function recordFailure(ContingentSyncState $state, string $hotelCode, string $error): ContingentSyncResult
|
private function recordFailure(ContingentSyncState $state, string $hotelCode, string $error): ContingentSyncResult
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class GroupsPriceCalculator
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, int|float> $config
|
* @param array<string, int|float> $config
|
||||||
|
*
|
||||||
* @return array<string, int|float>
|
* @return array<string, int|float>
|
||||||
*/
|
*/
|
||||||
private function resolveConfig(array $config): array
|
private function resolveConfig(array $config): array
|
||||||
@@ -47,8 +48,8 @@ class GroupsPriceCalculator
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param AccommodationPrice[] $prices all prices overlapping the booking window
|
* @param AccommodationPrice[] $prices all prices overlapping the booking window
|
||||||
* @param AdditionalService[] $additionalServices only the selected services
|
* @param AdditionalService[] $additionalServices only the selected services
|
||||||
*
|
*
|
||||||
* @return array{
|
* @return array{
|
||||||
* effectivePax: int,
|
* effectivePax: int,
|
||||||
@@ -88,7 +89,7 @@ class GroupsPriceCalculator
|
|||||||
$prices,
|
$prices,
|
||||||
$boardService?->getPrice(),
|
$boardService?->getPrice(),
|
||||||
array_map(
|
array_map(
|
||||||
fn(AdditionalService $s) => [
|
fn (AdditionalService $s) => [
|
||||||
'label' => $s->getLabel() ?? '',
|
'label' => $s->getLabel() ?? '',
|
||||||
'price' => $s->getPrice() ?? 0,
|
'price' => $s->getPrice() ?? 0,
|
||||||
'type' => $s->getType(),
|
'type' => $s->getType(),
|
||||||
@@ -103,7 +104,7 @@ class GroupsPriceCalculator
|
|||||||
* Same calculation but using frozen snapshot data from an AccommodationBooking entity
|
* Same calculation but using frozen snapshot data from an AccommodationBooking entity
|
||||||
* instead of live catalog entities.
|
* instead of live catalog entities.
|
||||||
*
|
*
|
||||||
* @param AccommodationPrice[] $prices all prices overlapping the booking window
|
* @param AccommodationPrice[] $prices all prices overlapping the booking window
|
||||||
* @param list<array{label: string, price: int, type: string, originalServiceId: int|null}> $additionalServiceSnapshots
|
* @param list<array{label: string, price: int, type: string, originalServiceId: int|null}> $additionalServiceSnapshots
|
||||||
*
|
*
|
||||||
* @return array{
|
* @return array{
|
||||||
@@ -136,7 +137,7 @@ class GroupsPriceCalculator
|
|||||||
string $currency,
|
string $currency,
|
||||||
): array {
|
): array {
|
||||||
$items = array_map(
|
$items = array_map(
|
||||||
fn(array $s) => [
|
fn (array $s) => [
|
||||||
'label' => $s['label'],
|
'label' => $s['label'],
|
||||||
'price' => $s['price'],
|
'price' => $s['price'],
|
||||||
'type' => AdditionalServiceType::tryFrom($s['type']) ?? AdditionalServiceType::Flat,
|
'type' => AdditionalServiceType::tryFrom($s['type']) ?? AdditionalServiceType::Flat,
|
||||||
@@ -158,7 +159,7 @@ class GroupsPriceCalculator
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param AccommodationPrice[] $prices
|
* @param AccommodationPrice[] $prices
|
||||||
* @param list<array{label: string, price: int, type: AdditionalServiceType}> $additionalItems
|
* @param list<array{label: string, price: int, type: AdditionalServiceType}> $additionalItems
|
||||||
*
|
*
|
||||||
* @return array{
|
* @return array{
|
||||||
@@ -193,7 +194,7 @@ class GroupsPriceCalculator
|
|||||||
// Derive effectivePax: children 0–3 don't count, but never drop below includedPax
|
// Derive effectivePax: children 0–3 don't count, but never drop below includedPax
|
||||||
$firstCandidates = array_values(array_filter(
|
$firstCandidates = array_values(array_filter(
|
||||||
$prices,
|
$prices,
|
||||||
fn(AccommodationPrice $p) => $p->getDateFrom() <= $dateFrom && $p->getDateTo() >= $dateFrom,
|
fn (AccommodationPrice $p) => $p->getDateFrom() <= $dateFrom && $p->getDateTo() >= $dateFrom,
|
||||||
));
|
));
|
||||||
$firstWinner = $this->priceTimelineBuilder->resolveWinner($firstCandidates);
|
$firstWinner = $this->priceTimelineBuilder->resolveWinner($firstCandidates);
|
||||||
$includedPaxFloor = $firstWinner?->getIncludedPax() ?? 1;
|
$includedPaxFloor = $firstWinner?->getIncludedPax() ?? 1;
|
||||||
@@ -224,11 +225,11 @@ class GroupsPriceCalculator
|
|||||||
|
|
||||||
$candidates = array_values(array_filter(
|
$candidates = array_values(array_filter(
|
||||||
$prices,
|
$prices,
|
||||||
fn(AccommodationPrice $p) => $p->getDateFrom() <= $segStart && $p->getDateTo() >= $segStart,
|
fn (AccommodationPrice $p) => $p->getDateFrom() <= $segStart && $p->getDateTo() >= $segStart,
|
||||||
));
|
));
|
||||||
$winner = $this->priceTimelineBuilder->resolveWinner($candidates) ?? $lastWinner;
|
$winner = $this->priceTimelineBuilder->resolveWinner($candidates) ?? $lastWinner;
|
||||||
|
|
||||||
if ($winner !== null) {
|
if (null !== $winner) {
|
||||||
$lastWinner = $winner;
|
$lastWinner = $winner;
|
||||||
$basePrice += ($winner->getPricePerNight() ?? 0) * $segNights;
|
$basePrice += ($winner->getPricePerNight() ?? 0) * $segNights;
|
||||||
$includedPax = $winner->getIncludedPax() ?? 0;
|
$includedPax = $winner->getIncludedPax() ?? 0;
|
||||||
@@ -254,7 +255,7 @@ class GroupsPriceCalculator
|
|||||||
// Rule 4: undersubscription surcharge (only when a paid board is selected)
|
// Rule 4: undersubscription surcharge (only when a paid board is selected)
|
||||||
$undersubscriptionSurcharge = 0;
|
$undersubscriptionSurcharge = 0;
|
||||||
$undersubscriptionThreshold = null;
|
$undersubscriptionThreshold = null;
|
||||||
if ($paidBoardPricePerPersonNight !== null) {
|
if (null !== $paidBoardPricePerPersonNight) {
|
||||||
$surcharge30 = (int) round(('CHF' === $currency ? $this->config['undersubscription30Chf'] : $this->config['undersubscription30Eur']) * 100);
|
$surcharge30 = (int) round(('CHF' === $currency ? $this->config['undersubscription30Chf'] : $this->config['undersubscription30Eur']) * 100);
|
||||||
$surcharge40 = (int) round(('CHF' === $currency ? $this->config['undersubscription40Chf'] : $this->config['undersubscription40Eur']) * 100);
|
$surcharge40 = (int) round(('CHF' === $currency ? $this->config['undersubscription40Chf'] : $this->config['undersubscription40Eur']) * 100);
|
||||||
|
|
||||||
@@ -269,7 +270,7 @@ class GroupsPriceCalculator
|
|||||||
|
|
||||||
// Rule 5: board price
|
// Rule 5: board price
|
||||||
$boardPrice = 0;
|
$boardPrice = 0;
|
||||||
if ($paidBoardPricePerPersonNight !== null) {
|
if (null !== $paidBoardPricePerPersonNight) {
|
||||||
$boardPrice = $paidBoardPricePerPersonNight * $effectivePax * $nights;
|
$boardPrice = $paidBoardPricePerPersonNight * $effectivePax * $nights;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,10 +115,10 @@ class InsuranceManager
|
|||||||
* participant's price - this splits the given insurances by type and evaluates
|
* participant's price - this splits the given insurances by type and evaluates
|
||||||
* each group against the appropriate price before merging the results back together.
|
* each group against the appropriate price before merging the results back together.
|
||||||
*
|
*
|
||||||
* @param array<Insurance> $insurances Available insurances to filter
|
* @param array<Insurance> $insurances Available insurances to filter
|
||||||
* @param ParticipantDto $participant The participant to match insurances for
|
* @param ParticipantDto $participant The participant to match insurances for
|
||||||
* @param BookingDto $booking The booking context for additional criteria
|
* @param BookingDto $booking The booking context for additional criteria
|
||||||
* @param float $individualPrice The participant's individual travel price (excluding insurance)
|
* @param float $individualPrice The participant's individual travel price (excluding insurance)
|
||||||
* @param float $totalBookingPrice The total booking price across all participants (excluding insurance)
|
* @param float $totalBookingPrice The total booking price across all participants (excluding insurance)
|
||||||
*
|
*
|
||||||
* @return array<Insurance> Filtered array of eligible insurances, sorted by price
|
* @return array<Insurance> Filtered array of eligible insurances, sorted by price
|
||||||
|
|||||||
@@ -39,8 +39,7 @@ class ParticipantCardAssembler
|
|||||||
BookingDto $bookingDto,
|
BookingDto $bookingDto,
|
||||||
int $index,
|
int $index,
|
||||||
?array $precomputedPrices = null,
|
?array $precomputedPrices = null,
|
||||||
): ParticipantCardDataDto
|
): ParticipantCardDataDto {
|
||||||
{
|
|
||||||
$participant = $bookingDto->participants[$index] ?? null;
|
$participant = $bookingDto->participants[$index] ?? null;
|
||||||
|
|
||||||
if (null === $participant) {
|
if (null === $participant) {
|
||||||
@@ -142,8 +141,7 @@ class ParticipantCardAssembler
|
|||||||
BookingDto $bookingDto,
|
BookingDto $bookingDto,
|
||||||
int $index,
|
int $index,
|
||||||
?array $precomputedPrices = null,
|
?array $precomputedPrices = null,
|
||||||
): ParticipantCardPriceDto
|
): ParticipantCardPriceDto {
|
||||||
{
|
|
||||||
// Check if canceled (only possible in edit mode when booking property is set)
|
// Check if canceled (only possible in edit mode when booking property is set)
|
||||||
$isCanceled = ($bookingDto->booking?->participantsStatus[$index] ?? null) === 'S';
|
$isCanceled = ($bookingDto->booking?->participantsStatus[$index] ?? null) === 'S';
|
||||||
|
|
||||||
@@ -189,8 +187,7 @@ class ParticipantCardAssembler
|
|||||||
BookingDto $bookingDto,
|
BookingDto $bookingDto,
|
||||||
int $index,
|
int $index,
|
||||||
bool $forceStrictRequired = false,
|
bool $forceStrictRequired = false,
|
||||||
): ParticipantCardDataDto
|
): ParticipantCardDataDto {
|
||||||
{
|
|
||||||
return $this->getCardDataWithValidationInternal(
|
return $this->getCardDataWithValidationInternal(
|
||||||
$bookingDto,
|
$bookingDto,
|
||||||
$index,
|
$index,
|
||||||
@@ -262,7 +259,7 @@ class ParticipantCardAssembler
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string> $validationGroups
|
* @param array<string> $validationGroups
|
||||||
* @param array<int, float>|null $precomputedPrices
|
* @param array<int, float>|null $precomputedPrices
|
||||||
*/
|
*/
|
||||||
private function getCardDataWithValidationInternal(
|
private function getCardDataWithValidationInternal(
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use App\BusProNet\ApiClient;
|
|||||||
use App\BusProNet\Model\Address;
|
use App\BusProNet\Model\Address;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Form\Model\BookingDto;
|
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Security\Crypt;
|
use App\Security\Crypt;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class ParticipantFormSupport
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, int> $options
|
* @param array<string, int> $options
|
||||||
|
*
|
||||||
* @return array<string, int>
|
* @return array<string, int>
|
||||||
*/
|
*/
|
||||||
private function resolveOptions(array $options): array
|
private function resolveOptions(array $options): array
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class PriceTimelineBuilder
|
|||||||
$lastDefault = null;
|
$lastDefault = null;
|
||||||
|
|
||||||
// Step 2: walk each segment [start, end) and resolve the effective price.
|
// Step 2: walk each segment [start, end) and resolve the effective price.
|
||||||
for ($i = 0, $count = count($boundaries) - 1; $i < $count; $i++) {
|
for ($i = 0, $count = count($boundaries) - 1; $i < $count; ++$i) {
|
||||||
$segStart = $boundaries[$i];
|
$segStart = $boundaries[$i];
|
||||||
$segEndNext = $boundaries[$i + 1];
|
$segEndNext = $boundaries[$i + 1];
|
||||||
|
|
||||||
@@ -81,12 +81,12 @@ class PriceTimelineBuilder
|
|||||||
|
|
||||||
$candidates = array_filter(
|
$candidates = array_filter(
|
||||||
$prices,
|
$prices,
|
||||||
fn($p) => $p->getDateFrom() <= $segStart && $p->getDateTo() >= $segStart,
|
fn ($p) => $p->getDateFrom() <= $segStart && $p->getDateTo() >= $segStart,
|
||||||
);
|
);
|
||||||
|
|
||||||
$winner = $this->resolveWinner($candidates);
|
$winner = $this->resolveWinner($candidates);
|
||||||
|
|
||||||
if ($winner === null) {
|
if (null === $winner) {
|
||||||
// Gap: reset merge state so the next winner always opens a new row.
|
// Gap: reset merge state so the next winner always opens a new row.
|
||||||
$lastWinner = null;
|
$lastWinner = null;
|
||||||
$lastDefault = null;
|
$lastDefault = null;
|
||||||
@@ -94,8 +94,8 @@ class PriceTimelineBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
$defaultWinner = null;
|
$defaultWinner = null;
|
||||||
if ($winner->getType() === PriceType::DISCOUNT) {
|
if (PriceType::DISCOUNT === $winner->getType()) {
|
||||||
$defaults = array_filter($candidates, fn($p) => $p->getType() === null);
|
$defaults = array_filter($candidates, fn ($p) => null === $p->getType());
|
||||||
$defaultWinner = $this->resolveWinner($defaults);
|
$defaultWinner = $this->resolveWinner($defaults);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,8 +125,8 @@ class PriceTimelineBuilder
|
|||||||
includedPax: $winner->getIncludedPax(),
|
includedPax: $winner->getIncludedPax(),
|
||||||
pricePerNight: round($winner->getPricePerNight() / 100, 2),
|
pricePerNight: round($winner->getPricePerNight() / 100, 2),
|
||||||
priceAdditionalPerson: round($winner->getPriceAdditionalPerson() / 100, 2),
|
priceAdditionalPerson: round($winner->getPriceAdditionalPerson() / 100, 2),
|
||||||
defaultPricePerNight: $defaultWinner !== null ? round($defaultWinner->getPricePerNight() / 100, 2) : null,
|
defaultPricePerNight: null !== $defaultWinner ? round($defaultWinner->getPricePerNight() / 100, 2) : null,
|
||||||
defaultPriceAdditionalPerson: $defaultWinner !== null ? round($defaultWinner->getPriceAdditionalPerson() / 100, 2) : null,
|
defaultPriceAdditionalPerson: null !== $defaultWinner ? round($defaultWinner->getPriceAdditionalPerson() / 100, 2) : null,
|
||||||
currency: $currency,
|
currency: $currency,
|
||||||
type: $winner->getType()?->value,
|
type: $winner->getType()?->value,
|
||||||
);
|
);
|
||||||
@@ -150,7 +150,7 @@ class PriceTimelineBuilder
|
|||||||
{
|
{
|
||||||
$winner = null;
|
$winner = null;
|
||||||
foreach ($candidates as $candidate) {
|
foreach ($candidates as $candidate) {
|
||||||
if ($winner === null) {
|
if (null === $winner) {
|
||||||
$winner = $candidate;
|
$winner = $candidate;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Form\Model\Filter\AbstractListFilterDto;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where to send somebody who should act on a role nomination.
|
||||||
|
*
|
||||||
|
* Deliberately not app_admin_user_permissions. That route renders the approval modal, which
|
||||||
|
* extends htmx_modal_admin.html.twig — a bare <div> with no page chrome, meant to be swapped
|
||||||
|
* into a page that is already open. Following it as a normal navigation, which is what a link
|
||||||
|
* in an email or a dashboard does, yields an unstyled fragment.
|
||||||
|
*
|
||||||
|
* The user list filtered down to the one account is one click away from the modal and shows the
|
||||||
|
* nomination badge on the way. The query string is flat because the list filter forms declare no
|
||||||
|
* block prefix.
|
||||||
|
*/
|
||||||
|
class RoleApprovalUrlGenerator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UrlGeneratorInterface $urlGenerator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forUser(User $user, int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
|
||||||
|
{
|
||||||
|
return $this->urlGenerator->generate(
|
||||||
|
'app_admin_user',
|
||||||
|
[
|
||||||
|
AbstractListFilterDto::MARKER => 1,
|
||||||
|
'q' => $user->getEmail(),
|
||||||
|
],
|
||||||
|
$referenceType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,13 @@ class ServiceAvailabilityCalculator
|
|||||||
*
|
*
|
||||||
* Delegates to isServiceUnavailable() so that both entry points share one rule set.
|
* Delegates to isServiceUnavailable() so that both entry points share one rule set.
|
||||||
*
|
*
|
||||||
|
* Never empties a non-empty choice group on the on-request rule alone: on some termine
|
||||||
|
* every outbound transport option or every ski pass is 'Anfrage', and an empty required
|
||||||
|
* group makes the travel unbookable - ParticipantEligibilityChecker reads "offered but
|
||||||
|
* none selectable" as the participant being unable to travel. Sold-out and Buchungsstop
|
||||||
|
* still empty a group, preserving the pre-existing semantics. Inert in create mode, where
|
||||||
|
* the on-request rule cannot fire.
|
||||||
|
*
|
||||||
* @param array<int, Service> $services Array of Service objects to filter
|
* @param array<int, Service> $services Array of Service objects to filter
|
||||||
* @param BookingDto $bookingDto The booking data with participant selections
|
* @param BookingDto $bookingDto The booking data with participant selections
|
||||||
* @param int $participantIndex The index of the participant currently filling the form
|
* @param int $participantIndex The index of the participant currently filling the form
|
||||||
@@ -63,7 +70,7 @@ class ServiceAvailabilityCalculator
|
|||||||
*/
|
*/
|
||||||
public function filterAvailableServices(array $services, BookingDto $bookingDto, int $participantIndex): array
|
public function filterAvailableServices(array $services, BookingDto $bookingDto, int $participantIndex): array
|
||||||
{
|
{
|
||||||
return array_filter($services, function (Service $service) use ($bookingDto, $participantIndex) {
|
$filtered = array_filter($services, function (Service $service) use ($bookingDto, $participantIndex) {
|
||||||
// Services without an ID cannot be resolved against travel data - treat as available
|
// Services without an ID cannot be resolved against travel data - treat as available
|
||||||
if (null === $service->id) {
|
if (null === $service->id) {
|
||||||
return true;
|
return true;
|
||||||
@@ -71,6 +78,17 @@ class ServiceAvailabilityCalculator
|
|||||||
|
|
||||||
return false === $this->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
|
return false === $this->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if ([] !== $filtered || [] === $services) {
|
||||||
|
return $filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_filter(
|
||||||
|
$services,
|
||||||
|
fn (Service $service): bool => $this->isBlockedByOnRequestStatus($service, $bookingDto)
|
||||||
|
&& Constants::STATUS_BLOCKED !== $service->status
|
||||||
|
&& false === $this->isContingentExhausted($service, $bookingDto, $participantIndex)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,6 +98,16 @@ class ServiceAvailabilityCalculator
|
|||||||
* contingent is exhausted, either at the API level or through selections made by the
|
* contingent is exhausted, either at the API level or through selections made by the
|
||||||
* other participants of the current booking.
|
* other participants of the current booking.
|
||||||
*
|
*
|
||||||
|
* In edit mode one further rule applies: a service that is only available on request
|
||||||
|
* (Anfrage) cannot be *acquired*. The participants of an existing booking are fixed at
|
||||||
|
* status 'F', and BusPro derives a Leistung's status from the travel data and refuses to
|
||||||
|
* attach it when the two differ ("Status der Leistung (A) ist unterschiedlich zum Status
|
||||||
|
* des Teilnehmers (F)"), rejecting the whole update. The create flow has no such problem,
|
||||||
|
* because the booking status can still move to 'A' there - hence the mode precondition.
|
||||||
|
*
|
||||||
|
* The rule is asymmetric: a service the participant already holds in the live booking
|
||||||
|
* stays available, so it can be kept and re-sent.
|
||||||
|
*
|
||||||
* @param int $serviceId The ID of the service to check
|
* @param int $serviceId The ID of the service to check
|
||||||
* @param BookingDto $bookingDto The booking data with participant selections
|
* @param BookingDto $bookingDto The booking data with participant selections
|
||||||
* @param int $participantIndex The index of the participant currently filling the form
|
* @param int $participantIndex The index of the participant currently filling the form
|
||||||
@@ -103,11 +131,32 @@ class ServiceAvailabilityCalculator
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A service the participant already holds is always keepable, whatever its state now
|
||||||
|
if (true === $this->isServiceHeldByParticipant($service, $bookingDto, $participantIndex)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// A booking stop blocks the service regardless of its contingent
|
// A booking stop blocks the service regardless of its contingent
|
||||||
if (Constants::STATUS_BLOCKED === $service->status) {
|
if (Constants::STATUS_BLOCKED === $service->status) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On request cannot be added to a participant whose status is already fixed
|
||||||
|
if (true === $this->isBlockedByOnRequestStatus($service, $bookingDto)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->isContingentExhausted($service, $bookingDto, $participantIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a service has no contingent left.
|
||||||
|
*
|
||||||
|
* Covers both the API-level figure and the seats taken by the other participants of the
|
||||||
|
* current booking.
|
||||||
|
*/
|
||||||
|
private function isContingentExhausted(Service $service, BookingDto $bookingDto, int $participantIndex): bool
|
||||||
|
{
|
||||||
// If no availability tracking (null), service is unlimited and available
|
// If no availability tracking (null), service is unlimited and available
|
||||||
if (null === $service->available) {
|
if (null === $service->available) {
|
||||||
return false;
|
return false;
|
||||||
@@ -121,7 +170,50 @@ class ServiceAvailabilityCalculator
|
|||||||
// For services with positive availability, calculate remaining based on booking selections
|
// For services with positive availability, calculate remaining based on booking selections
|
||||||
$remainingAvailability = $this->calculateRemainingAvailability($bookingDto, $participantIndex);
|
$remainingAvailability = $this->calculateRemainingAvailability($bookingDto, $participantIndex);
|
||||||
|
|
||||||
return ($remainingAvailability[$serviceId] ?? $service->available) <= 0;
|
return ($remainingAvailability[$service->id] ?? $service->available) <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the on-request rule blocks a service.
|
||||||
|
*
|
||||||
|
* Only applies in edit mode, and never to services the form would then be unable to
|
||||||
|
* satisfy: auto-booked services and mandatory services outside the transport categories
|
||||||
|
* are required by MandatoryAdditionalServicesSelectedValidator, so blocking one makes the
|
||||||
|
* form unsatisfiable. Mandatory transport legs are deliberately not exempt - there
|
||||||
|
* pflicht="True" means "pick one of this group" rather than "compulsory", and exempting
|
||||||
|
* them would exempt the bus legs this rule exists for.
|
||||||
|
*/
|
||||||
|
private function isBlockedByOnRequestStatus(Service $service, BookingDto $bookingDto): bool
|
||||||
|
{
|
||||||
|
if (BookingDto::MODE_EDIT !== $bookingDto->getMode()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Constants::STATUS_ON_REQUEST !== $service->status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true === $service->autoBook) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false === ($service->mandatory && Constants::CATEGORY_TRANSPORTATION !== $service->category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the participant already holds the service in the live booking.
|
||||||
|
*
|
||||||
|
* Reads the BusPro-side baseline (BookingDto::$booking), never the working selection,
|
||||||
|
* which already carries whatever the customer just picked. Always false in create mode,
|
||||||
|
* where there is no booking yet.
|
||||||
|
*/
|
||||||
|
private function isServiceHeldByParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
|
||||||
|
{
|
||||||
|
if (null === $bookingDto->booking || null === $service->id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bookingDto->booking->hasServiceForParticipant($participantIndex, $service->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ use App\Model\CalendarMonth;
|
|||||||
use App\Service\AccommodationPriceCoverage;
|
use App\Service\AccommodationPriceCoverage;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
|
|
||||||
use Symfony\Contracts\Cache\CacheInterface;
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
use Symfony\Contracts\Cache\ItemInterface;
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
|
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
|
||||||
|
|
||||||
#[AsTwigComponent('calendar', template: 'components/calendar/index.html.twig')]
|
#[AsTwigComponent('calendar', template: 'components/calendar/index.html.twig')]
|
||||||
class Calendar
|
class Calendar
|
||||||
@@ -55,8 +55,8 @@ class Calendar
|
|||||||
$this->accommodation = $accommodation;
|
$this->accommodation = $accommodation;
|
||||||
$this->startMonth = $startMonth;
|
$this->startMonth = $startMonth;
|
||||||
|
|
||||||
if ($startMonth !== null && preg_match('/^\d{4}-\d{2}$/', $startMonth)) {
|
if (null !== $startMonth && preg_match('/^\d{4}-\d{2}$/', $startMonth)) {
|
||||||
$start = CarbonImmutable::createFromFormat('Y-m-d', $startMonth . '-01');
|
$start = CarbonImmutable::createFromFormat('Y-m-d', $startMonth.'-01');
|
||||||
} else {
|
} else {
|
||||||
$allDates = $this->collectAllDates();
|
$allDates = $this->collectAllDates();
|
||||||
$start = empty($allDates)
|
$start = empty($allDates)
|
||||||
@@ -73,7 +73,7 @@ class Calendar
|
|||||||
|
|
||||||
$months = [];
|
$months = [];
|
||||||
$current = $start;
|
$current = $start;
|
||||||
for ($i = 0; $i < 3; $i++) {
|
for ($i = 0; $i < 3; ++$i) {
|
||||||
$months[] = $this->buildMonth($current, $map, $blockedDates);
|
$months[] = $this->buildMonth($current, $map, $blockedDates);
|
||||||
$current = $current->addMonth();
|
$current = $current->addMonth();
|
||||||
}
|
}
|
||||||
@@ -81,22 +81,22 @@ class Calendar
|
|||||||
$this->months = $months;
|
$this->months = $months;
|
||||||
|
|
||||||
$prevStart = $start->subMonths(3);
|
$prevStart = $start->subMonths(3);
|
||||||
$prevEnd = $prevStart->addMonths(2);
|
$prevEnd = $prevStart->addMonths(2);
|
||||||
$this->prevMonth = $prevStart->format('Y-m');
|
$this->prevMonth = $prevStart->format('Y-m');
|
||||||
$this->prevLabel = self::MONTHS[$prevStart->month] . ' – ' . self::MONTHS[$prevEnd->month] . ' ' . $prevEnd->year;
|
$this->prevLabel = self::MONTHS[$prevStart->month].' – '.self::MONTHS[$prevEnd->month].' '.$prevEnd->year;
|
||||||
|
|
||||||
$nextStart = $start->addMonths(3);
|
$nextStart = $start->addMonths(3);
|
||||||
$nextEnd = $nextStart->addMonths(2);
|
$nextEnd = $nextStart->addMonths(2);
|
||||||
$this->nextMonth = $nextStart->format('Y-m');
|
$this->nextMonth = $nextStart->format('Y-m');
|
||||||
$this->nextLabel = self::MONTHS[$nextStart->month] . ' – ' . self::MONTHS[$nextEnd->month] . ' ' . $nextEnd->year;
|
$this->nextLabel = self::MONTHS[$nextStart->month].' – '.self::MONTHS[$nextEnd->month].' '.$nextEnd->year;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, true> keyed by Y-m-d, only BLOCKED dates present
|
* @return array<string, true> keyed by Y-m-d, only BLOCKED dates present
|
||||||
*/
|
*/
|
||||||
private function fetchBlockedDates(string $hotelCode, string $dateFrom, string $dateTo): array
|
private function fetchBlockedDates(string $hotelCode, string $dateFrom, string $dateTo): array
|
||||||
{
|
{
|
||||||
if ($hotelCode === '') {
|
if ('' === $hotelCode) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ class Calendar
|
|||||||
|
|
||||||
$blocked = [];
|
$blocked = [];
|
||||||
foreach ($response->data as $entry) {
|
foreach ($response->data as $entry) {
|
||||||
if ($entry->status === ContingentStatus::Blocked) {
|
if (ContingentStatus::Blocked === $entry->status) {
|
||||||
$blocked[(new \DateTimeImmutable($entry->date))->format('Y-m-d')] = true;
|
$blocked[(new \DateTimeImmutable($entry->date))->format('Y-m-d')] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,26 +144,26 @@ class Calendar
|
|||||||
$dates = [];
|
$dates = [];
|
||||||
|
|
||||||
foreach ($this->accommodation->getAccommodationPrices() as $price) {
|
foreach ($this->accommodation->getAccommodationPrices() as $price) {
|
||||||
if ($price->getDateFrom() !== null) {
|
if (null !== $price->getDateFrom()) {
|
||||||
$dates[] = CarbonImmutable::instance($price->getDateFrom());
|
$dates[] = CarbonImmutable::instance($price->getDateFrom());
|
||||||
}
|
}
|
||||||
if ($price->getDateTo() !== null) {
|
if (null !== $price->getDateTo()) {
|
||||||
$dates[] = CarbonImmutable::instance($price->getDateTo());
|
$dates[] = CarbonImmutable::instance($price->getDateTo());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foreach ($this->accommodation->getBoardServices() as $service) {
|
foreach ($this->accommodation->getBoardServices() as $service) {
|
||||||
if ($service->getDateFrom() !== null) {
|
if (null !== $service->getDateFrom()) {
|
||||||
$dates[] = CarbonImmutable::instance($service->getDateFrom());
|
$dates[] = CarbonImmutable::instance($service->getDateFrom());
|
||||||
}
|
}
|
||||||
if ($service->getDateTo() !== null) {
|
if (null !== $service->getDateTo()) {
|
||||||
$dates[] = CarbonImmutable::instance($service->getDateTo());
|
$dates[] = CarbonImmutable::instance($service->getDateTo());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foreach ($this->accommodation->getAdditionalServices() as $service) {
|
foreach ($this->accommodation->getAdditionalServices() as $service) {
|
||||||
if ($service->getDateFrom() !== null) {
|
if (null !== $service->getDateFrom()) {
|
||||||
$dates[] = CarbonImmutable::instance($service->getDateFrom());
|
$dates[] = CarbonImmutable::instance($service->getDateFrom());
|
||||||
}
|
}
|
||||||
if ($service->getDateTo() !== null) {
|
if (null !== $service->getDateTo()) {
|
||||||
$dates[] = CarbonImmutable::instance($service->getDateTo());
|
$dates[] = CarbonImmutable::instance($service->getDateTo());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,11 +198,11 @@ class Calendar
|
|||||||
$map = [];
|
$map = [];
|
||||||
|
|
||||||
foreach ($this->accommodation->getAccommodationPrices() as $price) {
|
foreach ($this->accommodation->getAccommodationPrices() as $price) {
|
||||||
if ($price->getDateFrom() === null || $price->getDateTo() === null) {
|
if (null === $price->getDateFrom() || null === $price->getDateTo()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$priceFormatted = number_format($price->getPricePerNight() / 100, 2, ',', '.') . ' €';
|
$priceFormatted = number_format($price->getPricePerNight() / 100, 2, ',', '.').' €';
|
||||||
$label = sprintf('Preis: %s/Nacht (min. %d Nächte)', $priceFormatted, $price->getMinNights());
|
$label = sprintf('Preis: %s/Nacht (min. %d Nächte)', $priceFormatted, $price->getMinNights());
|
||||||
|
|
||||||
$day = CarbonImmutable::instance($price->getDateFrom());
|
$day = CarbonImmutable::instance($price->getDateFrom());
|
||||||
@@ -216,7 +216,7 @@ class Calendar
|
|||||||
$key = $day->format('Y-m-d');
|
$key = $day->format('Y-m-d');
|
||||||
$map[$key] ??= ['price' => null, 'board' => null, 'additional' => null];
|
$map[$key] ??= ['price' => null, 'board' => null, 'additional' => null];
|
||||||
$existing = $map[$key]['price'];
|
$existing = $map[$key]['price'];
|
||||||
$replace = $existing === null
|
$replace = null === $existing
|
||||||
|| $priority > $existing['priority']
|
|| $priority > $existing['priority']
|
||||||
|| ($priority === $existing['priority'] && $span < $existing['span']);
|
|| ($priority === $existing['priority'] && $span < $existing['span']);
|
||||||
if ($replace) {
|
if ($replace) {
|
||||||
@@ -227,11 +227,11 @@ class Calendar
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($this->accommodation->getBoardServices() as $service) {
|
foreach ($this->accommodation->getBoardServices() as $service) {
|
||||||
if ($service->getDateFrom() === null || $service->getDateTo() === null) {
|
if (null === $service->getDateFrom() || null === $service->getDateTo()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$label = 'Verpflegung: ' . $service->getLabel();
|
$label = 'Verpflegung: '.$service->getLabel();
|
||||||
|
|
||||||
$day = CarbonImmutable::instance($service->getDateFrom());
|
$day = CarbonImmutable::instance($service->getDateFrom());
|
||||||
$end = CarbonImmutable::instance($service->getDateTo());
|
$end = CarbonImmutable::instance($service->getDateTo());
|
||||||
@@ -241,7 +241,7 @@ class Calendar
|
|||||||
while ($day <= $end && $day <= $limit) {
|
while ($day <= $end && $day <= $limit) {
|
||||||
$key = $day->format('Y-m-d');
|
$key = $day->format('Y-m-d');
|
||||||
$map[$key] ??= ['price' => null, 'board' => null, 'additional' => null];
|
$map[$key] ??= ['price' => null, 'board' => null, 'additional' => null];
|
||||||
if ($map[$key]['board'] === null || $span < $map[$key]['board']['span']) {
|
if (null === $map[$key]['board'] || $span < $map[$key]['board']['span']) {
|
||||||
$map[$key]['board'] = ['label' => $label, 'span' => $span];
|
$map[$key]['board'] = ['label' => $label, 'span' => $span];
|
||||||
}
|
}
|
||||||
$day = $day->addDay();
|
$day = $day->addDay();
|
||||||
@@ -249,14 +249,14 @@ class Calendar
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($this->accommodation->getAdditionalServices() as $service) {
|
foreach ($this->accommodation->getAdditionalServices() as $service) {
|
||||||
if ($service->getDateFrom() === null || $service->getDateTo() === null) {
|
if (null === $service->getDateFrom() || null === $service->getDateTo()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$typeLabel = $service->getType() !== null
|
$typeLabel = null !== $service->getType()
|
||||||
? self::TYPE_LABELS[$service->getType()->value]
|
? self::TYPE_LABELS[$service->getType()->value]
|
||||||
: '';
|
: '';
|
||||||
$label = sprintf('Zusatz: %s%s', $service->getLabel(), $typeLabel !== '' ? ' (' . $typeLabel . ')' : '');
|
$label = sprintf('Zusatz: %s%s', $service->getLabel(), '' !== $typeLabel ? ' ('.$typeLabel.')' : '');
|
||||||
|
|
||||||
$day = CarbonImmutable::instance($service->getDateFrom());
|
$day = CarbonImmutable::instance($service->getDateFrom());
|
||||||
$end = CarbonImmutable::instance($service->getDateTo());
|
$end = CarbonImmutable::instance($service->getDateTo());
|
||||||
@@ -266,7 +266,7 @@ class Calendar
|
|||||||
while ($day <= $end && $day <= $limit) {
|
while ($day <= $end && $day <= $limit) {
|
||||||
$key = $day->format('Y-m-d');
|
$key = $day->format('Y-m-d');
|
||||||
$map[$key] ??= ['price' => null, 'board' => null, 'additional' => null];
|
$map[$key] ??= ['price' => null, 'board' => null, 'additional' => null];
|
||||||
if ($map[$key]['additional'] === null || $span < $map[$key]['additional']['span']) {
|
if (null === $map[$key]['additional'] || $span < $map[$key]['additional']['span']) {
|
||||||
$map[$key]['additional'] = ['label' => $label, 'span' => $span];
|
$map[$key]['additional'] = ['label' => $label, 'span' => $span];
|
||||||
}
|
}
|
||||||
$day = $day->addDay();
|
$day = $day->addDay();
|
||||||
@@ -293,8 +293,8 @@ class Calendar
|
|||||||
$firstDow = ($start->dayOfWeek + 6) % 7;
|
$firstDow = ($start->dayOfWeek + 6) % 7;
|
||||||
|
|
||||||
$days = [];
|
$days = [];
|
||||||
for ($d = 1; $d <= $start->daysInMonth; $d++) {
|
for ($d = 1; $d <= $start->daysInMonth; ++$d) {
|
||||||
$dateStr = $month->format('Y-m') . '-' . str_pad((string) $d, 2, '0', STR_PAD_LEFT);
|
$dateStr = $month->format('Y-m').'-'.str_pad((string) $d, 2, '0', STR_PAD_LEFT);
|
||||||
$coverage = $map[$dateStr] ?? [];
|
$coverage = $map[$dateStr] ?? [];
|
||||||
|
|
||||||
$parts = [];
|
$parts = [];
|
||||||
@@ -325,7 +325,7 @@ class Calendar
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new CalendarMonth(
|
return new CalendarMonth(
|
||||||
label: self::MONTHS[$month->month] . ' ' . $month->year,
|
label: self::MONTHS[$month->month].' '.$month->year,
|
||||||
firstDow: $firstDow,
|
firstDow: $firstDow,
|
||||||
days: $days,
|
days: $days,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends 'email/layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h1>
|
||||||
|
Neue Rollen-Freischaltung
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
Für ein MyE&P-Konto wurde im BusPro-CRM eine administrative Rolle hinterlegt. Sie muss
|
||||||
|
freigeschaltet werden, bevor sie gilt.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>{{ user.displayName }}</strong>
|
||||||
|
<br>
|
||||||
|
{{ user.email }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Beantragt {{ roles|length > 1 ? 'wurden folgende Rollen' : 'wurde folgende Rolle' }}:
|
||||||
|
{% for role in roles %}
|
||||||
|
<br>
|
||||||
|
- {{ role }}
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="{{ approvalUrl }}" class="button">
|
||||||
|
Freischaltung prüfen
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p class="small">
|
||||||
|
Solange die Freischaltung aussteht, hat die Rolle keinerlei Wirkung — das Konto kann damit
|
||||||
|
nichts tun. Möchtest du sie nicht erteilen, ignoriere diese E-Mail einfach. Die Rolle wird
|
||||||
|
erst entfernt, wenn die Auswahl im CRM entfernt wird.
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{% extends 'layout_booking.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Kontaktdaten vervollständigen{% endblock %}
|
||||||
|
|
||||||
|
{% block background %}bg-outer bg-outer--summer{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% form_theme form 'booking/_form_theme.html.twig' %}
|
||||||
|
|
||||||
|
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
|
||||||
|
|
||||||
|
{{ form_start(form, {
|
||||||
|
'attr': {
|
||||||
|
'class': 'flex-1 flex flex-col min-h-0',
|
||||||
|
'novalidate': 'novalidate',
|
||||||
|
}
|
||||||
|
}) }}
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
|
||||||
|
|
||||||
|
{# Sidebar summary #}
|
||||||
|
<div class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
|
||||||
|
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}>
|
||||||
|
{% include 'groups/offer/_summary.html.twig' with {
|
||||||
|
booking: booking,
|
||||||
|
ctx: ctx,
|
||||||
|
currency: currency,
|
||||||
|
} %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Contact data form #}
|
||||||
|
<div class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-white flex flex-col min-h-0">
|
||||||
|
{% include '_partials/_flashes.html.twig' %}
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto scroll-stable px-4 lg:px-8 py-8">
|
||||||
|
<hgroup class="pb-4">
|
||||||
|
<h1 class="text-2xl lg:text-4xl font-bold mb-2">
|
||||||
|
Kontaktdaten vervollständigen
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
Bevor du das Angebot für {{ ctx.accommodation.name }} buchen kannst, benötigen wir
|
||||||
|
noch deine vollständigen Kontaktdaten.
|
||||||
|
</p>
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
{% from '_partials/_validation_errors.html.twig' import validation_alert %}
|
||||||
|
{{ validation_alert(form, 'Bitte fülle alle Pflichtfelder aus.') }}
|
||||||
|
|
||||||
|
<div class="grid lg:grid-cols-2 lg:gap-x-8">
|
||||||
|
{{ form_row(form.groupName) }}
|
||||||
|
{{ form_row(form.salutation) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid lg:grid-cols-2 lg:gap-x-8">
|
||||||
|
{{ form_row(form.firstName) }}
|
||||||
|
{{ form_row(form.lastName) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid lg:grid-cols-2 lg:gap-x-8">
|
||||||
|
{{ form_row(form.email) }}
|
||||||
|
{{ form_row(form.phone) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="pt-4 pb-2">Adresse</h3>
|
||||||
|
|
||||||
|
{{ form_row(form.street) }}
|
||||||
|
|
||||||
|
<div class="grid lg:grid-cols-2 lg:gap-x-8">
|
||||||
|
{{ form_row(form.zip) }}
|
||||||
|
{{ form_row(form.city) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<a href="{{ path('app_groups_offer_view', { uuid: booking.uuid }) }}" class="button button--secondary">
|
||||||
|
Zurück zum Angebot
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="button button--primary">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ form_rest(form) }}
|
||||||
|
{{ form_end(form) }}
|
||||||
|
{% endblock %}
|
||||||
@@ -6,6 +6,7 @@ namespace App\Tests\BusProNet;
|
|||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\ApiClient;
|
||||||
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||||
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
use App\BusProNet\Exception\ImmediateConnectionCloseException;
|
use App\BusProNet\Exception\ImmediateConnectionCloseException;
|
||||||
use App\BusProNet\XmlParser\ApiResponseParser;
|
use App\BusProNet\XmlParser\ApiResponseParser;
|
||||||
use App\Service\RequestIdGenerator;
|
use App\Service\RequestIdGenerator;
|
||||||
@@ -70,7 +71,7 @@ class ApiClientReceiveTest extends TestCase
|
|||||||
$this->invokeReceive($stream);
|
$this->invokeReceive($stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testNonEmptyResponseReturnsData(): void
|
public function testNonEmptyResponseReturnsPayloadWithoutTheLengthHeader(): void
|
||||||
{
|
{
|
||||||
$stream = fopen('php://memory', 'r+');
|
$stream = fopen('php://memory', 'r+');
|
||||||
fwrite($stream, '0000000005Hello');
|
fwrite($stream, '0000000005Hello');
|
||||||
@@ -80,7 +81,56 @@ class ApiClientReceiveTest extends TestCase
|
|||||||
|
|
||||||
$result = $this->invokeReceive($stream);
|
$result = $this->invokeReceive($stream);
|
||||||
|
|
||||||
self::assertSame('0000000005Hello', $result);
|
self::assertSame('Hello', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A peer closing mid-stream used to yield a silently shortened payload, which only
|
||||||
|
* surfaced later as an unexplained parse failure. The announced length catches it here.
|
||||||
|
*/
|
||||||
|
public function testTruncatedResponseThrows(): void
|
||||||
|
{
|
||||||
|
$stream = fopen('php://memory', 'r+');
|
||||||
|
fwrite($stream, '0000000050Hello');
|
||||||
|
rewind($stream);
|
||||||
|
|
||||||
|
$this->setOperationStartTime();
|
||||||
|
$this->setSelectedPort(9000);
|
||||||
|
|
||||||
|
$this->expectException(ApiClientException::class);
|
||||||
|
$this->expectExceptionMessage('Truncated response, got 5 of 50 announced bytes');
|
||||||
|
|
||||||
|
$this->invokeReceive($stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResponseShorterThanTheLengthHeaderThrows(): void
|
||||||
|
{
|
||||||
|
$stream = fopen('php://memory', 'r+');
|
||||||
|
fwrite($stream, '00000');
|
||||||
|
rewind($stream);
|
||||||
|
|
||||||
|
$this->setOperationStartTime();
|
||||||
|
$this->setSelectedPort(9000);
|
||||||
|
|
||||||
|
$this->expectException(ApiClientException::class);
|
||||||
|
$this->expectExceptionMessage('Incomplete response header, got 5 of 10 bytes');
|
||||||
|
|
||||||
|
$this->invokeReceive($stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHeaderOnlyResponseThrows(): void
|
||||||
|
{
|
||||||
|
$stream = fopen('php://memory', 'r+');
|
||||||
|
fwrite($stream, '0000000000');
|
||||||
|
rewind($stream);
|
||||||
|
|
||||||
|
$this->setOperationStartTime();
|
||||||
|
$this->setSelectedPort(9000);
|
||||||
|
|
||||||
|
$this->expectException(ApiClientException::class);
|
||||||
|
$this->expectExceptionMessage('Response announced an empty body (header "0000000000")');
|
||||||
|
|
||||||
|
$this->invokeReceive($stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function invokeReceive($socket): string
|
private function invokeReceive($socket): string
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\BusProNet\XmlLoader;
|
||||||
|
|
||||||
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\Model\Availability;
|
||||||
|
use App\BusProNet\Model\Service;
|
||||||
|
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\BusProNet\XmlLoader\HotelLoader;
|
||||||
|
use App\BusProNet\XmlLoader\TravelLoader;
|
||||||
|
use App\BusProNet\XmlParser\TravelParser;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that patchAvailabilities() carries the live service status, not just the contingent.
|
||||||
|
*
|
||||||
|
* When a Leistung's contingent runs out BusPro moves it to 'Anfrage' without necessarily
|
||||||
|
* reporting frei="0", so the status is the only reliable signal. Dropping it meant every
|
||||||
|
* availability rule read the status from the nightly XML export instead.
|
||||||
|
*/
|
||||||
|
class TravelLoaderAvailabilityTest extends TestCase
|
||||||
|
{
|
||||||
|
private TravelLoader $loader;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->loader = new TravelLoader(
|
||||||
|
$this->createStub(HotelLoader::class),
|
||||||
|
$this->createStub(TravelParser::class),
|
||||||
|
'https://example.test',
|
||||||
|
$this->createStub(CacheInterface::class),
|
||||||
|
$this->createStub(FilesystemOperator::class),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchesStatusAlongsideAvailability(): void
|
||||||
|
{
|
||||||
|
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
|
||||||
|
$travel = $this->createTravel([$service]);
|
||||||
|
|
||||||
|
$this->loader->patchAvailabilities(
|
||||||
|
$travel,
|
||||||
|
$this->createResponse([$this->createAvailability(60, Constants::STATUS_ON_REQUEST, 3)])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(Constants::STATUS_ON_REQUEST, $service->status);
|
||||||
|
$this->assertSame(3, $service->available);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKeepsExportStatusWhenTheResponseOmitsIt(): void
|
||||||
|
{
|
||||||
|
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
|
||||||
|
$travel = $this->createTravel([$service]);
|
||||||
|
|
||||||
|
$this->loader->patchAvailabilities(
|
||||||
|
$travel,
|
||||||
|
$this->createResponse([$this->createAvailability(60, null, 3)])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(Constants::STATUS_AVAILABLE, $service->status);
|
||||||
|
$this->assertSame(3, $service->available);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKeepsExportStatusWhenTheResponseCarriesABlankOne(): void
|
||||||
|
{
|
||||||
|
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
|
||||||
|
$travel = $this->createTravel([$service]);
|
||||||
|
|
||||||
|
$this->loader->patchAvailabilities(
|
||||||
|
$travel,
|
||||||
|
$this->createResponse([$this->createAvailability(60, ' ', 3)])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(Constants::STATUS_AVAILABLE, $service->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchesTransportationServicesToo(): void
|
||||||
|
{
|
||||||
|
$service = $this->createService(80, Constants::STATUS_AVAILABLE, 10);
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->transportationServices = [80 => $service];
|
||||||
|
|
||||||
|
$this->loader->patchAvailabilities(
|
||||||
|
$travel,
|
||||||
|
$this->createResponse([$this->createAvailability(80, Constants::STATUS_BLOCKED, 0)])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(Constants::STATUS_BLOCKED, $service->status);
|
||||||
|
$this->assertSame(0, $service->available);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, Service> $services */
|
||||||
|
private function createTravel(array $services): Travel
|
||||||
|
{
|
||||||
|
$travel = new Travel();
|
||||||
|
|
||||||
|
$indexed = [];
|
||||||
|
foreach ($services as $service) {
|
||||||
|
$indexed[$service->id] = $service;
|
||||||
|
}
|
||||||
|
$travel->additionalServices = $indexed;
|
||||||
|
|
||||||
|
return $travel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createService(int $id, string $status, ?int $available): Service
|
||||||
|
{
|
||||||
|
$service = new Service();
|
||||||
|
$service->id = $id;
|
||||||
|
$service->status = $status;
|
||||||
|
$service->available = $available;
|
||||||
|
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAvailability(int $serviceId, ?string $status, ?int $available): Availability
|
||||||
|
{
|
||||||
|
$availability = new Availability();
|
||||||
|
$availability->serviceId = $serviceId;
|
||||||
|
$availability->status = $status;
|
||||||
|
$availability->available = $available;
|
||||||
|
|
||||||
|
return $availability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, Availability> $availabilities */
|
||||||
|
private function createResponse(array $availabilities): ServiceAvailabilityResponse
|
||||||
|
{
|
||||||
|
$indexed = [];
|
||||||
|
foreach ($availabilities as $availability) {
|
||||||
|
$indexed[$availability->serviceId] = $availability;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ServiceAvailabilityResponse($indexed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\BusProNet\XmlParser;
|
||||||
|
|
||||||
|
use App\BusProNet\ApiClient;
|
||||||
|
use App\BusProNet\Exception\ResponseParserException;
|
||||||
|
use App\BusProNet\XmlParser\ApiResponseParser;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A broken response used to collapse into either "Empty response received from server" —
|
||||||
|
* which is wrong for a malformed body — or an opaque "Unable to parse XML response".
|
||||||
|
* Each shape must now name itself, otherwise a production parse failure is not
|
||||||
|
* diagnosable after the fact.
|
||||||
|
*/
|
||||||
|
class ApiResponseParserFailureTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testEmptyResponse(): void
|
||||||
|
{
|
||||||
|
$this->expectException(ResponseParserException::class);
|
||||||
|
$this->expectExceptionMessage('empty response (0 bytes)');
|
||||||
|
|
||||||
|
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTruncatedResponseNamesTheLibxmlReason(): void
|
||||||
|
{
|
||||||
|
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="HINWEIS"></satz><hinweis';
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, $xml);
|
||||||
|
$this->fail('Expected a ResponseParserException');
|
||||||
|
} catch (ResponseParserException $e) {
|
||||||
|
self::assertStringContainsString('('.strlen($xml).' bytes)', $e->getMessage());
|
||||||
|
self::assertStringContainsString('line 1', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWellFormedResponseOfAnUnknownType(): void
|
||||||
|
{
|
||||||
|
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="IRGENDWAS"></satz></ergebnis>';
|
||||||
|
|
||||||
|
$this->expectException(ResponseParserException::class);
|
||||||
|
$this->expectExceptionMessage('Unrecognised BusProNet response type "IRGENDWAS"');
|
||||||
|
|
||||||
|
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, $xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownCustomerDataSubtype(): void
|
||||||
|
{
|
||||||
|
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz><art>Irgendwas</art></ergebnis>';
|
||||||
|
|
||||||
|
$this->expectException(ResponseParserException::class);
|
||||||
|
$this->expectExceptionMessage('Unrecognised customer data subtype "Irgendwas"');
|
||||||
|
|
||||||
|
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDiagnosingDoesNotLeakLibxmlErrorState(): void
|
||||||
|
{
|
||||||
|
$previous = libxml_use_internal_errors(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, '<ergebnis');
|
||||||
|
} catch (ResponseParserException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertFalse(libxml_use_internal_errors(false));
|
||||||
|
self::assertSame([], libxml_get_errors());
|
||||||
|
} finally {
|
||||||
|
libxml_use_internal_errors($previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user