47 Commits
Author SHA1 Message Date
fromme 679a864906 chore: update project dependencies 2026-09-21 10:10:59 +02:00
frommeandClaude Opus 5 f080d05dd6 fix: normalize the login email case
The login address was only trimmed, so the casing somebody happened to type at
their very first login was frozen into the user row forever — createOrUpdateLocalUser()
never wrote the address back. Everything downstream re-sends the stored address
rather than the one just authenticated with, which also leaked that casing into the
OAuth2 email claim and the log identities.

Harmless in practice, since BusPro matches an address case-insensitively and so does
the utf8mb4_unicode_ci column, but it left User out of step with the newsletter
entities, which have always normalized.

Fold the case once in authenticate(), which covers the BusPro calls, the lookup and a
new account alike, and write the address back on every login so an account created
before this converges instead of staying frozen. No backfill: a row nobody logs into
again is matched case-insensitively either way.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-21 10:03:06 +02:00
fromme 00637042e2 chore: adjust tests to changed role labels 2026-09-21 10:02:29 +02:00
fromme fbb3ebb8c1 fix: make bpn response failures diagnosable 2026-09-19 11:51:15 +02:00
fromme ec83ad598f feat: identify the authenticated account in the userinfo claims 2026-09-19 10:54:06 +02:00
fromme 2cb4871268 chore: update wording of role labels 2026-09-17 18:18:13 +02:00
fromme c1415dee41 chore: update project dependencies 2026-09-17 18:14:41 +02:00
fromme 540cd8eeb7 feat: reserve administrative roles for staff email addresses 2026-09-17 18:13:52 +02:00
fromme 725e32daab feat: gender role label correctly 2026-09-16 15:33:40 +02:00
fromme 160ebef39e fix: stop offering unbookable services in the booking edit flow 2026-09-16 15:28:07 +02:00
fromme 672fc30d7e doc: update bpn xml api reference 2026-09-16 15:25:50 +02:00
fromme ba4f66f117 chore: enable basic auth in staging environment 2026-09-16 15:25:16 +02:00
fromme 12fe4d8e15 feat: offer contact validation flow 2026-09-16 10:40:01 +02:00
fromme 5d7ffd7d58 feat: configurable recipient list for notifications of role nominations 2026-09-16 09:21:04 +02:00
fromme 6a8328a564 fix: cachetool 404 on deploy 2026-09-15 15:42:01 +02:00
fromme 019de4e705 feat: surface stuck booking edits to customer experts 2026-09-15 15:13:47 +02:00
fromme a1d1fdde14 feat: gate functionality for customer expert users 2026-09-15 15:13:47 +02:00
fromme 1d14f00bb7 feat: additional role 'customer expert' 2026-09-15 15:00:59 +02:00
fromme 042037867b feat: cli command to repair corrupted booking snapshots 2026-09-15 13:57:41 +02:00
fromme 5f6ff13ce7 chore: cleanup and optimize deployer config 2026-09-15 11:55:51 +02:00
fromme f08d9a9f40 chore: update project dependencies 2026-09-15 09:16:07 +02:00
fromme 7b49375efa chore: add deployer config for staging environment 2026-09-14 17:01:52 +02:00
fromme 91522ab32d fix: anonymize the person ids, not the participant slot ids 2026-09-14 15:37:38 +02:00
fromme a3e8c737de fix: key participant status like the participants they belong to 2026-09-14 15:13:21 +02:00
fromme 67c685f6c1 chore: update project dependencies 2026-09-14 15:03:33 +02:00
fromme 7aad54e6f2 fix: keep the booking room assignment when restoring an edit draft 2026-09-14 15:02:04 +02:00
fromme b41820c39d feat: email the administrators when a role nomination appears 2026-09-13 12:50:01 +02:00
fromme fab89dead6 feat: derive ROLE_EMPLOYEE from the account's email domain 2026-09-13 12:49:51 +02:00
fromme 443a3ed248 chore: update deployer config after final migration to new host 2026-09-12 08:22:11 +02:00
fromme 5b133f724d chore: cgl fixes 2026-09-11 19:37:00 +02:00
fromme 5f8a586385 fix: derive contingent change signal from the sync diff 2026-09-11 19:34:29 +02:00
fromme e863025fb1 chore: align local dev environment with prod 2026-09-11 19:24:32 +02:00
fromme 91a38faecd chore: update scheduler notification config 2026-09-11 12:58:09 +02:00
fromme 1d3263373e feat: update filename formatting of booking confirmation pdf 2026-09-09 08:39:10 +02:00
fromme f8ff4b82a3 feat: replace custom discount with absolute value in favor of percentage 2026-09-08 17:11:00 +02:00
fromme d6c2cfaf1c fix: remove incorrect validation group 2026-09-08 16:25:43 +02:00
fromme cfe901b9c2 feat: include accommodation bookings in db anonymization 2026-09-08 15:41:28 +02:00
fromme 8443dae5ad feat: additional custom discount applied to total price 2026-09-08 15:29:00 +02:00
fromme dea7a3dc97 chore: update wording of maintenance mode message 2026-09-08 13:53:08 +02:00
fromme 9e3be24c51 chore: cleanup obsolete environment variables 2026-09-08 13:02:08 +02:00
fromme e744ebbe33 chore: update project dependencies 2026-09-08 12:40:25 +02:00
fromme 45ca0d2b46 feat: maintenance mode 2026-09-08 12:35:50 +02:00
fromme f12561c62b feat: switch to sftp source for buspro xml data 2026-09-04 11:57:03 +02:00
fromme 976b30ccc0 feat: add cms product code for future use 2026-09-03 17:40:29 +02:00
fromme 198fbfe6a2 chore: prepare switching to sftp source for buspro xml data 2026-09-03 16:23:49 +02:00
fromme acb719730c feat: add root config option for sftp connection in flysystem 2026-09-03 10:24:01 +02:00
fromme 0e3d4cbedb chore: adjust deployer config for new hetzner host 2026-09-03 10:23:40 +02:00
156 changed files with 6470 additions and 1678 deletions
+43 -30
View File
@@ -1,3 +1,4 @@
name: myep
type: php type: php
docroot: public docroot: public
php_version: "8.4" php_version: "8.4"
@@ -7,15 +8,17 @@ 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>
# Its unusual to change this option, and we dont recommend it without Docker experience and a good reason.
# Typically, this means additions to the existing db image using a .ddev/db-build/Dockerfile.*
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80) # router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443) # router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
@@ -66,8 +77,7 @@ nodejs_version: "22"
# bind_all_ports is used (normally with router disabled) # bind_all_ports is used (normally with router disabled)
# xhprof_mode: [prepend|xhgui|global] # xhprof_mode: [prepend|xhgui|global]
# Set to "xhgui" to enable XHGui features # Default is "xhgui"
# "xhgui" will become default in a future major release
# webserver_type: nginx-fpm, apache-fpm, generic # webserver_type: nginx-fpm, apache-fpm, generic
@@ -85,22 +95,24 @@ nodejs_version: "22"
# commands are executed. # commands are executed.
# composer_version: "2" # composer_version: "2"
# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1 # You can set it to "" or "2" (default) for Composer v2
# to use the latest major version available at the time your container is built. # to use the latest major version available at the time your container is built.
# It is also possible to use each other Composer version channel. This includes: # It is also possible to use any other Composer version channel. This includes:
# - 2.2 (latest Composer LTS version) # - 2.2 (latest Composer LTS version)
# - stable # - stable
# - preview # - preview
# - snapshot # - snapshot
# Alternatively, an explicit Composer version may be specified, for example "2.2.18". # Alternatively, an explicit Composer version may be specified, for example "2.2.18".
# To reinstall Composer after the image was built, run "ddev debug rebuild". # To reinstall Composer after the image was built, run "ddev utility rebuild".
# nodejs_version: "22" # nodejs_root: <relative_path>
# Relative path to the directory containing the Node.js version file from the
# project root. Only used with "nodejs_version: auto" or "nodejs_version: engine".
# nodejs_version: "24"
# change from the default system Node.js version to any other version. # change from the default system Node.js version to any other version.
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information # See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation, # and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation.
# Note that using of 'ddev nvm' is discouraged because "nodejs_version" is much easier to use,
# can specify any version, and is more robust than using 'nvm'.
# corepack_enable: false # corepack_enable: false
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm # Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
@@ -160,9 +172,7 @@ nodejs_version: "22"
# - "global": uses the value from the global config. # - "global": uses the value from the global config.
# - "none": disables performance optimization for this project. # - "none": disables performance optimization for this project.
# - "mutagen": enables Mutagen for this project. # - "mutagen": enables Mutagen for this project.
# - "nfs": enables NFS for this project.
# #
# See https://docs.ddev.com/en/stable/users/install/performance/#nfs
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen # See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
# fail_on_hook_fail: False # fail_on_hook_fail: False
@@ -192,10 +202,10 @@ nodejs_version: "22"
# The mailpit port is not normally bound on the host at all, instead being routed # The mailpit port is not normally bound on the host at all, instead being routed
# through ddev-router, but it can be bound directly to localhost if specified here. # through ddev-router, but it can be bound directly to localhost if specified here.
# webimage_extra_packages: [php7.4-tidy, php-bcmath] # webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
# Extra Debian packages that are needed in the webimage can be added here # Extra Debian packages that are needed in the webimage can be added here
# dbimage_extra_packages: [telnet,netcat] # dbimage_extra_packages: [netcat, telnet, sudo]
# Extra Debian packages that are needed in the dbimage can be added here # Extra Debian packages that are needed in the dbimage can be added here
# use_dns_when_possible: true # use_dns_when_possible: true
@@ -207,12 +217,15 @@ nodejs_version: "22"
# project_tld: ddev.site # project_tld: ddev.site
# The top-level domain used for project URLs # The top-level domain used for project URLs
# The default "ddev.site" allows DNS lookup via a wildcard # The default "ddev.site" allows DNS lookup via a wildcard
# If you prefer you can change this to "ddev.local" to preserve
# pre-v1.9 behavior.
# ngrok_args: --basic-auth username:pass1234 # share_default_provider: ngrok
# Provide extra flags to the "ngrok http" command, see # The default share provider to use for "ddev share"
# https://ngrok.com/docs/agent/config/v3/#agent-configuration or run "ngrok http -h" # Defaults to global configuration, usually "ngrok"
# Can be "ngrok" or "cloudflared" or the name of a custom provider from .ddev/share-providers/
# share_provider_args: --basic-auth username:pass1234
# Provide extra flags to the share provider script
# See https://docs.ddev.com/en/stable/users/configuration/config/#share_provider_args
# disable_settings_management: false # disable_settings_management: false
# If true, DDEV will not create CMS-specific settings files like # If true, DDEV will not create CMS-specific settings files like
@@ -274,7 +287,7 @@ nodejs_version: "22"
# override_config: false # override_config: false
# By default, config.*.yaml files are *merged* into the configuration # By default, config.*.yaml files are *merged* into the configuration
# But this means that some things can't be overridden # But this means that some things can't be overridden
# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge # For example, if you have 'use_dns_when_possible: true' you can't override it with a merge
# and you can't erase existing hooks or all environment variables. # and you can't erase existing hooks or all environment variables.
# However, with "override_config: true" in a particular config.*.yaml file, # However, with "override_config: true" in a particular config.*.yaml file,
# 'use_dns_when_possible: false' can override the existing values, and # 'use_dns_when_possible: false' can override the existing values, and
@@ -284,7 +297,7 @@ nodejs_version: "22"
# web_environment: [] # web_environment: []
# or # or
# additional_hostnames: [] # additional_hostnames: []
# can have their intended affect. 'override_config' affects only behavior of the # can have their intended effect. 'override_config' affects only behavior of the
# config.*.yaml file it exists in. # config.*.yaml file it exists in.
# Many DDEV commands can be extended to run tasks before or after the # Many DDEV commands can be extended to run tasks before or after the
+13 -14
View File
@@ -25,7 +25,7 @@ APP_SECRET=642ead390b8a38f0a017ec84d5362129
# #
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4" # DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.4.0&charset=utf8mb4" DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=mariadb-11.8.9&charset=utf8mb4"
# DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8" # DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
@@ -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
@@ -112,19 +114,11 @@ APP_UNDERSUBSCRIPTION_SURCHARGE_40_CHF=2.5
# Use 'O' during beta phase, switch to 'F' for production # Use 'O' during beta phase, switch to 'F' for production
DEFAULT_BOOKING_STATUS=F DEFAULT_BOOKING_STATUS=F
XML_EXPORT_PATH="%kernel.project_dir%/var/xmlexport" SFTP_XML_SOURCE_HOST=
XML_EXPORT_CONTINGENTS_PATH="%kernel.project_dir%/var/xmlexportzimmer" SFTP_XML_SOURCE_PORT=
JSON_EXPORT_PATH="%kernel.project_dir%/var/jsonexport" SFTP_XML_SOURCE_USER=
SFTP_XML_SOURCE_PASSWORD=
SFTP_XML_EXPORT_HOST= SFTP_XML_SOURCE_ROOT=
SFTP_XML_EXPORT_PORT=
SFTP_XML_EXPORT_USER=
SFTP_XML_EXPORT_PASSWORD=
SFTP_XML_EXPORT_CONTINGENTS_HOST=
SFTP_XML_EXPORT_CONTINGENTS_PORT=
SFTP_XML_EXPORT_CONTINGENTS_USER=
SFTP_XML_EXPORT_CONTINGENTS_PASSWORD=
###> league/oauth2-server-bundle ### ###> league/oauth2-server-bundle ###
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/secret/private.key OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/secret/private.key
@@ -137,3 +131,8 @@ OAUTH_ENCRYPTION_KEY=580084fd179e67399467f59ee96658ac
# postgresql+advisory://db_user:db_password@localhost/db_name # postgresql+advisory://db_user:db_password@localhost/db_name
LOCK_DSN=flock LOCK_DSN=flock
###< symfony/lock ### ###< symfony/lock ###
MAINTENANCE_MODE_ENABLED=false
MAINTENANCE_MODE_IP_WHITELIST=188.245.62.21
MAINTENANCE_MODE_ROUTE_WHITELIST=
MAINTENANCE_MODE_PATH_WHITELIST=/api,/token
Generated
+416 -476
View File
File diff suppressed because it is too large Load Diff
+7 -6
View File
@@ -2,16 +2,17 @@ flysystem:
storages: storages:
xml_export.storage: xml_export.storage:
local: local:
directory: '%env(resolve:XML_EXPORT_PATH)%' directory: '%kernel.project_dir%/var/xmlexport'
xml_source.storage: xml_source.storage:
sftp: sftp:
host: '%env(SFTP_XML_EXPORT_HOST)%' host: '%env(SFTP_XML_SOURCE_HOST)%'
port: '%env(int:SFTP_XML_EXPORT_PORT)%' port: '%env(int:SFTP_XML_SOURCE_PORT)%'
username: '%env(SFTP_XML_EXPORT_USER)%' username: '%env(SFTP_XML_SOURCE_USER)%'
password: '%env(SFTP_XML_EXPORT_PASSWORD)%' password: '%env(SFTP_XML_SOURCE_PASSWORD)%'
root: '%env(SFTP_XML_SOURCE_ROOT)%'
xml_dump.storage: xml_dump.storage:
local: local:
directory: '%kernel.project_dir%/var/bpn' directory: '%kernel.project_dir%/var/bpn'
json_export.storage: json_export.storage:
local: local:
directory: '%env(resolve:JSON_EXPORT_PATH)%' directory: '%kernel.project_dir%/var/jsonexport'
+1
View File
@@ -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
+6 -3
View File
@@ -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:
+4
View File
@@ -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 -2
View File
@@ -3,13 +3,14 @@ zenstruck_schedule:
mailer: mailer:
service: mailer service: mailer
default_to: [email protected] default_to: [email protected]
default_from: info@ep-reisen.de default_from: noreply@ep-reisen.de
subject_prefix: "[MyE&P]" subject_prefix: "[MyE&P]"
schedule_extensions: schedule_extensions:
email_on_failure: email_on_failure:
enabled: true enabled: true
to: [email protected]
tasks: tasks:
- task: app:cleanup:xml-dumps - task: app:cleanup:xml-dumps
+26
View File
@@ -1,3 +1,4 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
# This file is the entry point to configure your own services. # This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies. # Files in the packages/ subdirectory configure your dependencies.
@@ -14,6 +15,7 @@ parameters:
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%' default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%' default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%' accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
role_nomination_notification_emails: '%env(csv:APP_ROLE_NOMINATION_EMAILS)%'
# MailJet list ids and their labels # MailJet list ids and their labels
mailjet_lists: mailjet_lists:
@@ -30,6 +32,12 @@ parameters:
10321389: 'Reisen-Alert Stubaital' 10321389: 'Reisen-Alert Stubaital'
10554990: 'Reisen-Alert Ski & Boarderweek' 10554990: 'Reisen-Alert Ski & Boarderweek'
# Email domains whose accounts are members of staff. BusPro has no CRM selection expressing
# "works here", so ROLE_EMPLOYEE is derived from the address instead. Matched exactly on the
# domain part, never as a suffix.
employee_email_domains:
- 'ep-reisen.de'
# BusProNet "Hausleitung {CODE}" CRM selections, by selection id. # BusProNet "Hausleitung {CODE}" CRM selections, by selection id.
# DEPLOYMENT-CRITICAL: roles and hotel codes are synced on every login, so an id missing # DEPLOYMENT-CRITICAL: roles and hotel codes are synced on every login, so an id missing
# here does not merely fail to nominate a Hausleitung — it revokes the role and the hotel # here does not merely fail to nominate a Hausleitung — it revokes the role and the hotel
@@ -259,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: ~
@@ -267,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'
@@ -298,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%'
@@ -356,3 +374,11 @@ services:
$apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%' $apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
$defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%' $defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%'
$contactMetadataFields: '%mailjet_contact_metadata_fields%' $contactMetadataFields: '%mailjet_contact_metadata_fields%'
App\EventListener\MaintenanceModeListener:
arguments:
$options:
enabled: '%env(bool:MAINTENANCE_MODE_ENABLED)%'
whitelisted_ips: '%env(csv:MAINTENANCE_MODE_IP_WHITELIST)%'
whitelisted_routes: '%env(csv:MAINTENANCE_MODE_ROUTE_WHITELIST)%'
whitelisted_paths: '%env(csv:MAINTENANCE_MODE_PATH_WHITELIST)%'
+32 -36
View File
@@ -9,7 +9,6 @@ require 'recipe/symfony.php';
add('shared_dirs', [ add('shared_dirs', [
'config/secret', 'config/secret',
'var/bpn', 'var/bpn',
'var/log',
'var/sessions', 'var/sessions',
'var/jsonexport', 'var/jsonexport',
'var/xmlexport', 'var/xmlexport',
@@ -35,6 +34,8 @@ $rsyncOptions = [
'.jj', '.jj',
'node_modules', 'node_modules',
'.editorconfig', '.editorconfig',
'.env.local',
'.env.local.php',
'.env.dev.local', '.env.dev.local',
'.env.dev.local.php', '.env.dev.local.php',
'.env.test', '.env.test',
@@ -76,75 +77,70 @@ $rsyncOptions = [
'timeout' => 300, 'timeout' => 300,
]; ];
// Shared by all hosts.
set('bin/php', '/usr/bin/php');
set('http_user', 'myepsf');
// Must stay here: contrib/rsync.php sets the same key, but its __DIR__ is the vendor dir.
set('rsync_src', __DIR__);
set('rsync', $rsyncOptions);
host('prod') host('prod')
->setHostname('185.243.135.29') ->setHostname('dedi10193.your-server.de')
->setRemoteUser('p704161') ->setDeployPath('/usr/www/users/myepsf/prod')
->setRemoteUser('myepsf')
->setForwardAgent(true) ->setForwardAgent(true)
->setSshMultiplexing(true) ->setSshMultiplexing(true)
->setDeployPath('/home/www/p704161/html/myep')
->set('writable_mode', 'chmod')
->set('http_user', 'p704161')
->set('bin/php', '/usr/local/bin/php')
->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de') ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
; ;
host('staging') host('staging')
->setHostname('185.243.135.29') ->setHostname('dedi10193.your-server.de')
->setRemoteUser('p704161') ->setDeployPath('/usr/www/users/myepsf/staging')
->setRemoteUser('myepsf')
->setForwardAgent(true) ->setForwardAgent(true)
->setSshMultiplexing(true) ->setSshMultiplexing(true)
->setDeployPath('/home/www/p704161/html/myep-staging')
->set('writable_mode', 'chmod')
->set('http_user', 'p704161')
->set('bin/php', '/usr/local/bin/php')
->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging') ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging')
->add('shared_files', [ ->add('shared_files', [
'public/.htpasswd', 'public/.htaccess',
])
;
host('develop')
->setHostname('185.243.135.29')
->setRemoteUser('p704161')
->setForwardAgent(true)
->setSshMultiplexing(true)
->setDeployPath('/home/www/p704161/html/myep-develop')
->set('writable_mode', 'chmod')
->set('http_user', 'p704161')
->set('bin/php', '/usr/local/bin/php')
->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://mydev.ep-reisen.net --web-basic-auth=myep:develop')
->add('shared_files', [
'public/.htpasswd', 'public/.htpasswd',
]) ])
; ;
task('deploy', [ task('deploy', [
'deploy:info', 'deploy:info',
'deploy:assets',
'deploy:setup', 'deploy:setup',
'deploy:lock', 'deploy:lock',
'deploy:release', 'deploy:release',
'deploy:assets',
'rsync', 'rsync',
'deploy:shared', 'deploy:shared',
'deploy:writable', 'deploy:writable',
'deploy:cache:clear', 'deploy:cache:warmup',
'database:migrate', 'database:migrate',
// Must run before the symlink flip: the cachetool probe file is only reachable through
// the release the docroot currently resolves to. It still pays off, because it drops the
// opcache entries keyed under current/public/*.
'cachetool:clear:opcache', 'cachetool:clear:opcache',
'deploy:publish', 'deploy:publish',
'deploy:stop-workers', 'deploy:stop-workers',
]); ]);
// Purely local, so it runs before anything is created on the remote. once(), so
// deploying multiple hosts at the same time builds the bundle only once.
task('deploy:assets', function () { task('deploy:assets', function () {
runLocally('ddev exec npm ci'); runLocally('ddev exec npm ci');
runLocally('ddev exec npm run build'); runLocally('ddev exec npm run build');
})->once();
// The recipe's deploy:cache:clear only does something when composer ran with
// --no-scripts, which never happens here: vendor/ is rsynced, composer never runs
// remotely. A fresh release has no var/cache to clear, so warm it up directly.
// Runs after deploy:writable so the default ACLs are already in place.
task('deploy:cache:warmup', function () {
run('{{bin/console}} cache:warmup {{console_options}}');
}); });
// Workers keep running the previous release's code until they are told to stop.
task('deploy:stop-workers', function () { task('deploy:stop-workers', function () {
run('{{bin/console}} messenger:stop-workers'); run('{{bin/console}} messenger:stop-workers');
}); });
+40 -4
View File
@@ -276,6 +276,8 @@ Single booking by UUID. `404 {"message":"Not found"}` if unknown.
"accommodationDiscount": null, "accommodationDiscount": null,
"boardServiceDiscount": null, "boardServiceDiscount": null,
"additionalServicesDiscount": null, "additionalServicesDiscount": null,
"totalDiscountAmount": null,
"totalDiscountLabel": null,
"totalPrice": 123456, "totalPrice": 123456,
"pricingCurrency": "EUR", "pricingCurrency": "EUR",
"pricingVersion": 3, "pricingVersion": 3,
@@ -283,7 +285,20 @@ Single booking by UUID. `404 {"message":"Not found"}` if unknown.
} }
``` ```
`totalPrice` and the `*Discount` fields are integer **minor units** (cents) — unlike the contingent endpoints. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure. `totalPrice` and `totalDiscountAmount` are integer **minor units** (cents) — unlike the contingent endpoints. The three `*Discount` fields are whole **percentages** (1100) or `null`, not amounts. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure.
The first three discounts each apply to one section of the breakdown: `accommodationDiscount` to `basePrice + additionalPersonsPrice`, `boardServiceDiscount` to `boardPrice`, `additionalServicesDiscount` to `servicesPrice`. Surcharges and running costs sit in `total` but in no section, so they are never discounted.
`totalDiscountAmount` is a freely named discount on the whole price, labelled by `totalDiscountLabel`. It is a fixed sum, not a percentage, and is subtracted **after** the three section discounts have reduced `total`. It is capped at what is left of that subtotal, so `discountedTotal` is never negative and the amount shown in the breakdown may be smaller than `totalDiscountAmount` itself. The section discounts are each rounded to the nearest cent as they are applied.
`priceBreakdown` carries the resulting rows, all in minor units:
| field | meaning |
| --- | --- |
| `discounts` | list of `{label, percent, amount}` for the section discounts; entries rounding to `0` are omitted, and `label` already includes its `Rabatt ` prefix |
| `discountSubtotal` | `total` minus the section discounts — present only when `totalDiscountAmount` applies *and* at least one section discount did, otherwise `null` |
| `totalDiscountDetails` | `{label, amount}` for the total discount, or `null`; `amount` is the capped figure actually subtracted |
| `discountedTotal` | the final price, equal to the booking's `totalPrice` |
#### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api` #### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api`
@@ -364,9 +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
@@ -379,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.
+4
View File
@@ -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
+13 -1
View File
@@ -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 | |
+35 -5
View File
@@ -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.
--- ---
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260903153641 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE accommodation ADD product_code VARCHAR(16) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE accommodation DROP product_code');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260908145156 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add a generic labelled discount on the accommodation booking total';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE accommodation_booking ADD total_discount INT DEFAULT NULL, ADD total_discount_label VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE accommodation_booking DROP total_discount, DROP total_discount_label');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The percentage column is dropped rather than converted: it shipped with Version20260908145156
* but no booking was ever given a value, so there is nothing to carry over.
*/
final class Version20260908161500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Store the accommodation booking total discount as an absolute amount in minor units instead of a percentage';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE accommodation_booking DROP total_discount, ADD total_discount_amount INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE accommodation_booking DROP total_discount_amount, ADD total_discount INT DEFAULT NULL');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260911170000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Drops contingent_sync_state.content_hash: the rolling sync window makes a snapshot digest unusable as a change signal.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE contingent_sync_state DROP content_hash');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE contingent_sync_state ADD content_hash VARCHAR(64) NOT NULL');
}
}
+70 -21
View File
@@ -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);
try {
// the length header is consumed and verified by receive(), this is the payload
$xml = $this->receive($socket);
} finally {
$this->disconnect($socket); $this->disconnect($socket);
// message length (10 bytes) is prepended to actual message
$xml = substr($response, 10);
if ('' === $xml) {
$this->logger->error('Empty response body received from API (header-only response)', [
'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('Unexpected response received from API', [ $this->logger->error('Unable to parse response received from API', [
'request_id' => $requestId, 'request_id' => $requestId,
'type' => $type,
'response_length' => strlen($xml),
'error_message' => $e->getMessage(),
]); ]);
throw new ApiClientException('Unexpected response received from API'); throw new ApiClientException('Unable to parse response received from API: '.$e->getMessage(), 0, $e);
}
} }
/** /**
@@ -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(),
]; ];
+1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\BusProNet\Model; namespace App\BusProNet\Model;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use function Symfony\Component\String\u; use function Symfony\Component\String\u;
/** /**
+25
View File
@@ -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.
* *
+15 -1
View File
@@ -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,
+34
View File
@@ -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);
}
}
} }
+16 -5
View File
@@ -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));
} }
} }
+37 -3
View File
@@ -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;
} }
} }
+489
View File
@@ -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;
}
}
+2 -1
View File
@@ -114,12 +114,13 @@ class BpnSyncContingentsCommand extends Command
} }
$io->writeln(sprintf( $io->writeln(sprintf(
'<info>%s</info>: %s (+%d ~%d -%d)', '<info>%s</info>: %s (+%d ~%d -%d, %d beyond the previous horizon)',
(string) $accommodation->getCalendarCode(), (string) $accommodation->getCalendarCode(),
$result->changed ? 'changed' : 'unchanged', $result->changed ? 'changed' : 'unchanged',
$result->added, $result->added,
$result->updated, $result->updated,
$result->removed, $result->removed,
$result->extended,
), OutputInterface::VERBOSITY_VERBOSE); ), OutputInterface::VERBOSITY_VERBOSE);
} }
+2 -1
View File
@@ -50,11 +50,12 @@ final class DbAnonymizeCommand extends Command
} }
$io->success(sprintf( $io->success(sprintf(
'Anonymized %d users, %d newsletter consents, %d newsletter opt-in requests, and %d booking drafts.', 'Anonymized %d users, %d newsletter consents, %d newsletter opt-in requests, %d booking drafts, and %d accommodation bookings.',
$report['users'], $report['users'],
$report['newsletterConsents'], $report['newsletterConsents'],
$report['newsletterOptInRequests'], $report['newsletterOptInRequests'],
$report['bookingEditDrafts'], $report['bookingEditDrafts'],
$report['accommodationBookings'],
)); ));
return Command::SUCCESS; return Command::SUCCESS;
@@ -530,7 +530,7 @@ class ImportGroupsLegacyDataCommand extends Command
* @param list<AdditionalService> $additionalServicesNeedingDefaultRange only these * @param list<AdditionalService> $additionalServicesNeedingDefaultRange only these
* (titles without a leading year) get the accommodation- * (titles without a leading year) get the accommodation-
* wide fallback range; year-prefixed ones already have * wide fallback range; year-prefixed ones already have
* their scoped range set in importAdditionalServices(). * their scoped range set in importAdditionalServices()
*/ */
private function applyDateRangeToServices(array $accommodationByUid, array $additionalServicesNeedingDefaultRange): void private function applyDateRangeToServices(array $accommodationByUid, array $additionalServicesNeedingDefaultRange): void
{ {
@@ -8,7 +8,6 @@ use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData; use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\PersonalDataUpdateResponse;
use App\Entity\User; use App\Entity\User;
use App\Exception\NewsletterProviderException; use App\Exception\NewsletterProviderException;
use App\Form\PersonalDataType; use App\Form\PersonalDataType;
@@ -91,6 +91,7 @@ class EditController extends AbstractController
'current_board_service' => $currentBoardService, 'current_board_service' => $currentBoardService,
'current_additional_services' => $currentAdditionalServices, 'current_additional_services' => $currentAdditionalServices,
'assignable_managers' => $assignableManagers, 'assignable_managers' => $assignableManagers,
'currency' => $booking->getPricingCurrency() ?? $accommodation?->getCurrency() ?? 'EUR',
]); ]);
$form->handleRequest($request); $form->handleRequest($request);
@@ -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;
+1 -1
View File
@@ -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)
+10 -2
View File
@@ -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,
]);
}
}
+1 -102
View File
@@ -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'),
);
}
}
+15
View File
@@ -35,6 +35,9 @@ class Accommodation implements BlameableEntityInterface, TimestampableEntityInte
#[ORM\Column(length: 16, nullable: true)] #[ORM\Column(length: 16, nullable: true)]
private ?string $cmsCode = null; private ?string $cmsCode = null;
#[ORM\Column(length: 16, nullable: true)]
private ?string $productCode = null;
/** /**
* @var Collection<int, AccommodationPrice> * @var Collection<int, AccommodationPrice>
*/ */
@@ -122,6 +125,18 @@ class Accommodation implements BlameableEntityInterface, TimestampableEntityInte
return $this->cmsCode ?? $this->calendarCode; return $this->cmsCode ?? $this->calendarCode;
} }
public function getProductCode(): ?string
{
return $this->productCode;
}
public function setProductCode(?string $productCode): self
{
$this->productCode = $productCode;
return $this;
}
/** /**
* @return Collection<int, AccommodationPrice> * @return Collection<int, AccommodationPrice>
*/ */
@@ -156,6 +156,18 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
#[Assert\Range(min: 1, max: 100)] #[Assert\Range(min: 1, max: 100)]
private ?int $additionalServicesDiscount = null; private ?int $additionalServicesDiscount = null;
/** Absolute discount on the total, in minor units. Applied after the three section discounts. */
#[ORM\Column(nullable: true)]
#[Assert\Positive]
private ?int $totalDiscountAmount = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Expression(
'null === this.getTotalDiscountAmount() or (null !== value and "" !== value)',
message: 'Bitte eine Bezeichnung für den Rabatt angeben.',
)]
private ?string $totalDiscountLabel = null;
#[ORM\ManyToOne] #[ORM\ManyToOne]
#[ORM\JoinColumn(onDelete: 'SET NULL')] #[ORM\JoinColumn(onDelete: 'SET NULL')]
private ?User $managedBy = null; private ?User $managedBy = null;
@@ -638,6 +650,30 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this; return $this;
} }
public function getTotalDiscountAmount(): ?int
{
return $this->totalDiscountAmount;
}
public function setTotalDiscountAmount(?int $totalDiscountAmount): self
{
$this->totalDiscountAmount = $totalDiscountAmount;
return $this;
}
public function getTotalDiscountLabel(): ?string
{
return $this->totalDiscountLabel;
}
public function setTotalDiscountLabel(?string $totalDiscountLabel): self
{
$this->totalDiscountLabel = $totalDiscountLabel;
return $this;
}
public function getManagedBy(): ?User public function getManagedBy(): ?User
{ {
return $this->managedBy; return $this->managedBy;
+3 -21
View File
@@ -11,8 +11,8 @@ use Doctrine\ORM\Mapping as ORM;
/** /**
* Bookkeeping for the scheduled contingent sync of a single accommodation. * Bookkeeping for the scheduled contingent sync of a single accommodation.
* *
* Holds the fingerprint of the stored contingent days, which is how the sync decides whether * `changedAt` records when the sync last saw a day appear, vanish or switch status within the
* anything actually changed, and `changedAt` records when it last did. * horizon it had already reached; `horizonTo` is how far that reach extends.
*/ */
#[ORM\Entity(repositoryClass: ContingentSyncStateRepository::class)] #[ORM\Entity(repositoryClass: ContingentSyncStateRepository::class)]
#[ORM\Table(name: 'contingent_sync_state')] #[ORM\Table(name: 'contingent_sync_state')]
@@ -28,13 +28,7 @@ class ContingentSyncState
private ?Accommodation $accommodation = null; private ?Accommodation $accommodation = null;
/** /**
* sha256 over the stored contingent days, contingent status only. * Last time the stored snapshot actually changed. Kept for operators, not exposed via the API.
*/
#[ORM\Column(length: 64)]
private string $contentHash = '';
/**
* Last time the fingerprint actually changed. Kept for operators, not exposed via the API.
*/ */
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $changedAt = null; private ?\DateTimeImmutable $changedAt = null;
@@ -76,18 +70,6 @@ class ContingentSyncState
return $this; return $this;
} }
public function getContentHash(): string
{
return $this->contentHash;
}
public function setContentHash(string $contentHash): self
{
$this->contentHash = $contentHash;
return $this;
}
public function getChangedAt(): ?\DateTimeImmutable public function getChangedAt(): ?\DateTimeImmutable
{ {
return $this->changedAt; return $this->changedAt;
+2 -2
View File
@@ -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,86 @@
<?php
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Twig\Environment;
#[AsEventListener(event: KernelEvents::REQUEST, method: 'onKernelRequest')]
class MaintenanceModeListener
{
/**
* @var array<string, mixed>
*/
private array $config;
/**
* @param array<string, mixed> $options
*/
public function __construct(private readonly Environment $twig, array $options)
{
$this->config = $this->resolveConfig($options);
}
public function onKernelRequest(RequestEvent $event): void
{
if (false === $event->isMainRequest()) {
return;
}
if (false === $this->config['enabled']) {
return;
}
$request = $event->getRequest();
// Check requested path against whitelist
foreach ($this->config['whitelisted_paths'] as $whitelistedPath) {
$prefix = rtrim((string) $whitelistedPath, '/');
if ($request->getPathInfo() === $prefix || str_starts_with($request->getPathInfo(), $prefix.'/')) {
return;
}
}
// Check user's IP against whitelist
if (true === IpUtils::checkIp($request->getClientIp(), $this->config['whitelisted_ips'])) {
$request->attributes->set('_maintenance_mode_allowed', true);
return;
}
// Check requested route against whitelist
if (true === in_array($request->attributes->get('_route'), $this->config['whitelisted_routes'])) {
return;
}
$content = $this->twig->render('maintenance/index.html.twig');
$event->setResponse(new Response($content, Response::HTTP_SERVICE_UNAVAILABLE));
}
/**
* @param array<string, mixed> $options
*
* @return array<string, mixed>
*/
private function resolveConfig(array $options): array
{
$optionsResolver = new OptionsResolver();
$optionsResolver
->setDefaults([
'enabled' => false,
'whitelisted_ips' => [],
'whitelisted_routes' => [],
'whitelisted_paths' => [],
])
;
return $optionsResolver->resolve($options);
}
}
+76
View File
@@ -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');
}
}
+2 -2
View File
@@ -10,9 +10,9 @@ use App\Service\AdditionalServiceExclusionResolver;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents; use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
/** /**
@@ -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.'[]');
} }
} }
+5 -38
View File
@@ -6,10 +6,7 @@ namespace App\Form;
use App\Form\Model\AccommodationBookingDto; use App\Form\Model\AccommodationBookingDto;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -21,41 +18,6 @@ class AccommodationStep3Type extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
->add('groupName', TextType::class, [
'label' => 'Name der Gruppe',
])
->add('salutation', ChoiceType::class, [
'label' => 'Anrede',
'choices' => [
'Herr' => 'Herr',
'Frau' => 'Frau',
'divers' => 'divers',
],
'expanded' => false,
'multiple' => false,
'placeholder' => 'Bitte wählen',
])
->add('firstName', TextType::class, [
'label' => 'Vorname',
])
->add('lastName', TextType::class, [
'label' => 'Nachname',
])
->add('email', EmailType::class, [
'label' => 'E-Mail-Adresse',
])
->add('phone', TextType::class, [
'label' => 'Telefon',
])
->add('street', TextType::class, [
'label' => 'Straße und Hausnummer',
])
->add('zip', TextType::class, [
'label' => 'Postleitzahl',
])
->add('city', TextType::class, [
'label' => 'Ort',
])
->add('remarks', TextareaType::class, [ ->add('remarks', TextareaType::class, [
'label' => false, 'label' => false,
'required' => false, 'required' => false,
@@ -71,4 +33,9 @@ class AccommodationStep3Type extends AbstractType
'validation_groups' => ['step_3'], 'validation_groups' => ['step_3'],
]); ]);
} }
public function getParent(): string
{
return AccommodationContactType::class;
}
} }
@@ -14,11 +14,13 @@ use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Positive;
use Symfony\Component\Validator\Constraints\Range; use Symfony\Component\Validator\Constraints\Range;
/** @extends AbstractType<AccommodationBooking> */ /** @extends AbstractType<AccommodationBooking> */
@@ -158,6 +160,23 @@ class AccommodationBookingType extends AbstractType
new Range(min: 1, max: 100), new Range(min: 1, max: 100),
], ],
]) ])
// A fixed sum, not a percentage, applied to the total after the three section
// discounts. The label is not marked required here — an amount may legitimately be
// absent — but the entity refuses an amount without one, so no unnamed discount can
// reach the customer.
->add('totalDiscountAmount', MoneyType::class, [
'label' => 'Rabatt Gesamtpreis',
'currency' => $options['currency'],
'divisor' => 100,
'required' => false,
'constraints' => [
new Positive(),
],
])
->add('totalDiscountLabel', TextType::class, [
'label' => 'Bezeichnung Rabatt Gesamtpreis',
'required' => false,
])
->add('remarks', TextareaType::class, [ ->add('remarks', TextareaType::class, [
'label' => 'Bemerkungen', 'label' => 'Bemerkungen',
'required' => false, 'required' => false,
@@ -187,6 +206,7 @@ class AccommodationBookingType extends AbstractType
'current_board_service' => null, 'current_board_service' => null,
'current_additional_services' => [], 'current_additional_services' => [],
'assignable_managers' => [], 'assignable_managers' => [],
'currency' => 'EUR',
]); ]);
$resolver->setAllowedTypes('max_adolescent_age', 'int'); $resolver->setAllowedTypes('max_adolescent_age', 'int');
$resolver->setAllowedTypes('board_services', 'array'); $resolver->setAllowedTypes('board_services', 'array');
@@ -194,5 +214,6 @@ class AccommodationBookingType extends AbstractType
$resolver->setAllowedTypes('current_board_service', ['null', BoardService::class]); $resolver->setAllowedTypes('current_board_service', ['null', BoardService::class]);
$resolver->setAllowedTypes('current_additional_services', 'array'); $resolver->setAllowedTypes('current_additional_services', 'array');
$resolver->setAllowedTypes('assignable_managers', 'array'); $resolver->setAllowedTypes('assignable_managers', 'array');
$resolver->setAllowedTypes('currency', 'string');
} }
} }
@@ -37,12 +37,12 @@ class AccommodationPriceType extends AbstractType
'class' => PriceType::class, 'class' => PriceType::class,
'required' => false, 'required' => false,
'placeholder' => 'Standard', 'placeholder' => 'Standard',
'choice_label' => fn($item) => $item->label(), 'choice_label' => fn ($item) => $item->label(),
]) ])
->add('season', EnumType::class, [ ->add('season', EnumType::class, [
'label' => 'Saison', 'label' => 'Saison',
'class' => Season::class, 'class' => Season::class,
'choice_label' => fn($item) => $item->token(), 'choice_label' => fn ($item) => $item->token(),
]) ])
->add('includedPax', IntegerType::class, [ ->add('includedPax', IntegerType::class, [
'label' => 'Inklusiv-Personen', 'label' => 'Inklusiv-Personen',
+6 -2
View File
@@ -24,10 +24,14 @@ class AccommodationType extends AbstractType
'label' => 'Name', 'label' => 'Name',
]) ])
->add('calendarCode', TextType::class, [ ->add('calendarCode', TextType::class, [
'label' => 'Code für Kalender', 'label' => 'Kalender-Code in BusPro',
]) ])
->add('cmsCode', TextType::class, [ ->add('cmsCode', TextType::class, [
'label' => 'Code für CMS-Daten', 'label' => 'Hotel-Code in TYPO3',
'required' => false,
])
->add('productCode', TextType::class, [
'label' => 'Produkt-Code in TYPO3',
'required' => false, 'required' => false,
]) ])
->add('maxAdolescentAge', IntegerType::class, [ ->add('maxAdolescentAge', IntegerType::class, [
@@ -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',
+14 -12
View File
@@ -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;
+1
View File
@@ -7,6 +7,7 @@ namespace App\Form\Model;
use App\Validator\Constraints as AppAssert; use App\Validator\Constraints as AppAssert;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Context\ExecutionContextInterface;
use function Symfony\Component\String\u; use function Symfony\Component\String\u;
/** /**
@@ -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,68 +863,27 @@ 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
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex); 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;
}
/** /**
* Checks if a service array contains a service with the given ID. * Checks if a service array contains a service with the given ID.
* *
@@ -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 -1
View File
@@ -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',
+6
View File
@@ -22,6 +22,7 @@ class AdminMenuBuilder extends AbstractMenuBuilder
'icon' => 'chart', 'icon' => 'chart',
], ],
]); ]);
if ($this->security->isGranted('ROLE_CUSTOMER_EXPERT')) {
$menu->addChild('Buchungsentwürfe', [ $menu->addChild('Buchungsentwürfe', [
'route' => 'app_admin_bookingeditdraft', 'route' => 'app_admin_bookingeditdraft',
'linkAttributes' => [ 'linkAttributes' => [
@@ -32,6 +33,7 @@ class AdminMenuBuilder extends AbstractMenuBuilder
'routes' => [['pattern' => '/^app_admin_bookingeditdraft/']], '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,6 +58,7 @@ class AdminMenuBuilder extends AbstractMenuBuilder
]); ]);
} }
if ($this->security->isGranted('ROLE_ADMIN')) {
$menu->addChild('Benutzer', [ $menu->addChild('Benutzer', [
'route' => 'app_admin_user', 'route' => 'app_admin_user',
'linkAttributes' => [ 'linkAttributes' => [
@@ -65,6 +68,8 @@ class AdminMenuBuilder extends AbstractMenuBuilder
'icon' => 'users', 'icon' => 'users',
], ],
]); ]);
}
if ($this->security->isGranted('ROLE_CUSTOMER_EXPERT')) {
$menu->addChild('Logs', [ $menu->addChild('Logs', [
'route' => 'app_admin_log', 'route' => 'app_admin_log',
'linkAttributes' => [ 'linkAttributes' => [
@@ -75,6 +80,7 @@ class AdminMenuBuilder extends AbstractMenuBuilder
'routes' => [['pattern' => '/^app_admin_log/']], 'routes' => [['pattern' => '/^app_admin_log/']],
], ],
]); ]);
}
$this->addLogoutItem($menu); $this->addLogoutItem($menu);
+25
View File
@@ -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,
],
);
}
}
@@ -73,6 +73,13 @@ final readonly class AccommodationBookingApiResponse
#[Groups(['api:single'])] #[Groups(['api:single'])]
public ?int $additionalServicesDiscount; public ?int $additionalServicesDiscount;
/** An absolute amount in minor units, subtracted after the three section discounts above. */
#[Groups(['api:single'])]
public ?int $totalDiscountAmount;
#[Groups(['api:single'])]
public ?string $totalDiscountLabel;
#[Groups(['api:single'])] #[Groups(['api:single'])]
public ?int $totalPrice; public ?int $totalPrice;
@@ -115,6 +122,8 @@ final readonly class AccommodationBookingApiResponse
$this->accommodationDiscount = $booking->getAccommodationDiscount(); $this->accommodationDiscount = $booking->getAccommodationDiscount();
$this->boardServiceDiscount = $booking->getBoardServiceDiscount(); $this->boardServiceDiscount = $booking->getBoardServiceDiscount();
$this->additionalServicesDiscount = $booking->getAdditionalServicesDiscount(); $this->additionalServicesDiscount = $booking->getAdditionalServicesDiscount();
$this->totalDiscountAmount = $booking->getTotalDiscountAmount();
$this->totalDiscountLabel = $booking->getTotalDiscountLabel();
$this->totalPrice = $booking->getTotalPrice(); $this->totalPrice = $booking->getTotalPrice();
$this->pricingCurrency = $booking->getPricingCurrency(); $this->pricingCurrency = $booking->getPricingCurrency();
$this->pricingVersion = $booking->getPricingVersion(); $this->pricingVersion = $booking->getPricingVersion();
@@ -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 = [],
) { ) {
} }
} }
+8 -3
View File
@@ -13,19 +13,24 @@ readonly class ContingentSyncResult
public bool $successful, public bool $successful,
public bool $changed, public bool $changed,
public int $added, public int $added,
public int $extended,
public int $updated, public int $updated,
public int $removed, public int $removed,
public ?string $error = null, public ?string $error = null,
) { ) {
} }
public static function synced(bool $changed, int $added, int $updated, int $removed): self /**
* $added counts days newly stored within the horizon the previous run already reached;
* $extended counts days beyond it, which is the rolling window growing rather than a change.
*/
public static function synced(bool $changed, int $added, int $extended, int $updated, int $removed): self
{ {
return new self(true, $changed, $added, $updated, $removed); return new self(true, $changed, $added, $extended, $updated, $removed);
} }
public static function failed(string $error): self public static function failed(string $error): self
{ {
return new self(false, false, 0, 0, 0, $error); return new self(false, false, 0, 0, 0, 0, $error);
} }
} }
@@ -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.
*/ */
+54 -11
View File
@@ -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) {
return [];
}
if ([] !== $nominated) {
// The CRM claims an administrative role for somebody who does not hold it. It grants // The CRM claims an administrative role for somebody who does not hold it. It grants
// nothing until an administrator approves it in /admin/user. // nothing until an administrator approves it in /admin/user.
$this->authLogger->info('Nominated for administrative roles by the BPN CRM', [ $this->authLogger->info('Nominated for administrative roles by the BPN CRM', [
'email' => $user->getEmail(), 'email' => $user->getEmail(),
'roles' => array_values($nominated), '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
+58
View File
@@ -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
View File
@@ -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']);
} }
} }
@@ -37,14 +37,18 @@ class AccommodationBookingBreakdownCalculator
* *
* @param array<string, mixed> $breakdown * @param array<string, mixed> $breakdown
* *
* @return array<string, mixed> the breakdown plus `discounts` and `discountedTotal` * @return array<string, mixed> the breakdown plus `discounts`, `discountSubtotal`,
* `totalDiscountDetails` and `discountedTotal`
*/ */
public function withDiscounts(AccommodationBooking $booking, array $breakdown): array public function withDiscounts(AccommodationBooking $booking, array $breakdown): array
{ {
// The labels carry their own "Rabatt" prefix so that every row renders as plain
// {label} {percent} %, while the freely named discount on the total below is a fixed
// amount and renders as its label alone.
$buckets = [ $buckets = [
['Unterkunft', $booking->getAccommodationDiscount(), (int) ($breakdown['basePrice'] ?? 0) + (int) ($breakdown['additionalPersonsPrice'] ?? 0)], ['Rabatt Unterkunft', $booking->getAccommodationDiscount(), (int) ($breakdown['basePrice'] ?? 0) + (int) ($breakdown['additionalPersonsPrice'] ?? 0)],
['Verpflegung', $booking->getBoardServiceDiscount(), (int) ($breakdown['boardPrice'] ?? 0)], ['Rabatt Verpflegung', $booking->getBoardServiceDiscount(), (int) ($breakdown['boardPrice'] ?? 0)],
['Zusatzleistungen', $booking->getAdditionalServicesDiscount(), (int) ($breakdown['servicesPrice'] ?? 0)], ['Rabatt Zusatzleistungen', $booking->getAdditionalServicesDiscount(), (int) ($breakdown['servicesPrice'] ?? 0)],
]; ];
$discounts = []; $discounts = [];
@@ -63,10 +67,26 @@ class AccommodationBookingBreakdownCalculator
$discounts[] = ['label' => $label, 'percent' => $percent, 'amount' => $amount]; $discounts[] = ['label' => $label, 'percent' => $percent, 'amount' => $amount];
} }
$breakdown['discounts'] = $discounts;
// Deliberately not $booking->getTotalPrice(): refreshPriceSnapshot() calls this while // Deliberately not $booking->getTotalPrice(): refreshPriceSnapshot() calls this while
// computing the new total, where the stored one is still the outdated value. // computing the new total, where the stored one is still the outdated value.
$breakdown['discountedTotal'] = (int) ($breakdown['total'] ?? 0) - $discountSum; $subtotal = (int) ($breakdown['total'] ?? 0) - $discountSum;
// A fixed sum rather than a percentage, subtracted after the section discounts. Clamped to
// what is left of the subtotal so that a discount larger than the price cannot produce a
// negative total, the same guard BookingSummaryAssembler applies to vouchers.
$totalDiscountAmount = min($booking->getTotalDiscountAmount() ?? 0, max(0, $subtotal));
$breakdown['discounts'] = $discounts;
$breakdown['totalDiscountDetails'] = 0 !== $totalDiscountAmount
? [
'label' => (string) $booking->getTotalDiscountLabel(),
'amount' => $totalDiscountAmount,
]
: null;
// Only worth a row when it sits between two sets of discounts — with no section discount
// above it, a subtotal would merely restate the total one line up.
$breakdown['discountSubtotal'] = 0 !== $totalDiscountAmount && [] !== $discounts ? $subtotal : null;
$breakdown['discountedTotal'] = $subtotal - $totalDiscountAmount;
return $breakdown; return $breakdown;
} }
@@ -11,7 +11,6 @@ use Dompdf\Options;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\String\Slugger\AsciiSlugger;
use Twig\Environment; use Twig\Environment;
/** /**
@@ -81,10 +80,6 @@ class AccommodationBookingPdfGenerator
return $response; return $response;
} }
/**
* Named after arrival date and group rather than the entity id, which is a local
* detail with no counterpart in BusPro and would read as a booking number.
*/
/** /**
* @throws \RuntimeException when the booking carries no price information * @throws \RuntimeException when the booking carries no price information
*/ */
@@ -95,14 +90,13 @@ class AccommodationBookingPdfGenerator
private function buildFilename(AccommodationBooking $booking): string private function buildFilename(AccommodationBooking $booking): string
{ {
$groupName = (new AsciiSlugger('de'))->slug((string) $booking->getGroupName())->lower()->toString(); $parts = array_filter([
$booking->getDateFrom()?->format('Y-m-d'),
$booking->getAccommodation()?->getCalendarCode(),
'Buchung',
]);
return sprintf( return implode(' ', $parts).'.pdf';
'%s-%s%s.pdf',
mb_strtolower($booking->recordLabel()),
$booking->getDateFrom()?->format('Y-m-d') ?? 'ohne-datum',
'' !== $groupName ? '-'.$groupName : ''
);
} }
private function logoDataUri(): string private function logoDataUri(): string
+62 -10
View File
@@ -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),
); );
} }
+19
View File
@@ -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);
+30 -2
View File
@@ -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'),
+121 -30
View File
@@ -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) {
+63
View File
@@ -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.
* *
+27 -1
View File
@@ -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,
); );
} }
} }
+163 -14
View File
@@ -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) {
return $value;
} }
/**
* Finds the nearest id of the given kind on the node or one of its ancestors.
*
* @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;
} }
} }
+2 -2
View File
@@ -21,7 +21,7 @@ class CalendarGridBuilder
$months = []; $months = [];
$base = $from->modify('first day of this month')->setTime(0, 0, 0); $base = $from->modify('first day of this month')->setTime(0, 0, 0);
for ($i = 0; $i < $count; $i++) { for ($i = 0; $i < $count; ++$i) {
$monthStart = $base->modify("+{$i} months"); $monthStart = $base->modify("+{$i} months");
$months[] = [ $months[] = [
'month' => $monthStart, 'month' => $monthStart,
@@ -54,7 +54,7 @@ class CalendarGridBuilder
while ($current <= $gridEnd) { while ($current <= $gridEnd) {
$week = []; $week = [];
for ($d = 0; $d < 7; $d++) { for ($d = 0; $d < 7; ++$d) {
$week[] = [ $week[] = [
'date' => $current, 'date' => $current,
'inMonth' => $current->format('Y-m') === $monthKey, 'inMonth' => $current->format('Y-m') === $monthKey,
+17 -12
View File
@@ -53,6 +53,10 @@ class ContingentSnapshotManager
$state = $this->syncStateRepository->findOneByAccommodation($accommodation) ?? new ContingentSyncState($accommodation); $state = $this->syncStateRepository->findOneByAccommodation($accommodation) ?? new ContingentSyncState($accommodation);
// Read before recordSuccess() moves it: days beyond the horizon the previous run reached
// are the window growing, not the contingent situation changing.
$previousHorizonTo = $state->getHorizonTo();
try { try {
$calendar = $this->contingentsClient->getContingentCalendar( $calendar = $this->contingentsClient->getContingentCalendar(
$hotelCode, $hotelCode,
@@ -72,6 +76,7 @@ class ContingentSnapshotManager
} }
$added = 0; $added = 0;
$extended = 0;
$updated = 0; $updated = 0;
$seen = []; $seen = [];
@@ -102,7 +107,12 @@ class ContingentSnapshotManager
->setDate($date); ->setDate($date);
$this->entityManager->persist($day); $this->entityManager->persist($day);
if (null !== $previousHorizonTo && $date > $previousHorizonTo) {
++$extended;
} else {
++$added; ++$added;
}
} elseif ($day->getStatus() === $entry->status) { } elseif ($day->getStatus() === $entry->status) {
continue; continue;
} else { } else {
@@ -124,26 +134,21 @@ class ContingentSnapshotManager
$this->entityManager->flush(); $this->entityManager->flush();
$now = CarbonImmutable::now()->toDateTimeImmutable(); $now = CarbonImmutable::now()->toDateTimeImmutable();
$hash = $this->fingerprint($accommodation);
$changed = $hash !== $state->getContentHash(); // The diff is the change signal. A digest of the stored snapshot cannot be one: the window
// rolls forward a day at a time, so every digest would cover a different span than the one
// it is compared against and every run would report a change.
$changed = $added > 0 || $updated > 0 || $removed > 0;
if ($changed) { if ($changed) {
$state->setContentHash($hash)->setChangedAt($now); $state->setChangedAt($now);
} }
$state->recordSuccess($now, $dateTo); $state->recordSuccess($now, $dateTo);
$this->entityManager->persist($state); $this->entityManager->persist($state);
$this->entityManager->flush(); $this->entityManager->flush();
return ContingentSyncResult::synced($changed, $added, $updated, $removed); return ContingentSyncResult::synced($changed, $added, $extended, $updated, $removed);
}
/**
* sha256 over the accommodation's complete stored snapshot, contingent status only.
*/
public function fingerprint(Accommodation $accommodation): string
{
return hash('sha256', implode('|', $this->dayRepository->findStatusFingerprintParts($accommodation)));
} }
private function recordFailure(ContingentSyncState $state, string $hotelCode, string $error): ContingentSyncResult private function recordFailure(ContingentSyncState $state, string $hotelCode, string $error): ContingentSyncResult
+68 -7
View File
@@ -5,12 +5,13 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Entity\BookingEditDraft; use App\Entity\BookingEditDraft;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\NewsletterConsent; use App\Entity\NewsletterConsent;
use App\Entity\NewsletterOptInRequest; use App\Entity\NewsletterOptInRequest;
use App\Entity\User; use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Faker\Factory; use Faker\Factory;
use Faker\Generator; use Faker\Generator;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
/** /**
@@ -49,7 +50,7 @@ class DatabaseAnonymizer
} }
/** /**
* @return array{users: int, newsletterConsents: int, newsletterOptInRequests: int, bookingEditDrafts: int} * @return array{users: int, newsletterConsents: int, newsletterOptInRequests: int, bookingEditDrafts: int, accommodationBookings: int}
*/ */
public function anonymizeAll(int $batchSize = self::DEFAULT_BATCH_SIZE): array public function anonymizeAll(int $batchSize = self::DEFAULT_BATCH_SIZE): array
{ {
@@ -80,6 +81,13 @@ class DatabaseAnonymizer
fn (BookingEditDraft $draft) => $this->anonymizeBookingEditDraft($draft), fn (BookingEditDraft $draft) => $this->anonymizeBookingEditDraft($draft),
$batchSize, $batchSize,
), ),
// Last, so that a booking whose email matches a mirrored BusPro account resolves to
// the synthetic identity the user pass has already minted for that person.
'accommodationBookings' => $this->anonymizeQuery(
'SELECT b FROM App\Entity\Groups\AccommodationBooking b ORDER BY b.id ASC',
fn (AccommodationBooking $booking) => $this->anonymizeAccommodationBooking($booking),
$batchSize,
),
]; ];
$this->entityManager->flush(); $this->entityManager->flush();
@@ -108,8 +116,8 @@ class DatabaseAnonymizer
$consent->setEmail($identity['email']); $consent->setEmail($identity['email']);
$consent->setNames( $consent->setNames(
$this->shouldReplaceName($consent->getFirstName()) ? $identity['firstName'] : $consent->getFirstName(), $this->hasReplaceableValue($consent->getFirstName()) ? $identity['firstName'] : $consent->getFirstName(),
$this->shouldReplaceName($consent->getLastName()) ? $identity['lastName'] : $consent->getLastName(), $this->hasReplaceableValue($consent->getLastName()) ? $identity['lastName'] : $consent->getLastName(),
); );
} }
@@ -123,8 +131,8 @@ class DatabaseAnonymizer
$request->setEmail($identity['email']); $request->setEmail($identity['email']);
$request->setNames( $request->setNames(
$this->shouldReplaceName($request->getFirstName()) ? $identity['firstName'] : $request->getFirstName(), $this->hasReplaceableValue($request->getFirstName()) ? $identity['firstName'] : $request->getFirstName(),
$this->shouldReplaceName($request->getLastName()) ? $identity['lastName'] : $request->getLastName(), $this->hasReplaceableValue($request->getLastName()) ? $identity['lastName'] : $request->getLastName(),
); );
} }
@@ -136,6 +144,58 @@ class DatabaseAnonymizer
$draft->replaceFormData($formData); $draft->replaceFormData($formData);
} }
/**
* The uuid is deliberately left alone: it is what the signed customer access link resolves
* against, so regenerating it would silently invalidate every link in an anonymized dump.
* The salutation stays too, being no more identifying than the gender it stands for.
*/
public function anonymizeAccommodationBooking(AccommodationBooking $booking): void
{
$identity = $this->resolveIdentity(
email: $booking->getEmail(),
firstName: $booking->getFirstName(),
lastName: $booking->getLastName(),
);
// Only fields that actually hold something are replaced, so a half-prepared draft stays
// half-prepared instead of becoming a record the `edit` group would suddenly accept.
if ($this->hasReplaceableValue($booking->getGroupName())) {
$booking->setGroupName($identity['groupName']);
}
if ($this->hasReplaceableValue($booking->getFirstName())) {
$booking->setFirstName($identity['firstName']);
}
if ($this->hasReplaceableValue($booking->getLastName())) {
$booking->setLastName($identity['lastName']);
}
if ($this->hasReplaceableValue($booking->getEmail())) {
$booking->setEmail($identity['email']);
}
if ($this->hasReplaceableValue($booking->getPhone())) {
$booking->setPhone($identity['phone']);
}
if ($this->hasReplaceableValue($booking->getStreet())) {
$booking->setStreet($identity['street']);
}
if ($this->hasReplaceableValue($booking->getZip())) {
$booking->setZip($identity['postCode']);
}
if ($this->hasReplaceableValue($booking->getCity())) {
$booking->setCity($identity['city']);
}
if ($this->hasReplaceableValue($booking->getRemarks())) {
$booking->setRemarks($this->placeholderText($identity, 'remarks'));
}
}
/** /**
* @param callable(object):void $anonymize * @param callable(object):void $anonymize
*/ */
@@ -385,6 +445,7 @@ class DatabaseAnonymizer
'city' => $this->faker->city(), 'city' => $this->faker->city(),
'country' => self::SYNTHETIC_COUNTRY, 'country' => self::SYNTHETIC_COUNTRY,
'district' => $this->faker->citySuffix(), 'district' => $this->faker->citySuffix(),
'groupName' => $this->faker->company(),
'accountHolder' => $firstName.' '.$lastName, 'accountHolder' => $firstName.' '.$lastName,
'bankName' => $this->faker->company(), 'bankName' => $this->faker->company(),
'iban' => self::SYNTHETIC_IBAN, 'iban' => self::SYNTHETIC_IBAN,
@@ -403,7 +464,7 @@ class DatabaseAnonymizer
return $type.':'.mb_strtolower($normalized); return $type.':'.mb_strtolower($normalized);
} }
private function shouldReplaceName(?string $value): bool private function hasReplaceableValue(?string $value): bool
{ {
return null !== $this->stringOrNull($value); return null !== $this->stringOrNull($value);
} }
+8 -7
View File
@@ -30,6 +30,7 @@ class GroupsPriceCalculator
/** /**
* @param array<string, int|float> $config * @param array<string, int|float> $config
*
* @return array<string, int|float> * @return array<string, int|float>
*/ */
private function resolveConfig(array $config): array private function resolveConfig(array $config): array
@@ -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(),
@@ -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,
@@ -193,7 +194,7 @@ class GroupsPriceCalculator
// Derive effectivePax: children 03 don't count, but never drop below includedPax // Derive effectivePax: children 03 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;
} }

Some files were not shown because too many files have changed in this diff Show More