Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03cb74370b | ||
|
|
d05524222b | ||
|
|
c02b1b57c7 | ||
|
|
efff024196 | ||
|
|
021671a45f | ||
|
|
744cf55a7e | ||
|
|
f2c12a9f03 | ||
|
|
5cf4b0f9ea | ||
|
|
370bc9fc5f | ||
|
|
a1d43b9c5a | ||
|
|
90fc66277e | ||
|
|
cd5d353158 | ||
|
|
590146be9e | ||
|
|
8e2c688641 | ||
|
|
f705a851c2 | ||
|
|
d33ff9779c | ||
|
|
876cd5a8fa | ||
|
|
5a64c85fb6 | ||
|
|
31153e90b4 | ||
|
|
949bea723d | ||
|
|
0365daa57b | ||
|
|
3996e02dd9 | ||
|
|
bd488211bd | ||
|
|
ca34d4a4f3 | ||
|
|
a93a8469c4 | ||
|
|
cec481741b | ||
|
|
39bacd2927 | ||
|
|
39972c1418 | ||
|
|
45cfc459aa | ||
|
|
21356f00d5 | ||
|
|
4529fe5472 | ||
|
|
08aaff1e2d | ||
|
|
0884a39f3d | ||
|
|
41b0f56b5b | ||
|
|
b034b8c7f8 | ||
|
|
a4cae4f321 | ||
|
|
ab67f9d7b5 | ||
|
|
a22688c87e | ||
|
|
cfc52a9cf3 | ||
|
|
33e09bbe89 | ||
|
|
a339f8a517 | ||
|
|
abeb91b59f | ||
|
|
350a9e6742 | ||
|
|
c373a98953 | ||
|
|
3863999f2a | ||
|
|
54410628b4 | ||
|
|
2c3ce32282 | ||
|
|
dd03cdd77f | ||
|
|
bc8d4f74c1 | ||
|
|
6f869bdb1a | ||
|
|
6adc0769c7 | ||
|
|
2f9930a240 | ||
|
|
3036a7773c | ||
|
|
e668c2d882 | ||
|
|
bb4768c491 | ||
|
|
9140e4f3fc | ||
|
|
6b205c7a86 | ||
|
|
0aa4bb5055 | ||
|
|
ee3b1f47a7 | ||
|
|
40da2ecaf3 | ||
|
|
5d0ba0ef9a | ||
|
|
786a4306dd | ||
|
|
677ffc7368 | ||
|
|
93eb08c610 | ||
|
|
6365e4b24c | ||
|
|
e138fbeca8 | ||
|
|
d29a6a1835 | ||
|
|
dc91660488 | ||
|
|
e9548d9f38 | ||
|
|
d5944bf717 | ||
|
|
2355442ac0 | ||
|
|
789555078d | ||
|
|
34f190f469 | ||
|
|
8646c4ffdd | ||
|
|
419d3b78c6 | ||
|
|
e8df7d193e | ||
|
|
aa08307500 | ||
|
|
c41524c1fd | ||
|
|
8dec0be375 | ||
|
|
9471abce94 | ||
|
|
2aeed762fd | ||
|
|
512b63b88b | ||
|
|
92d7eac3a1 | ||
|
|
548b8a8ef0 | ||
|
|
fb8aabe34c | ||
|
|
0c1f143b12 | ||
|
|
24ac2efc53 | ||
|
|
e7a139b025 | ||
|
|
cfe19b7a74 | ||
|
|
f725a6b72f | ||
|
|
a097159897 | ||
|
|
da33620ea9 | ||
|
|
0b44394b11 | ||
|
|
2949870acc | ||
|
|
35c289240d | ||
|
|
3b165bf292 | ||
|
|
f0c43a17d8 | ||
|
|
2776429639 | ||
|
|
5c9f8c2a2e | ||
|
|
3e6533829c | ||
|
|
054505d853 | ||
|
|
c793550af5 | ||
|
|
b18fccff9d | ||
|
|
398eb448cb | ||
|
|
6cdede138e | ||
|
|
3046aad94d | ||
|
|
ec2ce6ec62 | ||
|
|
3c61e7eaf3 | ||
|
|
f942600dd8 | ||
|
|
efacbf64cb | ||
|
|
e38445f184 | ||
|
|
00803b2e73 | ||
|
|
a52f65919f | ||
|
|
3d4d88e986 | ||
|
|
5221cea53b | ||
|
|
7636ee41af | ||
|
|
830fd2464c | ||
|
|
d922fd2b9c | ||
|
|
d22078b26e | ||
|
|
195af95995 | ||
|
|
d95d0a8930 | ||
|
|
6fca40963d | ||
|
|
ace251c1dd | ||
|
|
db2e92c058 | ||
|
|
28b62f5071 | ||
|
|
168c128b1d | ||
|
|
90af012e56 | ||
|
|
d6c36b4171 | ||
|
|
9575aad10c | ||
|
|
7b98cccbe9 | ||
|
|
fe2268c897 | ||
|
|
f2c1988d75 | ||
|
|
5b9f35eddc | ||
|
|
c8ed5b2cc7 | ||
|
|
da517b37b6 | ||
|
|
0992c05539 | ||
|
|
ed8a8a2f8b | ||
|
|
5516ef45f6 | ||
|
|
6beb965c85 | ||
|
|
479a88e131 | ||
|
|
e4a1a992be | ||
|
|
97a5d7f9f1 | ||
|
|
776b9d0912 | ||
|
|
df35ed3032 | ||
|
|
59b08ed098 | ||
|
|
936004bc2d | ||
|
|
365658d727 | ||
|
|
d603e57c62 | ||
|
|
58ec94eaeb | ||
|
|
2f4184f60f | ||
|
|
b0b608f12e | ||
|
|
97be776b10 | ||
|
|
ddb876bc1c | ||
|
|
6fee5c048a | ||
|
|
80f22b7cd0 | ||
|
|
e1123577f4 | ||
|
|
5f33f82b8b | ||
|
|
5285ded46f | ||
|
|
69df93b773 | ||
|
|
1e3389e21a | ||
|
|
192b1fdcf4 | ||
|
|
6008a1b2d9 | ||
|
|
62fc734f82 | ||
|
|
7fb5466eb4 | ||
|
|
aafac9d43b | ||
|
|
585f87e1f9 | ||
|
|
eb1d05aa10 | ||
|
|
db1de2653c | ||
|
|
8bdc33b4f0 | ||
|
|
1c1766bbd4 | ||
|
|
96205c14db | ||
|
|
7ec4562064 | ||
|
|
1eb1e9f02f | ||
|
|
0b35b3494b | ||
|
|
e021fc7570 | ||
|
|
2a13a62291 | ||
|
|
60c1459902 |
@@ -1,11 +0,0 @@
|
||||
name: opencode
|
||||
repository: /Users/fromme/Development/Misc/ddev-opencode
|
||||
version: ""
|
||||
install_date: "2026-01-05T17:21:57+01:00"
|
||||
project_files:
|
||||
- web-build/Dockerfile.opencode
|
||||
- commands/web/opencode
|
||||
- config.opencode.yaml
|
||||
- opencode/.gitignore
|
||||
global_files: []
|
||||
removal_actions: []
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
## Description: Run Claude Code
|
||||
## Usage: claude [args]
|
||||
## Example: ddev claude
|
||||
## Example: ddev claude --help
|
||||
## Example: ddev claude doctor
|
||||
|
||||
# Ensure ~/.local/bin is in PATH
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
# Check if claude is installed
|
||||
if [ ! -f "$HOME/.local/bin/claude" ]; then
|
||||
echo "Claude Code is not installed. Installing now..."
|
||||
curl -fsSL https://claude.ai/install.sh | bash
|
||||
fi
|
||||
|
||||
# Run claude with all arguments
|
||||
exec "$HOME/.local/bin/claude" "$@"
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/bin/bash
|
||||
#ddev-generated
|
||||
|
||||
## Description: OpenCode AI coding assistant
|
||||
## Usage: opencode
|
||||
## Example: ddev opencode [...]
|
||||
|
||||
set -e
|
||||
|
||||
opencode $@
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#ddev-generated
|
||||
webimage_extra_packages:
|
||||
- xdg-utils
|
||||
hooks:
|
||||
post-start:
|
||||
- exec: "mkdir -p /var/www/html/.ddev/opencode/.opencode && ln -sf /var/www/html/.ddev/opencode/.opencode ~/.opencode"
|
||||
@@ -1,44 +1,51 @@
|
||||
type: php
|
||||
docroot: public
|
||||
php_version: "8.2"
|
||||
php_version: "8.3"
|
||||
webserver_type: apache-fpm
|
||||
xdebug_enabled: false
|
||||
additional_hostnames: []
|
||||
additional_hostnames:
|
||||
- mysnz
|
||||
- mysbw
|
||||
- myser
|
||||
additional_fqdns: []
|
||||
database:
|
||||
type: mariadb
|
||||
version: "10.11"
|
||||
type: mysql
|
||||
version: "8.4"
|
||||
use_dns_when_possible: true
|
||||
composer_version: "2"
|
||||
web_environment: []
|
||||
corepack_enable: false
|
||||
nodejs_version: "22"
|
||||
|
||||
# Key features of DDEV's config.yaml:
|
||||
|
||||
# name: <projectname> # Name of the project, automatically provides
|
||||
# http://projectname.ddev.site and https://projectname.ddev.site
|
||||
# If the name is omitted, the project will take the name of the enclosing directory,
|
||||
# which is useful if you want to have a copy of the project side by side with this one.
|
||||
|
||||
# type: <projecttype> # backdrop, craftcms, django4, drupal, drupal6, drupal7, laravel, magento, magento2, php, python, shopware6, silverstripe, typo3, wordpress
|
||||
# See https://ddev.readthedocs.io/en/stable/users/quickstart/ for more
|
||||
# type: <projecttype> # backdrop, cakephp, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, generic, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress
|
||||
# See https://docs.ddev.com/en/stable/users/quickstart/ for more
|
||||
# information on the different project types
|
||||
# "drupal" covers recent Drupal 8+
|
||||
|
||||
# docroot: <relative_path> # Relative path to the directory containing index.php.
|
||||
|
||||
# php_version: "8.2" # PHP version to use, "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4"
|
||||
# php_version: "8.3" # PHP version to use, "5.6" through "8.5"
|
||||
|
||||
# You can explicitly specify the webimage but this
|
||||
# is not recommended, as the images are often closely tied to DDEV's' behavior,
|
||||
# so this can break upgrades.
|
||||
|
||||
# webimage: <docker_image> # nginx/php docker image.
|
||||
# webimage: <docker_image>
|
||||
# It’s unusual to change this option, and we don’t recommend it without Docker experience and a good reason.
|
||||
# Typically, this means additions to the existing web image using a .ddev/web-build/Dockerfile.*
|
||||
|
||||
# database:
|
||||
# type: <dbtype> # mysql, mariadb, postgres
|
||||
# version: <version> # database version, like "10.11" or "8.0"
|
||||
# MariaDB versions can be 5.5-10.8, 10.11, and 11.4.
|
||||
# MySQL versions can be 5.5-8.0.
|
||||
# PostgreSQL versions can be 9-17.
|
||||
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8
|
||||
# MySQL versions can be 5.5-8.0, 8.4
|
||||
# PostgreSQL versions can be 9-18
|
||||
|
||||
# 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)
|
||||
@@ -48,12 +55,21 @@ corepack_enable: false
|
||||
# "ddev xdebug" to enable Xdebug and "ddev xdebug off" to disable it work better,
|
||||
# as leaving Xdebug enabled all the time is a big performance hit.
|
||||
|
||||
# xhprof_enabled: false # Set to true to enable Xhprof and "ddev start" or "ddev restart"
|
||||
# Note that for most people the commands
|
||||
# "ddev xhprof" to enable Xhprof and "ddev xhprof off" to disable it work better,
|
||||
# as leaving Xhprof enabled all the time is a big performance hit.
|
||||
# xhgui_http_port: "8143"
|
||||
# xhgui_https_port: "8142"
|
||||
# The XHGui ports can be changed from the default 8143 and 8142
|
||||
# Very rarely used
|
||||
|
||||
# webserver_type: nginx-fpm, apache-fpm, or nginx-gunicorn
|
||||
# host_xhgui_port: "8142"
|
||||
# Can be used to change the host binding port of the XHGui
|
||||
# application. Rarely used; only when port conflict and
|
||||
# bind_all_ports is used (normally with router disabled)
|
||||
|
||||
# xhprof_mode: [prepend|xhgui|global]
|
||||
# Set to "xhgui" to enable XHGui features
|
||||
# "xhgui" will become default in a future major release
|
||||
|
||||
# webserver_type: nginx-fpm, apache-fpm, generic
|
||||
|
||||
# timezone: Europe/Berlin
|
||||
# If timezone is unset, DDEV will attempt to derive it from the host system timezone
|
||||
@@ -79,9 +95,9 @@ corepack_enable: false
|
||||
# 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".
|
||||
|
||||
# nodejs_version: "20"
|
||||
# nodejs_version: "22"
|
||||
# change from the default system Node.js version to any other version.
|
||||
# See https://ddev.readthedocs.io/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,
|
||||
# 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'.
|
||||
@@ -117,7 +133,7 @@ corepack_enable: false
|
||||
|
||||
# ddev_version_constraint: ""
|
||||
# Example:
|
||||
# ddev_version_constraint: ">= 1.22.4"
|
||||
# ddev_version_constraint: ">= 1.24.8"
|
||||
# This will enforce that the running ddev version is within this constraint.
|
||||
# See https://github.com/Masterminds/semver#checking-version-constraints for
|
||||
# supported constraint formats
|
||||
@@ -146,8 +162,8 @@ corepack_enable: false
|
||||
# - "mutagen": enables Mutagen for this project.
|
||||
# - "nfs": enables NFS for this project.
|
||||
#
|
||||
# See https://ddev.readthedocs.io/en/stable/users/install/performance/#nfs
|
||||
# See https://ddev.readthedocs.io/en/stable/users/install/performance/#mutagen
|
||||
# See https://docs.ddev.com/en/stable/users/install/performance/#nfs
|
||||
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
|
||||
|
||||
# fail_on_hook_fail: False
|
||||
# Decide whether 'ddev start' should be interrupted by a failing hook
|
||||
@@ -196,7 +212,7 @@ corepack_enable: false
|
||||
|
||||
# ngrok_args: --basic-auth username:pass1234
|
||||
# Provide extra flags to the "ngrok http" command, see
|
||||
# https://ngrok.com/docs/ngrok-agent/config or run "ngrok http -h"
|
||||
# https://ngrok.com/docs/agent/config/v3/#agent-configuration or run "ngrok http -h"
|
||||
|
||||
# disable_settings_management: false
|
||||
# If true, DDEV will not create CMS-specific settings files like
|
||||
@@ -274,7 +290,7 @@ corepack_enable: false
|
||||
# Many DDEV commands can be extended to run tasks before or after the
|
||||
# DDEV command is executed, for example "post-start", "post-import-db",
|
||||
# "pre-composer", "post-composer"
|
||||
# See https://ddev.readthedocs.io/en/stable/users/extend/custom-commands/ for more
|
||||
# See https://docs.ddev.com/en/stable/users/extend/custom-commands/ for more
|
||||
# information on the commands that can be extended and the tasks you can define
|
||||
# for them. Example:
|
||||
#hooks:
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
#ddev-generated
|
||||
|
||||
# OpenCode internal config
|
||||
/.opencode/
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#ddev-generated
|
||||
|
||||
RUN npm install -g opencode-ai
|
||||
|
||||
@@ -25,7 +25,7 @@ APP_SECRET=642ead390b8a38f0a017ec84d5362129
|
||||
#
|
||||
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
|
||||
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
|
||||
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.11-MariaDB&charset=utf8mb4"
|
||||
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.4.0&charset=utf8mb4"
|
||||
# DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
@@ -43,20 +43,29 @@ MAILER_DSN=null://null
|
||||
APP_BPN_USER=
|
||||
APP_BPN_PASSWORD=
|
||||
APP_BPN_IP=
|
||||
APP_BPN_PORT=
|
||||
APP_BPN_PORTS=
|
||||
APP_BPN_DEBUG=false
|
||||
APP_BPN_CONNECTION_TIMEOUT=5
|
||||
APP_BPN_STREAM_TIMEOUT=30
|
||||
APP_BPN_TOTAL_TIMEOUT=45
|
||||
|
||||
APP_TRAVEL_INFO_BASE_URL="https://www.ep-reisen.de/reiseinformationen/"
|
||||
APP_LEGAL_NOTICE_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/"
|
||||
APP_TERMS_AND_CONDITIONS_URL="https://www.ep-reisen.de/skireisen/infos/allgemeine-infos/agb/"
|
||||
APP_PRIVACY_POLICY_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/datenschutz-winterreisen/"
|
||||
APP_CMS_API_BASE_URL="https://www.ep-reisen.de/"
|
||||
APP_WEBSITE_BASE_URL="https://www.ep-reisen.de/"
|
||||
APP_CUSTOMER_SERVICE_EMAIL="[email protected]"
|
||||
|
||||
# Travel Data Service Configuration
|
||||
APP_TRAVEL_PREFER_REMOTE=false
|
||||
APP_TRAVEL_ENABLE_FALLBACK=true
|
||||
|
||||
# Booking Configuration
|
||||
# Status for new bookings: 'F' (Fixed/Final), 'O' (Option - requires agency confirmation)
|
||||
# Use 'O' during beta phase, switch to 'F' for production
|
||||
DEFAULT_BOOKING_STATUS=F
|
||||
|
||||
API_KEYS=
|
||||
|
||||
XML_EXPORT_PATH="%kernel.project_dir%/var/xmlexport"
|
||||
|
||||
@@ -31,3 +31,5 @@ yarn-error.log
|
||||
/.php-cs-fixer.php
|
||||
/.php-cs-fixer.cache
|
||||
###< friendsofphp/php-cs-fixer ###
|
||||
|
||||
/ray.php
|
||||
|
||||
@@ -72,7 +72,7 @@ Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
### API hotel availability remote
|
||||
# @no-cookie-jar
|
||||
GET {{base_url}}/api/travels/11603/152546/2026-01-03/availability
|
||||
GET {{base_url}}/api/travels/11606/152546/2026-01-24/availability
|
||||
Accept: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
@@ -90,14 +90,14 @@ Content-Type: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
{
|
||||
"lastName": "Duschen",
|
||||
"firstName": "Isolde",
|
||||
"gender": "f",
|
||||
"street": "Fooroad 1",
|
||||
"zipCode": "12345",
|
||||
"city": "Bartown",
|
||||
"email": "[email protected]",
|
||||
"phone": "12345"
|
||||
"lastName": "{{$random.name.lastName}}",
|
||||
"firstName": "{{$random.name.firstName}}",
|
||||
"gender": "d",
|
||||
"street": "{{$random.address.streetAddress}}",
|
||||
"zipCode": "{{$random.address.zipCode}}",
|
||||
"city": "{{$random.address.city}}",
|
||||
"email": "{{$random.email}}",
|
||||
"phone": "{{$random.phoneNumber.cellPhone}}"
|
||||
}
|
||||
|
||||
### API pickups planning webhook
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
/**
|
||||
* Monitors birthday input fields (day, month, year) and dispatches a custom event
|
||||
* only when all three fields are filled with valid values.
|
||||
*
|
||||
* This prevents premature HTMX refreshes when users are still entering the date,
|
||||
* improving UX by waiting until the complete date is entered.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ['day', 'month', 'year']
|
||||
|
||||
/**
|
||||
* Checks if all birthday fields are filled and dispatches a completion event.
|
||||
*
|
||||
* Called on input/change events from the day, month, and year fields.
|
||||
* Only dispatches the 'birthday:complete' event when all three fields
|
||||
* contain valid values that could form a complete date.
|
||||
*/
|
||||
check() {
|
||||
const day = this.dayTarget.value.trim()
|
||||
const month = this.monthTarget.value.trim()
|
||||
const year = this.yearTarget.value.trim()
|
||||
|
||||
if (false === this.isComplete(day, month, year)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.element.dispatchEvent(new CustomEvent('birthday:complete', { bubbles: true }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all date components are filled with plausible values.
|
||||
*
|
||||
* @param {string} day The day value
|
||||
* @param {string} month The month value
|
||||
* @param {string} year The year value
|
||||
*
|
||||
* @returns {boolean} True if all fields contain complete values
|
||||
*/
|
||||
isComplete(day, month, year) {
|
||||
// Day: 1-2 digits
|
||||
if (0 === day.length || day.length > 2) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Month: 1-2 digits
|
||||
if (0 === month.length || month.length > 2) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Year: exactly 4 digits for a complete year
|
||||
if (4 !== year.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
payload: Object
|
||||
};
|
||||
|
||||
connect() {
|
||||
if (typeof window.dataLayer !== 'undefined') {
|
||||
window.dataLayer.push(this.payloadValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,24 +24,15 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
handleNotifications(event) {
|
||||
console.log('[VOUCHER DEBUG] showNotifications event received:', event)
|
||||
console.log('[VOUCHER DEBUG] event.detail:', event.detail)
|
||||
|
||||
// HTMX wraps the trigger value in an object with 'value' and 'elt' properties
|
||||
// event.detail.value contains the actual array from the HX-Trigger header
|
||||
const notifications = event.detail?.value || event.detail || []
|
||||
|
||||
console.log('[VOUCHER DEBUG] notifications array:', notifications)
|
||||
console.log('[VOUCHER DEBUG] notifications count:', Array.isArray(notifications) ? notifications.length : 'not an array')
|
||||
|
||||
if (Array.isArray(notifications)) {
|
||||
notifications.forEach((notification, index) => {
|
||||
console.log(`[VOUCHER DEBUG] Processing notification ${index}:`, notification)
|
||||
const className = this.getClassForType(notification.type)
|
||||
this.showToast(notification.message, className)
|
||||
})
|
||||
} else {
|
||||
console.error('[VOUCHER DEBUG] Notifications is not an array:', notifications)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,4 +57,4 @@ export default class extends Controller {
|
||||
|
||||
return typeMap[type] || 'toastify--info'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 130 130" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(3, 28)">
|
||||
<path d="M107,0.3c-36.4,5.5 -81.6,19.1 -81.6,38.2c0,17.7 41.8,13.2 41.8,13.2c0,0 -67,22.3 -67,-7c0,-33.2 84.5,-44.4 106.8,-44.4Z" fill="#0084d4"/>
|
||||
<path d="M17,73.5c36.4,-5.4 81.6,-19.1 81.6,-38.2c0,-17.7 -41.8,-13.2 -41.8,-13.2c0,0 67,-22.3 67,7c0,33.3 -84.5,44.4 -106.8,44.4Z" fill="#f8c62f"/>
|
||||
<path d="M60.7,34.3l-12.8,4l12.2,0.8l-0.4,-2.4l1,-2.4Z" fill="#0084d4"/>
|
||||
<path d="M75.9,35.2l-9.2,2.9l-3.5,9.7l-1.7,-11.3l4,-10.7l1.7,7.9l8.7,1.5Z" fill="#f8c62f"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 628 B |
@@ -5,6 +5,8 @@ const DEBOUNCE_MS = 200
|
||||
|
||||
let debounceTimeout = null
|
||||
let historyRestoreGracePeriod = false
|
||||
let savedScrollPosition = null
|
||||
let activeRequests = 0
|
||||
|
||||
function getIndicator() {
|
||||
return document.getElementById(INDICATOR_ID)
|
||||
@@ -28,12 +30,20 @@ function hide() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleBeforeRequest() {
|
||||
function handleBeforeRequest(event) {
|
||||
// Ignore requests triggered during history restore grace period
|
||||
if (true === historyRestoreGracePeriod) {
|
||||
return
|
||||
}
|
||||
|
||||
activeRequests++
|
||||
|
||||
// Save scroll position of the target container before HTMX request
|
||||
const target = event.detail?.target || document.getElementById('main-content')
|
||||
if (target && typeof target.scrollTop === 'number') {
|
||||
savedScrollPosition = { element: target, top: target.scrollTop }
|
||||
}
|
||||
|
||||
// Don't start a new debounce if already pending
|
||||
if (debounceTimeout) {
|
||||
return
|
||||
@@ -45,7 +55,29 @@ function handleBeforeRequest() {
|
||||
}, DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
function handleRequestEnd() {
|
||||
// Counter may already be 0 if a force-reset (history save, page show) fired
|
||||
// before this afterRequest — ignore the stale completion in that case
|
||||
if (0 === activeRequests) {
|
||||
return
|
||||
}
|
||||
|
||||
activeRequests--
|
||||
if (0 === activeRequests) {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
function handleAfterSwap() {
|
||||
// Restore scroll position after HTMX swaps content
|
||||
if (savedScrollPosition !== null && savedScrollPosition.element) {
|
||||
savedScrollPosition.element.scrollTop = savedScrollPosition.top
|
||||
savedScrollPosition = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleHistoryRestore() {
|
||||
activeRequests = 0
|
||||
hide()
|
||||
|
||||
// Set grace period to ignore change events triggered by history restore
|
||||
@@ -56,23 +88,29 @@ function handleHistoryRestore() {
|
||||
}
|
||||
|
||||
function handleTimeout() {
|
||||
hide()
|
||||
// Don't decrement here — htmx:afterRequest always fires alongside htmx:timeout
|
||||
// and handles the counter. This handler only shows the alert.
|
||||
alert('Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut. Falls das Problem weiterhin besteht, kontaktieren Sie bitte unseren Support.')
|
||||
}
|
||||
|
||||
function handleBeforeHistorySave() {
|
||||
// Hide indicator before HTMX saves the page to history cache
|
||||
activeRequests = 0
|
||||
hide()
|
||||
}
|
||||
|
||||
function handlePageShow() {
|
||||
activeRequests = 0
|
||||
hide()
|
||||
}
|
||||
|
||||
// Initialize event listeners
|
||||
document.body.addEventListener('htmx:beforeRequest', handleBeforeRequest)
|
||||
document.body.addEventListener('htmx:afterRequest', hide)
|
||||
document.body.addEventListener('htmx:afterRequest', handleRequestEnd)
|
||||
document.body.addEventListener('htmx:afterSwap', handleAfterSwap)
|
||||
document.body.addEventListener('htmx:timeout', handleTimeout)
|
||||
document.body.addEventListener('htmx:historyRestore', handleHistoryRestore)
|
||||
document.body.addEventListener('htmx:sendError', hide)
|
||||
document.body.addEventListener('htmx:responseError', hide)
|
||||
document.body.addEventListener('htmx:beforeHistorySave', handleBeforeHistorySave)
|
||||
|
||||
window.addEventListener('pageshow', hide)
|
||||
window.addEventListener('popstate', handleHistoryRestore)
|
||||
window.addEventListener('pageshow', handlePageShow)
|
||||
window.addEventListener('popstate', handleHistoryRestore)
|
||||
|
||||
@@ -14,12 +14,15 @@
|
||||
"doctrine/doctrine-bundle": "^2.13",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.3",
|
||||
"doctrine/orm": "^3.3",
|
||||
"easycorp/easyadmin-bundle": "^4.27",
|
||||
"fakerphp/faker": "^1.24",
|
||||
"league/flysystem-bundle": "^3.4",
|
||||
"league/flysystem-sftp-v3": "^3.29",
|
||||
"league/oauth2-server-bundle": "^1.0",
|
||||
"nelexa/zip": "^4.0",
|
||||
"nesbot/carbon": "^3.8",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
"phpoffice/phpspreadsheet": "^5.4",
|
||||
"phpstan/phpdoc-parser": "^2.0",
|
||||
"spatie/crypto": "^2.1",
|
||||
"symfony/apache-pack": "^1.0",
|
||||
@@ -69,7 +72,7 @@
|
||||
},
|
||||
"sort-packages": true,
|
||||
"platform": {
|
||||
"php": "8.2.28"
|
||||
"php": "8.3.30"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
|
||||
@@ -16,4 +16,6 @@ return [
|
||||
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
|
||||
League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true],
|
||||
Zenstruck\ScheduleBundle\ZenstruckScheduleBundle::class => ['all' => true],
|
||||
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
|
||||
EasyCorp\Bundle\EasyAdminBundle\EasyAdminBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
@@ -15,5 +15,7 @@ framework:
|
||||
#app: cache.adapter.apcu
|
||||
|
||||
# Namespaced pools use the above "app" backend by default
|
||||
#pools:
|
||||
#my.dedicated.cache: null
|
||||
pools:
|
||||
bpn.cache:
|
||||
adapter: cache.adapter.filesystem
|
||||
tags: true
|
||||
|
||||
@@ -104,4 +104,4 @@ when@prod:
|
||||
type: stream
|
||||
channels: [deprecation]
|
||||
path: php://stderr
|
||||
formatter: monolog.formatter.json
|
||||
formatter: monolog.formatter.json
|
||||
@@ -33,7 +33,8 @@ security:
|
||||
# Note: Only the *first* access control that matches will be used
|
||||
access_control:
|
||||
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https }
|
||||
- { path: '^/', roles: PUBLIC_ACCESS, requires_channel: https }
|
||||
- { path: ^/admin, roles: ROLE_ADMIN, requires_channel: https }
|
||||
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
|
||||
|
||||
when@test:
|
||||
security:
|
||||
|
||||
@@ -4,6 +4,10 @@ twig:
|
||||
globals:
|
||||
htmx_change_trigger: 'change delay:100ms'
|
||||
website_base_url: '%env(APP_WEBSITE_BASE_URL)%'
|
||||
customer_service_email: '%env(APP_CUSTOMER_SERVICE_EMAIL)%'
|
||||
legal_notice_url: '%env(APP_LEGAL_NOTICE_URL)%'
|
||||
terms_and_conditions_url: '%env(APP_TERMS_AND_CONDITIONS_URL)%'
|
||||
privacy_policy_url: '%env(APP_PRIVACY_POLICY_URL)%'
|
||||
|
||||
when@test:
|
||||
twig:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
twig_component:
|
||||
anonymous_template_directory: 'components/'
|
||||
defaults:
|
||||
# Namespace & directory for components
|
||||
App\Twig\Components\: 'components/'
|
||||
@@ -9,21 +9,36 @@ zenstruck_schedule:
|
||||
|
||||
schedule_extensions:
|
||||
email_on_failure:
|
||||
to: [email protected]
|
||||
enabled: true
|
||||
|
||||
tasks:
|
||||
- task: app:cleanup:xml-dumps
|
||||
frequency: "0 1 * * *"
|
||||
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging"
|
||||
|
||||
- task: app:bpn:xml-sync
|
||||
frequency: "*/15 8-19 * * *"
|
||||
description: "Sync BusPro XML data every 15 min. during peak hours"
|
||||
|
||||
- task: app:bpn:xml-sync
|
||||
frequency: "0 20-23,0-7 * * *"
|
||||
description: "Sync BusPro XML data hourly outside peak hours"
|
||||
|
||||
- task: app:cleanup:log-entries
|
||||
frequency: "30 1 * * *"
|
||||
frequency: "15 1 * * *"
|
||||
description: "Removes outdated log entries with a retention period of 6 months"
|
||||
|
||||
- task: app:draft:cleanup
|
||||
frequency: "30 1 * * *"
|
||||
description: "Removes expired drafts"
|
||||
|
||||
when@staging:
|
||||
zenstruck_schedule:
|
||||
mailer:
|
||||
subject_prefix: "[MyE&P STAGING]"
|
||||
|
||||
when@prod:
|
||||
zenstruck_schedule:
|
||||
mailer:
|
||||
subject_prefix: "[MyE&P PROD]"
|
||||
|
||||
tasks:
|
||||
- task: app:bpn:xml-sync
|
||||
frequency: "*/15 8-19 * * *"
|
||||
description: "Sync BusPro XML data every 15 min. during peak hours"
|
||||
|
||||
- task: app:bpn:xml-sync
|
||||
frequency: "0 20-23,0-7 * * *"
|
||||
description: "Sync BusPro XML data hourly outside peak hours"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
easyadmin:
|
||||
resource: .
|
||||
type: easyadmin.routes
|
||||
@@ -5,8 +5,10 @@
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
travel_info_base_url: '%env(APP_TRAVEL_INFO_BASE_URL)%'
|
||||
terms_and_conditions_url: '%env(APP_TERMS_AND_CONDITIONS_URL)%'
|
||||
path_to_keys: '%kernel.project_dir%/config/secret'
|
||||
bpn_debug: '%env(APP_BPN_DEBUG)%'
|
||||
default_booking_status: '%env(DEFAULT_BOOKING_STATUS)%'
|
||||
|
||||
# Body dimensions choices for BodyDimensionsType
|
||||
body_dimensions.height_choices:
|
||||
@@ -16,7 +18,7 @@ parameters:
|
||||
'167 - 178cm': '167-178'
|
||||
'179 - 185cm': '179-185'
|
||||
'186 - 194cm': '186-194'
|
||||
'195cm oder mehr': '195cm+'
|
||||
'195cm oder mehr': '195+'
|
||||
body_dimensions.shoe_size_min: 36
|
||||
body_dimensions.shoe_size_max: 48
|
||||
body_dimensions.weight_choices:
|
||||
@@ -26,7 +28,26 @@ parameters:
|
||||
'67 - 78kg': '67-78'
|
||||
'79 - 85kg': '79-85'
|
||||
'86 - 94kg': '86-94'
|
||||
'95kg oder mehr': '95k+'
|
||||
'95kg oder mehr': '95+'
|
||||
|
||||
# domain mapping for theme, gtm id and cmp url
|
||||
domain_config:
|
||||
ep-reisen.de:
|
||||
theme: base
|
||||
gtm_id: GTM-56LWRF
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.ep-reisen.de-28eabc35-7085-4d8f-b450-522bd1a5247c/consent.js
|
||||
ski-boarderweek.de:
|
||||
theme: sbw
|
||||
gtm_id: GTM-MS2C8CH
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.ski-boarderweek.de-d3641c34-8183-4b8d-b56e-c052e7a9223e/consent.js
|
||||
semesterende-skireisen.de:
|
||||
theme: ser
|
||||
gtm_id: GTM-MNKDKL9
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.semesterende-skireisen.de-c9b3422d-eb62-4ce4-b8f7-91e9b509e26f/consent.js
|
||||
snowzone.net:
|
||||
theme: snz
|
||||
gtm_id: GTM-M96CKL
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.snowzone.net-8e6aea0c-7d93-42a0-a822-29fff2aa5613/consent.js
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
@@ -39,6 +60,7 @@ services:
|
||||
$xmlDump: '@xml_dump.storage'
|
||||
$jsonExport: '@json_export.storage'
|
||||
$travelInfoBaseUrl: '%env(APP_TRAVEL_INFO_BASE_URL)%'
|
||||
$termsAndConditionsUrl: '%terms_and_conditions_url%'
|
||||
$logger: '@monolog.logger.core'
|
||||
$environment: '%kernel.environment%'
|
||||
|
||||
@@ -59,7 +81,7 @@ services:
|
||||
bpn_username: '%env(APP_BPN_USER)%'
|
||||
bpn_password: '%env(APP_BPN_PASSWORD)%'
|
||||
bpn_api_ip: '%env(APP_BPN_IP)%'
|
||||
bpn_api_port: '%env(APP_BPN_PORT)%'
|
||||
bpn_api_ports: '%env(csv:APP_BPN_PORTS)%'
|
||||
debug: '%env(bool:APP_BPN_DEBUG)%'
|
||||
connection_timeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
|
||||
stream_timeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
|
||||
@@ -83,6 +105,10 @@ services:
|
||||
$preferRemote: '%env(bool:APP_TRAVEL_PREFER_REMOTE)%'
|
||||
$enableFallback: '%env(bool:APP_TRAVEL_ENABLE_FALLBACK)%'
|
||||
|
||||
App\Service\BookingEditDataLoaderService:
|
||||
arguments:
|
||||
$bpnCache: '@bpn.cache'
|
||||
|
||||
App\Service\VoucherValidationService:
|
||||
arguments:
|
||||
$cache: '@cache.app'
|
||||
@@ -111,6 +137,7 @@ services:
|
||||
- 'App\Form\Service\ParticipantTransportationOutboundFieldHandler'
|
||||
- 'App\Form\Service\ParticipantTransportationInboundFieldHandler'
|
||||
- 'App\Form\Service\ParticipantPickupFieldHandler'
|
||||
- 'App\Form\Service\ParticipantDropOffFieldHandler'
|
||||
- 'App\Form\Service\ParticipantParkingFieldHandler'
|
||||
- 'App\Form\Service\ParticipantRentalInsuranceFieldHandler'
|
||||
- 'App\Form\Service\ParticipantLicensePlateFieldHandler'
|
||||
@@ -121,7 +148,25 @@ services:
|
||||
- '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler'
|
||||
- '@App\Form\Service\ParticipantPromoVoucherFieldHandler'
|
||||
|
||||
# Participant Status Rules
|
||||
App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule: ~
|
||||
|
||||
# Participant Status Rule Registry
|
||||
App\BusProNet\Service\ParticipantStatusRuleRegistry:
|
||||
arguments:
|
||||
$rules:
|
||||
- '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule'
|
||||
|
||||
App\Service\CmsDataService:
|
||||
arguments:
|
||||
$httpClient: '@typo3.client'
|
||||
$apiKey: 'AbcAbc123'# dummy key, not yet implemented
|
||||
|
||||
App\Logger\RequestIdProcessor:
|
||||
tags:
|
||||
- { name: monolog.processor }
|
||||
|
||||
App\Service\DomainConfigProvider:
|
||||
arguments:
|
||||
$domainConfig: '%domain_config%'
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
parameters:
|
||||
# domain mapping for theme, gtm id and cmp url
|
||||
domain_config:
|
||||
myep.ddev.site:
|
||||
theme: base
|
||||
gtm_id: GTM-56LWRF
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.ep-reisen.de-28eabc35-7085-4d8f-b450-522bd1a5247c/consent.js
|
||||
myswb.ddev.site:
|
||||
theme: sbw
|
||||
gtm_id: GTM-MS2C8CH
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.ski-boarderweek.de-d3641c34-8183-4b8d-b56e-c052e7a9223e/consent.js
|
||||
myser.ddev.site:
|
||||
theme: ser
|
||||
gtm_id: GTM-MNKDKL9
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.semesterende-skireisen.de-c9b3422d-eb62-4ce4-b8f7-91e9b509e26f/consent.js
|
||||
mysnz.ddev.site:
|
||||
theme: snz
|
||||
gtm_id: GTM-M96CKL
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.snowzone.net-8e6aea0c-7d93-42a0-a822-29fff2aa5613/consent.js
|
||||
@@ -17,7 +17,6 @@ add('shared_dirs', [
|
||||
|
||||
add('shared_files', [
|
||||
'public/.htaccess',
|
||||
'.env.local.php',
|
||||
]);
|
||||
|
||||
set('keep_releases', 3);
|
||||
@@ -33,6 +32,7 @@ $rsyncOptions = [
|
||||
'.ddev',
|
||||
'.git',
|
||||
'.github',
|
||||
'.jj',
|
||||
'node_modules',
|
||||
'.editorconfig',
|
||||
'.env.dev.local',
|
||||
@@ -46,14 +46,17 @@ $rsyncOptions = [
|
||||
'.phpunit.result.cache',
|
||||
'config/secret/',
|
||||
'CLAUDE.md',
|
||||
'cypress*',
|
||||
'deploy.php',
|
||||
'docs',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'*.http',
|
||||
'http-client.private.env.json',
|
||||
'http-client*.json',
|
||||
'postcss.config.js',
|
||||
'phpunit.xml.dist',
|
||||
'public/.htaccess',
|
||||
'ray.php',
|
||||
'tailwind.config.js',
|
||||
'webpack.config.js',
|
||||
'bin/.phpunit',
|
||||
@@ -74,31 +77,31 @@ $rsyncOptions = [
|
||||
];
|
||||
|
||||
host('prod')
|
||||
->setHostname('92.205.212.39')
|
||||
->setRemoteUser('ssh-53857-root')
|
||||
->setHostname('185.243.135.29')
|
||||
->setRemoteUser('p704161')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/kunden/tiefschnee.de/myep')
|
||||
->set('writable_mode', 'chmod')
|
||||
->set('http_user', 'ssh-53857-root')
|
||||
->set('bin/php', '/usr/bin/php82')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{release_or_current_path}}/public/ --web-url=https://my.ep-reisen.de')
|
||||
;
|
||||
|
||||
host('staging')
|
||||
->setHostname('185.243.132.170')
|
||||
->setRemoteUser('p546473')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/home/www/p546473/html/myep')
|
||||
->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('keep_releases', 5)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{release_or_current_path}}/public/ --web-url=https://myep.dreipunktnull.net')
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
|
||||
;
|
||||
|
||||
host('staging')
|
||||
->setHostname('185.243.135.29')
|
||||
->setRemoteUser('p704161')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/home/www/p704161/html/myep-staging')
|
||||
->set('writable_mode', 'chmod')
|
||||
->set('http_user', 'p704161')
|
||||
->set('bin/php', '/usr/local/bin/php')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net')
|
||||
;
|
||||
|
||||
task('deploy', [
|
||||
@@ -110,14 +113,15 @@ task('deploy', [
|
||||
'rsync',
|
||||
'deploy:shared',
|
||||
'deploy:writable',
|
||||
'deploy:cache:clear',
|
||||
'database:migrate',
|
||||
'deploy:publish',
|
||||
'cachetool:clear:opcache',
|
||||
'deploy:publish',
|
||||
'deploy:stop-workers',
|
||||
]);
|
||||
|
||||
task('deploy:assets', function () {
|
||||
runLocally('npm i && npm run build');
|
||||
runLocally('npm ci && npm run build');
|
||||
});
|
||||
|
||||
task('deploy:stop-workers', function () {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Symfony 7.4 Upgrade Plan
|
||||
|
||||
**Project:** MyEP Next Booking
|
||||
**Current Version:** Symfony 6.4.31
|
||||
**Current Version:** Symfony 6.4.x (6.4.33)
|
||||
**Target Version:** Symfony 7.4 (LTS)
|
||||
**Date:** 2026-01-05
|
||||
**Date:** 2026-02-23
|
||||
**Status:** Ready for Upgrade
|
||||
|
||||
---
|
||||
@@ -18,17 +18,16 @@ The MyEP Next Booking application is well-positioned for a Symfony 7.4 upgrade.
|
||||
|
||||
**Key Facts:**
|
||||
- Symfony 7.4 was released on November 27, 2025
|
||||
- Requires PHP 8.2.0+ (current: PHP 8.2.29)
|
||||
- Requires PHP 8.2.0+ (current: PHP 8.3.30)
|
||||
- LTS version with bug fixes until November 2028, security fixes until November 2029
|
||||
- Symfony 8.0 = Symfony 7.4 minus deprecations (same features, PHP 8.4 required)
|
||||
|
||||
**Pre-Upgrade Status:**
|
||||
- PHP version: 8.2.29 (compatible)
|
||||
- PHP version: 8.3.30 (compatible; Symfony 7.4 requires 8.2+)
|
||||
- OAuth2 Server Bundle: 1.1.0 (compatible)
|
||||
- Stimulus Bundle: 2.32.0 (compatible)
|
||||
- KnpMenuBundle: Removed (no longer in use)
|
||||
- Doctrine ORM: 3.6.0 (compatible)
|
||||
- All 312 tests passing
|
||||
- All 433 tests passing
|
||||
- Email validation mode: Configured (html5)
|
||||
- 18 test-only deprecation warnings (cosmetic, do not affect runtime)
|
||||
|
||||
@@ -49,19 +48,14 @@ The previous upgrade plan (dated 2025-12-06) has been updated. All pre-upgrade t
|
||||
| Email validator "loose" mode deprecation | Needs fix | **Completed** (validator.yaml configured) |
|
||||
| Failing test in BookingDataProcessorTest | Unrelated to upgrade | **Resolved** (test now passes) |
|
||||
|
||||
### Items No Longer Relevant
|
||||
| Item | Reason |
|
||||
|------|--------|
|
||||
| KnpMenuBundle upgrade | **Bundle removed from project** |
|
||||
|
||||
---
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### PHP Version Compatibility
|
||||
|
||||
- **Current Runtime:** PHP 8.2.29
|
||||
- **Composer Platform:** PHP 8.2.28
|
||||
- **Current Runtime:** PHP 8.3.30
|
||||
- **Composer Platform:** PHP 8.3.30
|
||||
- **Symfony 7.4 Required:** PHP 8.2.0+
|
||||
- **Status:** Compatible
|
||||
|
||||
@@ -81,6 +75,11 @@ All 33 Symfony packages currently at 6.4.x need version constraint updates to 7.
|
||||
| `symfony/webpack-encore-bundle` | 2.4.0 | Yes |
|
||||
| `symfony/monolog-bundle` | 3.11.0 | Yes |
|
||||
| `zenstruck/schedule-bundle` | 1.9.0 | Yes |
|
||||
| `league/flysystem-bundle` | 3.6.1 | Yes |
|
||||
| `league/flysystem-sftp-v3` | 3.30.0 | Yes |
|
||||
| `nelexa/zip` | 4.0.2 | Yes (verify post-upgrade) |
|
||||
| `spatie/crypto` | 2.1.1 | Yes |
|
||||
| `easycorp/easyadmin-bundle` | 4.29.1 (^4.27) | Yes |
|
||||
|
||||
### Code Quality Assessment
|
||||
|
||||
@@ -124,7 +123,7 @@ All Email constraints in the codebase also explicitly use `mode: 'strict'`:
|
||||
**Impact:** These warnings are cosmetic and do not affect:
|
||||
- Runtime behavior (all Email constraints use explicit `mode: 'strict'`)
|
||||
- Symfony 7.4 compatibility (no code changes needed)
|
||||
- Test results (all 312 tests pass)
|
||||
- Test results (all 433 tests pass)
|
||||
|
||||
**Note:** Warnings can be silenced with `SYMFONY_DEPRECATIONS_HELPER=disabled` if desired.
|
||||
|
||||
@@ -182,12 +181,16 @@ Update all packages from `6.4.*` to `7.4.*`:
|
||||
"symfony/browser-kit": "7.4.*",
|
||||
"symfony/css-selector": "7.4.*",
|
||||
"symfony/debug-bundle": "7.4.*",
|
||||
"symfony/maker-bundle": "^1.0",
|
||||
"symfony/phpunit-bridge": "7.4.*",
|
||||
"symfony/stopwatch": "7.4.*",
|
||||
"symfony/web-profiler-bundle": "7.4.*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep `symfony/maker-bundle` at `^1.0` (compatible with Symfony 7.4).
|
||||
|
||||
### 2. Update Symfony Flex Configuration
|
||||
|
||||
**File:** `composer.json`
|
||||
@@ -252,6 +255,7 @@ Deprecations accumulated in Symfony 7.1-7.4 will need review:
|
||||
1. **Update composer.json**
|
||||
- Update all Symfony package constraints to `7.4.*`
|
||||
- Update `extra.symfony.require` to `"7.4.*"`
|
||||
- Consider tightening `php` to `>=8.2` (Symfony 7.4 requires 8.2+; optional if already on 8.3)
|
||||
|
||||
2. **Run Composer Update**
|
||||
```bash
|
||||
@@ -305,6 +309,7 @@ Deprecations accumulated in Symfony 7.1-7.4 will need review:
|
||||
- [ ] User registration
|
||||
- [ ] Password reset
|
||||
- [ ] Personal data management
|
||||
- [ ] EasyAdmin CRUD / admin panels
|
||||
|
||||
4. **Check Symfony Profiler**
|
||||
- Review deprecation warnings (should be minimal)
|
||||
@@ -320,6 +325,10 @@ Deprecations accumulated in Symfony 7.1-7.4 will need review:
|
||||
- Verify Toastify notifications
|
||||
- Check TailwindCSS compilation
|
||||
|
||||
6. **Verify ZIP File Functionality**
|
||||
- Test any features using `nelexa/zip` (package last updated 2022)
|
||||
- Verify document generation and downloads work correctly
|
||||
|
||||
### Phase 4: Bug Fixes (0-2 hours)
|
||||
|
||||
Address any issues discovered during testing:
|
||||
@@ -386,7 +395,7 @@ ddev exec vendor/bin/dep rollback production
|
||||
## Testing Checklist
|
||||
|
||||
### Unit Tests
|
||||
- [ ] All PHPUnit tests pass (312 tests)
|
||||
- [ ] All PHPUnit tests pass (433 tests)
|
||||
- [ ] No new deprecation warnings
|
||||
- [ ] Code coverage maintained
|
||||
|
||||
@@ -442,7 +451,7 @@ Symfony 8.0 was released alongside 7.4 (November 2025) with identical features b
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] All PHPUnit tests pass (312 tests)
|
||||
- [ ] All PHPUnit tests pass (433 tests)
|
||||
- [ ] No deprecation warnings in Symfony Profiler
|
||||
- [ ] All critical user flows functional
|
||||
- [ ] OAuth2 authentication working
|
||||
@@ -469,7 +478,7 @@ Symfony 8.0 was released alongside 7.4 (November 2025) with identical features b
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 5.0
|
||||
**Previous Version:** 4.0 (dated 2025-12-06)
|
||||
**Last Updated:** 2026-01-05
|
||||
**Status:** Ready for Symfony 7.4 Upgrade
|
||||
**Document Version:** 7.0
|
||||
**Previous Version:** 6.0 (dated 2026-01-09)
|
||||
**Last Updated:** 2026-02-23
|
||||
**Status:** Ready for Symfony 7.4 Upgrade (updated for current project state)
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"Grant Type": "Authorization Code",
|
||||
"Client ID": "{{oauth2_client_id}}",
|
||||
"Client Secret": "{{oauth2_client_secret}}",
|
||||
"Auth URL": "https://myep-next-booking.ddev.site/authorize",
|
||||
"Token URL": "https://myep-next-booking.ddev.site/token",
|
||||
"Auth URL": "https://myep.ddev.site/authorize",
|
||||
"Token URL": "https://myep.ddev.site/token",
|
||||
"Redirect URL": "https://myep-team.ddev.site/auth/check",
|
||||
"Scope": "email"
|
||||
},
|
||||
@@ -17,8 +17,8 @@
|
||||
"Grant Type": "Authorization Code",
|
||||
"Client ID": "{{oauth2_client_id}}",
|
||||
"Client Secret": "{{oauth2_client_secret}}",
|
||||
"Auth URL": "https://myep-next-booking.ddev.site/authorize",
|
||||
"Token URL": "https://myep-next-booking.ddev.site/token",
|
||||
"Auth URL": "https://myep.ddev.site/authorize",
|
||||
"Token URL": "https://myep.ddev.site/token",
|
||||
"Redirect URL": "https://myep-team.ddev.site/auth/check",
|
||||
"Scope": "email profile"
|
||||
},
|
||||
@@ -27,8 +27,8 @@
|
||||
"Grant Type": "Client Credentials",
|
||||
"Client ID": "{{oauth2_api_client_id}}",
|
||||
"Client Secret": "{{oauth2_api_client_secret}}",
|
||||
"Auth URL": "https://myep-next-booking.ddev.site/authorize",
|
||||
"Token URL": "https://myep-next-booking.ddev.site/token",
|
||||
"Auth URL": "https://myep.ddev.site/authorize",
|
||||
"Token URL": "https://myep.ddev.site/token",
|
||||
"Scope": "api"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?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 Version20260106091117 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create booking_edit_draft table for persisting user edits during booking edit flow';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE booking_edit_draft (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, booking_id INT NOT NULL, form_data JSON NOT NULL COMMENT \'(DC2Type:json)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_E6548098A76ED395 (user_id), UNIQUE INDEX user_booking_unique (user_id, booking_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('ALTER TABLE booking_edit_draft ADD CONSTRAINT FK_E6548098A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE booking_edit_draft DROP FOREIGN KEY FK_E6548098A76ED395');
|
||||
$this->addSql('DROP TABLE booking_edit_draft');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?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 Version20260109081315 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 booking_edit_draft ADD travel_date DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\'');
|
||||
$this->addSql('CREATE INDEX IDX_DRAFT_TRAVEL_DATE ON booking_edit_draft (travel_date)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('DROP INDEX IDX_DRAFT_TRAVEL_DATE ON booking_edit_draft');
|
||||
$this->addSql('ALTER TABLE booking_edit_draft DROP travel_date');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260110105253 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add booking_number column to booking_edit_draft table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE booking_edit_draft ADD booking_number INT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE booking_edit_draft DROP booking_number');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260113140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add date_id and hotel_id columns to booking_edit_draft table for export functionality';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE booking_edit_draft ADD date_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE booking_edit_draft ADD hotel_id INT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE booking_edit_draft DROP date_id');
|
||||
$this->addSql('ALTER TABLE booking_edit_draft DROP hotel_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?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 Version20260119174522 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add profile_complete flag to user table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE user ADD profile_complete TINYINT(1) NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE user DROP profile_complete');
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -1 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 124 74" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;"><g><g><path d="M107,0.3c-36.4,5.5 -81.6,19.1 -81.6,38.2c0,17.7 41.8,13.2 41.8,13.2c0,0 -67,22.3 -67,-7c0,-33.2 84.5,-44.4 106.8,-44.4Z" style="fill:#0084d4;fill-rule:nonzero;"/><path d="M17,73.5c36.4,-5.4 81.6,-19.1 81.6,-38.2c0,-17.7 -41.8,-13.2 -41.8,-13.2c0,0 67,-22.3 67,7c0,33.3 -84.5,44.4 -106.8,44.4Z" style="fill:#f8c62f;fill-rule:nonzero;"/><g><path d="M60.7,34.3l-12.8,4l12.2,0.8l-0.4,-2.4l1,-2.4Z" style="fill:#0084d4;fill-rule:nonzero;"/><path d="M75.9,35.2l-9.2,2.9l-3.5,9.7l-1.7,-11.3l4,-10.7l1.7,7.9l8.7,1.5Z" style="fill:#f8c62f;fill-rule:nonzero;"/></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 995 B |
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" },
|
||||
{ "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" }
|
||||
]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'host' => 'host.orb.internal',
|
||||
'port' => 23517,
|
||||
'remote_path' => '/var/www/html',
|
||||
'local_path' => '/Users/fromme/Development/Symfony/myep-next-booking',
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Admin\Field;
|
||||
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\FieldTrait;
|
||||
|
||||
class JsonDataField implements FieldInterface
|
||||
{
|
||||
use FieldTrait;
|
||||
|
||||
public static function new(string $propertyName, ?string $label = null): self
|
||||
{
|
||||
return (new self())
|
||||
->setProperty($propertyName)
|
||||
->setLabel($label)
|
||||
->setTemplatePath('admin/field/json.html.twig');
|
||||
}
|
||||
}
|
||||
@@ -4,34 +4,35 @@ namespace App\BusProNet;
|
||||
|
||||
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\Exception\ImmediateConnectionCloseException;
|
||||
use App\BusProNet\Exception\ResponseParserException;
|
||||
use App\BusProNet\Exception\TimeoutException;
|
||||
use App\BusProNet\Model\BaseData;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\BookingResponse;
|
||||
use App\BusProNet\Model\BookingUpdate;
|
||||
use App\BusProNet\Model\ContactFormResponse;
|
||||
use App\BusProNet\Model\CrmAttributes;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\BusProNet\Model\PromoVoucher;
|
||||
use App\BusProNet\Model\PurchaseVoucher;
|
||||
use App\BusProNet\Model\RegistrationResponse;
|
||||
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\Traits\ApiClientTrait;
|
||||
use App\BusProNet\XmlParser\ApiResponseParser;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\RegistrationDto;
|
||||
use App\Service\RequestIdGenerator;
|
||||
use League\Flysystem\FilesystemException;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Serializer\Encoder\XmlEncoder;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
class ApiClient
|
||||
{
|
||||
use ApiClientTrait;
|
||||
|
||||
public const TYPE_NOTIFICATION = 'HINWEIS';
|
||||
public const TYPE_CUSTOMER_DATA = 'KUNDENKONTO';
|
||||
public const TYPE_BASE_DATA_COUNTRIES = 'STAMMLAENDER';
|
||||
@@ -47,6 +48,9 @@ class ApiClient
|
||||
public const TYPE_PROMO_VOUCHER = 'AKTIONSGUTSCHEIN';
|
||||
|
||||
private array $config;
|
||||
private float $operationStartTime;
|
||||
private int $selectedPort;
|
||||
private int $requestCounter = 0;
|
||||
|
||||
public function __construct(
|
||||
private readonly SerializerInterface $serializer,
|
||||
@@ -54,6 +58,8 @@ class ApiClient
|
||||
private readonly FilesystemOperator $xmlDump,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly BookingDataProcessor $bookingDataProcessor,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly RequestIdGenerator $requestIdGenerator,
|
||||
array $options,
|
||||
) {
|
||||
$this->config = $this->resolveOptions($options);
|
||||
@@ -79,21 +85,14 @@ class ApiClient
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function register(RegistrationDto $registrationData): Notification
|
||||
public function register(RegistrationDto $registrationData): Notification|RegistrationResponse
|
||||
{
|
||||
$data = [
|
||||
'user' => $this->config['bpn_username'],
|
||||
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
|
||||
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
|
||||
'art' => 'Adresse_Neu',
|
||||
'adressdaten' => [
|
||||
'geschlecht' => $registrationData->gender,
|
||||
'vorname' => $registrationData->firstName,
|
||||
'name' => $registrationData->name,
|
||||
'kommunikation' => [
|
||||
'email' => $registrationData->email,
|
||||
],
|
||||
],
|
||||
'adressdaten' => $registrationData->toPayload(),
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
|
||||
@@ -144,13 +143,13 @@ class ApiClient
|
||||
public function createAddress(
|
||||
PersonalData $personalData,
|
||||
bool $debug = false,
|
||||
): ContactFormResponse|Notification {
|
||||
): RegistrationResponse|Notification {
|
||||
$data = [
|
||||
'user' => $this->config['bpn_username'],
|
||||
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
|
||||
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
|
||||
'art' => 'Adresse_Neu',
|
||||
'adressdaten' => $personalData->toPayload(),
|
||||
'adressdaten' => $personalData->toPayload(false),
|
||||
'ohnemailversand' => 'True',
|
||||
];
|
||||
|
||||
@@ -397,6 +396,30 @@ class ApiClient
|
||||
return $this->sendRequest(static::TYPE_PRODUCT_DATA, $data, ['hotelId' => $hotelId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches raw XML travel data from the BusProNet API.
|
||||
*
|
||||
* Returns the unprocessed XML response for direct storage in the XML export directory.
|
||||
* The response format matches the XML export structure from BusPro.
|
||||
*
|
||||
* @param int $travelId The travel product ID to fetch
|
||||
*
|
||||
* @return string The raw XML response
|
||||
*
|
||||
* @throws ApiClientException If the API request fails
|
||||
*/
|
||||
public function getTravelDataXml(int $travelId): string
|
||||
{
|
||||
$data = [
|
||||
'user' => $this->config['bpn_username'],
|
||||
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PRODUCT_DATA),
|
||||
'satz' => ['@typ' => static::TYPE_PRODUCT_DATA],
|
||||
'idprodukt' => $travelId,
|
||||
];
|
||||
|
||||
return $this->sendRequestRaw($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
@@ -489,10 +512,11 @@ class ApiClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends raw XML to the BPN API with key regeneration.
|
||||
* Sends raw XML to the BPN API with key regeneration and automatic retry.
|
||||
*
|
||||
* Parses the XML to extract the request type, regenerates the authentication key
|
||||
* with the current date, and sends the request. Returns the raw XML response.
|
||||
* Automatically retries on immediate connection close (server busy).
|
||||
*
|
||||
* @param string $xml The raw XML request body
|
||||
* @param bool $debug Enable debug mode (XML dumps)
|
||||
@@ -503,7 +527,18 @@ class ApiClient
|
||||
*/
|
||||
public function sendRawXml(string $xml, bool $debug = false): string
|
||||
{
|
||||
$requestId = date(DATE_ATOM).uniqid();
|
||||
return $this->executeWithRetry(fn () => $this->doSendRawXml($xml, $debug));
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the actual raw XML request to the BPN API.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
* @throws ImmediateConnectionCloseException
|
||||
*/
|
||||
private function doSendRawXml(string $xml, bool $debug = false): string
|
||||
{
|
||||
$requestId = $this->getRequestId();
|
||||
|
||||
$doc = new \DOMDocument();
|
||||
if (false === @$doc->loadXML($xml)) {
|
||||
@@ -534,25 +569,19 @@ class ApiClient
|
||||
|
||||
$body = $doc->saveXML();
|
||||
|
||||
$this->logger->info('Sending raw XML request to BPN API', [
|
||||
'requestId' => $requestId,
|
||||
'type' => $type,
|
||||
]);
|
||||
|
||||
if (true === $debug || true === $this->config['debug']) {
|
||||
$this->dumpXmlToFile('request', $requestId, $body);
|
||||
}
|
||||
|
||||
$socket = $this->connect(
|
||||
$this->config['bpn_api_ip'],
|
||||
$this->config['bpn_api_port'],
|
||||
$this->config['max_retries'],
|
||||
$this->config['connection_timeout'],
|
||||
$this->config['stream_timeout'],
|
||||
$this->config['total_timeout']
|
||||
);
|
||||
$this->send($socket, $body, $this->config['total_timeout']);
|
||||
$response = $this->receive($socket, $this->config['total_timeout']);
|
||||
$socket = $this->connect();
|
||||
|
||||
$this->logger->info('Sending raw XML request to BPN API', [
|
||||
'requestId' => $requestId,
|
||||
'type' => $type,
|
||||
'port' => $this->selectedPort,
|
||||
]);
|
||||
$this->send($socket, $body);
|
||||
$response = $this->receive($socket);
|
||||
$this->disconnect($socket);
|
||||
|
||||
$responseXml = substr($response, 10);
|
||||
@@ -569,7 +598,70 @@ class ApiClient
|
||||
*/
|
||||
private function sendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed
|
||||
{
|
||||
$requestId = date(DATE_ATOM).uniqid();
|
||||
return $this->executeWithRetry(fn () => $this->doSendRequest($type, $data, $additionalArgs, $debug), $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
private function sendRequestRaw(array $data): string
|
||||
{
|
||||
return $this->executeWithRetry(fn () => $this->doSendRequestRaw($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an operation with automatic retry on immediate connection close.
|
||||
*
|
||||
* When the BPN server is busy, it may close connections immediately without responding.
|
||||
* This method detects such conditions and automatically retries after a short delay.
|
||||
*
|
||||
* @throws ImmediateConnectionCloseException If all retry attempts fail
|
||||
*/
|
||||
private function executeWithRetry(callable $operation, ?string $type = null): mixed
|
||||
{
|
||||
$maxAttempts = $this->config['busy_retry_attempts'];
|
||||
$retryDelay = $this->config['busy_retry_delay'];
|
||||
$lastException = null;
|
||||
|
||||
for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) {
|
||||
try {
|
||||
return $operation();
|
||||
} catch (ImmediateConnectionCloseException $e) {
|
||||
$lastException = $e;
|
||||
|
||||
if ($attempt < $maxAttempts) {
|
||||
$context = [
|
||||
'attempt' => $attempt,
|
||||
'maxAttempts' => $maxAttempts,
|
||||
'retryDelay' => $retryDelay,
|
||||
];
|
||||
if (null !== $type) {
|
||||
$context['type'] = $type;
|
||||
}
|
||||
$this->logger->warning('BPN server busy, retrying request', $context);
|
||||
sleep($retryDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$context = ['attempts' => $maxAttempts];
|
||||
if (null !== $type) {
|
||||
$context['type'] = $type;
|
||||
}
|
||||
$this->logger->error('BPN server busy after all retry attempts', $context);
|
||||
|
||||
throw $lastException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the actual request to the BPN API.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
* @throws ImmediateConnectionCloseException
|
||||
*/
|
||||
private function doSendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed
|
||||
{
|
||||
$requestId = $this->getRequestId();
|
||||
|
||||
$body = $this
|
||||
->serializer
|
||||
@@ -579,30 +671,33 @@ class ApiClient
|
||||
])
|
||||
;
|
||||
|
||||
$this->logger->info('Sending request to BPN API', [
|
||||
'requestId' => $requestId,
|
||||
'type' => $data['satz']['@typ'],
|
||||
]);
|
||||
|
||||
if (true === $debug || true === $this->config['debug']) {
|
||||
$this->dumpXmlToFile('request', $requestId, $body);
|
||||
}
|
||||
|
||||
$socket = $this->connect(
|
||||
$this->config['bpn_api_ip'],
|
||||
$this->config['bpn_api_port'],
|
||||
$this->config['max_retries'],
|
||||
$this->config['connection_timeout'],
|
||||
$this->config['stream_timeout'],
|
||||
$this->config['total_timeout']
|
||||
);
|
||||
$this->send($socket, $body, $this->config['total_timeout']);
|
||||
$response = $this->receive($socket, $this->config['total_timeout']);
|
||||
$socket = $this->connect();
|
||||
|
||||
$this->logger->info('Sending request to BPN API', [
|
||||
'requestId' => $requestId,
|
||||
'type' => $data['satz']['@typ'],
|
||||
'port' => $this->selectedPort,
|
||||
]);
|
||||
|
||||
$this->send($socket, $body);
|
||||
$response = $this->receive($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']) {
|
||||
$this->dumpXmlToFile('response', $requestId, $xml);
|
||||
}
|
||||
@@ -620,6 +715,44 @@ class ApiClient
|
||||
throw new ApiClientException('Unexpected response received from API');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
* @throws ImmediateConnectionCloseException
|
||||
*/
|
||||
private function doSendRequestRaw(array $data): string
|
||||
{
|
||||
$requestId = $this->getRequestId();
|
||||
|
||||
$body = $this->serializer->serialize($data, 'xml', [
|
||||
XmlEncoder::ROOT_NODE_NAME => 'anfrage',
|
||||
XmlEncoder::ENCODING => 'UTF-8',
|
||||
]);
|
||||
|
||||
if (true === $this->config['debug']) {
|
||||
$this->dumpXmlToFile('request', $requestId, $body);
|
||||
}
|
||||
|
||||
$socket = $this->connect();
|
||||
|
||||
$this->logger->info('Sending raw request to BPN API', [
|
||||
'requestId' => $requestId,
|
||||
'type' => $data['satz']['@typ'],
|
||||
'port' => $this->selectedPort,
|
||||
]);
|
||||
|
||||
$this->send($socket, $body);
|
||||
$response = $this->receive($socket);
|
||||
$this->disconnect($socket);
|
||||
|
||||
$xml = substr($response, 10);
|
||||
|
||||
if (true === $this->config['debug']) {
|
||||
$this->dumpXmlToFile('response', $requestId, $xml);
|
||||
}
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
private function dumpXmlToFile(string $type, string $requestId, string $body): void
|
||||
{
|
||||
try {
|
||||
@@ -628,6 +761,14 @@ class ApiClient
|
||||
}
|
||||
}
|
||||
|
||||
private function getRequestId(): string
|
||||
{
|
||||
$baseId = $this->requestStack->getMainRequest()?->attributes->get('request_id')
|
||||
?? $this->requestIdGenerator->generateBaseId();
|
||||
|
||||
return $baseId.'_'.++$this->requestCounter;
|
||||
}
|
||||
|
||||
private function createKey(string $username, string $password, string $type): string
|
||||
{
|
||||
$date = (new \DateTimeImmutable())->format('Ymd');
|
||||
@@ -635,6 +776,155 @@ class ApiClient
|
||||
return md5($username.$password.$date.$type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
* @throws TimeoutException
|
||||
*/
|
||||
private function connect()
|
||||
{
|
||||
// Randomly select a port from the available ports for load balancing
|
||||
$this->selectedPort = $this->config['bpn_api_ports'][array_rand($this->config['bpn_api_ports'])];
|
||||
$this->operationStartTime = microtime(true);
|
||||
|
||||
$tries = 1;
|
||||
$errNo = $errStr = '';
|
||||
$errorCodesForRetry = [
|
||||
SOCKET_ECONNREFUSED,
|
||||
SOCKET_EBADF,
|
||||
];
|
||||
|
||||
$openSocket = function (&$errNo, &$errStr) {
|
||||
return @fsockopen(
|
||||
$this->config['bpn_api_ip'],
|
||||
$this->selectedPort,
|
||||
$errNo,
|
||||
$errStr,
|
||||
$this->config['connection_timeout']
|
||||
);
|
||||
};
|
||||
|
||||
$socket = $openSocket($errNo, $errStr);
|
||||
|
||||
while (false === $socket && true === in_array($errNo, $errorCodesForRetry) && $this->config['max_retries'] > $tries) {
|
||||
// Check if we've exceeded total timeout during retries
|
||||
if (microtime(true) - $this->operationStartTime > $this->config['total_timeout']) {
|
||||
$this->logger->error('Connection retry timeout exceeded', [
|
||||
'elapsed_time' => microtime(true) - $this->operationStartTime,
|
||||
'total_timeout' => $this->config['total_timeout'],
|
||||
]);
|
||||
throw new TimeoutException('Connection timeout exceeded during retries');
|
||||
}
|
||||
|
||||
$this->logger->warning('Could not connect to socket, retrying', [
|
||||
'error_message' => $errStr,
|
||||
'error_number' => $errNo,
|
||||
'attempt' => $tries,
|
||||
]);
|
||||
++$tries;
|
||||
sleep(1);
|
||||
$socket = $openSocket($errNo, $errStr);
|
||||
}
|
||||
|
||||
if (false !== $socket) {
|
||||
stream_set_timeout($socket, $this->config['stream_timeout']);
|
||||
} else {
|
||||
$this->logger->error('Unable to open socket', [
|
||||
'error_message' => $errStr,
|
||||
'error_number' => $errNo,
|
||||
'elapsed_time' => microtime(true) - $this->operationStartTime,
|
||||
]);
|
||||
throw new ApiClientException('Unable to open socket');
|
||||
}
|
||||
|
||||
return $socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TimeoutException
|
||||
*/
|
||||
private function send($socket, string $data): void
|
||||
{
|
||||
// Check total timeout before sending
|
||||
if (microtime(true) - $this->operationStartTime > $this->config['total_timeout']) {
|
||||
$this->logger->error('Total timeout exceeded before send', [
|
||||
'elapsed_time' => microtime(true) - $this->operationStartTime,
|
||||
]);
|
||||
throw new TimeoutException('Total operation timeout exceeded before send');
|
||||
}
|
||||
|
||||
// message length is prepended to actual message
|
||||
$send = sprintf('%010s', strlen($data)).$data;
|
||||
fwrite($socket, $send);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TimeoutException
|
||||
* @throws ImmediateConnectionCloseException
|
||||
*/
|
||||
private function receive($socket): string
|
||||
{
|
||||
$response = '';
|
||||
$readAttempts = 0;
|
||||
$totalTimeout = $this->config['total_timeout'];
|
||||
|
||||
while (false === feof($socket)) {
|
||||
// Check total timeout before each read
|
||||
$elapsedTime = microtime(true) - $this->operationStartTime;
|
||||
if ($elapsedTime > $totalTimeout) {
|
||||
$this->logger->error('Total timeout exceeded during receive', [
|
||||
'elapsed_time' => $elapsedTime,
|
||||
'total_timeout' => $totalTimeout,
|
||||
'bytes_received' => strlen($response),
|
||||
'read_attempts' => $readAttempts,
|
||||
]);
|
||||
throw new TimeoutException('Total operation timeout exceeded while receiving data');
|
||||
}
|
||||
|
||||
$chunk = fread($socket, 4096);
|
||||
++$readAttempts;
|
||||
|
||||
// Check if stream timed out on this specific read
|
||||
$metadata = stream_get_meta_data($socket);
|
||||
if (true === $metadata['timed_out']) {
|
||||
// Check if this is an immediate rejection (0 bytes, < 1 second)
|
||||
// This indicates the server is busy rather than a true timeout
|
||||
if (0 === strlen($response) && $elapsedTime < 1.0) {
|
||||
$this->logger->warning('Server closed connection immediately', [
|
||||
'elapsed_time' => $elapsedTime,
|
||||
'bytes_received' => 0,
|
||||
'read_attempts' => $readAttempts,
|
||||
]);
|
||||
throw new ImmediateConnectionCloseException('Server closed connection immediately - server may be busy');
|
||||
}
|
||||
|
||||
$this->logger->error('Stream read timeout detected', [
|
||||
'elapsed_time' => $elapsedTime,
|
||||
'bytes_received' => strlen($response),
|
||||
'read_attempts' => $readAttempts,
|
||||
]);
|
||||
throw new TimeoutException('Stream timeout while reading from socket');
|
||||
}
|
||||
|
||||
$response .= $chunk;
|
||||
}
|
||||
|
||||
if (0 === strlen($response)) {
|
||||
$elapsedTime = microtime(true) - $this->operationStartTime;
|
||||
$this->logger->warning('Server closed connection without sending data', [
|
||||
'elapsed_time' => $elapsedTime,
|
||||
'read_attempts' => $readAttempts,
|
||||
]);
|
||||
throw new ImmediateConnectionCloseException('Server closed connection without sending data');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function disconnect($socket): void
|
||||
{
|
||||
@fclose($socket);
|
||||
}
|
||||
|
||||
private function resolveOptions(array $options): array
|
||||
{
|
||||
$optionsResolver = new OptionsResolver();
|
||||
@@ -642,14 +932,17 @@ class ApiClient
|
||||
'bpn_username',
|
||||
'bpn_password',
|
||||
'bpn_api_ip',
|
||||
'bpn_api_port',
|
||||
'bpn_api_ports',
|
||||
]);
|
||||
$optionsResolver->setAllowedTypes('bpn_api_ports', ['array']);
|
||||
$optionsResolver->setDefaults([
|
||||
'max_retries' => 25,
|
||||
'debug' => false,
|
||||
'connection_timeout' => 5,
|
||||
'stream_timeout' => 30,
|
||||
'total_timeout' => 45,
|
||||
'busy_retry_attempts' => 3,
|
||||
'busy_retry_delay' => 1,
|
||||
]);
|
||||
|
||||
return $optionsResolver->resolve($options);
|
||||
|
||||
@@ -14,6 +14,7 @@ final class Constants
|
||||
public const TOKEN_SKI_PASS = 'SPA';
|
||||
public const TOKEN_ADDITIONAL = 'SON';
|
||||
public const TOKEN_BOARD = 'VPF';
|
||||
public const TOKEN_VEG = 'VEG';
|
||||
public const TOKEN_RENTALS = ['VER', 'VE2', 'VE3', 'VE4', 'VE5', 'VE6', 'VE7', 'VE8'];
|
||||
public const TOKEN_RENTAL_INSURANCE = 'LVS';
|
||||
public const TOKEN_INSURANCES = ['RRV', 'PAK', 'OHN', 'PKG'];
|
||||
@@ -37,6 +38,7 @@ final class Constants
|
||||
self::TOKEN_SKI_PASS => 'Skipässe',
|
||||
self::TOKEN_ADDITIONAL => 'Zusatzleistungen',
|
||||
self::TOKEN_BOARD => 'Verpflegung',
|
||||
self::TOKEN_VEG => 'Verpflegungswunsch',
|
||||
self::TOKEN_RENTAL_INSURANCE => 'Leihmaterial-Versicherung',
|
||||
self::TOKEN_PARKING => 'Parkplatz',
|
||||
self::GROUP_TRANSPORTATION => 'Beförderung',
|
||||
@@ -55,7 +57,7 @@ final class Constants
|
||||
// Booking status codes from buchungstatusmoeglich attribute
|
||||
public const BOOKING_STATUS_FREE = 'F';
|
||||
public const BOOKING_STATUS_INQUIRY = 'A';
|
||||
public const BOOKING_STATUS_OPEN = 'O'; // Optionsbuchung - not relevant for current implementation
|
||||
public const BOOKING_STATUS_OPEN = 'O'; // Optionsbuchung
|
||||
|
||||
// Payment methods
|
||||
public const PAYMENT_METHOD_TRANSFER = 'transfer';
|
||||
@@ -68,4 +70,7 @@ final class Constants
|
||||
// Baby room constraints
|
||||
public const BABY_MAX_AGE = 2;
|
||||
public const BABY_ROOM_CODE = 'Baby';
|
||||
|
||||
// Pseudo price room - used for website display only, not bookable
|
||||
public const PSEUDO_PRICE_ROOM_CODE = 'PDGS';
|
||||
}
|
||||
|
||||
@@ -124,9 +124,19 @@ class BookingDataProcessor
|
||||
// Pickup location
|
||||
$participantData->pickup = $booking->getPickupForParticipant($index);
|
||||
|
||||
// Parking (stored in form data, not in booking entity - needs special handling)
|
||||
// For now, leave as false - may need to extract from transportation services
|
||||
$participantData->parking = false;
|
||||
// Drop-off location
|
||||
$participantData->dropOff = $booking->getDropOffForParticipant($index);
|
||||
$participantData->differentDropOff = null !== $participantData->dropOff;
|
||||
|
||||
// Parking service (for self-organized PKW transportation)
|
||||
$parkingServices = $booking->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_PARKING);
|
||||
if (false === empty($parkingServices)) {
|
||||
$participantData->parking = true;
|
||||
$participantData->parkingService = reset($parkingServices);
|
||||
} else {
|
||||
$participantData->parking = false;
|
||||
$participantData->parkingService = null;
|
||||
}
|
||||
|
||||
// License plate (not stored in booking entity)
|
||||
$participantData->licensePlate = null;
|
||||
@@ -156,6 +166,8 @@ class BookingDataProcessor
|
||||
$roomSelection->label = $room->label;
|
||||
$roomSelection->price = $room->price;
|
||||
$roomSelection->quantity = $bookingRoom->totalCount; // Actual quantity from booking XML (anzahl)
|
||||
$roomSelection->capacity = $room->minPax ?? 1;
|
||||
$roomSelection->maxQuantity = $bookingRoom->totalCount + ($room->available ?? 0);
|
||||
$dto->roomSelections[] = $roomSelection;
|
||||
}
|
||||
}
|
||||
@@ -213,6 +225,11 @@ class BookingDataProcessor
|
||||
$participant->rentalInsurance = $travel->additionalServices[$participant->rentalInsurance->id];
|
||||
}
|
||||
|
||||
// Enrich parking service
|
||||
if (null !== $participant->parkingService && isset($travel->additionalServices[$participant->parkingService->id])) {
|
||||
$participant->parkingService = $travel->additionalServices[$participant->parkingService->id];
|
||||
}
|
||||
|
||||
// Enrich transportation services
|
||||
if (null !== $participant->transportationOutbound && isset($travel->transportationServices[$participant->transportationOutbound->id])) {
|
||||
$participant->transportationOutbound = $travel->transportationServices[$participant->transportationOutbound->id];
|
||||
@@ -222,9 +239,33 @@ class BookingDataProcessor
|
||||
$participant->transportationInbound = $travel->transportationServices[$participant->transportationInbound->id];
|
||||
}
|
||||
|
||||
// Enrich pickup
|
||||
if (null !== $participant->pickup && isset($travel->pickupsOutbound[$participant->pickup->id])) {
|
||||
$participant->pickup = $travel->pickupsOutbound[$participant->pickup->id];
|
||||
// Enrich pickup with data from travel (includes both outbound and inbound prices)
|
||||
// The pickups already have inbound prices merged in from TravelParser
|
||||
if (null !== $participant->pickup && isset($travel->pickups[$participant->pickup->id])) {
|
||||
$enrichedPickup = $travel->pickups[$participant->pickup->id];
|
||||
|
||||
// Copy all properties from enriched pickup to participant's pickup
|
||||
$participant->pickup->price = $enrichedPickup->price;
|
||||
$participant->pickup->priceOutbound = $enrichedPickup->priceOutbound;
|
||||
$participant->pickup->priceInbound = $enrichedPickup->priceInbound;
|
||||
$participant->pickup->time = $enrichedPickup->time;
|
||||
$participant->pickup->city = $enrichedPickup->city;
|
||||
$participant->pickup->street = $enrichedPickup->street;
|
||||
$participant->pickup->postalCode = $enrichedPickup->postalCode;
|
||||
$participant->pickup->code = $enrichedPickup->code;
|
||||
}
|
||||
|
||||
// Enrich drop-off with data from travel
|
||||
if (null !== $participant->dropOff && isset($travel->dropOffs[$participant->dropOff->id])) {
|
||||
$enrichedDropOff = $travel->dropOffs[$participant->dropOff->id];
|
||||
$participant->dropOff->price = $enrichedDropOff->price;
|
||||
$participant->dropOff->priceOutbound = $enrichedDropOff->priceOutbound;
|
||||
$participant->dropOff->priceInbound = $enrichedDropOff->priceInbound;
|
||||
$participant->dropOff->time = $enrichedDropOff->time;
|
||||
$participant->dropOff->city = $enrichedDropOff->city;
|
||||
$participant->dropOff->street = $enrichedDropOff->street;
|
||||
$participant->dropOff->postalCode = $enrichedDropOff->postalCode;
|
||||
$participant->dropOff->code = $enrichedDropOff->code;
|
||||
}
|
||||
|
||||
// Enrich insurance
|
||||
@@ -266,13 +307,18 @@ class BookingDataProcessor
|
||||
}
|
||||
|
||||
$this->serviceProcessor->removeUnusedServices($bookingData);
|
||||
$this->personalDataSynchronizer->updateParticipantPersonalData($formData->participants, $bookingData);
|
||||
$this->personalDataSynchronizer->updateParticipantPersonalData(
|
||||
$formData->participants,
|
||||
$bookingData,
|
||||
$formData->isInternalAgencyBooking()
|
||||
);
|
||||
|
||||
$payload = $this->payloadBuilder->buildBasePayload($bookingData);
|
||||
$this->payloadBuilder->addBankAccountToPayload($payload, $bookingData);
|
||||
$this->payloadBuilder->buildParticipantPayload($payload, $bookingData, $formData->participants);
|
||||
$this->payloadBuilder->buildServicesPayload($payload, $bookingData);
|
||||
$this->payloadBuilder->buildPickupPayload($payload, $bookingData);
|
||||
$this->payloadBuilder->buildDropOffPayload($payload, $bookingData);
|
||||
|
||||
// Add purchase vouchers if any exist
|
||||
$purchaseVouchers = $this->mappingCollector->collectPurchaseVouchers($formData);
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\BusProNet\DataProcessor;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Service\ParticipantStatusRuleRegistry;
|
||||
use App\Form\Model\BookingDto;
|
||||
|
||||
/**
|
||||
@@ -18,6 +19,7 @@ class BookingPayloadBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ServiceMappingCollector $mappingCollector,
|
||||
private readonly ParticipantStatusRuleRegistry $statusRuleRegistry,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -145,22 +147,25 @@ class BookingPayloadBuilder
|
||||
public function buildServicesPayload(array &$payload, Booking $bookingData): void
|
||||
{
|
||||
foreach ($bookingData->additionalServices as $service) {
|
||||
$uniqueMapping = array_unique($service->mapping);
|
||||
$payload['zusatzleistungen']['zusatzleistung'][] = [
|
||||
'@idleistung' => $service->id,
|
||||
'@anzahl' => count($service->mapping),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $service->mapping)),
|
||||
'@anzahl' => count($uniqueMapping),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($bookingData->transportationServices as $service) {
|
||||
$uniqueMapping = array_unique($service->mapping);
|
||||
$payload['beförderungen']['beförderung'][] = [
|
||||
'@idleistung' => $service->id,
|
||||
'@anzahl' => count($service->mapping),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $service->mapping)),
|
||||
'@anzahl' => count($uniqueMapping),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($bookingData->rooms as $room) {
|
||||
$uniqueMapping = array_unique($room->mapping);
|
||||
$payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [
|
||||
'@idzimmer' => $room->id,
|
||||
'@kategorie' => $room->category,
|
||||
@@ -168,7 +173,7 @@ class BookingPayloadBuilder
|
||||
'@anreise' => $room->dateFrom ? $room->dateFrom->format('d.m.Y') : null,
|
||||
'@abreise' => $room->dateTo ? $room->dateTo->format('d.m.Y') : null,
|
||||
'@anzahl' => $room->totalCount,
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $room->mapping)),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -176,11 +181,12 @@ class BookingPayloadBuilder
|
||||
if (false === empty($bookingData->insurances)) {
|
||||
$payload['versicherungen']['versicherung'] = [];
|
||||
foreach ($bookingData->insurances as $insurance) {
|
||||
if (count($insurance->mapping) > 0) {
|
||||
$uniqueMapping = array_unique($insurance->mapping);
|
||||
if (count($uniqueMapping) > 0) {
|
||||
$payload['versicherungen']['versicherung'][] = [
|
||||
'@idversicherung' => $insurance->id,
|
||||
'@anzahl' => count($insurance->mapping),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $insurance->mapping)),
|
||||
'@anzahl' => count($uniqueMapping),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -197,13 +203,39 @@ class BookingPayloadBuilder
|
||||
*/
|
||||
public function buildPickupPayload(array &$payload, Booking $bookingData): void
|
||||
{
|
||||
if (0 < count($bookingData->pickupsOutbound)) {
|
||||
$payload['zustiege'] = [];
|
||||
|
||||
if (0 < count($bookingData->pickups)) {
|
||||
$payload['zustiege']['zustieg'] = [];
|
||||
foreach ($bookingData->pickupsOutbound as $pickup) {
|
||||
foreach ($bookingData->pickups as $pickup) {
|
||||
$uniqueMapping = array_unique($pickup->mapping);
|
||||
$payload['zustiege']['zustieg'][] = [
|
||||
'@idzustieg' => $pickup->id,
|
||||
'@anzahl' => count($pickup->mapping),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $pickup->mapping)),
|
||||
'@anzahl' => count($uniqueMapping),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the drop-off locations section of the payload.
|
||||
*
|
||||
* Only included in payload if there are actual drop-off assignments for bus transportation.
|
||||
*
|
||||
* @param array $payload The payload array to modify
|
||||
* @param Booking $bookingData The booking data object
|
||||
*/
|
||||
public function buildDropOffPayload(array &$payload, Booking $bookingData): void
|
||||
{
|
||||
if (0 < count($bookingData->dropOffs)) {
|
||||
$payload['ausstiege']['ausstieg'] = [];
|
||||
foreach ($bookingData->dropOffs as $dropOff) {
|
||||
$uniqueMapping = array_unique($dropOff->mapping);
|
||||
$payload['ausstiege']['ausstieg'][] = [
|
||||
'@idzustieg' => $dropOff->id,
|
||||
'@anzahl' => count($uniqueMapping),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -270,6 +302,7 @@ class BookingPayloadBuilder
|
||||
foreach ($bookingDto->participants as $index => $participant) {
|
||||
$participantData = [
|
||||
'@id' => $index + 1,
|
||||
'status' => $this->statusRuleRegistry->evaluateStatus($participant),
|
||||
'name' => $participant->lastName,
|
||||
'vorname' => $participant->firstName,
|
||||
'geschlecht' => $participant->gender ?? '',
|
||||
@@ -359,6 +392,13 @@ class BookingPayloadBuilder
|
||||
$this->addRoomMappingsToPayload($payload, $roomMap, $bookingDto);
|
||||
$this->addServicesFromMap($payload, 'zusatzleistungen', 'zusatzleistung', '@idleistung', $serviceMap);
|
||||
$this->addServicesFromMap($payload, 'zustiege', 'zustieg', '@idzustieg', $pickupMap);
|
||||
if (false === array_key_exists('zustiege', $payload)) {
|
||||
$payload['zustiege'] = [];
|
||||
}
|
||||
|
||||
$dropOffMap = $this->mappingCollector->collectDropOffMappings($bookingDto);
|
||||
$this->addServicesFromMap($payload, 'ausstiege', 'ausstieg', '@idzustieg', $dropOffMap);
|
||||
|
||||
$this->addServicesFromMap($payload, 'versicherungen', 'versicherung', '@idversicherung', $insuranceMap);
|
||||
|
||||
// Add purchase vouchers (regular purchase vouchers, excluding goodwill vouchers)
|
||||
@@ -415,10 +455,11 @@ class BookingPayloadBuilder
|
||||
if (false === empty($serviceMap)) {
|
||||
$payload[$sectionKey][$itemKey] = [];
|
||||
foreach ($serviceMap as $serviceId => $participantIds) {
|
||||
$uniqueParticipantIds = array_unique($participantIds);
|
||||
$payload[$sectionKey][$itemKey][] = [
|
||||
$idAttributeName => $serviceId,
|
||||
'@anzahl' => count($participantIds),
|
||||
'@zuordnung' => implode(',', $participantIds),
|
||||
'@anzahl' => count($uniqueParticipantIds),
|
||||
'@zuordnung' => implode(',', $uniqueParticipantIds),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -462,6 +503,7 @@ class BookingPayloadBuilder
|
||||
}
|
||||
|
||||
$quantity = $roomQuantities[$roomId] ?? 1;
|
||||
$uniqueParticipantIds = array_unique($participantIds);
|
||||
|
||||
$payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [
|
||||
'@idzimmer' => $room->id,
|
||||
@@ -470,7 +512,7 @@ class BookingPayloadBuilder
|
||||
'@anreise' => $bookingDto->travel->dateFrom->format('d.m.Y'),
|
||||
'@abreise' => $bookingDto->travel->dateTo->format('d.m.Y'),
|
||||
'@anzahl' => $quantity,
|
||||
'@zuordnung' => implode(',', $participantIds),
|
||||
'@zuordnung' => implode(',', $uniqueParticipantIds),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\BusProNet\DataProcessor;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Processes participant service selections for booking updates.
|
||||
@@ -17,6 +18,11 @@ use App\Form\Model\ParticipantDto;
|
||||
*/
|
||||
class ParticipantServiceProcessor
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all existing participant-to-service mappings to start with a clean slate.
|
||||
*
|
||||
@@ -30,8 +36,8 @@ class ParticipantServiceProcessor
|
||||
$servicesToReset = [
|
||||
...$bookingData->additionalServices,
|
||||
...$bookingData->transportationServices,
|
||||
...$bookingData->pickupsOutbound,
|
||||
...$bookingData->pickupsInbound,
|
||||
...$bookingData->pickups,
|
||||
...$bookingData->dropOffs,
|
||||
...$bookingData->rooms,
|
||||
...$bookingData->insurances,
|
||||
];
|
||||
@@ -60,6 +66,7 @@ class ParticipantServiceProcessor
|
||||
$this->processAdditionalServices($participant, $bookingData, $travelData);
|
||||
$this->processTransportationServices($participant, $bookingData, $travelData);
|
||||
$this->processPickupLocations($participant, $bookingData);
|
||||
$this->processDropOffLocations($participant, $bookingData);
|
||||
$this->processRoomAssignment($participant, $bookingData);
|
||||
$this->processInsurance($participant, $bookingData, $travelData);
|
||||
}
|
||||
@@ -86,15 +93,15 @@ class ParticipantServiceProcessor
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($bookingData->pickupsOutbound as $pickup) {
|
||||
foreach ($bookingData->pickups as $pickup) {
|
||||
if (0 === count($pickup->mapping)) {
|
||||
unset($bookingData->pickupsOutbound[$pickup->id]);
|
||||
unset($bookingData->pickups[$pickup->id]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($bookingData->pickupsInbound as $pickup) {
|
||||
if (0 === count($pickup->mapping)) {
|
||||
unset($bookingData->pickupsInbound[$pickup->id]);
|
||||
foreach ($bookingData->dropOffs as $dropOff) {
|
||||
if (0 === count($dropOff->mapping)) {
|
||||
unset($bookingData->dropOffs[$dropOff->id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +116,7 @@ class ParticipantServiceProcessor
|
||||
* Collects additional services from a participant for mapping.
|
||||
*
|
||||
* Extracts all additional services (courses, board, rentals, ski pass, rental insurance,
|
||||
* additional services) from a participant into a flat array of Service objects.
|
||||
* parking, additional services) from a participant into a flat array of Service objects.
|
||||
*
|
||||
* @param ParticipantDto $participant The participant data
|
||||
*
|
||||
@@ -134,6 +141,11 @@ class ParticipantServiceProcessor
|
||||
$services[] = $participant->rentalInsurance;
|
||||
}
|
||||
|
||||
// Add parking service if selected (for self-organized PKW transportation)
|
||||
if (true === $participant->parking && null !== $participant->parkingService) {
|
||||
$services[] = $participant->parkingService;
|
||||
}
|
||||
|
||||
return $services;
|
||||
}
|
||||
|
||||
@@ -154,11 +166,18 @@ class ParticipantServiceProcessor
|
||||
foreach ($servicesToMap as $service) {
|
||||
if (false === isset($bookingData->additionalServices[$service->id])) {
|
||||
$serviceToAdd = $travelData->additionalServices[$service->id] ?? null;
|
||||
if (null !== $serviceToAdd) {
|
||||
$bookingData->additionalServices[$service->id] = $serviceToAdd;
|
||||
$bookingData->additionalServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
|
||||
if (null === $serviceToAdd) {
|
||||
$this->logger->warning('Additional service not found in travel data, skipping mapping', [
|
||||
'bookingId' => $bookingData->id,
|
||||
'serviceId' => $service->id,
|
||||
'participantIndex' => $participant->index,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
$bookingData->additionalServices[$service->id] = $serviceToAdd;
|
||||
$bookingData->additionalServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
|
||||
}
|
||||
|
||||
$bookingData->additionalServices[$service->id]->mapping[] = $participant->index;
|
||||
}
|
||||
}
|
||||
@@ -176,13 +195,24 @@ class ParticipantServiceProcessor
|
||||
private function processTransportationServices(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void
|
||||
{
|
||||
foreach ([$participant->transportationOutbound, $participant->transportationInbound] as $service) {
|
||||
if (null === $service) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (false === isset($bookingData->transportationServices[$service->id])) {
|
||||
$serviceToAdd = $travelData->transportationServices[$service->id] ?? null;
|
||||
if (null !== $serviceToAdd) {
|
||||
$bookingData->transportationServices[$service->id] = $serviceToAdd;
|
||||
$bookingData->transportationServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
|
||||
if (null === $serviceToAdd) {
|
||||
$this->logger->warning('Transportation service not found in travel data, skipping mapping', [
|
||||
'bookingId' => $bookingData->id,
|
||||
'serviceId' => $service->id,
|
||||
'participantIndex' => $participant->index,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
$bookingData->transportationServices[$service->id] = $serviceToAdd;
|
||||
$bookingData->transportationServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
|
||||
}
|
||||
|
||||
$bookingData->transportationServices[$service->id]->mapping[] = $participant->index;
|
||||
}
|
||||
}
|
||||
@@ -203,10 +233,34 @@ class ParticipantServiceProcessor
|
||||
$hasInboundBus = null !== $participant->transportationInbound && 'BUS' === $participant->transportationInbound->subType;
|
||||
|
||||
if (($hasOutboundBus || $hasInboundBus) && null !== $selectedPickup = $participant->pickup) {
|
||||
if (false === isset($bookingData->pickupsOutbound[$selectedPickup->id])) {
|
||||
$bookingData->pickupsOutbound[$selectedPickup->id] = $selectedPickup;
|
||||
if (false === isset($bookingData->pickups[$selectedPickup->id])) {
|
||||
$bookingData->pickups[$selectedPickup->id] = $selectedPickup;
|
||||
}
|
||||
$bookingData->pickupsOutbound[$selectedPickup->id]->mapping[] = $participant->index;
|
||||
$bookingData->pickups[$selectedPickup->id]->mapping[] = $participant->index;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes drop-off locations for participants using bus transportation.
|
||||
*
|
||||
* Only processes drop-off locations for bus transportation services and maps the participant
|
||||
* to their selected drop-off location.
|
||||
*
|
||||
* @param ParticipantDto $participant The participant data from the form
|
||||
* @param Booking $bookingData The booking data object to update
|
||||
*/
|
||||
private function processDropOffLocations(ParticipantDto $participant, Booking $bookingData): void
|
||||
{
|
||||
$hasOutboundBus = null !== $participant->transportationOutbound
|
||||
&& 'BUS' === $participant->transportationOutbound->subType;
|
||||
$hasInboundBus = null !== $participant->transportationInbound
|
||||
&& 'BUS' === $participant->transportationInbound->subType;
|
||||
|
||||
if (($hasOutboundBus || $hasInboundBus) && null !== $selectedDropOff = $participant->dropOff) {
|
||||
if (false === isset($bookingData->dropOffs[$selectedDropOff->id])) {
|
||||
$bookingData->dropOffs[$selectedDropOff->id] = $selectedDropOff;
|
||||
}
|
||||
$bookingData->dropOffs[$selectedDropOff->id]->mapping[] = $participant->index;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\BusProNet\DataProcessor;
|
||||
use App\BusProNet\Model\Address;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\Communication;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
|
||||
/**
|
||||
@@ -20,28 +21,53 @@ class PersonalDataSynchronizer
|
||||
/**
|
||||
* Updates participant personal data from form input.
|
||||
*
|
||||
* Processes all active participants (status 'F' or 'A'). Skips canceled participants (status 'S').
|
||||
* Processes all participants regardless of status, including canceled participants (status 'S').
|
||||
* Updates all personal data fields and communication information.
|
||||
*
|
||||
* Also fills missing mandatory fields on the applicant object for company bookings
|
||||
* created by travel agencies. The applicant is separate from participant 0 and may
|
||||
* contain only company data without personal fields required by the BPN API.
|
||||
*
|
||||
* IMPORTANT: The applicant's address must never be modified. This method updates
|
||||
* participant addresses independently to ensure applicant data remains intact.
|
||||
*
|
||||
* @param array<ParticipantDto> $participants The participants array from the form
|
||||
* @param Booking $bookingData The booking data object to update
|
||||
* @param array<ParticipantDto> $participants The participants array from the form
|
||||
* @param Booking $bookingData The booking data object to update
|
||||
* @param bool $isInternalAgencyBooking Whether to fill default names for participants with empty names
|
||||
*/
|
||||
public function updateParticipantPersonalData(array $participants, Booking $bookingData): void
|
||||
{
|
||||
foreach ($participants as $participant) {
|
||||
// Skip canceled participants (status 'S')
|
||||
if ('S' === $participant->status) {
|
||||
continue;
|
||||
}
|
||||
public function updateParticipantPersonalData(
|
||||
array $participants,
|
||||
Booking $bookingData,
|
||||
bool $isInternalAgencyBooking = false,
|
||||
): void {
|
||||
// Fill missing mandatory fields on applicant (for company bookings by travel agencies)
|
||||
// This is separate from participants - applicant may be a company while participants are real people
|
||||
$this->fillMissingApplicantFields($bookingData);
|
||||
|
||||
foreach ($participants as $participant) {
|
||||
$bookingData->participants[$participant->index]->firstName = $participant->firstName;
|
||||
$bookingData->participants[$participant->index]->name = $participant->lastName;
|
||||
|
||||
// For internal agency bookings, participants with empty names get default values.
|
||||
// Agencies pre-book slots with placeholder data (e.g. applicant surname, empty first name),
|
||||
// and the BPN API rejects updates with empty name fields.
|
||||
if ($isInternalAgencyBooking) {
|
||||
$defaultName = sprintf('Teilnehmer:in %d', $participant->index + 1);
|
||||
$personalData = $bookingData->participants[$participant->index];
|
||||
|
||||
if (null === $personalData->firstName || '' === $personalData->firstName) {
|
||||
$personalData->firstName = $defaultName;
|
||||
}
|
||||
if (null === $personalData->name || '' === $personalData->name) {
|
||||
$personalData->name = $defaultName;
|
||||
}
|
||||
}
|
||||
|
||||
$bookingData->participants[$participant->index]->dateOfBirth = $participant->dateOfBirth;
|
||||
$bookingData->participants[$participant->index]->gender = $participant->gender;
|
||||
$bookingData->participants[$participant->index]->nationality = $participant->nationality;
|
||||
|
||||
$this->fillMissingParticipantFields($bookingData->participants[$participant->index]);
|
||||
$bookingData->participants[$participant->index]->height = $participant->height;
|
||||
$bookingData->participants[$participant->index]->weight = $participant->weight;
|
||||
$bookingData->participants[$participant->index]->shoeSize = $participant->shoeSize;
|
||||
@@ -68,4 +94,60 @@ class PersonalDataSynchronizer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills missing mandatory fields on the applicant for company bookings.
|
||||
*
|
||||
* Travel agencies often create bookings with company data as the applicant,
|
||||
* which lacks personal data fields. These fields are mandatory for the BPN
|
||||
* API, so we fill them with sensible defaults. The applicant is separate from
|
||||
* participants - participant 0 is a real person and must not be modified here.
|
||||
*/
|
||||
private function fillMissingApplicantFields(Booking $bookingData): void
|
||||
{
|
||||
if (null === $bookingData->applicant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$applicant = $bookingData->applicant;
|
||||
|
||||
if (null === $applicant->firstName || '' === $applicant->firstName) {
|
||||
$applicant->firstName = 'Anmelder';
|
||||
}
|
||||
|
||||
if (null === $applicant->gender || '' === $applicant->gender) {
|
||||
$applicant->gender = 'D';
|
||||
}
|
||||
|
||||
if (null === $applicant->nationality || '' === $applicant->nationality) {
|
||||
$applicant->nationality = 'D';
|
||||
}
|
||||
|
||||
if (null === $applicant->dateOfBirth) {
|
||||
$applicant->dateOfBirth = new \DateTimeImmutable('-20 years');
|
||||
}
|
||||
|
||||
if (null === $applicant->communication) {
|
||||
$applicant->communication = new Communication();
|
||||
}
|
||||
|
||||
if (null === $applicant->communication->mobile || '' === $applicant->communication->mobile) {
|
||||
$applicant->communication->mobile = '12345';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills missing mandatory fields on a participant.
|
||||
*
|
||||
* Ensures mandatory fields have sensible defaults before submission to the
|
||||
* BPN API. Currently handles nationality which defaults to 'D' (German).
|
||||
*
|
||||
* @param PersonalData $participant The participant to fill defaults for
|
||||
*/
|
||||
private function fillMissingParticipantFields(PersonalData $participant): void
|
||||
{
|
||||
if (null === $participant->nationality || '' === $participant->nationality) {
|
||||
$participant->nationality = 'D';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ class PickupPlanningTransformer
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $payload
|
||||
*
|
||||
* @return array<string, array<int, array<string, string>>>
|
||||
* @return array<string, array<int, array{city: string, location: string, datetime: string, busNumber: string}>>
|
||||
*/
|
||||
public function transform(array $payload): array
|
||||
{
|
||||
@@ -27,26 +27,25 @@ class PickupPlanningTransformer
|
||||
continue;
|
||||
}
|
||||
|
||||
$travelCodes = $item['Reisecodes'] ?? [];
|
||||
$code = $item['Reisecode'] ?? '';
|
||||
|
||||
if (0 === count($travelCodes)) {
|
||||
if ('' === $code) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dateTime = \DateTimeImmutable::createFromFormat('d.m.Y H:i', $item['Zeit'] ?? '');
|
||||
|
||||
foreach ($travelCodes as $code) {
|
||||
$pickup = [
|
||||
'city' => $item['Zustieg/Ausstieg Ort'] ?? '',
|
||||
'location' => $item['Zustieg/Ausstieg Strasse'] ?? '',
|
||||
'datetime' => false !== $dateTime ? $dateTime->format('Y-m-d H:i') : '',
|
||||
];
|
||||
$pickup = [
|
||||
'city' => $item['Zustieg/Ausstieg Ort'] ?? '',
|
||||
'location' => $item['Zustieg/Ausstieg Strasse'] ?? '',
|
||||
'datetime' => false !== $dateTime ? $dateTime->format('Y-m-d H:i') : '',
|
||||
'busNumber' => $item['BusNummer'] ?? '',
|
||||
];
|
||||
|
||||
$key = sprintf('%s|%s|%s', $pickup['city'], $pickup['location'], $pickup['datetime']);
|
||||
$key = sprintf('%s|%s|%s|%s', $pickup['city'], $pickup['location'], $pickup['datetime'], $pickup['busNumber']);
|
||||
|
||||
if (false === isset($result[$code][$key])) {
|
||||
$result[$code][$key] = $pickup;
|
||||
}
|
||||
if (false === isset($result[$code][$key])) {
|
||||
$result[$code][$key] = $pickup;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,11 @@ class ServiceMappingCollector
|
||||
$serviceMap[$board->id][] = $participantId;
|
||||
}
|
||||
|
||||
// Veg (vegetarian/vegan) preference
|
||||
if (null !== $participant->veg) {
|
||||
$serviceMap[$participant->veg->id][] = $participantId;
|
||||
}
|
||||
|
||||
// Ski pass
|
||||
if (null !== $participant->skiPass) {
|
||||
$serviceMap[$participant->skiPass->id][] = $participantId;
|
||||
@@ -138,6 +143,28 @@ class ServiceMappingCollector
|
||||
return $pickupMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects drop-off location mappings.
|
||||
*
|
||||
* Collects the drop-off selection for the inbound/return direction.
|
||||
*
|
||||
* @return array<string, array<int>> Map of drop-off ID to participant IDs
|
||||
*/
|
||||
public function collectDropOffMappings(BookingDto $bookingDto): array
|
||||
{
|
||||
$dropOffMap = [];
|
||||
|
||||
foreach ($bookingDto->participants as $index => $participant) {
|
||||
$participantId = $index + 1;
|
||||
|
||||
if (null !== $participant->dropOff) {
|
||||
$dropOffMap[$participant->dropOff->id][] = $participantId;
|
||||
}
|
||||
}
|
||||
|
||||
return $dropOffMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects insurance mappings.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Exception;
|
||||
|
||||
/**
|
||||
* Thrown when the BPN server closes the connection immediately without sending data.
|
||||
*
|
||||
* This typically indicates the server is busy processing other requests.
|
||||
* Unlike a true timeout, this is a transient condition that may succeed on retry.
|
||||
*/
|
||||
class ImmediateConnectionCloseException extends ApiClientException
|
||||
{
|
||||
}
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Represents a physical address with street, postal code, city, and country information.
|
||||
*
|
||||
@@ -13,14 +15,18 @@ namespace App\BusProNet\Model;
|
||||
*/
|
||||
class Address
|
||||
{
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||
public ?string $street = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||
public ?string $postCode = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||
public ?string $city = null;
|
||||
|
||||
public ?string $district = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||
public ?string $country = null;
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,8 +41,8 @@ class Booking
|
||||
public array $transportationServices = [];
|
||||
public array $additionalServices = [];
|
||||
public array $rooms = [];
|
||||
public array $pickupsOutbound = [];
|
||||
public array $pickupsInbound = [];
|
||||
public array $pickups = [];
|
||||
public array $dropOffs = [];
|
||||
public array $surcharges = [];
|
||||
public array $insurances = [];
|
||||
public ?int $invoiceNumber = null;
|
||||
@@ -136,7 +136,7 @@ class Booking
|
||||
* Retrieves pickup service for a specific participant.
|
||||
*
|
||||
* Finds the pickup service assigned to the specified participant
|
||||
* from the outbound pickup services.
|
||||
* from the pickup services.
|
||||
*
|
||||
* @param int $participantIndex The participant index to search for
|
||||
*
|
||||
@@ -144,7 +144,7 @@ class Booking
|
||||
*/
|
||||
public function getPickupForParticipant(int $participantIndex): ?Pickup
|
||||
{
|
||||
foreach ($this->pickupsOutbound as $pickup) {
|
||||
foreach ($this->pickups as $pickup) {
|
||||
if (in_array($participantIndex, $pickup->mapping)) {
|
||||
return $pickup;
|
||||
}
|
||||
@@ -153,6 +153,27 @@ class Booking
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves drop-off service for a specific participant.
|
||||
*
|
||||
* Finds the drop-off service assigned to the specified participant
|
||||
* from the drop-off services (inbound/return direction).
|
||||
*
|
||||
* @param int $participantIndex The participant index to search for
|
||||
*
|
||||
* @return Pickup|null The matching drop-off service or null if not found
|
||||
*/
|
||||
public function getDropOffForParticipant(int $participantIndex): ?Pickup
|
||||
{
|
||||
foreach ($this->dropOffs as $dropOff) {
|
||||
if (in_array($participantIndex, $dropOff->mapping)) {
|
||||
return $dropOff;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the insurance assigned to a specific participant.
|
||||
*
|
||||
|
||||
@@ -16,15 +16,15 @@ namespace App\BusProNet\Model;
|
||||
class BookingResponse
|
||||
{
|
||||
/**
|
||||
* @param string $status Booking status (möglich|erfolgt)
|
||||
* @param string|null $transactionNumber Transaction number (vorgang)
|
||||
* @param array<int, PriceItem> $priceItems Individual price items from response
|
||||
* @param float|null $totalPrice Total price (gesamtpreis)
|
||||
* @param PaymentTerms|null $paymentTerms Payment terms (anzahlung/restzahlung)
|
||||
* @param string $status Booking status (möglich|erfolgt)
|
||||
* @param int|null $bookingNumber Booking number (BPN XML: vorgang)
|
||||
* @param array<int, PriceItem> $priceItems Individual price items from response
|
||||
* @param float|null $totalPrice Total price (gesamtpreis)
|
||||
* @param PaymentTerms|null $paymentTerms Payment terms (anzahlung/restzahlung)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $status,
|
||||
public readonly ?string $transactionNumber = null,
|
||||
public readonly ?int $bookingNumber = null,
|
||||
public readonly array $priceItems = [],
|
||||
public readonly ?float $totalPrice = null,
|
||||
public readonly ?PaymentTerms $paymentTerms = null,
|
||||
@@ -102,4 +102,48 @@ class BookingResponse
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets API-applied discounts that cannot be predicted by the local price calculator.
|
||||
*
|
||||
* The BusProNet API may apply automatic discounts based on business rules that
|
||||
* are not known to the local application, such as:
|
||||
* - ERM/GRU: Gruppenrabatt (group discount for large bookings)
|
||||
* - Other ERM subtypes: Various automatic discounts
|
||||
*
|
||||
* These discounts appear as negative price items with type "ERM" and must be
|
||||
* accounted for when comparing the API total price against the locally calculated price.
|
||||
*
|
||||
* Important: Only ERM type discounts are considered API-applied. Other negative price
|
||||
* items (like BEF transportation discounts) are already handled by the local price
|
||||
* calculator and should not be subtracted again.
|
||||
*
|
||||
* Note: Voucher discounts (AKTION, KULANZGUTSCHEIN) are handled separately via
|
||||
* getVoucherDiscountFromPrices() as they are user-initiated, not automatic.
|
||||
*
|
||||
* @return array<PriceItem> Price items representing API-applied automatic discounts
|
||||
*/
|
||||
public function getApiAppliedDiscounts(): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->priceItems,
|
||||
fn (PriceItem $item) => $item->totalPrice < 0 && 'ERM' === $item->type
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets total discount from API-applied automatic discounts.
|
||||
*
|
||||
* Returns the absolute sum of negative price items that represent automatic
|
||||
* discounts applied by the API (excluding voucher discounts which are handled separately).
|
||||
*/
|
||||
public function getApiAppliedDiscountTotal(): float
|
||||
{
|
||||
$discount = 0.0;
|
||||
foreach ($this->getApiAppliedDiscounts() as $item) {
|
||||
$discount += abs($item->totalPrice);
|
||||
}
|
||||
|
||||
return round($discount, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,18 @@ class PersonalData
|
||||
|
||||
public ?string $salutation = null;
|
||||
public ?string $title = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||
#[Assert\Choice(choices: ['M', 'W', 'D'], message: 'Bitte gib einen gültigen Wert an', groups: ['personal_data'])]
|
||||
public ?string $gender = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||
public ?string $nationality = null;
|
||||
public ?string $height = null;
|
||||
public ?string $shoeSize = null;
|
||||
public ?string $weight = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||
public ?\DateTimeImmutable $dateOfBirth = null;
|
||||
public ?string $remarks = null;
|
||||
|
||||
@@ -71,17 +78,11 @@ class PersonalData
|
||||
*
|
||||
* @return array<string, mixed> The payload array for API transmission
|
||||
*/
|
||||
public function toPayload(): array
|
||||
public function toPayload(bool $includeDateOfBirth = true): array
|
||||
{
|
||||
// Ensure date of birth is populated
|
||||
if (null === $dob = $this->dateOfBirth) {
|
||||
$dob = new \DateTimeImmutable(self::DEFAULT_AGE_YEARS.' years ago');
|
||||
}
|
||||
|
||||
return [
|
||||
$payload = [
|
||||
'idadresse' => $this->addressId,
|
||||
'idadresseperson' => $this->personId,
|
||||
'geburtsdatum' => $dob->format('d.m.Y'),
|
||||
'anrede' => $this->salutation,
|
||||
'geschlecht' => $this->gender,
|
||||
'nationalitaet' => $this->nationality,
|
||||
@@ -95,6 +96,13 @@ class PersonalData
|
||||
'sonstiges3' => $this->shoeSize,
|
||||
'bemerkung' => $this->remarks,
|
||||
];
|
||||
|
||||
if (true === $includeDateOfBirth) {
|
||||
$dob = $this->dateOfBirth ?? new \DateTimeImmutable(self::DEFAULT_AGE_YEARS.' years ago');
|
||||
$payload['geburtsdatum'] = $dob->format('d.m.Y');
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public static function fromContactFormSubmission(ContactFormSubmission $dto): self
|
||||
|
||||
@@ -39,6 +39,12 @@ class Pickup
|
||||
#[Groups(['api:single'])]
|
||||
public ?float $price = null;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?float $priceOutbound = null;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public ?float $priceInbound = null;
|
||||
|
||||
#[Groups(['api:booking'])]
|
||||
public array $mapping = [];
|
||||
|
||||
@@ -90,4 +96,75 @@ class Pickup
|
||||
|
||||
return sprintf('%s (€%s)', $label, number_format($this->price, 2, ',', '.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the formatted label with context-aware pricing based on transportation directions.
|
||||
*
|
||||
* Calculates the effective price based on which directions use bus transportation:
|
||||
* - Both directions BUS: shows combined price (outbound + inbound)
|
||||
* - Only outbound BUS: shows outbound price only
|
||||
* - Only inbound BUS: shows inbound price only
|
||||
* - Falls back to legacy price property when split pricing data is unavailable
|
||||
*
|
||||
* @param bool $hasOutboundBus Whether outbound transportation is bus
|
||||
* @param bool $hasInboundBus Whether inbound transportation is bus
|
||||
*
|
||||
* @return string The formatted pickup label with contextual pricing
|
||||
*/
|
||||
public function getLabelWithContextualPrice(bool $hasOutboundBus, bool $hasInboundBus): string
|
||||
{
|
||||
$label = $this->getLabel();
|
||||
$effectivePrice = $this->calculateEffectivePrice($hasOutboundBus, $hasInboundBus);
|
||||
|
||||
if (null === $effectivePrice) {
|
||||
return $label;
|
||||
}
|
||||
|
||||
if (0.0 === $effectivePrice) {
|
||||
return sprintf('%s (inkl.)', $label);
|
||||
}
|
||||
|
||||
if ($effectivePrice < 0) {
|
||||
return sprintf('%s (-%s€ Rabatt)', $label, number_format(abs($effectivePrice), 2, ',', '.'));
|
||||
}
|
||||
|
||||
return sprintf('%s (€%s)', $label, number_format($effectivePrice, 2, ',', '.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the effective pickup price based on transportation directions.
|
||||
*
|
||||
* Uses split pricing (priceOutbound/priceInbound) when available, falling back
|
||||
* to the legacy price property for backward compatibility with older data.
|
||||
*
|
||||
* @param bool $hasOutboundBus Whether outbound transportation is bus
|
||||
* @param bool $hasInboundBus Whether inbound transportation is bus
|
||||
*
|
||||
* @return float|null The calculated effective price or null if no applicable price
|
||||
*/
|
||||
public function calculateEffectivePrice(bool $hasOutboundBus, bool $hasInboundBus): ?float
|
||||
{
|
||||
// Neither direction is bus - no pickup price applies
|
||||
if (false === $hasOutboundBus && false === $hasInboundBus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$effectivePrice = 0.0;
|
||||
$hasSplitPricing = null !== $this->priceOutbound || null !== $this->priceInbound;
|
||||
|
||||
if ($hasOutboundBus) {
|
||||
if ($hasSplitPricing) {
|
||||
$effectivePrice += $this->priceOutbound ?? 0.0;
|
||||
} else {
|
||||
// Fallback to legacy price for backward compatibility
|
||||
$effectivePrice += $this->price ?? 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasInboundBus && null !== $this->priceInbound) {
|
||||
$effectivePrice += $this->priceInbound;
|
||||
}
|
||||
|
||||
return $effectivePrice;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
class ContactFormResponse
|
||||
class RegistrationResponse
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $addressId,
|
||||
@@ -84,10 +84,10 @@ class Travel
|
||||
public bool $transportationServicesMutable = true;
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public array $pickupsOutbound = [];
|
||||
public array $pickups = [];
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public array $pickupsInbound = [];
|
||||
public array $dropOffs = [];
|
||||
|
||||
#[Groups(['api:single'])]
|
||||
public bool $pickupsMutable = true;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Service\Contract;
|
||||
|
||||
use App\Form\Model\ParticipantDto;
|
||||
|
||||
/**
|
||||
* Defines the contract for participant status evaluation rules.
|
||||
*
|
||||
* Status rules are evaluated in priority order to determine the appropriate
|
||||
* status code for a participant during booking creation. The first rule that
|
||||
* matches (evaluate returns true) determines the participant's status.
|
||||
*/
|
||||
interface ParticipantStatusRuleInterface
|
||||
{
|
||||
/**
|
||||
* Evaluates whether this rule applies to the given participant.
|
||||
*
|
||||
* @param ParticipantDto $participant The participant to evaluate
|
||||
*
|
||||
* @return bool True if this rule applies, false otherwise
|
||||
*/
|
||||
public function evaluate(ParticipantDto $participant): bool;
|
||||
|
||||
/**
|
||||
* Returns the status code to assign when this rule matches.
|
||||
*
|
||||
* @return string The status code (e.g., 'O' for Option, 'F' for Final)
|
||||
*/
|
||||
public function getStatus(): string;
|
||||
|
||||
/**
|
||||
* Returns the priority of this rule.
|
||||
*
|
||||
* Higher priority rules are evaluated first. Rules with the same priority
|
||||
* are evaluated in registration order.
|
||||
*
|
||||
* @return int The priority value (higher = evaluated first)
|
||||
*/
|
||||
public function getPriority(): int;
|
||||
|
||||
/**
|
||||
* Returns a human-readable description of this rule.
|
||||
*
|
||||
* Used for debugging and logging purposes.
|
||||
*
|
||||
* @return string Description of what this rule checks
|
||||
*/
|
||||
public function getDescription(): string;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Service;
|
||||
|
||||
use App\BusProNet\Service\Contract\ParticipantStatusRuleInterface;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
|
||||
/**
|
||||
* Registry for participant status evaluation rules.
|
||||
*
|
||||
* Manages a collection of status rules and evaluates them in priority order
|
||||
* to determine the appropriate status code for a participant during booking
|
||||
* creation. Returns a default status of 'F' (Final) when no rules match.
|
||||
*/
|
||||
class ParticipantStatusRuleRegistry
|
||||
{
|
||||
private const DEFAULT_STATUS = 'F';
|
||||
|
||||
/**
|
||||
* @var ParticipantStatusRuleInterface[]
|
||||
*/
|
||||
private array $sortedRules;
|
||||
|
||||
/**
|
||||
* @param ParticipantStatusRuleInterface[] $rules The rules to register
|
||||
*/
|
||||
public function __construct(array $rules)
|
||||
{
|
||||
$this->sortedRules = $this->sortRulesByPriority($rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates all rules to determine the participant's status.
|
||||
*
|
||||
* Rules are evaluated in priority order (highest first). The first rule
|
||||
* that matches determines the status. Returns 'F' if no rules match.
|
||||
*
|
||||
* @param ParticipantDto $participant The participant to evaluate
|
||||
*
|
||||
* @return string The determined status code
|
||||
*/
|
||||
public function evaluateStatus(ParticipantDto $participant): string
|
||||
{
|
||||
foreach ($this->sortedRules as $rule) {
|
||||
if (true === $rule->evaluate($participant)) {
|
||||
return $rule->getStatus();
|
||||
}
|
||||
}
|
||||
|
||||
return self::DEFAULT_STATUS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts rules by priority in descending order.
|
||||
*
|
||||
* @param ParticipantStatusRuleInterface[] $rules The rules to sort
|
||||
*
|
||||
* @return ParticipantStatusRuleInterface[] The sorted rules
|
||||
*/
|
||||
private function sortRulesByPriority(array $rules): array
|
||||
{
|
||||
usort($rules, static fn (
|
||||
ParticipantStatusRuleInterface $a,
|
||||
ParticipantStatusRuleInterface $b,
|
||||
): int => $b->getPriority() <=> $a->getPriority());
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Service\StatusRule;
|
||||
|
||||
use App\BusProNet\Model\Service;
|
||||
use App\BusProNet\Service\Contract\ParticipantStatusRuleInterface;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
|
||||
/**
|
||||
* Status rule for participants with chaperon (Begleitperson) services.
|
||||
*
|
||||
* Assigns 'Option' status to participants who have selected any service
|
||||
* containing 'Begleitperson' in its label. This applies to accompanying
|
||||
* persons whose booking confirmation may depend on the main participant.
|
||||
*/
|
||||
class ChaperonServiceStatusRule implements ParticipantStatusRuleInterface
|
||||
{
|
||||
private const STATUS = 'O';
|
||||
private const PRIORITY = 100;
|
||||
private const SEARCH_TERM = 'begleitperson';
|
||||
|
||||
/**
|
||||
* @see ParticipantStatusRuleInterface::evaluate()
|
||||
*/
|
||||
public function evaluate(ParticipantDto $participant): bool
|
||||
{
|
||||
// Check array services
|
||||
$arrayServices = [
|
||||
$participant->additionalServices,
|
||||
$participant->courses,
|
||||
$participant->board,
|
||||
$participant->rentals,
|
||||
];
|
||||
|
||||
foreach ($arrayServices as $services) {
|
||||
foreach ($services as $service) {
|
||||
if ($service instanceof Service && true === $this->containsSearchTerm($service->label)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check single service properties
|
||||
$singleServices = [
|
||||
$participant->skiPass,
|
||||
$participant->veg,
|
||||
];
|
||||
|
||||
foreach ($singleServices as $service) {
|
||||
if ($service instanceof Service && true === $this->containsSearchTerm($service->label)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see ParticipantStatusRuleInterface::getStatus()
|
||||
*/
|
||||
public function getStatus(): string
|
||||
{
|
||||
return self::STATUS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see ParticipantStatusRuleInterface::getPriority()
|
||||
*/
|
||||
public function getPriority(): int
|
||||
{
|
||||
return self::PRIORITY;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see ParticipantStatusRuleInterface::getDescription()
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Assigns Option status when a Begleitperson service is selected';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the label contains the search term (case-insensitive).
|
||||
*/
|
||||
private function containsSearchTerm(?string $label): bool
|
||||
{
|
||||
if (null === $label) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false !== stripos($label, self::SEARCH_TERM);
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\BusProNet\Traits;
|
||||
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\Exception\TimeoutException;
|
||||
|
||||
trait ApiClientTrait
|
||||
{
|
||||
private float $operationStartTime;
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
* @throws TimeoutException
|
||||
*/
|
||||
private function connect(string $host, int $port, int $maxRetries = 25, int $connectionTimeout = 5, int $streamTimeout = 30, int $totalTimeout = 45)
|
||||
{
|
||||
$this->operationStartTime = microtime(true);
|
||||
|
||||
$tries = 1;
|
||||
$errNo = $errStr = '';
|
||||
$errorCodesForRetry = [
|
||||
SOCKET_ECONNREFUSED,
|
||||
SOCKET_EBADF,
|
||||
];
|
||||
|
||||
$openSocket = function (&$errNo, &$errStr) use ($host, $port, $connectionTimeout) {
|
||||
return @fsockopen(
|
||||
$host,
|
||||
$port,
|
||||
$errNo,
|
||||
$errStr,
|
||||
$connectionTimeout
|
||||
);
|
||||
};
|
||||
|
||||
$socket = $openSocket($errNo, $errStr);
|
||||
|
||||
while (false === $socket && true === in_array($errNo, $errorCodesForRetry) && $maxRetries > $tries) {
|
||||
// Check if we've exceeded total timeout during retries
|
||||
if (microtime(true) - $this->operationStartTime > $totalTimeout) {
|
||||
$this->logger->error('Connection retry timeout exceeded', [
|
||||
'elapsed_time' => microtime(true) - $this->operationStartTime,
|
||||
'total_timeout' => $totalTimeout,
|
||||
]);
|
||||
throw new TimeoutException('Connection timeout exceeded during retries');
|
||||
}
|
||||
|
||||
$this->logger->warning('Could not connect to socket, retrying', [
|
||||
'error_message' => $errStr,
|
||||
'error_number' => $errNo,
|
||||
'attempt' => $tries,
|
||||
]);
|
||||
++$tries;
|
||||
sleep(1);
|
||||
$socket = $openSocket($errNo, $errStr);
|
||||
}
|
||||
|
||||
if (false !== $socket) {
|
||||
stream_set_timeout($socket, $streamTimeout);
|
||||
} else {
|
||||
$this->logger->error('Unable to open socket', [
|
||||
'error_message' => $errStr,
|
||||
'error_number' => $errNo,
|
||||
'elapsed_time' => microtime(true) - $this->operationStartTime,
|
||||
]);
|
||||
throw new ApiClientException('Unable to open socket');
|
||||
}
|
||||
|
||||
return $socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TimeoutException
|
||||
*/
|
||||
private function send($socket, string $data, int $totalTimeout): void
|
||||
{
|
||||
// Check total timeout before sending
|
||||
if (microtime(true) - $this->operationStartTime > $totalTimeout) {
|
||||
$this->logger->error('Total timeout exceeded before send', [
|
||||
'elapsed_time' => microtime(true) - $this->operationStartTime,
|
||||
]);
|
||||
throw new TimeoutException('Total operation timeout exceeded before send');
|
||||
}
|
||||
|
||||
// message length is prepended to actual message
|
||||
$send = sprintf('%010s', strlen($data)).$data;
|
||||
fwrite($socket, $send);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TimeoutException
|
||||
*/
|
||||
private function receive($socket, int $totalTimeout): string
|
||||
{
|
||||
$response = '';
|
||||
$readAttempts = 0;
|
||||
|
||||
while (false === feof($socket)) {
|
||||
// Check total timeout before each read
|
||||
$elapsedTime = microtime(true) - $this->operationStartTime;
|
||||
if ($elapsedTime > $totalTimeout) {
|
||||
$this->logger->error('Total timeout exceeded during receive', [
|
||||
'elapsed_time' => $elapsedTime,
|
||||
'total_timeout' => $totalTimeout,
|
||||
'bytes_received' => strlen($response),
|
||||
'read_attempts' => $readAttempts,
|
||||
]);
|
||||
throw new TimeoutException('Total operation timeout exceeded while receiving data');
|
||||
}
|
||||
|
||||
$chunk = fread($socket, 4096);
|
||||
++$readAttempts;
|
||||
|
||||
// Check if stream timed out on this specific read
|
||||
$metadata = stream_get_meta_data($socket);
|
||||
if (true === $metadata['timed_out']) {
|
||||
$this->logger->error('Stream read timeout detected', [
|
||||
'elapsed_time' => $elapsedTime,
|
||||
'bytes_received' => strlen($response),
|
||||
'read_attempts' => $readAttempts,
|
||||
]);
|
||||
throw new TimeoutException('Stream timeout while reading from socket');
|
||||
}
|
||||
|
||||
$response .= $chunk;
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function disconnect($socket): void
|
||||
{
|
||||
@fclose($socket);
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@ class DayTimeUtility
|
||||
return 'abends';
|
||||
} elseif ($time >= '20:00' && $time <= '23:59') {
|
||||
return 'nachts';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use Symfony\Contracts\Cache\ItemInterface;
|
||||
class AgencyLoader
|
||||
{
|
||||
public const DEFAULT_AGENCY_CODE = 'INTERN';
|
||||
public const INTERNAL_AGENCY_CODE = '0004';
|
||||
|
||||
public function __construct(
|
||||
private readonly CacheInterface $cache,
|
||||
|
||||
@@ -36,12 +36,17 @@ class HotelLoader extends AbstractLoader
|
||||
}
|
||||
|
||||
public function mapCodeToId(string $hotelCode, ?string $filename = 'hotel.xml'): ?int
|
||||
{
|
||||
return $this->loadByCode($hotelCode, $filename)?->id;
|
||||
}
|
||||
|
||||
public function loadByCode(string $hotelCode, ?string $filename = 'hotel.xml'): ?Hotel
|
||||
{
|
||||
$hotels = $this->loadAll($filename);
|
||||
|
||||
foreach ($hotels as $hotel) {
|
||||
if ($hotelCode === $hotel->code) {
|
||||
return $hotel->id;
|
||||
return $hotel;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,24 +61,88 @@ class PickupLoader extends AbstractLoader
|
||||
|
||||
public function patchPickupsDetails(Travel $travel): void
|
||||
{
|
||||
$travel->pickupsOutbound = $this->patchAndSortPickups($travel->pickupsOutbound);
|
||||
$travel->pickupsInbound = $this->patchAndSortPickups($travel->pickupsInbound);
|
||||
$travel->pickups = $this->patchAndSortPickups($travel->pickups);
|
||||
$travel->dropOffs = $this->patchAndOrderDropOffs($travel->dropOffs, $travel->pickups);
|
||||
}
|
||||
|
||||
private function patchAndSortPickups(array $pickups): array
|
||||
{
|
||||
foreach ($pickups as $pickupId => $pickup) {
|
||||
$pickupData = $this->loadById($pickupId);
|
||||
$this->enrichPickupDetails($pickups);
|
||||
uasort($pickups, fn (Pickup $a, Pickup $b): int => $this->comparePickupsByTime($a, $b));
|
||||
|
||||
return $pickups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches drop-off details and determines their order.
|
||||
*
|
||||
* When drop-offs carry their own time data, they are sorted chronologically.
|
||||
* Otherwise the return journey order is derived by reversing the chronologically
|
||||
* sorted outbound pickup order. Any drop-offs without a matching pickup ID are
|
||||
* appended at the end.
|
||||
*
|
||||
* @param array<int, Pickup> $dropOffs Drop-offs indexed by ID
|
||||
* @param array<int, Pickup> $sortedPickups Already sorted pickups indexed by ID
|
||||
*
|
||||
* @return array<int, Pickup> Ordered drop-offs
|
||||
*/
|
||||
private function patchAndOrderDropOffs(array $dropOffs, array $sortedPickups): array
|
||||
{
|
||||
$this->enrichPickupDetails($dropOffs);
|
||||
|
||||
if ($this->hasTimeData($dropOffs)) {
|
||||
uasort($dropOffs, fn (Pickup $a, Pickup $b): int => $this->comparePickupsByTime($a, $b));
|
||||
|
||||
return $dropOffs;
|
||||
}
|
||||
|
||||
// Fallback: order drop-offs in reverse of the sorted pickup route
|
||||
$reversedPickupIds = array_reverse(array_keys($sortedPickups));
|
||||
|
||||
$ordered = [];
|
||||
foreach ($reversedPickupIds as $id) {
|
||||
if (isset($dropOffs[$id])) {
|
||||
$ordered[$id] = $dropOffs[$id];
|
||||
}
|
||||
}
|
||||
|
||||
// Append any drop-offs not present in pickups
|
||||
foreach ($dropOffs as $id => $dropOff) {
|
||||
if (false === isset($ordered[$id])) {
|
||||
$ordered[$id] = $dropOff;
|
||||
}
|
||||
}
|
||||
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, Pickup> $pickups
|
||||
*/
|
||||
private function hasTimeData(array $pickups): bool
|
||||
{
|
||||
foreach ($pickups as $pickup) {
|
||||
if (null !== $pickup->time) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, Pickup> $pickups Pickups indexed by ID (modified in place)
|
||||
*/
|
||||
private function enrichPickupDetails(array $pickups): void
|
||||
{
|
||||
foreach ($pickups as $pickup) {
|
||||
$pickupData = $this->loadById($pickup->id);
|
||||
|
||||
$pickup->code = $pickupData->code;
|
||||
$pickup->postalCode = $pickupData->postalCode;
|
||||
$pickup->city = $pickupData->city;
|
||||
$pickup->street = $pickupData->street;
|
||||
}
|
||||
|
||||
uasort($pickups, fn (Pickup $a, Pickup $b): int => $this->comparePickupsByTime($a, $b));
|
||||
|
||||
return $pickups;
|
||||
}
|
||||
|
||||
private function comparePickupsByTime(Pickup $a, Pickup $b): int
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\Model\AgeConstraintResult;
|
||||
use App\BusProNet\XmlParser\Contract\AgeConstraintParserInterface;
|
||||
|
||||
/**
|
||||
* Registry for managing and executing age constraint parsers.
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\Model\AgeConstraintResult;
|
||||
use App\BusProNet\XmlParser\Contract\AgeConstraintParserInterface;
|
||||
|
||||
/**
|
||||
* Parser for birth year constraint data in JG:YYYY-YYYY format.
|
||||
|
||||
@@ -91,11 +91,12 @@ class BookingParser extends AbstractParser
|
||||
|
||||
$pickupsData = $node->filterXPath('//zustiege/zustieg');
|
||||
if (0 < $pickupsData->count()) {
|
||||
$booking->pickupsOutbound = $this->pickupsParser->parse($pickupsData);
|
||||
$booking->pickups = $this->pickupsParser->parse($pickupsData);
|
||||
}
|
||||
$pickupsData = $node->filterXPath('//zustiege_rueck/zustieg_rueck');
|
||||
if (0 < $pickupsData->count()) {
|
||||
$booking->pickupsInbound = $this->pickupsParser->parse($pickupsData);
|
||||
|
||||
$dropOffsData = $node->filterXPath('//ausstiege/ausstieg');
|
||||
if (0 < $dropOffsData->count()) {
|
||||
$booking->dropOffs = $this->pickupsParser->parse($dropOffsData);
|
||||
}
|
||||
|
||||
$surchargesData = $node->filterXPath('//zuschlaege/zuschlag');
|
||||
|
||||
@@ -33,7 +33,7 @@ class BookingResponseParser extends AbstractParser
|
||||
public function parse(Crawler $node): BookingResponse
|
||||
{
|
||||
$status = $node->filterXPath('//buchung')->text();
|
||||
$transactionNumber = $this->getStringOrNullValue($node->filterXPath('//vorgang'));
|
||||
$bookingNumber = $this->getIntOrNullValue($node->filterXPath('//vorgang'));
|
||||
$totalPrice = $this->getFloatOrNullValue($node->filterXPath('//gesamtpreis'));
|
||||
$message = $this->getStringOrNullValue($node->filterXPath('//hinweis'));
|
||||
|
||||
@@ -42,7 +42,7 @@ class BookingResponseParser extends AbstractParser
|
||||
|
||||
return new BookingResponse(
|
||||
status: $status,
|
||||
transactionNumber: $transactionNumber,
|
||||
bookingNumber: $bookingNumber,
|
||||
priceItems: $priceItems,
|
||||
totalPrice: $totalPrice,
|
||||
paymentTerms: $paymentTerms,
|
||||
|
||||
@@ -4,18 +4,18 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\Model\ContactFormResponse;
|
||||
use App\BusProNet\Model\RegistrationResponse;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
class ContactFormResponseParser extends AbstractParser
|
||||
{
|
||||
public function parse(Crawler $node): ContactFormResponse
|
||||
public function parse(Crawler $node): RegistrationResponse
|
||||
{
|
||||
$addressId = (int) $node->filterXPath('//idadresse')->text();
|
||||
$personId = (int) $node->filterXPath('//idperson')->text();
|
||||
$isNewRecord = $this->getBoolValue($node->filterXPath('//neuanlage'));
|
||||
|
||||
return new ContactFormResponse(
|
||||
return new RegistrationResponse(
|
||||
addressId: $addressId,
|
||||
personId: $personId,
|
||||
isNewRecord: $isNewRecord,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\XmlParser;
|
||||
namespace App\BusProNet\XmlParser\Contract;
|
||||
|
||||
use App\BusProNet\Model\AgeConstraintResult;
|
||||
|
||||
@@ -24,6 +24,13 @@ class TravelParser extends AbstractParser
|
||||
{
|
||||
private AgeConstraintParserRegistry $ageConstraintRegistry;
|
||||
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
private array $ignoredRoomCodes = [
|
||||
Constants::PSEUDO_PRICE_ROOM_CODE,
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->ageConstraintRegistry = new AgeConstraintParserRegistry();
|
||||
@@ -69,8 +76,12 @@ class TravelParser extends AbstractParser
|
||||
$travel->transportationServices = $this
|
||||
->getTransportationServices($node->filterXPath('//lei_befoerderung/leistung'));
|
||||
$travel->rooms = $this->getRooms($hotelNode);
|
||||
$travel->pickupsOutbound = $this->getPickups($node->filterXPath('//zustiege/zustieg'), $dateFrom);
|
||||
$travel->pickupsInbound = $this->getPickups($node->filterXPath('//zustiege_rueck/zustieg_rueck'), $dateTo);
|
||||
$travel->pickups = $this->getPickups($node->filterXPath('//zustiege/zustieg'), $dateFrom, true);
|
||||
$travel->dropOffs = $this->getPickups($node->filterXPath('//zustiege_rueck/zustieg_rueck'), $dateTo, false);
|
||||
|
||||
// Merge drop-off prices into pickups for split pricing support
|
||||
$this->mergeDropOffPricesIntoPickups($travel->pickups, $travel->dropOffs);
|
||||
|
||||
$travel->guide = $this->getGuide($node);
|
||||
|
||||
return $travel;
|
||||
@@ -249,14 +260,16 @@ class TravelParser extends AbstractParser
|
||||
*
|
||||
* @param Crawler $node The XML node containing pickup data
|
||||
* @param \DateTimeImmutable|null $defaultDate Default date for time parsing (outbound only)
|
||||
* @param bool $isOutbound Whether these are outbound pickups (for split pricing)
|
||||
*
|
||||
* @return array<int, Pickup> Array of pickup locations indexed by ID
|
||||
*/
|
||||
public function getPickups(Crawler $node, ?\DateTimeImmutable $defaultDate = null): array
|
||||
public function getPickups(Crawler $node, ?\DateTimeImmutable $defaultDate = null, bool $isOutbound = true): array
|
||||
{
|
||||
$pickups = [];
|
||||
$timeOnlyIds = [];
|
||||
|
||||
$node->each(function (Crawler $pickupNode) use (&$pickups, $defaultDate) {
|
||||
$node->each(function (Crawler $pickupNode) use (&$pickups, &$timeOnlyIds, $defaultDate, $isOutbound) {
|
||||
$pickupId = (int) $pickupNode->attr('idbuspro');
|
||||
|
||||
$pickup = new Pickup();
|
||||
@@ -264,14 +277,28 @@ class TravelParser extends AbstractParser
|
||||
$pickup->price = $pickupNode->attr('preis') ?
|
||||
$this->stringToFloat($pickupNode->attr('preis')) : null;
|
||||
|
||||
// Set direction-specific price for split pricing support
|
||||
if ($isOutbound) {
|
||||
$pickup->priceOutbound = $pickup->price;
|
||||
} else {
|
||||
$pickup->priceInbound = $pickup->price;
|
||||
}
|
||||
|
||||
// parse date and time only for direction 'to' indicated by provided default date
|
||||
if (null !== $defaultDate) {
|
||||
$pickup->time = $this->stringToDateTimeFuzzy($pickupNode->attr('zeit'), $defaultDate);
|
||||
$timeValue = $pickupNode->attr('zeit');
|
||||
$pickup->time = $this->stringToDateTimeFuzzy($timeValue, $defaultDate);
|
||||
|
||||
if (null !== $timeValue && 1 === preg_match('/^\d{2}:\d{2}$/', $timeValue)) {
|
||||
$timeOnlyIds[] = $pickupId;
|
||||
}
|
||||
}
|
||||
|
||||
$pickups[$pickupId] = $pickup;
|
||||
});
|
||||
|
||||
$this->adjustOvernightTimes($pickups, $timeOnlyIds);
|
||||
|
||||
return $pickups;
|
||||
}
|
||||
|
||||
@@ -319,11 +346,17 @@ class TravelParser extends AbstractParser
|
||||
$rooms = [];
|
||||
|
||||
$roomNodes->each(function (Crawler $roomNode) use (&$rooms) {
|
||||
$roomCode = $roomNode->attr('zimmercode');
|
||||
|
||||
if (true === in_array($roomCode, $this->ignoredRoomCodes, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$roomId = (int) $roomNode->attr('idbuspro_zimmer');
|
||||
|
||||
$room = new Room();
|
||||
$room->id = $roomId;
|
||||
$room->code = $roomNode->attr('zimmercode');
|
||||
$room->code = $roomCode;
|
||||
$room->category = $roomNode->attr('kat');
|
||||
$room->boardId = (int) $roomNode->attr('idbuspro_vp');
|
||||
$room->label = $roomNode->attr('zimmertext');
|
||||
@@ -395,4 +428,63 @@ class TravelParser extends AbstractParser
|
||||
$service->rawAgeConstraintData = $constraintData;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges drop-off prices into pickups for split pricing support.
|
||||
*
|
||||
* For each pickup, finds the matching drop-off by ID and copies
|
||||
* the inbound price to the pickup's priceInbound property. This enables
|
||||
* split pricing calculations where costs are distributed between outbound
|
||||
* and inbound transportation.
|
||||
*
|
||||
* @param array<int, Pickup> $pickups Pickups indexed by ID (modified in place)
|
||||
* @param array<int, Pickup> $dropOffs Drop-offs indexed by ID
|
||||
*/
|
||||
private function mergeDropOffPricesIntoPickups(array &$pickups, array $dropOffs): void
|
||||
{
|
||||
foreach ($pickups as $pickupId => $pickup) {
|
||||
if (isset($dropOffs[$pickupId])) {
|
||||
$pickup->priceInbound = $dropOffs[$pickupId]->priceInbound;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts time-only pickup entries for overnight bus routes.
|
||||
*
|
||||
* When a bus route crosses midnight, time-only entries (e.g. "01:00") are
|
||||
* initially stamped with the departure date. This method detects overnight
|
||||
* scenarios by comparing time-only entries against the latest full-datetime
|
||||
* entry and adds one day where needed.
|
||||
*
|
||||
* @param array<int, Pickup> $pickups Pickups indexed by ID (modified in place)
|
||||
* @param array<int> $timeOnlyIds IDs of pickups parsed from time-only values
|
||||
*/
|
||||
private function adjustOvernightTimes(array $pickups, array $timeOnlyIds): void
|
||||
{
|
||||
if (true === empty($timeOnlyIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find latest datetime among full-datetime entries
|
||||
$latestFullDateTime = null;
|
||||
foreach ($pickups as $id => $pickup) {
|
||||
if (null !== $pickup->time && false === in_array($id, $timeOnlyIds, true)) {
|
||||
if (null === $latestFullDateTime || $pickup->time > $latestFullDateTime) {
|
||||
$latestFullDateTime = $pickup->time;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $latestFullDateTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Adjust time-only entries that fall before the latest full-datetime
|
||||
foreach ($timeOnlyIds as $id) {
|
||||
if (isset($pickups[$id]) && null !== $pickups[$id]->time && $pickups[$id]->time < $latestFullDateTime) {
|
||||
$pickups[$id]->time = $pickups[$id]->time->modify('+1 day');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use League\Flysystem\FilesystemException;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
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\Contracts\Cache\CacheInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:bpn:fetch-travel',
|
||||
description: 'Fetches travel data directly from the BusProNet API and saves it to the XML export directory'
|
||||
)]
|
||||
class BpnFetchTravelCommand extends Command
|
||||
{
|
||||
private const CACHE_KEYS_TO_INVALIDATE = [
|
||||
'bpn_travels_mapping',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly FilesystemOperator $xmlExport,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('travel-id', InputArgument::REQUIRED, 'The travel product ID to fetch')
|
||||
->addOption('filename', 'f', InputOption::VALUE_REQUIRED, 'Output filename (default: Ziel_{travel-id}.xml)')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Fetch and display info without saving');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$travelId = (int) $input->getArgument('travel-id');
|
||||
$filename = $input->getOption('filename') ?? sprintf('Ziel_%d.xml', $travelId);
|
||||
$dryRun = $input->getOption('dry-run');
|
||||
|
||||
$io->text(sprintf('Fetching travel data for product ID %d...', $travelId));
|
||||
|
||||
try {
|
||||
$xml = $this->apiClient->getTravelDataXml($travelId);
|
||||
} catch (ApiClientException $e) {
|
||||
$io->error(sprintf('API request failed: %s', $e->getMessage()));
|
||||
$this->logger->error('Failed to fetch travel data from API', [
|
||||
'travelId' => $travelId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->text(sprintf('Received %d bytes of XML data', strlen($xml)));
|
||||
|
||||
if ($dryRun) {
|
||||
$io->note('Dry-run mode: not saving to file');
|
||||
$io->text($xml);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->xmlExport->write($filename, $xml);
|
||||
} catch (FilesystemException $e) {
|
||||
$io->error(sprintf('Failed to write file: %s', $e->getMessage()));
|
||||
$this->logger->error('Failed to write travel XML to file', [
|
||||
'travelId' => $travelId,
|
||||
'filename' => $filename,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->invalidateCaches();
|
||||
|
||||
$io->success(sprintf('Saved to %s and invalidated travel mapping cache', $filename));
|
||||
$this->logger->info('Travel data fetched and saved', [
|
||||
'travelId' => $travelId,
|
||||
'filename' => $filename,
|
||||
'bytes' => strlen($xml),
|
||||
]);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function invalidateCaches(): void
|
||||
{
|
||||
foreach (self::CACHE_KEYS_TO_INVALIDATE as $key) {
|
||||
$this->cache->delete($key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Service\BpnXmlAnonymizer;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:bpn:xml-anonymize',
|
||||
description: 'Anonymize personal data in BPN XML request/response dumps',
|
||||
)]
|
||||
class BpnXmlAnonymizeCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BpnXmlAnonymizer $xmlAnonymizer,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('infile', InputArgument::REQUIRED, 'Path to the input XML file')
|
||||
->addArgument('outfile', InputArgument::OPTIONAL, 'Path to the output XML file (defaults to infile)')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$inputFile = (string) $input->getArgument('infile');
|
||||
$outputFile = $input->getArgument('outfile');
|
||||
|
||||
if (false === is_file($inputFile)) {
|
||||
$io->error(sprintf('Input file not found: %s', $inputFile));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (false === is_readable($inputFile)) {
|
||||
$io->error(sprintf('Input file is not readable: %s', $inputFile));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$targetFile = null === $outputFile ? $inputFile : (string) $outputFile;
|
||||
$this->xmlAnonymizer->resetState();
|
||||
|
||||
$processingResult = $this->anonymizeFile($io, $inputFile, $targetFile, false);
|
||||
if (Command::SUCCESS !== $processingResult) {
|
||||
return $processingResult;
|
||||
}
|
||||
|
||||
$pairedInputFile = $this->derivePairedFilename($inputFile);
|
||||
if (null !== $pairedInputFile && true === is_file($pairedInputFile)) {
|
||||
if (false === is_readable($pairedInputFile)) {
|
||||
$io->error(sprintf('Paired file is not readable: %s', $pairedInputFile));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$pairedTargetFile = $this->derivePairedOutputFilename($targetFile, $inputFile, $pairedInputFile);
|
||||
if (null === $pairedTargetFile) {
|
||||
$io->error(sprintf(
|
||||
'Unable to derive paired output filename from: %s',
|
||||
$targetFile
|
||||
));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$processingResult = $this->anonymizeFile($io, $pairedInputFile, $pairedTargetFile, true);
|
||||
if (Command::SUCCESS !== $processingResult) {
|
||||
return $processingResult;
|
||||
}
|
||||
|
||||
$io->note(sprintf('Paired file anonymized: %s -> %s', $pairedInputFile, $pairedTargetFile));
|
||||
}
|
||||
|
||||
$io->success(sprintf(
|
||||
'Anonymized %d persons from %s to %s',
|
||||
$this->xmlAnonymizer->getLastIdentityCount(),
|
||||
$inputFile,
|
||||
$targetFile,
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function anonymizeFile(SymfonyStyle $io, string $inputFile, string $targetFile, bool $keepState): int
|
||||
{
|
||||
$xml = file_get_contents($inputFile);
|
||||
if (false === $xml) {
|
||||
$io->error(sprintf('Unable to read input file: %s', $inputFile));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$anonymizedXml = $this->xmlAnonymizer->anonymize($xml, false === $keepState);
|
||||
} catch (\InvalidArgumentException|\RuntimeException $exception) {
|
||||
$io->error(sprintf('Failed to anonymize %s: %s', $inputFile, $exception->getMessage()));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$writeResult = file_put_contents($targetFile, $anonymizedXml);
|
||||
if (false === $writeResult) {
|
||||
$io->error(sprintf('Unable to write output file: %s', $targetFile));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function derivePairedFilename(string $filename): ?string
|
||||
{
|
||||
if (str_ends_with($filename, '_request.xml')) {
|
||||
return substr($filename, 0, -12).'_response.xml';
|
||||
}
|
||||
|
||||
if (str_ends_with($filename, '_response.xml')) {
|
||||
return substr($filename, 0, -13).'_request.xml';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function derivePairedOutputFilename(string $outputFile, string $inputFile, string $pairedInputFile): ?string
|
||||
{
|
||||
if (str_ends_with($inputFile, '_request.xml') && str_ends_with($pairedInputFile, '_response.xml')) {
|
||||
if (false === str_ends_with($outputFile, '_request.xml')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return substr($outputFile, 0, -12).'_response.xml';
|
||||
}
|
||||
|
||||
if (str_ends_with($inputFile, '_response.xml') && str_ends_with($pairedInputFile, '_request.xml')) {
|
||||
if (false === str_ends_with($outputFile, '_response.xml')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return substr($outputFile, 0, -13).'_request.xml';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\Repository\BookingEditDraftRepository;
|
||||
use App\Service\BookingEditDataLoaderService;
|
||||
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;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:draft:backfill-booking-number',
|
||||
description: 'Backfill booking numbers (vorgang) for existing drafts from API',
|
||||
)]
|
||||
class DraftBackfillBookingNumberCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BookingEditDraftRepository $draftRepository,
|
||||
private readonly BookingEditDataLoaderService $dataLoader,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be updated without actually updating');
|
||||
}
|
||||
|
||||
/**
|
||||
* @see Command::execute()
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = $input->getOption('dry-run');
|
||||
|
||||
$drafts = $this->draftRepository->findBy(['bookingNumber' => null]);
|
||||
|
||||
if (0 === \count($drafts)) {
|
||||
$io->success('No drafts without booking number found.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->title(sprintf('Processing %d draft(s)', \count($drafts)));
|
||||
|
||||
$updated = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($drafts as $draft) {
|
||||
$bookingId = $draft->getBookingId();
|
||||
$user = $draft->getUser();
|
||||
|
||||
$bookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
|
||||
|
||||
if (!$bookingData instanceof Booking) {
|
||||
$io->warning(sprintf('Could not fetch booking %d for user %s', $bookingId, $user->getEmail()));
|
||||
++$failed;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// bookingNumber maps to BPN XML vorgang
|
||||
$bookingNumber = $bookingData->bookingNumber;
|
||||
|
||||
if (null === $bookingNumber) {
|
||||
$io->warning(sprintf('No booking number (vorgang) found for booking %d', $bookingId));
|
||||
++$skipped;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$io->text(sprintf(
|
||||
'[DRY RUN] Would set booking number %d for draft %d (bookingId %d)',
|
||||
$bookingNumber,
|
||||
$draft->getId(),
|
||||
$bookingId
|
||||
));
|
||||
} else {
|
||||
$draft->setBookingNumber($bookingNumber);
|
||||
}
|
||||
|
||||
++$updated;
|
||||
}
|
||||
|
||||
if (false === $dryRun) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
$io->newLine();
|
||||
$io->table(
|
||||
['Status', 'Count'],
|
||||
[
|
||||
['Updated', $updated],
|
||||
['Failed (API error)', $failed],
|
||||
['Skipped (no booking number)', $skipped],
|
||||
]
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$io->note('Dry run - no changes were made.');
|
||||
} else {
|
||||
$io->success(sprintf('Updated %d draft(s).', $updated));
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Repository\BookingEditDraftRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
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;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:draft:cleanup',
|
||||
description: 'Delete booking edit drafts for departed travels',
|
||||
)]
|
||||
class DraftCleanupCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BookingEditDraftRepository $draftRepository,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be deleted without actually deleting');
|
||||
}
|
||||
|
||||
/**
|
||||
* @see Command::execute()
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = $input->getOption('dry-run');
|
||||
|
||||
if ($dryRun) {
|
||||
$expiredDrafts = $this->draftRepository->createQueryBuilder('d')
|
||||
->where('d.travelDate < :today')
|
||||
->setParameter('today', new \DateTimeImmutable('today'))
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$count = \count($expiredDrafts);
|
||||
|
||||
if (0 === $count) {
|
||||
$io->success('No expired drafts found.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->note(sprintf('[DRY RUN] Would delete %d expired draft(s)', $count));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$deletedCount = $this->draftRepository->deleteExpiredDrafts();
|
||||
|
||||
if (0 === $deletedCount) {
|
||||
$io->success('No expired drafts found.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->logger->info('Deleted expired booking edit drafts', [
|
||||
'count' => $deletedCount,
|
||||
]);
|
||||
|
||||
$io->success(sprintf('Deleted %d expired draft(s).', $deletedCount));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Repository\BookingEditDraftRepository;
|
||||
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;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:draft:inspect',
|
||||
description: 'Inspect booking edit drafts in the database',
|
||||
)]
|
||||
class DraftInspectCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BookingEditDraftRepository $draftRepository,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('days', 'd', InputOption::VALUE_OPTIONAL, 'Show only drafts older than N days', null)
|
||||
->addOption('booking-id', 'b', InputOption::VALUE_OPTIONAL, 'Filter by booking ID', null)
|
||||
->addOption('delete', null, InputOption::VALUE_NONE, 'Delete the listed drafts (use with caution)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$days = $input->getOption('days');
|
||||
$bookingId = $input->getOption('booking-id');
|
||||
$shouldDelete = $input->getOption('delete');
|
||||
|
||||
$qb = $this->draftRepository->createQueryBuilder('d')
|
||||
->join('d.user', 'u')
|
||||
->select('d', 'u')
|
||||
->orderBy('d.updatedAt', 'DESC');
|
||||
|
||||
if (null !== $days) {
|
||||
$cutoffDate = new \DateTimeImmutable(sprintf('-%d days', (int) $days));
|
||||
$qb->andWhere('d.updatedAt < :cutoff')
|
||||
->setParameter('cutoff', $cutoffDate);
|
||||
}
|
||||
|
||||
if (null !== $bookingId) {
|
||||
$qb->andWhere('d.bookingId = :bookingId')
|
||||
->setParameter('bookingId', (int) $bookingId);
|
||||
}
|
||||
|
||||
$drafts = $qb->getQuery()->getResult();
|
||||
|
||||
if (0 === \count($drafts)) {
|
||||
$io->success('No drafts found matching the criteria.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->title(sprintf('Found %d draft(s)', \count($drafts)));
|
||||
|
||||
$rows = [];
|
||||
foreach ($drafts as $draft) {
|
||||
$formData = $draft->getFormData();
|
||||
$participantCount = isset($formData['participants']) ? \count($formData['participants']) : 0;
|
||||
|
||||
$rows[] = [
|
||||
$draft->getId(),
|
||||
$draft->getBookingId(),
|
||||
$draft->getUser()->getEmail(),
|
||||
$draft->getTravelDate()->format('Y-m-d'),
|
||||
$draft->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
$draft->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||
$this->getAgeDays($draft->getUpdatedAt()),
|
||||
$participantCount,
|
||||
];
|
||||
}
|
||||
|
||||
$io->table(
|
||||
['ID', 'Booking ID', 'User Email', 'Travel Date', 'Created At', 'Updated At', 'Days Old', 'Participants'],
|
||||
$rows
|
||||
);
|
||||
|
||||
$io->section('Investigation Tips');
|
||||
$io->listing([
|
||||
'Check logs for "Booking update successful" with matching booking_id after the draft\'s updated_at',
|
||||
'If a successful update exists after draft creation, the draft should have been deleted (potential bug)',
|
||||
'Drafts are preserved when: user cancels, API rejects submission, or user simply leaves',
|
||||
]);
|
||||
|
||||
if ($shouldDelete) {
|
||||
if (false === $io->confirm(sprintf('Are you sure you want to delete %d draft(s)?', \count($drafts)), false)) {
|
||||
$io->warning('Deletion cancelled.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$em = $this->draftRepository->getEntityManager();
|
||||
foreach ($drafts as $draft) {
|
||||
$em->remove($draft);
|
||||
}
|
||||
$em->flush();
|
||||
|
||||
$io->success(sprintf('Deleted %d draft(s).', \count($drafts)));
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function getAgeDays(\DateTimeImmutable $date): string
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$diff = $now->diff($date);
|
||||
|
||||
if ($diff->days > 0) {
|
||||
return sprintf('%d days', $diff->days);
|
||||
}
|
||||
|
||||
if ($diff->h > 0) {
|
||||
return sprintf('%d hours', $diff->h);
|
||||
}
|
||||
|
||||
return sprintf('%d min', $diff->i);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Account;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
@@ -9,6 +11,9 @@ use App\BusProNet\Model\PersonalData;
|
||||
use App\Entity\User;
|
||||
use App\Form\PersonalDataType;
|
||||
use App\Security\Crypt;
|
||||
use App\Service\BookingEditDataLoaderService;
|
||||
use App\Service\ProfileCompletenessChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -25,14 +30,22 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
*/
|
||||
class PersonalDataController extends AbstractController
|
||||
{
|
||||
public const SESSION_REDIRECT_KEY = '_profile_completion_redirect';
|
||||
|
||||
/**
|
||||
* @param ApiClient $apiClient BusProNet API client for data operations
|
||||
* @param Crypt $crypt Encryption service for password handling
|
||||
* @param LoggerInterface $logger Logger for audit trails and debugging
|
||||
* @param ApiClient $apiClient BusProNet API client for data operations
|
||||
* @param Crypt $crypt Encryption service for password handling
|
||||
* @param BookingEditDataLoaderService $dataLoader Data loader for cache invalidation
|
||||
* @param ProfileCompletenessChecker $completenessChecker Profile validation service
|
||||
* @param EntityManagerInterface $entityManager Entity manager for persisting user changes
|
||||
* @param LoggerInterface $logger Logger for audit trails and debugging
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly Crypt $crypt,
|
||||
private readonly BookingEditDataLoaderService $dataLoader,
|
||||
private readonly ProfileCompletenessChecker $completenessChecker,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -87,10 +100,29 @@ class PersonalDataController extends AbstractController
|
||||
if ($personalDataForm->isSubmitted() && $personalDataForm->isValid()) {
|
||||
try {
|
||||
$this->apiClient->updatePersonalData($email, $password, $personalData);
|
||||
|
||||
// Invalidate cached bookings to ensure edit mode shows updated applicant data
|
||||
$this->dataLoader->invalidateUserBookingCaches($user);
|
||||
|
||||
// Update profile completeness flag on user entity
|
||||
$isComplete = $this->completenessChecker->isComplete($personalData);
|
||||
$user->setProfileComplete($isComplete);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Deine persönlichen Daten wurden aktualisiert');
|
||||
$this->logger->info('Updated personal data', [
|
||||
'email' => $user->getEmail(),
|
||||
]);
|
||||
|
||||
// Handle profile completion redirect
|
||||
$session = $request->getSession();
|
||||
$redirectUrl = $session->get(self::SESSION_REDIRECT_KEY);
|
||||
|
||||
if (null !== $redirectUrl && true === $isComplete) {
|
||||
$session->remove(self::SESSION_REDIRECT_KEY);
|
||||
|
||||
return $this->redirect($redirectUrl);
|
||||
}
|
||||
} catch (ApiClientException $e) {
|
||||
$this->addFlash('error', $e->getMessage());
|
||||
}
|
||||
@@ -130,7 +162,18 @@ class PersonalDataController extends AbstractController
|
||||
->getPersonalData($email, $password);
|
||||
} catch (ApiClientException $e) {
|
||||
$this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden');
|
||||
$personalData = new PersonalData();
|
||||
|
||||
return $this->redirectToRoute('app_personal_data');
|
||||
}
|
||||
|
||||
if ($personalData instanceof Notification) {
|
||||
$this->logger->error('Unable to fetch personal data', [
|
||||
'code' => $personalData->code,
|
||||
'error' => $personalData->message,
|
||||
]);
|
||||
$this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden');
|
||||
|
||||
return $this->redirectToRoute('app_personal_data');
|
||||
}
|
||||
|
||||
$personalData->communication->newsletter = !$personalData->communication->newsletter;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Admin\Field\JsonDataField;
|
||||
use App\Entity\BookingEditDraft;
|
||||
use App\Entity\User;
|
||||
use App\Service\BookingExportService;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class BookingEditDraftCrudController extends AbstractCrudController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BookingExportService $exportService,
|
||||
private readonly AdminUrlGenerator $adminUrlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getEntityFqcn(): string
|
||||
{
|
||||
return BookingEditDraft::class;
|
||||
}
|
||||
|
||||
public function configureActions(Actions $actions): Actions
|
||||
{
|
||||
$exportAction = Action::new('exportExcel', 'Excel Export', 'fa fa-file-excel')
|
||||
->linkToRoute(
|
||||
'admin_booking_draft_export',
|
||||
static fn (BookingEditDraft $entity): array => ['id' => $entity->getId()]
|
||||
)
|
||||
->displayIf(static fn (BookingEditDraft $entity): bool => $entity->hasExportData());
|
||||
|
||||
return $actions
|
||||
->add(Crud::PAGE_INDEX, Action::DETAIL)
|
||||
->add(Crud::PAGE_INDEX, $exportAction)
|
||||
->add(Crud::PAGE_DETAIL, $exportAction)
|
||||
->remove(Crud::PAGE_INDEX, Action::NEW)
|
||||
->remove(Crud::PAGE_INDEX, Action::EDIT)
|
||||
->remove(Crud::PAGE_DETAIL, Action::EDIT)
|
||||
;
|
||||
}
|
||||
|
||||
#[Route('/admin/booking-draft/{id}/export', name: 'admin_booking_draft_export', requirements: ['id' => '\d+'])]
|
||||
public function export(BookingEditDraft $draft): Response
|
||||
{
|
||||
if (false === $draft->hasExportData()) {
|
||||
$this->addFlash('danger', 'Export nicht möglich: Reisedaten fehlen');
|
||||
|
||||
$url = $this->adminUrlGenerator
|
||||
->setController(self::class)
|
||||
->setAction(Action::INDEX)
|
||||
->generateUrl();
|
||||
|
||||
return $this->redirect($url);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->exportService->createExportResponse($draft);
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->addFlash('danger', 'Export fehlgeschlagen: '.$e->getMessage());
|
||||
|
||||
$url = $this->adminUrlGenerator
|
||||
->setController(self::class)
|
||||
->setAction(Action::INDEX)
|
||||
->generateUrl();
|
||||
|
||||
return $this->redirect($url);
|
||||
}
|
||||
}
|
||||
|
||||
public function configureCrud(Crud $crud): Crud
|
||||
{
|
||||
return $crud
|
||||
->setEntityLabelInSingular('Buchungsentwurf')
|
||||
->setEntityLabelInPlural('Buchungsentwürfe')
|
||||
->setDefaultSort(['createdAt' => 'DESC']);
|
||||
}
|
||||
|
||||
public function configureFields(string $pageName): iterable
|
||||
{
|
||||
return [
|
||||
IntegerField::new('bookingNumber', 'Vorgang'),
|
||||
AssociationField::new('user', 'Kundenaccount')->formatValue(fn (User $user) => $user->getEmail()),
|
||||
DateTimeField::new('createdAt', 'erstellt am'),
|
||||
DateTimeField::new('updatedAt', 'aktualisiert am'),
|
||||
JsonDataField::new('formData', 'Daten')->onlyOnDetail(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\BookingEditDraft;
|
||||
use App\Entity\LogEntry;
|
||||
use App\Entity\User;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminDashboard;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\UserMenu;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
#[AdminDashboard(routePath: '/admin', routeName: 'admin')]
|
||||
class DashboardController extends AbstractDashboardController
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('admin/dashboard/index.html.twig');
|
||||
}
|
||||
|
||||
public function configureDashboard(): Dashboard
|
||||
{
|
||||
return Dashboard::new()
|
||||
->setTitle('MyE&P')
|
||||
->setLocales(['de'])
|
||||
->setFaviconPath('build/favicon/icon.svg')
|
||||
;
|
||||
}
|
||||
|
||||
public function configureUserMenu(UserInterface $user): UserMenu
|
||||
{
|
||||
return parent::configureUserMenu($user)
|
||||
->addMenuItems([
|
||||
MenuItem::linkToRoute('MyE&P', 'fa fa-user', 'app_account'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureMenuItems(): iterable
|
||||
{
|
||||
yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home');
|
||||
yield MenuItem::linkToCrud('Log', 'fa fa-list', LogEntry::class);
|
||||
yield MenuItem::linkToCrud('Buchungsentwürfe', 'fa fa-pen-to-square', BookingEditDraft::class);
|
||||
yield MenuItem::linkToCrud('Benutzeraccounts', 'fa fa-users', User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\LogEntry;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
|
||||
|
||||
class LogEntryCrudController extends AbstractCrudController
|
||||
{
|
||||
public static function getEntityFqcn(): string
|
||||
{
|
||||
return LogEntry::class;
|
||||
}
|
||||
|
||||
public function configureActions(Actions $actions): Actions
|
||||
{
|
||||
$viewDumps = Action::new('viewDumps', 'XML Dumps', 'fa fa-file-code')
|
||||
->linkToRoute('admin_xml_dump_list', static fn (LogEntry $entity): array => ['id' => $entity->getId()])
|
||||
->displayIf(static fn (LogEntry $entity): bool => 'bpn' === $entity->getChannel());
|
||||
|
||||
return $actions
|
||||
->add(Crud::PAGE_INDEX, Action::DETAIL)
|
||||
->add(Crud::PAGE_INDEX, $viewDumps)
|
||||
->add(Crud::PAGE_DETAIL, $viewDumps)
|
||||
->remove(Crud::PAGE_INDEX, Action::NEW)
|
||||
->remove(Crud::PAGE_INDEX, Action::EDIT)
|
||||
->remove(Crud::PAGE_INDEX, Action::DELETE)
|
||||
->remove(Crud::PAGE_DETAIL, Action::EDIT)
|
||||
->remove(Crud::PAGE_DETAIL, Action::DELETE)
|
||||
;
|
||||
}
|
||||
|
||||
public function configureCrud(Crud $crud): Crud
|
||||
{
|
||||
return $crud
|
||||
->setEntityLabelInSingular('Logeintrag')
|
||||
->setEntityLabelInPlural('Logeinträge')
|
||||
->setDefaultSort(['createdAt' => 'DESC']);
|
||||
}
|
||||
|
||||
public function configureFields(string $pageName): iterable
|
||||
{
|
||||
return [
|
||||
DateTimeField::new('createdAt', 'Zeitstempel'),
|
||||
TextField::new('errorCode', 'Fehlercode'),
|
||||
TextField::new('username', 'Benutzer'),
|
||||
TextField::new('message', 'Aktion'),
|
||||
TextField::new('requestId', 'Request ID'),
|
||||
TextField::new('uri', 'URI'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\User;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
|
||||
|
||||
class UserCrudController extends AbstractCrudController
|
||||
{
|
||||
public static function getEntityFqcn(): string
|
||||
{
|
||||
return User::class;
|
||||
}
|
||||
|
||||
public function configureActions(Actions $actions): Actions
|
||||
{
|
||||
return $actions
|
||||
->remove(Crud::PAGE_INDEX, Action::NEW)
|
||||
->remove(Crud::PAGE_INDEX, Action::EDIT)
|
||||
->remove(Crud::PAGE_INDEX, Action::DELETE)
|
||||
;
|
||||
}
|
||||
|
||||
public function configureCrud(Crud $crud): Crud
|
||||
{
|
||||
return $crud
|
||||
->setEntityLabelInSingular('Benutzeraccount')
|
||||
->setEntityLabelInPlural('Benutzeraccounts')
|
||||
->setDefaultSort(['lastLoginAt' => 'DESC']);
|
||||
}
|
||||
|
||||
public function configureFields(string $pageName): iterable
|
||||
{
|
||||
return [
|
||||
TextField::new('email', 'E-Mail'),
|
||||
DateTimeField::new('lastLoginAt', 'letzter Login'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Repository\LogEntryRepository;
|
||||
use App\Service\XmlDumpService;
|
||||
use League\Flysystem\FilesystemException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/admin/xml-dump')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
class XmlDumpController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly XmlDumpService $xmlDumpService,
|
||||
private readonly LogEntryRepository $logEntryRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'admin_xml_dump_list', methods: ['GET'])]
|
||||
public function list(int $id): Response
|
||||
{
|
||||
$logEntry = $this->logEntryRepository->find($id);
|
||||
|
||||
if (null === $logEntry) {
|
||||
throw new NotFoundHttpException('Log entry not found.');
|
||||
}
|
||||
|
||||
if ('bpn' !== $logEntry->getChannel()) {
|
||||
throw new NotFoundHttpException('XML dumps are only available for BPN channel entries.');
|
||||
}
|
||||
|
||||
$requestId = $logEntry->getRequestId();
|
||||
$dumps = [];
|
||||
|
||||
try {
|
||||
$dumps = $this->xmlDumpService->findDumpsForRequestId($requestId);
|
||||
} catch (FilesystemException) {
|
||||
// Dumps directory may not exist or be inaccessible
|
||||
}
|
||||
|
||||
return $this->render('admin/xml_dump/list.html.twig', [
|
||||
'logEntry' => $logEntry,
|
||||
'requestId' => $requestId,
|
||||
'dumps' => $dumps,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/download/{filename}', name: 'admin_xml_dump_download', methods: ['GET'], requirements: ['filename' => '.+'])]
|
||||
public function download(string $filename): Response
|
||||
{
|
||||
try {
|
||||
if (false === $this->xmlDumpService->fileExists($filename)) {
|
||||
throw new NotFoundHttpException('Dump file not found. It may have been cleaned up.');
|
||||
}
|
||||
|
||||
$content = $this->xmlDumpService->getContent($filename);
|
||||
} catch (FilesystemException $e) {
|
||||
throw new NotFoundHttpException('Failed to read dump file: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$response = new Response($content);
|
||||
$response->headers->set('Content-Type', 'application/xml');
|
||||
|
||||
$disposition = $response->headers->makeDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||
basename($filename)
|
||||
);
|
||||
$response->headers->set('Content-Disposition', $disposition);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\Model\ContactFormSubmission;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -20,7 +21,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[IsGranted('ROLE_OAUTH2_API')]
|
||||
class ContactFormController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly ApiClient $apiClient)
|
||||
public function __construct(private readonly ApiClient $apiClient, private readonly LoggerInterface $logger)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -33,12 +34,21 @@ class ContactFormController extends AbstractController
|
||||
$result = $this->apiClient->createAddress($personalData);
|
||||
|
||||
if ($result instanceof Notification) {
|
||||
$this->logger->info('Unable to submit contact form', [
|
||||
'message' => $result->message,
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => false,
|
||||
'message' => $result->message,
|
||||
], Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
$this->logger->info('Contact form submitted', [
|
||||
'addressId' => $result->addressId,
|
||||
'personId' => $result->personId,
|
||||
]);
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'addressId' => $result->addressId,
|
||||
@@ -46,6 +56,11 @@ class ContactFormController extends AbstractController
|
||||
'isNewRecord' => $result->isNewRecord,
|
||||
], Response::HTTP_CREATED);
|
||||
} catch (ApiClientException $e) {
|
||||
$this->logger->error('Unable to submit contact form', [
|
||||
'message' => $e->getMessage(),
|
||||
'dto' => $dto,
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
|
||||
@@ -47,7 +47,7 @@ class IndexController extends AbstractController
|
||||
path: '/bookings/create',
|
||||
name: 'app_booking_create',
|
||||
)]
|
||||
public function index(#[MapQueryString] ?BookingQueryParams $params): Response
|
||||
public function index(Request $request, #[MapQueryString] ?BookingQueryParams $params): Response
|
||||
{
|
||||
if (null === $params) {
|
||||
throw $this->createNotFoundException('Invalid booking parameters provided');
|
||||
@@ -92,9 +92,6 @@ class IndexController extends AbstractController
|
||||
// Store optional return URL in session (defaults to main EP site)
|
||||
$this->bookingService->storeReturnUrl($request, $params->returnUrl);
|
||||
|
||||
// Store theme for consistent styling throughout the booking flow
|
||||
$this->bookingService->storeTheme($request, $params->theme);
|
||||
|
||||
// Create fresh booking session with the provided parameters
|
||||
$bookingDto = $this->bookingService->startFreshBooking($request, $dateId, $hotelId, $agencyId);
|
||||
|
||||
@@ -106,7 +103,9 @@ class IndexController extends AbstractController
|
||||
|
||||
// Redirect to login page (optional authentication before Step 1)
|
||||
return $this->htmxRedirect($request, $this->generateUrl('app_login'));
|
||||
} catch (TravelNotFoundException|HotelNotFoundException|HotelNotInTravelException|NoRoomsAvailableException) {
|
||||
} catch (TravelNotFoundException|HotelNotFoundException|HotelNotInTravelException|NoRoomsAvailableException $e) {
|
||||
$this->addFlash('error', $e->getMessage());
|
||||
|
||||
return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_error'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,8 +110,6 @@ class Step1Controller extends AbstractController
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
$this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
|
||||
// Get complete summary data (pricing, rooms, CMS data)
|
||||
$summaryData = $this->summaryDataService->getSummaryData($bookingCreateDto);
|
||||
$availableRooms = $bookingCreateDto->travel->getAvailableRooms();
|
||||
|
||||
@@ -4,11 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Booking\Create;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\Controller\Booking\Traits\BookingCreateTrait;
|
||||
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
|
||||
use App\Controller\Booking\Traits\ParticipantCardFlowTrait;
|
||||
use App\Form\BookingCreateStep2Type;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Service\DummyDataFillService;
|
||||
use App\Form\Service\ParticipantFieldOptionsProvider;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Service\BookingService;
|
||||
@@ -18,6 +20,7 @@ use App\Service\ParticipantPrepopulationService;
|
||||
use App\Service\RoomAssignmentService;
|
||||
use App\Service\TravelDataService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -43,6 +46,7 @@ class Step2Controller extends AbstractController
|
||||
private readonly ParticipantCardDataService $participantCardService,
|
||||
private readonly ParticipantFieldOptionsProvider $fieldOptionsProvider,
|
||||
private readonly ParticipantPrepopulationService $prepopulationService,
|
||||
private readonly DummyDataFillService $dummyDataFillService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -146,6 +150,27 @@ class Step2Controller extends AbstractController
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
// Detect dummy data fill token — render pre-filled form immediately, skipping validation
|
||||
$isDummyDataFill = $this
|
||||
->dummyDataFillService
|
||||
->isTokenMatch($bookingDto->participants[$index], $bookingDto->getMode())
|
||||
;
|
||||
if (true === $form->isSubmitted() && true === $isDummyDataFill) {
|
||||
$this->dummyDataFillService->fill($bookingDto->participants[$index], $index);
|
||||
|
||||
// Keep behavior consistent with cards view: newly filled participant data
|
||||
// (especially dateOfBirth) must immediately trigger mandatory service preselection.
|
||||
$this->bookingService->preselectMandatoryServices($bookingDto);
|
||||
|
||||
$bookingDto->bookingStatus = Constants::BOOKING_STATUS_OPEN;
|
||||
$this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
|
||||
// Recreate form with filled DTO so the view shows the dummy data
|
||||
$form = $this->createParticipantForm($bookingDto, $index);
|
||||
|
||||
return $this->renderParticipantForm($form, $index, $bookingDto);
|
||||
}
|
||||
|
||||
// Collect notifications from field handlers (run during PRE_SUBMIT)
|
||||
$notifications = $this->collectAndClearNotifications($bookingDto);
|
||||
|
||||
@@ -160,18 +185,7 @@ class Step2Controller extends AbstractController
|
||||
return $this->redirectToRoute('app_booking_create_step_2');
|
||||
}
|
||||
|
||||
// Get complete summary data (pricing, rooms, CMS data)
|
||||
$summaryData = $this->summaryDataService->getSummaryData($bookingDto);
|
||||
|
||||
$templateData = [
|
||||
'form' => $form->createView(),
|
||||
'participantIndex' => $index,
|
||||
'bookingDto' => $bookingDto,
|
||||
'summaryData' => $summaryData,
|
||||
'refreshRouteName' => 'app_booking_create_step_2_participant_refresh',
|
||||
];
|
||||
|
||||
return $this->render('booking/create/step_2_participant.html.twig', $templateData);
|
||||
return $this->renderParticipantForm($form, $index, $bookingDto);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,4 +221,18 @@ class Step2Controller extends AbstractController
|
||||
'app_booking_create_step_2_participant_refresh'
|
||||
);
|
||||
}
|
||||
|
||||
private function renderParticipantForm(
|
||||
FormInterface $form,
|
||||
int $index,
|
||||
BookingDto $bookingDto,
|
||||
): Response {
|
||||
return $this->render('booking/create/step_2_participant.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'participantIndex' => $index,
|
||||
'bookingDto' => $bookingDto,
|
||||
'summaryData' => $this->summaryDataService->getSummaryData($bookingDto),
|
||||
'refreshRouteName' => 'app_booking_create_step_2_participant_refresh',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ class Step3Controller extends AbstractController
|
||||
return $this->handleApiError(
|
||||
'Booking inquiry failed',
|
||||
['message' => $inquiryResponse->message],
|
||||
$inquiryResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.',
|
||||
$inquiryResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuche es erneut oder wende dich an den Kundenservice.',
|
||||
$bookingCreateDto,
|
||||
$form
|
||||
);
|
||||
@@ -115,24 +115,28 @@ class Step3Controller extends AbstractController
|
||||
}
|
||||
|
||||
// Validate price match (rounded to cent precision to avoid floating-point errors)
|
||||
// API gesamtpreis includes promotional/goodwill voucher discounts (negative price items),
|
||||
// but NOT purchase vouchers (those reduce restzahlung, not gesamtpreis)
|
||||
// API gesamtpreis includes:
|
||||
// - Promotional/goodwill voucher discounts (negative price items with art=AKTION/KULANZGUTSCHEIN)
|
||||
// - API-applied automatic discounts (e.g., Gruppenrabatt with art=ERM)
|
||||
// - NOT purchase vouchers (those reduce restzahlung, not gesamtpreis)
|
||||
$apiTotal = round($inquiryResponse->totalPrice ?? 0.0, 2);
|
||||
$calculatedSubtotal = round($this->priceCalculator->calculateGrandTotal($bookingCreateDto), 2);
|
||||
$promoGoodwillDiscount = round($inquiryResponse->getVoucherDiscountFromPrices(), 2);
|
||||
$expectedTotal = round($calculatedSubtotal - $promoGoodwillDiscount, 2);
|
||||
$apiAppliedDiscount = round($inquiryResponse->getApiAppliedDiscountTotal(), 2);
|
||||
$expectedTotal = round($calculatedSubtotal - $promoGoodwillDiscount - $apiAppliedDiscount, 2);
|
||||
|
||||
if ($apiTotal !== $expectedTotal) {
|
||||
if ((int) round($apiTotal * 100) !== (int) round($expectedTotal * 100)) {
|
||||
return $this->handleApiError(
|
||||
'Price mismatch detected - payload incomplete',
|
||||
[
|
||||
'apiTotal' => $apiTotal,
|
||||
'calculatedSubtotal' => $calculatedSubtotal,
|
||||
'promoGoodwillDiscount' => $promoGoodwillDiscount,
|
||||
'apiAppliedDiscount' => $apiAppliedDiscount,
|
||||
'expectedTotal' => $expectedTotal,
|
||||
'difference' => abs($apiTotal - $expectedTotal),
|
||||
],
|
||||
'Ein technischer Fehler ist aufgetreten.',
|
||||
'Preisabweichung festgestellt. Bitte wende dich an den Kundenservice.',
|
||||
$bookingCreateDto,
|
||||
$form
|
||||
);
|
||||
@@ -154,7 +158,7 @@ class Step3Controller extends AbstractController
|
||||
'exception' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
],
|
||||
'Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut.',
|
||||
'Die Anfrage hat zu lange gedauert. Bitte versuche es erneut oder wende dich an den Kundenservice.',
|
||||
$bookingCreateDto,
|
||||
$form
|
||||
);
|
||||
@@ -165,7 +169,7 @@ class Step3Controller extends AbstractController
|
||||
'exception' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
],
|
||||
'Ein technischer Fehler ist aufgetreten.',
|
||||
'Ein allgemeiner Fehler ist aufgetreten. Bitte wende dich an den Kundenservice.',
|
||||
$bookingCreateDto,
|
||||
$form
|
||||
);
|
||||
@@ -192,8 +196,6 @@ class Step3Controller extends AbstractController
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
$this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
|
||||
return $this->renderStepForm($bookingCreateDto, $form);
|
||||
}
|
||||
|
||||
|
||||
@@ -92,11 +92,20 @@ class Step4Controller extends AbstractController
|
||||
);
|
||||
}
|
||||
|
||||
// Success: Store booking number in flash and clear session
|
||||
$this->addFlash('booking_number', $bookingResponse->transactionNumber);
|
||||
// Success: Store booking data in flash for conversion tracking
|
||||
$summaryData = $this->summaryDataService->getSummaryData($bookingCreateDto);
|
||||
$this->addFlash('booking_number', $bookingResponse->bookingNumber);
|
||||
$this->addFlash('booking_total', $summaryData->payableAmount);
|
||||
$this->addFlash('booking_travel_name', $bookingCreateDto->travel->label);
|
||||
$this->clearTravelDataCache($bookingCreateDto);
|
||||
$this->bookingService->clearBookingDto($request, BookingDto::MODE_CREATE);
|
||||
|
||||
$this->logger->info('Booking successfully created.', [
|
||||
'date_id' => $bookingCreateDto->travel->id,
|
||||
'hotel_id' => $bookingCreateDto->hotelId,
|
||||
'booking_number' => $bookingResponse->bookingNumber,
|
||||
]);
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_success');
|
||||
} catch (TimeoutException $e) {
|
||||
return $this->handleApiError(
|
||||
@@ -156,10 +165,5 @@ class Step4Controller extends AbstractController
|
||||
// Clear travel data cache (both local and remote variants)
|
||||
$this->cache->delete(sprintf('travel_unified_%d_%d_local', $dateId, $hotelId));
|
||||
$this->cache->delete(sprintf('travel_unified_%d_%d_remote', $dateId, $hotelId));
|
||||
|
||||
$this->logger->info('Cleared travel data cache after successful booking', [
|
||||
'dateId' => $dateId,
|
||||
'hotelId' => $hotelId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ class SuccessController extends AbstractController
|
||||
#[Route('/bookings/create/success', name: 'app_booking_create_success')]
|
||||
public function success(Request $request): Response
|
||||
{
|
||||
$bookingNumber = $request->getSession()->getFlashBag()->get('booking_number')[0] ?? null;
|
||||
$flashBag = $request->getSession()->getFlashBag();
|
||||
$bookingNumber = $flashBag->get('booking_number')[0] ?? null;
|
||||
$bookingTotal = $flashBag->get('booking_total')[0] ?? null;
|
||||
$travelName = $flashBag->get('booking_travel_name')[0] ?? null;
|
||||
$returnUrl = $this->bookingService->getReturnUrl($request);
|
||||
|
||||
// Redirect to return URL if no booking number (direct access or refresh)
|
||||
@@ -33,6 +36,8 @@ class SuccessController extends AbstractController
|
||||
|
||||
return $this->render('booking/create/success.html.twig', [
|
||||
'bookingNumber' => $bookingNumber,
|
||||
'bookingTotal' => $bookingTotal,
|
||||
'travelName' => $travelName,
|
||||
'returnUrl' => $returnUrl,
|
||||
]);
|
||||
}
|
||||
|
||||