Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97f8baba06 | ||
|
|
3ffa80e2bc | ||
|
|
ab1a21dffc | ||
|
|
bccd8c6fc3 | ||
|
|
2ea298854d | ||
|
|
b7e4f29813 | ||
|
|
7780646601 | ||
|
|
0f58f07757 | ||
|
|
ef263e064a | ||
|
|
7127b4d050 | ||
|
|
42744fa30f | ||
|
|
be3f6e6288 | ||
|
|
9291adfdff | ||
|
|
c9426eff3b | ||
|
|
cd350c212e | ||
|
|
1f1ac1a0cc | ||
|
|
2dd5b7ad64 | ||
|
|
62ac8038dc | ||
|
|
5f94f0487b | ||
|
|
64880355e8 | ||
|
|
1dc8489239 | ||
|
|
5831294d33 | ||
|
|
073bbdc0f8 | ||
|
|
ef75383885 | ||
|
|
bfb25a4051 | ||
|
|
f55febd545 | ||
|
|
d27b78cec1 | ||
|
|
1bb145e3be | ||
|
|
59b9a42009 | ||
|
|
a576fa2d23 | ||
|
|
14fe9bd44f | ||
|
|
cce59b8ed6 | ||
|
|
8904e1c7fe | ||
|
|
a0f763af63 | ||
|
|
667c9d4677 | ||
|
|
1622f2be6a | ||
|
|
28914d6586 | ||
|
|
cfc3957fec | ||
|
|
aee2eb848d | ||
|
|
42f17db542 | ||
|
|
6315fbd0f7 | ||
|
|
79e5a3c12d | ||
|
|
e9c1dd8b9d | ||
|
|
b1ff6989ec | ||
|
|
1d97a19982 | ||
|
|
c3519124b7 | ||
|
|
05c769bab2 | ||
|
|
c1539a47f9 | ||
|
|
f54f66cd32 | ||
|
|
c8a33cdf43 | ||
|
|
b4c03160ca | ||
|
|
72cd36a573 | ||
|
|
4d3a4b7c04 | ||
|
|
b9dfeb2d3e | ||
|
|
e42ef2d3c9 | ||
|
|
bb69a28ec2 | ||
|
|
e8c2261b7e | ||
|
|
7665385cbf | ||
|
|
346dee23e4 | ||
|
|
109f36e7f5 | ||
|
|
0e9c5e6e1a | ||
|
|
145a08e511 | ||
|
|
2b8046b416 | ||
|
|
e27b5da3c8 | ||
|
|
613e0b9a5c | ||
|
|
0564e3c424 | ||
|
|
32c73ab7b1 | ||
|
|
420efb25ad | ||
|
|
150998e120 | ||
|
|
31f414b4d8 | ||
|
|
9c6860ecc6 | ||
|
|
6b32b6baa4 | ||
|
|
3527be9c29 | ||
|
|
0239aee89a | ||
|
|
f21f4a0385 | ||
|
|
fa2b5e0d7f | ||
|
|
1ac7720765 | ||
|
|
4182c30b72 | ||
|
|
e4aad8a456 | ||
|
|
d78638e6e5 | ||
|
|
1726523eaf | ||
|
|
1aa564960d | ||
|
|
cd2765bab5 | ||
|
|
390ee81675 | ||
|
|
6dbbfd87f8 | ||
|
|
118f86b2a0 | ||
|
|
27d77624da | ||
|
|
20a67e9b96 | ||
|
|
233748a6c5 | ||
|
|
67491a7f68 | ||
|
|
c7129d5791 | ||
|
|
4abee677f6 | ||
|
|
21600dc2d6 | ||
|
|
f0b84c9943 | ||
|
|
59418adc02 | ||
|
|
de52907eec | ||
|
|
bfb1a04ea0 | ||
|
|
b206c3990b | ||
|
|
54a37c5bf7 | ||
|
|
fa98ceac29 | ||
|
|
1bab230f10 | ||
|
|
5b55d0984a | ||
|
|
2b3a8f1c69 | ||
|
|
696800aafd | ||
|
|
e7997f84eb | ||
|
|
8d3a7b99b4 | ||
|
|
61a8afdde7 | ||
|
|
1f3e79d57c | ||
|
|
f4691ec67e | ||
|
|
32bef9a4e9 | ||
|
|
30704f936a | ||
|
|
9167c84b4c | ||
|
|
f5f5d43ab5 | ||
|
|
9b2ad3845d | ||
|
|
e2513d6245 | ||
|
|
126fce9687 | ||
|
|
c1364a458b | ||
|
|
ecc4741f56 | ||
|
|
f33e55f201 | ||
|
|
ce1b9ece09 | ||
|
|
9d28d2f102 | ||
|
|
aaa7e9f4c3 | ||
|
|
26681e5c62 | ||
|
|
33c3dc6022 | ||
|
|
a6b0051f1b | ||
|
|
d0d1edb3ae | ||
|
|
6e96f6fb44 | ||
|
|
c6b28e7f4e | ||
|
|
3ce4d81a4c | ||
|
|
d5c572c25b | ||
|
|
bcded403be | ||
|
|
aeb25f3b7e | ||
|
|
421851cc4c | ||
|
|
66eb392151 | ||
|
|
beaccd93e0 | ||
|
|
111cda95f3 | ||
|
|
84ed41f325 | ||
|
|
e7cf172b9f | ||
|
|
e52cb98163 | ||
|
|
d85e81e8b9 | ||
|
|
1e5fe23159 | ||
|
|
2f78ddb8ad | ||
|
|
f19762d686 | ||
|
|
eba2f8e503 | ||
|
|
0a0bef9e82 | ||
|
|
29f415a91f | ||
|
|
a619add4e3 | ||
|
|
34a8d42fc3 | ||
|
|
cfd346d5df | ||
|
|
3d694ed7eb | ||
|
|
5021f414d1 | ||
|
|
51dbf20118 | ||
|
|
a50bd205cd | ||
|
|
da7b4dfb48 | ||
|
|
eee7be0c1e | ||
|
|
d55a201681 | ||
|
|
ba0eab9e96 | ||
|
|
b63804df88 | ||
|
|
647a25a78f | ||
|
|
6b0dfb3426 | ||
|
|
c8bb051ae6 | ||
|
|
b326510b04 | ||
|
|
293574187f | ||
|
|
b74f84e8ad | ||
|
|
19a272db96 | ||
|
|
67843c2f2e | ||
|
|
a01e966d6d | ||
|
|
9140b97a71 | ||
|
|
405f03ee7a | ||
|
|
b88ad24c3d | ||
|
|
bb630da5ed | ||
|
|
39d944351c | ||
|
|
2b272ef789 | ||
|
|
3e54a58b9b | ||
|
|
60176d19f4 | ||
|
|
9c716477aa | ||
|
|
be2fffad23 | ||
|
|
34230e5520 | ||
|
|
1949bf9b97 | ||
|
|
7ab60b1048 | ||
|
|
0e6c31b8ce | ||
|
|
a2cfb693e0 | ||
|
|
10df3ebb35 | ||
|
|
ab91c138d5 | ||
|
|
cc1d1e5746 | ||
|
|
8c75d0070d | ||
|
|
4df04d0504 | ||
|
|
38bc594ca4 | ||
|
|
fb9c9d91ee | ||
|
|
ee69346eb4 | ||
|
|
e6c47019d5 | ||
|
|
b894bbf5e6 | ||
|
|
0f8fed605c | ||
|
|
479b177e90 | ||
|
|
08210b8e52 | ||
|
|
b5dfe17b73 | ||
|
|
49b5ccf308 | ||
|
|
434c4d0a11 | ||
|
|
dc7ed0728a | ||
|
|
b1dbb15ad7 | ||
|
|
e76170b6dd | ||
|
|
8225d4e92d | ||
|
|
a5addf8d33 | ||
|
|
0542a669bd | ||
|
|
49b7a3fe34 | ||
|
|
ead2c092da | ||
|
|
6e818c3480 | ||
|
|
a02490ea36 | ||
|
|
9eda12981a | ||
|
|
4939a1a9ad | ||
|
|
989f599b54 | ||
|
|
67c642bc2f | ||
|
|
cba0f747cd | ||
|
|
c3008d7f7a | ||
|
|
7304fb50c6 | ||
|
|
ec1bba7c46 | ||
|
|
b66d453881 | ||
|
|
b7b80296e9 | ||
|
|
2f582f0548 | ||
|
|
fad28c2442 | ||
|
|
7f1ef4059d | ||
|
|
139ec3c1db | ||
|
|
798beae597 | ||
|
|
3edf5a80aa | ||
|
|
fc93b28af0 | ||
|
|
cd82b70139 | ||
|
|
fba9edc931 | ||
|
|
8e43edcbaf | ||
|
|
8984b2edd5 | ||
|
|
32ec2c1deb | ||
|
|
25029dff5d | ||
|
|
875b181aff | ||
|
|
0f3d9b87fa | ||
|
|
d7929dd855 | ||
|
|
8a495346d6 | ||
|
|
fef4997132 | ||
|
|
b4eaf695c7 | ||
|
|
372490ed5a | ||
|
|
e37b6234e8 | ||
|
|
71b42786f7 | ||
|
|
009eabd09c | ||
|
|
49a4393980 | ||
|
|
c0e20a15fe | ||
|
|
061317ed4c | ||
|
|
a5dce776de | ||
|
|
90ae78262a | ||
|
|
0809f07d46 | ||
|
|
199ef08e35 | ||
|
|
4930a2ba42 | ||
|
|
0ceabe465b | ||
|
|
bd7b70e465 | ||
|
|
55a24b99f0 | ||
|
|
122df1a3f5 | ||
|
|
391dd63ebf | ||
|
|
9d44197246 | ||
|
|
582fb9b52d | ||
|
|
eed7ba4d7b | ||
|
|
5642193610 | ||
|
|
1a1e120cf6 | ||
|
|
cf66b85993 | ||
|
|
8677e9a8a5 | ||
|
|
906f591979 | ||
|
|
97cc0ac422 | ||
|
|
bdc7100f74 | ||
|
|
29a51ed66d | ||
|
|
c9a258a350 | ||
|
|
096dd96db0 | ||
|
|
ddd5971c06 | ||
|
|
b7638f5e9e | ||
|
|
b197152f12 | ||
|
|
297becf7a0 | ||
|
|
b6f991b291 | ||
|
|
5469c834a6 | ||
|
|
a3ea02a136 | ||
|
|
195246af89 | ||
|
|
2953ed0b2d | ||
|
|
ccb2fa75ac | ||
|
|
a33e6a0dd4 | ||
|
|
435e3a79e8 | ||
|
|
21054f0c45 | ||
|
|
605b6ca64c | ||
|
|
bc9616e7de | ||
|
|
491169b9d7 | ||
|
|
0e0ce254a9 | ||
|
|
7b25fbf28b | ||
|
|
2a5100e76d | ||
|
|
e98695ea06 | ||
|
|
1c2421fb7e | ||
|
|
76aeb101bd | ||
|
|
60b655a195 | ||
|
|
369d660e24 | ||
|
|
47703dc205 | ||
|
|
25bbf3d44f | ||
|
|
803b10f981 | ||
|
|
b992e4a5ea | ||
|
|
9c5597eb23 | ||
|
|
2ec61d5086 | ||
|
|
8bae958e24 | ||
|
|
e7f0c09b36 | ||
|
|
ae2ed306b9 | ||
|
|
2b122a7cc4 | ||
|
|
787e76c21c | ||
|
|
7b1f2e7468 | ||
|
|
3f94c51bc8 | ||
|
|
64d51c01b2 | ||
|
|
955dbd2f05 | ||
|
|
318a326fdb | ||
|
|
86223dc8a6 | ||
|
|
4bb62830a4 | ||
|
|
4ce731bfcb | ||
|
|
f225dac5fe | ||
|
|
5e1f2a8711 | ||
|
|
8746397d83 | ||
|
|
e8c27cb51b | ||
|
|
d2a80ea8fd | ||
|
|
c85c72863c | ||
|
|
77afc0fe7b | ||
|
|
7a53908b80 | ||
|
|
ca4495169f | ||
|
|
d1344bbfb4 |
No files matched your search
@@ -0,0 +1,11 @@
|
||||
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,11 @@
|
||||
#!/bin/bash
|
||||
#ddev-generated
|
||||
|
||||
## Description: OpenCode AI coding assistant
|
||||
## Usage: opencode
|
||||
## Example: ddev opencode [...]
|
||||
|
||||
set -e
|
||||
|
||||
opencode $@
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#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,51 +1,44 @@
|
||||
type: php
|
||||
docroot: public
|
||||
php_version: "8.3"
|
||||
php_version: "8.2"
|
||||
webserver_type: apache-fpm
|
||||
xdebug_enabled: false
|
||||
additional_hostnames:
|
||||
- mysnz
|
||||
- mysbw
|
||||
- myser
|
||||
additional_hostnames: []
|
||||
additional_fqdns: []
|
||||
database:
|
||||
type: mysql
|
||||
version: "8.4"
|
||||
type: mariadb
|
||||
version: "10.11"
|
||||
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, 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
|
||||
# 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
|
||||
# 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.3" # PHP version to use, "5.6" through "8.5"
|
||||
# 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"
|
||||
|
||||
# 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>
|
||||
# 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.*
|
||||
# webimage: <docker_image> # nginx/php docker image.
|
||||
|
||||
# database:
|
||||
# type: <dbtype> # mysql, mariadb, postgres
|
||||
# version: <version> # database version, like "10.11" or "8.0"
|
||||
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8
|
||||
# MySQL versions can be 5.5-8.0, 8.4
|
||||
# PostgreSQL versions can be 9-18
|
||||
# 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.
|
||||
|
||||
# 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)
|
||||
@@ -55,21 +48,12 @@ nodejs_version: "22"
|
||||
# "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.
|
||||
|
||||
# xhgui_http_port: "8143"
|
||||
# xhgui_https_port: "8142"
|
||||
# The XHGui ports can be changed from the default 8143 and 8142
|
||||
# Very rarely used
|
||||
# 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.
|
||||
|
||||
# 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
|
||||
# webserver_type: nginx-fpm, apache-fpm, or nginx-gunicorn
|
||||
|
||||
# timezone: Europe/Berlin
|
||||
# If timezone is unset, DDEV will attempt to derive it from the host system timezone
|
||||
@@ -95,9 +79,9 @@ nodejs_version: "22"
|
||||
# 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: "22"
|
||||
# nodejs_version: "20"
|
||||
# change from the default system Node.js version to any other version.
|
||||
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
|
||||
# See https://ddev.readthedocs.io/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'.
|
||||
@@ -133,7 +117,7 @@ nodejs_version: "22"
|
||||
|
||||
# ddev_version_constraint: ""
|
||||
# Example:
|
||||
# ddev_version_constraint: ">= 1.24.8"
|
||||
# ddev_version_constraint: ">= 1.22.4"
|
||||
# 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
|
||||
@@ -162,8 +146,8 @@ nodejs_version: "22"
|
||||
# - "mutagen": enables Mutagen for this project.
|
||||
# - "nfs": enables NFS for this project.
|
||||
#
|
||||
# See https://docs.ddev.com/en/stable/users/install/performance/#nfs
|
||||
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
|
||||
# See https://ddev.readthedocs.io/en/stable/users/install/performance/#nfs
|
||||
# See https://ddev.readthedocs.io/en/stable/users/install/performance/#mutagen
|
||||
|
||||
# fail_on_hook_fail: False
|
||||
# Decide whether 'ddev start' should be interrupted by a failing hook
|
||||
@@ -212,7 +196,7 @@ nodejs_version: "22"
|
||||
|
||||
# ngrok_args: --basic-auth username:pass1234
|
||||
# Provide extra flags to the "ngrok http" command, see
|
||||
# https://ngrok.com/docs/agent/config/v3/#agent-configuration or run "ngrok http -h"
|
||||
# https://ngrok.com/docs/ngrok-agent/config or run "ngrok http -h"
|
||||
|
||||
# disable_settings_management: false
|
||||
# If true, DDEV will not create CMS-specific settings files like
|
||||
@@ -290,7 +274,7 @@ nodejs_version: "22"
|
||||
# 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://docs.ddev.com/en/stable/users/extend/custom-commands/ for more
|
||||
# See https://ddev.readthedocs.io/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:
|
||||
@@ -0,0 +1,5 @@
|
||||
#ddev-generated
|
||||
|
||||
# OpenCode internal config
|
||||
/.opencode/
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#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=8.4.0&charset=utf8mb4"
|
||||
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.11-MariaDB&charset=utf8mb4"
|
||||
# DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
@@ -40,54 +40,26 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
||||
MAILER_DSN=null://null
|
||||
###< symfony/mailer ###
|
||||
|
||||
MAILJET_API_KEY=
|
||||
MAILJET_API_SECRET=
|
||||
MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST
|
||||
MAILJET_DEFAULT_LIST_ID=
|
||||
# Development default for HTTP Basic Auth password; override in production secrets.
|
||||
MAILJET_WEBHOOK_BASIC_PASSWORD=secret
|
||||
APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1
|
||||
|
||||
APP_BPN_USER=
|
||||
APP_BPN_PASSWORD=
|
||||
APP_BPN_IP=
|
||||
APP_BPN_PORTS=
|
||||
APP_BPN_PORT=
|
||||
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_BASE_URL="https://my.ep-reisen.de"
|
||||
APP_CUSTOMER_SERVICE_EMAIL="[email protected]"
|
||||
APP_INSURANCE_REVOCATION_URL="https://www.hmrv.de/daten-aendern/vertrag-beenden/widerruf"
|
||||
|
||||
# Travel Data Service Configuration
|
||||
APP_TRAVEL_PREFER_REMOTE=false
|
||||
APP_TRAVEL_ENABLE_FALLBACK=true
|
||||
APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
|
||||
|
||||
# Feature Flags
|
||||
FEATURE_TRAVEL_SNAPSHOT=true
|
||||
|
||||
# Emails
|
||||
APP_DEFAULT_EMAIL_FROM=[email protected]
|
||||
APP_DEFAULT_EMAIL_TO=[email protected]
|
||||
|
||||
# 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"
|
||||
XML_EXPORT_CONTINGENTS_PATH="%kernel.project_dir%/var/xmlexportzimmer"
|
||||
JSON_EXPORT_PATH="%kernel.project_dir%/var/jsonexport"
|
||||
|
||||
SFTP_XML_EXPORT_HOST=
|
||||
@@ -95,11 +67,6 @@ SFTP_XML_EXPORT_PORT=
|
||||
SFTP_XML_EXPORT_USER=
|
||||
SFTP_XML_EXPORT_PASSWORD=
|
||||
|
||||
SFTP_XML_EXPORT_CONTINGENTS_HOST=
|
||||
SFTP_XML_EXPORT_CONTINGENTS_PORT=
|
||||
SFTP_XML_EXPORT_CONTINGENTS_USER=
|
||||
SFTP_XML_EXPORT_CONTINGENTS_PASSWORD=
|
||||
|
||||
###> league/oauth2-server-bundle ###
|
||||
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/secret/private.key
|
||||
OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/secret/public.key
|
||||
|
||||
@@ -4,4 +4,3 @@ APP_SECRET='$ecretf0rt3st'
|
||||
SYMFONY_DEPRECATIONS_HELPER=999999
|
||||
PANTHER_APP_ENV=panther
|
||||
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
|
||||
MAILJET_WEBHOOK_BASIC_PASSWORD=secret
|
||||
@@ -31,9 +31,3 @@ yarn-error.log
|
||||
/.php-cs-fixer.php
|
||||
/.php-cs-fixer.cache
|
||||
###< friendsofphp/php-cs-fixer ###
|
||||
|
||||
/ray.php
|
||||
|
||||
###> phpstan/phpstan ###
|
||||
phpstan.neon
|
||||
###< phpstan/phpstan ###
|
||||
@@ -60,7 +60,7 @@ Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
### API travel
|
||||
# @no-cookie-jar
|
||||
GET {{base_url}}/api/travels/DPWMP140326
|
||||
GET {{base_url}}/api/travels/SBW131225
|
||||
Accept: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
@@ -72,7 +72,7 @@ Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
### API hotel availability remote
|
||||
# @no-cookie-jar
|
||||
GET {{base_url}}/api/travels/11606/152546/2026-01-24/availability
|
||||
GET {{base_url}}/api/travels/11603/152546/2026-01-03/availability
|
||||
Accept: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
@@ -90,35 +90,14 @@ Content-Type: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_api")}}
|
||||
|
||||
{
|
||||
"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 newsletter lists map
|
||||
# @no-cookie-jar
|
||||
GET {{base_url}}/api/newsletters
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_newsletter")}}
|
||||
|
||||
### API newsletter subscriptions
|
||||
# @no-cookie-jar
|
||||
POST {{base_url}}/api/newsletter-subscriptions
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{$auth.token("oauth2_newsletter")}}
|
||||
|
||||
{
|
||||
"email": "{{$random.email}}",
|
||||
"firstName": "{{$random.address.firstName}}",
|
||||
"lastName": "{{$random.address.lastName}}",
|
||||
"listIds": [10321569,10321382,10321383]
|
||||
"lastName": "Duschen",
|
||||
"firstName": "Isolde",
|
||||
"gender": "f",
|
||||
"street": "Fooroad 1",
|
||||
"zipCode": "12345",
|
||||
"city": "Bartown",
|
||||
"email": "[email protected]",
|
||||
"phone": "12345"
|
||||
}
|
||||
|
||||
### API pickups planning webhook
|
||||
|
||||
@@ -13,12 +13,3 @@ htmx.config.allowScriptTags = false
|
||||
htmx.config.withCredentials = true
|
||||
htmx.config.selfRequestsOnly = false
|
||||
htmx.config.timeout = 50000 // 50 seconds - slightly higher than backend timeout (45s)
|
||||
|
||||
document.body.addEventListener('htmx:beforeSwap', (event) => {
|
||||
// Symfony renders invalid forms with 422; let HTMX swap those responses so inline
|
||||
// validation errors are actually shown to the user.
|
||||
if (event.detail?.xhr?.status === 422) {
|
||||
event.detail.shouldSwap = true
|
||||
event.detail.isError = false
|
||||
}
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static outlets = [ 'mobilenav' ]
|
||||
static classes = [ 'closed' ]
|
||||
|
||||
trigger({ params: { mode } }) {
|
||||
this.mobilenavOutlet.toggle(mode)
|
||||
}
|
||||
|
||||
toggle(mode) {
|
||||
// Add animation classes only now to avoid fouc
|
||||
this.element.classList.add('transition-transform', 'duration-300')
|
||||
this.element.classList.toggle(this.closedClass, 'close' === mode)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
|
||||
static targets = [ 'field' ]
|
||||
|
||||
toggle() {
|
||||
let type = this.fieldTarget.getAttribute('type') === 'password' ? 'text' : 'password'
|
||||
this.fieldTarget.setAttribute('type', type)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
|
||||
static classes = ['closed', 'iconClosed', 'iconExpanded']
|
||||
static targets = ['submenu', 'button', 'icon']
|
||||
static values = { expanded: Boolean }
|
||||
|
||||
toggleSubmenu() {
|
||||
this.expandedValue = !this.expandedValue
|
||||
}
|
||||
|
||||
expandedValueChanged(expanded) {
|
||||
this.submenuTarget.classList.toggle(this.closedClass, false === expanded)
|
||||
if (false === this.hasButtonTarget) {
|
||||
return
|
||||
}
|
||||
this.buttonTarget.setAttribute('aria-expanded', expanded)
|
||||
if (false === this.hasIconTarget) {
|
||||
return
|
||||
}
|
||||
if (true === expanded) {
|
||||
this.iconTarget.classList.add(...this.iconExpandedClasses)
|
||||
this.iconTarget.classList.remove(...this.iconClosedClasses)
|
||||
} else {
|
||||
this.iconTarget.classList.add(...this.iconClosedClasses)
|
||||
this.iconTarget.classList.remove(...this.iconExpandedClasses)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,24 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,4 +66,4 @@ export default class extends Controller {
|
||||
|
||||
return typeMap[type] || 'toastify--info'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 44 KiB |
@@ -1,9 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 628 B |
@@ -1,250 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="icon-table" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 1.5v-1.5m0 0c0-.621.504-1.125 1.125-1.125m0 0h7.5" />
|
||||
</symbol>
|
||||
<symbol id="icon-dots" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
|
||||
</symbol>
|
||||
<symbol id="icon-feedback" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
|
||||
</symbol>
|
||||
<symbol id="icon-login" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||
</symbol>
|
||||
<symbol id="icon-logout" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||
</symbol>
|
||||
<symbol id="icon-alert" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</symbol>
|
||||
<symbol id="icon-close" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</symbol>
|
||||
<symbol id="icon-back" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" />
|
||||
</symbol>
|
||||
<symbol id="icon-list" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
|
||||
</symbol>
|
||||
<symbol id="icon-menu" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</symbol>
|
||||
<symbol id="icon-edit" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</symbol>
|
||||
<symbol id="icon-plus" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</symbol>
|
||||
<symbol id="icon-minus" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15" />
|
||||
</symbol>
|
||||
<symbol id="icon-search" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</symbol>
|
||||
<symbol id="icon-user" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</symbol>
|
||||
<symbol id="icon-users" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</symbol>
|
||||
<symbol id="icon-delete" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</symbol>
|
||||
<symbol id="icon-check" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</symbol>
|
||||
<symbol id="icon-upload" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
|
||||
</symbol>
|
||||
<symbol id="icon-download" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</symbol>
|
||||
<symbol id="icon-sort" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
|
||||
</symbol>
|
||||
<symbol id="icon-arrow-left" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15m0 0l6.75 6.75M4.5 12l6.75-6.75" />
|
||||
</symbol>
|
||||
<symbol id="icon-arrow-right" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" />
|
||||
</symbol>
|
||||
<symbol id="icon-document" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</symbol>
|
||||
<symbol id="icon-filter" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
|
||||
</symbol>
|
||||
<symbol id="icon-refresh" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</symbol>
|
||||
<symbol id="icon-settings" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</symbol>
|
||||
<symbol id="icon-at" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" d="M16.5 12a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zm0 0c0 1.657 1.007 3 2.25 3S21 13.657 21 12a9 9 0 10-2.636 6.364M16.5 12V8.25" />
|
||||
</symbol>
|
||||
<symbol id="icon-mail" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</symbol>
|
||||
<symbol id="icon-house" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 21v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21m0 0h4.5V3.545M12.75 21h7.5V10.75M2.25 21h1.5m18 0h-18M2.25 9l4.5-1.636M18.75 3l-1.5.545m0 6.205l3 1m1.5.5l-1.5-.5M6.75 7.364V3h-3v18m3-13.636l10.5-3.819" />
|
||||
</symbol>
|
||||
<symbol id="icon-phone" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" />
|
||||
</symbol>
|
||||
<symbol id="icon-mobile" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||||
</symbol>
|
||||
<symbol id="icon-calendar" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5m-9-6h.008v.008H12v-.008zM12 15h.008v.008H12V15zm0 2.25h.008v.008H12v-.008zM9.75 15h.008v.008H9.75V15zm0 2.25h.008v.008H9.75v-.008zM7.5 15h.008v.008H7.5V15zm0 2.25h.008v.008H7.5v-.008zm6.75-4.5h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V15zm0 2.25h.008v.008h-.008v-.008zm2.25-4.5h.008v.008H16.5v-.008zm0 2.25h.008v.008H16.5V15z" />
|
||||
</symbol>
|
||||
<symbol id="icon-hand" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.05 4.575a1.575 1.575 0 10-3.15 0v3m3.15-3v-1.5a1.575 1.575 0 013.15 0v1.5m-3.15 0l.075 5.925m3.075.75V4.575m0 0a1.575 1.575 0 013.15 0V15M6.9 7.575a1.575 1.575 0 10-3.15 0v8.175a6.75 6.75 0 006.75 6.75h2.018a5.25 5.25 0 003.712-1.538l1.732-1.732a5.25 5.25 0 001.538-3.712l.003-2.024a.668.668 0 01.198-.471 1.575 1.575 0 10-2.228-2.228 3.818 3.818 0 00-1.12 2.687M6.9 7.575V12m6.27 4.318A4.49 4.49 0 0116.35 15m.002 0h-.002" />
|
||||
</symbol>
|
||||
<symbol id="icon-copy" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6" />
|
||||
</symbol>
|
||||
<symbol id="icon-photo" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
|
||||
</symbol>
|
||||
<symbol id="icon-print" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0110.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0l.229 2.523a1.125 1.125 0 01-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0021 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 00-1.913-.247M6.34 18H5.25A2.25 2.25 0 013 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 011.913-.247m10.5 0a48.536 48.536 0 00-10.5 0m10.5 0V3.375c0-.621-.504-1.125-1.125-1.125h-8.25c-.621 0-1.125.504-1.125 1.125v3.659M18 10.5h.008v.008H18V10.5zm-3 0h.008v.008H15V10.5z" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="icon-locked" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
</symbol>
|
||||
<symbol id="icon-unlocked" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 10.5V6.75a4.5 4.5 0 119 0v3.75M3.75 21.75h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H3.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
</symbol>
|
||||
<symbol id="icon-external-link" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 6V8H5V19H16V14H18V20C18 20.5523 17.5523 21 17 21H4C3.44772 21 3 20.5523 3 20V7C3 6.44772 3.44772 6 4 6H10ZM21 3V11H19L18.9999 6.413L11.2071 14.2071L9.79289 12.7929L17.5849 5H13V3H21Z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-cookie" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21.1634 10.9711C21.5132 10.8204 21.9579 11.0073 21.9811 11.3875C22.1494 14.1467 21.1794 16.9625 19.071 19.0709C15.1657 22.976 8.83405 22.9761 4.92886 19.0709C1.02367 15.1657 1.02377 8.83405 4.92885 4.9288C7.03704 2.82061 9.8525 1.85015 12.6115 2.01806C12.9916 2.04119 13.1787 2.48598 13.028 2.83576C12.4774 4.11367 12.7239 5.65275 13.7677 6.69657C14.3307 7.25963 15.038 7.58971 15.7706 7.68848C16.0483 7.72594 16.2738 7.95144 16.3113 8.2292C16.4101 8.96178 16.7402 9.66905 17.3032 10.2321C18.347 11.2759 19.8857 11.5218 21.1634 10.9711ZM19.7845 13.8397C19.8558 13.5362 19.6123 13.2603 19.3017 13.2337C18.0569 13.1271 16.8417 12.599 15.889 11.6463C15.3166 11.0739 14.8979 10.4061 14.6309 9.69455C14.5744 9.54422 14.4556 9.42534 14.3052 9.36892C13.5936 9.10186 12.9259 8.68322 12.3535 8.11078C11.4005 7.15778 10.8715 5.94208 10.7648 4.69683C10.7381 4.38618 10.4621 4.14264 10.1586 4.2141C8.76039 4.54327 7.43341 5.25267 6.34306 6.34301C3.21903 9.46721 3.21893 14.5326 6.34307 17.6567C9.46721 20.7809 14.5326 20.7808 17.6568 17.6567C18.7474 16.566 19.4557 15.2384 19.7845 13.8397ZM9.8786 14.1212C10.4644 14.707 10.4644 15.6567 9.8786 16.2425C9.29281 16.8283 8.34307 16.8283 7.75728 16.2425C7.17149 15.6567 7.17149 14.707 7.75728 14.1212C8.34306 13.5354 9.29281 13.5354 9.8786 14.1212ZM10.2321 8.11078C10.8179 8.69657 10.8179 9.64631 10.2321 10.2321C9.64636 10.8179 8.69661 10.8179 8.11083 10.2321C7.52504 9.64631 7.52504 8.69656 8.11083 8.11078C8.69661 7.52499 9.64636 7.52499 10.2321 8.11078ZM15.889 13.7676C16.4748 14.3534 16.4748 15.3032 15.889 15.889C15.3032 16.4747 14.3535 16.4747 13.7677 15.889C13.1819 15.3032 13.1819 14.3534 13.7677 13.7676C14.3535 13.1818 15.3032 13.1818 15.889 13.7676Z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-mask" fill="currentColor" stroke="none" viewBox="0 0 576 512">
|
||||
<!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
|
||||
<path d="M288 64C39.52 64 0 182.1 0 273.5C0 379.5 78.8 448 176 448c27.33 0 51.21-6.516 66.11-36.79l19.93-40.5C268.3 358.6 278.1 352.4 288 352.1c9.9 .3711 19.7 6.501 25.97 18.63l19.93 40.5C348.8 441.5 372.7 448 400 448c97.2 0 176-68.51 176-174.5C576 182.1 536.5 64 288 64zM400 400c-18.12 0-19.56-2.924-23.04-9.986l-20.35-41.33c-13.87-26.86-38.85-43.53-66.82-44.57L288 304L286.2 304.1c-27.96 1.049-52.94 17.71-67.24 45.41l-19.93 40.49C195.6 397.1 194.1 400 176 400c-75.36 0-128-52.04-128-126.5C48 229.3 48 112 288 112s240 117.3 240 161.5C528 347.1 475.4 400 400 400zM160 192C124.7 192 96 220.7 96 256s28.65 64 64 64c35.35 0 64-28.65 64-64S195.3 192 160 192zM416 192c-35.35 0-64 28.65-64 64s28.65 64 64 64c35.35 0 64-28.65 64-64S451.3 192 416 192z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-excel" fill="currentColor" stroke="none" viewBox="0 0 384 512">
|
||||
<!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
|
||||
<path d="M365.3 93.38l-74.63-74.64C278.6 6.742 262.3 0 245.4 0H64C28.65 0 0 28.65 0 64l.0065 384c0 35.34 28.65 64 64 64H320c35.2 0 64-28.8 64-64V138.6C384 121.7 377.3 105.4 365.3 93.38zM336 448c0 8.836-7.164 16-16 16H64.02c-8.838 0-16-7.164-16-16L48 64.13c0-8.836 7.164-16 16-16h160L224 128c0 17.67 14.33 32 32 32h79.1V448zM229.1 233.3L192 280.9L154.9 233.3C146.8 222.8 131.8 220.9 121.3 229.1C110.8 237.2 108.9 252.3 117.1 262.8L161.6 320l-44.53 57.25c-8.156 10.47-6.25 25.56 4.188 33.69C125.7 414.3 130.8 416 135.1 416c7.156 0 14.25-3.188 18.97-9.25L192 359.1l37.06 47.65C233.8 412.8 240.9 416 248 416c5.125 0 10.31-1.656 14.72-5.062c10.44-8.125 12.34-23.22 4.188-33.69L222.4 320l44.53-57.25c8.156-10.47 6.25-25.56-4.188-33.69C252.2 220.9 237.2 222.8 229.1 233.3z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-pdf" fill="currentColor" stroke="none" viewBox="0 0 384 512">
|
||||
<!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
|
||||
<path d="M365.3 93.38l-74.63-74.64C278.6 6.742 262.3 0 245.4 0H64C28.65 0 0 28.65 0 64l.0065 384c0 35.34 28.65 64 64 64H320c35.2 0 64-28.8 64-64V138.6C384 121.7 377.3 105.4 365.3 93.38zM336 448c0 8.836-7.164 16-16 16H64.02c-8.838 0-16-7.164-16-16L48 64.13c0-8.836 7.164-16 16-16h160L224 128c0 17.67 14.33 32 32 32h79.1V448zM202 286.1c.877-2.688 1.74-5.398 2.582-8.145c1.434-5.762 7.488-31.54 7.488-52.47C212.1 207 197.1 192 178.6 192C160.1 192 145.1 207 145.1 225.5c0 .2969 .1641 28.81 13.85 62.3c-7.035 19.36-15.57 38.8-25.41 57.93c-21.49 10.11-39.24 22.23-52.8 36.07c-6.234 6.438-9.367 14.74-9.367 24.72c0 18.45 15.01 33.46 33.46 33.46c10.8 0 20.98-5.227 27.22-13.98c7.322-10.28 18.38-26.9 30.47-48.95c15.8-6.352 33.88-11.72 53.88-16c13.55 9.578 28.9 17.29 45.71 22.95c4.527 1.551 9.402 2.348 14.43 2.348c20.26 0 36.13-16.19 36.13-36.86c0-20.33-16.54-36.87-36.87-36.87h-3.705c-2.727 .125-20.51 1.141-45.37 5.367C216.9 308.9 208.6 298.3 202 286.1zM110.2 410.4c-3.273 4.688-12.03 2.777-12.03-5.312c0-1.754 .6289-3.43 1.729-4.555c9.02-9.219 19.94-17.05 31.85-23.72C122.3 393.1 114.3 404.7 110.2 410.4zM178.6 218.8c3.693 0 6.703 3.008 6.703 6.703c0 15.21-4.109 34.84-5.746 42.1C172.1 245 171.9 227.2 171.9 225.5C171.9 221.8 174.9 218.8 178.6 218.8zM162.3 348.3c6.611-13.48 13.22-28.46 19.38-44.7c6.389 10.92 14.56 21.86 24.96 31.97C192.6 338.8 177.4 342.9 162.3 348.3zM272.4 339.5h3.352c5.539 0 10.05 4.5 10.05 10.79c0 5.129-4.176 9.32-9.32 9.32c-2.029 0-4.059-.3164-5.852-.9414c-12.33-4.137-23.11-9.32-32.54-15.19C258.3 340.3 272.1 339.5 272.4 339.5z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-file" fill="currentColor" stroke="none" viewBox="0 0 384 512">
|
||||
<!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
|
||||
<path d="M365.3 93.38l-74.63-74.64C278.6 6.743 262.3 0 245.4 0L64-.0001c-35.35 0-64 28.65-64 64l.0065 384c0 35.35 28.65 64 64 64H320c35.2 0 64-28.8 64-64V138.6C384 121.7 377.3 105.4 365.3 93.38zM320 464H64.02c-8.836 0-15.1-7.163-16-15.1L48 64.13c-.0004-8.837 7.163-16 16-16h160L224 128c0 17.67 14.33 32 32 32h79.1v288C336 456.8 328.8 464 320 464z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-sigma" fill="currentColor" stroke="none" viewBox="0 0 384 512">
|
||||
<!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
|
||||
<path d="M335.1 136l.0005-56H83.59l165 158.7C253.3 243.2 255.1 249.5 255.1 256s-2.656 12.78-7.375 17.31L83.59 432h252.4l-.0005-56c0-13.25 10.75-24 24-24C373.2 352 384 362.8 384 376v80c0 13.25-10.75 24-24 24H23.99c-9.782 0-18.59-5.938-22.25-15.03s-1.438-19.47 5.625-26.28L197.4 256L7.364 73.31C.3015 66.5-1.917 56.13 1.739 47.03S14.21 32 23.99 32h336C373.2 32 384 42.75 384 56v80C384 149.3 373.2 160 359.1 160C346.7 160 335.1 149.3 335.1 136z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-percent" fill="currentColor" stroke="none" viewBox="0 0 384 512">
|
||||
<!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
|
||||
<path d="M376.1 71.03c-9.375-9.375-24.56-9.375-33.94 0l-336 336c-9.375 9.375-9.375 24.56 0 33.94C11.72 445.7 17.84 448 24 448s12.28-2.344 16.97-7.031l336-336C386.3 95.59 386.3 80.41 376.1 71.03zM64 176c26.51 0 48-21.49 48-48S90.51 80 64 80C37.49 80 16 101.5 16 128S37.49 176 64 176zM320 336c-26.51 0-48 21.49-48 48s21.49 48 48 48c26.51 0 48-21.49 48-48S346.5 336 320 336z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-chair" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
|
||||
<!--! Font Awesome Pro 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M288 16c26.5 0 48 21.5 48 48V256h16V64c0-35.3-28.7-64-64-64H160C124.7 0 96 28.7 96 64V256h16V64c0-26.5 21.5-48 48-48H288zM48 168c0-4.4-3.6-8-8-8s-8 3.6-8 8v80c0 4.4 3.6 8 8 8s8-3.6 8-8V168zm368 0c0-4.4-3.6-8-8-8s-8 3.6-8 8v80c0 4.4 3.6 8 8 8s8-3.6 8-8V168zM80 304H368c6.1 0 11.6 3.4 14.3 8.8l16 32c2.5 5 2.2 10.9-.7 15.6s-8.1 7.6-13.6 7.6H224 64c-5.5 0-10.7-2.9-13.6-7.6s-3.2-10.6-.7-15.6l16-32c2.7-5.4 8.3-8.8 14.3-8.8zm152 80H384c11.1 0 21.4-5.7 27.2-15.2s6.4-21.2 1.4-31.1l-16-32C391.2 294.8 380.1 288 368 288H80c-12.1 0-23.2 6.8-28.6 17.7l-16 32c-5 9.9-4.4 21.7 1.4 31.1S52.9 384 64 384H216v64H184c-30.9 0-56 25.1-56 56c0 4.4 3.6 8 8 8s8-3.6 8-8c0-22.1 17.9-40 40-40h32v40c0 4.4 3.6 8 8 8s8-3.6 8-8V464h32c22.1 0 40 17.9 40 40c0 4.4 3.6 8 8 8s8-3.6 8-8c0-30.9-25.1-56-56-56H232V384z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-eye" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</symbol>
|
||||
<symbol id="icon-profile" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a7.464 7.464 0 01-1.15 3.993m1.989 3.559A11.209 11.209 0 008.25 10.5a3.75 3.75 0 117.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 01-3.6 9.75m6.633-4.596a18.666 18.666 0 01-2.485 5.33" />
|
||||
</symbol>
|
||||
<symbol id="icon-chart" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6" />
|
||||
</symbol>
|
||||
<symbol id="icon-info" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</symbol>
|
||||
<symbol id="icon-star" fill="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
|
||||
</symbol>
|
||||
<symbol id="icon-cancel" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</symbol>
|
||||
<symbol id="icon-bell" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
|
||||
</symbol>
|
||||
<symbol id="icon-folder" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol id="icon-power" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
|
||||
</symbol>
|
||||
<symbol id="icon-no-symbol" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</symbol>
|
||||
<symbol id="icon-bus" xmlns="http://www.w3.org/2000/svg" stroke-width="1.5" viewBox="0 0 24 24" stroke="currentColor" fill="none">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M6 17m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M18 17m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M4 17h-2v-11a1 1 0 0 1 1 -1h14a5 7 0 0 1 5 7v5h-2m-4 0h-8" />
|
||||
<path d="M16 5l1.5 7l4.5 0" />
|
||||
<path d="M2 10l15 0" />
|
||||
<path d="M7 5l0 5" />
|
||||
<path d="M12 5l0 5" />
|
||||
</symbol>
|
||||
<symbol id="icon-ski" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<!--! Font Awesome Pro 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
|
||||
<path d="M380.7 48a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zM2.7 268.9c6.1-11.8 20.6-16.3 32.4-10.2L239.8 365l49.6-74.5-82.7-87.9c-9.5-10.1-14.4-22.4-15.1-34.9l87.7 42 49.4 52.5c12.8 13.6 14.5 34.1 4.2 49.6l-50.2 75.4 135.8 70.5c13.6 7.1 29.8 7.2 43.6 .3l15.2-7.6c11.9-5.9 26.3-1.1 32.2 10.7s1.1 26.3-10.7 32.2l-15.2 7.6c-27.5 13.7-59.9 13.5-87.2-.7L12.9 301.3C1.2 295.2-3.4 280.7 2.7 268.9zM118.9 65.6L137 74.2l8.7-17.4c4-7.9 13.6-11.1 21.5-7.2s11.1 13.6 7.2 21.5l-8.5 16.9 55.5 26.6c1.7-.9 3.5-1.7 5.4-2.5l80.9-32.4c32.4-13 68.6 6.5 75.6 40.7l12.3 59.7 62.9 30.1c12 5.7 17 20 11.3 32s-20 17-32 11.3l-54.2-25.9c-1-.3-1.9-.6-2.8-1l-229.1-110-9.2 18.4c-4 7.9-13.6 11.1-21.5 7.2s-11.1-13.6-7.2-21.5l9-18-17.6-8.4c-8-3.8-11.3-13.4-7.5-21.3s13.4-11.3 21.3-7.5zm217.3 64.7c-1-4.9-6.2-7.7-10.8-5.8l-45.7 18.3 65.5 31.5-9-43.9z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-snowboard" fill="currentColor" stroke="nonw" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<!--! Font Awesome Pro 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
|
||||
<path d="M204.3 3c11.6-6.4 26.2-2.3 32.6 9.3l31.9 57.4c1.3 2.3 3.6 3.8 6.2 4.1l33.8 3.4c24.1 2.4 46.9 12 65.4 27.6L503.5 213.6c10.1 8.5 11.4 23.7 2.9 33.8s-23.7 11.4-33.8 2.9l-68.2-57.5-82.3 43.9 24.7 18.9c24.9 19 34.6 51.9 24.1 81.4l-28.2 79c-1.4 3.8-3.6 7-6.3 9.6l93.3 35.7c4.6 1.8 9.4 2.6 14.3 2.6H472c13.3 0 24 10.7 24 24s-10.7 24-24 24H443.8c-10.8 0-21.4-2-31.5-5.8L60.1 371.3c-11.5-4.4-22-11.2-30.8-20L7 329c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l22.4 22.4c4 4 8.7 7.1 14 9.1l19.4 7.4c-2.9-11.4 2.9-23.4 14-28.1L192 272l0-68.2c0-21.2 12-40.6 31-50.1L284.4 123l-14.1-1.4c-18.3-1.8-34.5-12.5-43.4-28.5L195 35.7c-6.4-11.6-2.3-26.2 9.3-32.6zm91.8 407.2c-.3-3.4 .1-6.9 1.3-10.3l28.2-79c3.5-9.8 .3-20.8-8-27.1L240 234.4l0 42.9c0 16.1-9.7 30.7-24.6 36.9L134 348.2l162.1 62.1zM259.6 189l20.5 15.7 84.6-45.1-17.2-14.5L259.6 189zM384 48a48 48 0 1 1 96 0 48 48 0 1 1 -96 0z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-hourglass" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
|
||||
<!--! Font Awesome Pro 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
|
||||
<path d="M24 0C10.7 0 0 10.7 0 24S10.7 48 24 48h8V67c0 40.3 16 79 44.5 107.5L158.1 256 76.5 337.5C48 366 32 404.7 32 445v19H24c-13.3 0-24 10.7-24 24s10.7 24 24 24H360c13.3 0 24-10.7 24-24s-10.7-24-24-24h-8V445c0-40.3-16-79-44.5-107.5L225.9 256l81.5-81.5C336 146 352 107.3 352 67V48h8c13.3 0 24-10.7 24-24s-10.7-24-24-24H24zM192 289.9l81.5 81.5C293 391 304 417.4 304 445v19H80V445c0-27.6 11-54 30.5-73.5L192 289.9zm0-67.9l-81.5-81.5C91 121 80 94.6 80 67V48H304V67c0 27.6-11 54-30.5 73.5L192 222.1z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-car" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="64" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="192" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="88" y1="176" x2="168" y2="176" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M216,176h24a8,8,0,0,0,8-8V128a8,8,0,0,0-8-8H208L162.34,74.34A8,8,0,0,0,156.69,72H44.28a8,8,0,0,0-6.65,3.56L8,120v48a8,8,0,0,0,8,8H40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="208" y1="120" x2="8" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-flag-a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#fff" d="M640 480H0V0h640z"/><path fill="#ed2939" d="M640 480H0V320h640zm0-319.9H0V.1h640z"/></g></symbol>
|
||||
<symbol id="icon-flag-ch" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#d52b1e" d="M0 0h640v480H0z"/><g fill="#fff"><path d="M170 195h300v90H170z"/><path d="M275 90h90v300h-90z"/></g></g></symbol>
|
||||
<symbol id="icon-flag-d" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><path fill="#ffce00" d="M0 320h640v160H0z"/><path d="M0 0h640v160H0z"/><path fill="#d00" d="M0 160h640v160H0z"/></symbol>
|
||||
<symbol id="icon-flag-f" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#00267f" d="M0 0h213.3v480H0z"/><path fill="#f31830" d="M426.7 0H640v480H426.7z"/></g></symbol>
|
||||
<symbol id="icon-flag-i" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#009246" d="M0 0h213.3v480H0z"/><path fill="#ce2b37" d="M426.7 0H640v480H426.7z"/></g></symbol>
|
||||
<symbol id="icon-flag-nl" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#21468b" d="M0 320h640v160H0z"/><path fill="#fff" d="M0 160h640v160H0z"/><path fill="#ae1c28" d="M0 0h640v160H0z"/></g></symbol>
|
||||
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
|
||||
<symbol id="icon-spinner" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">
|
||||
<g fill="none" fill-rule="evenodd" stroke-width="2">
|
||||
<circle cx="22" cy="22" r="1">
|
||||
<animate attributeName="r"
|
||||
begin="0s" dur="1.8s"
|
||||
values="1; 20"
|
||||
calcMode="spline"
|
||||
keyTimes="0; 1"
|
||||
keySplines="0.165, 0.84, 0.44, 1"
|
||||
repeatCount="indefinite" />
|
||||
<animate attributeName="stroke-opacity"
|
||||
begin="0s" dur="1.8s"
|
||||
values="1; 0"
|
||||
calcMode="spline"
|
||||
keyTimes="0; 1"
|
||||
keySplines="0.3, 0.61, 0.355, 1"
|
||||
repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="22" cy="22" r="1">
|
||||
<animate attributeName="r"
|
||||
begin="-0.9s" dur="1.8s"
|
||||
values="1; 20"
|
||||
calcMode="spline"
|
||||
keyTimes="0; 1"
|
||||
keySplines="0.165, 0.84, 0.44, 1"
|
||||
repeatCount="indefinite" />
|
||||
<animate attributeName="stroke-opacity"
|
||||
begin="-0.9s" dur="1.8s"
|
||||
values="1; 0"
|
||||
calcMode="spline"
|
||||
keyTimes="0; 1"
|
||||
keySplines="0.3, 0.61, 0.355, 1"
|
||||
repeatCount="indefinite" />
|
||||
</circle>
|
||||
</g>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
@@ -5,8 +5,6 @@ const DEBOUNCE_MS = 200
|
||||
|
||||
let debounceTimeout = null
|
||||
let historyRestoreGracePeriod = false
|
||||
let savedScrollPosition = null
|
||||
let activeRequests = 0
|
||||
|
||||
function getIndicator() {
|
||||
return document.getElementById(INDICATOR_ID)
|
||||
@@ -30,20 +28,12 @@ function hide() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleBeforeRequest(event) {
|
||||
function handleBeforeRequest() {
|
||||
// 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
|
||||
@@ -55,29 +45,7 @@ function handleBeforeRequest(event) {
|
||||
}, 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
|
||||
@@ -88,29 +56,23 @@ function handleHistoryRestore() {
|
||||
}
|
||||
|
||||
function handleTimeout() {
|
||||
// Don't decrement here — htmx:afterRequest always fires alongside htmx:timeout
|
||||
// and handles the counter. This handler only shows the alert.
|
||||
hide()
|
||||
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', handleRequestEnd)
|
||||
document.body.addEventListener('htmx:afterSwap', handleAfterSwap)
|
||||
document.body.addEventListener('htmx:afterRequest', hide)
|
||||
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', handlePageShow)
|
||||
window.addEventListener('popstate', handleHistoryRestore)
|
||||
window.addEventListener('pageshow', hide)
|
||||
window.addEventListener('popstate', handleHistoryRestore)
|
||||
@@ -1,7 +1,6 @@
|
||||
@import "components/button.css";
|
||||
@import "components/datatable.css";
|
||||
@import "components/forms.css";
|
||||
@import "components/pagination.css";
|
||||
@import "components/toast.css";
|
||||
@import "components/tooltip.css";
|
||||
@import "components/typography.css";
|
||||
@import "components/forms.css";
|
||||
@import "components/button.css";
|
||||
@import "components/tooltip.css";
|
||||
@import "components/toast.css";
|
||||
@import "components/pagination.css";
|
||||
@@ -1,7 +1,7 @@
|
||||
.button {
|
||||
@apply inline-flex items-center justify-center space-x-2 h-8 leading-8 md:h-10 md:leading-10 px-4 md:px-8 cursor-pointer;
|
||||
@apply uppercase leading-none text-center text-sm md:text-base;
|
||||
@apply transition-colors outline-none focus:outline-2 focus:outline-offset-2 focus:outline-primary-dark;
|
||||
@apply inline-flex items-center justify-center space-x-2 h-10 leading-10 px-4 md:px-8 cursor-pointer;
|
||||
@apply uppercase leading-none text-center;
|
||||
@apply transition-colors outline-none focus:outline-2 focus:outline-offset-2 focus:outline-primary-light;
|
||||
@apply rounded-md;
|
||||
}
|
||||
|
||||
@@ -20,52 +20,42 @@
|
||||
}
|
||||
|
||||
.button--secondary {
|
||||
@apply bg-none bg-primary-light text-white;
|
||||
@apply hover:bg-primary-light/50;
|
||||
}
|
||||
|
||||
.button--neutral {
|
||||
@apply bg-none bg-gray-200;
|
||||
@apply hover:bg-gray-200/50;
|
||||
}
|
||||
|
||||
.button--warning {
|
||||
@apply bg-none bg-red-500 text-white;
|
||||
@apply hover:bg-red-500/50;
|
||||
@apply bg-none bg-primary-dark text-white;
|
||||
@apply hover:bg-primary-light/80;
|
||||
}
|
||||
|
||||
.sbw {
|
||||
.button--primary {
|
||||
@apply bg-none bg-sbw-secondary text-white;
|
||||
@apply hover:bg-none hover:bg-sbw-primary/50;
|
||||
@apply hover:bg-none hover:bg-sbw-primary/80;
|
||||
}
|
||||
|
||||
.button--secondary {
|
||||
@apply bg-none bg-sbw-primary-dark text-white;
|
||||
@apply hover:bg-none hover:bg-sbw-primary/50;
|
||||
@apply hover:bg-none hover:bg-sbw-primary/80;
|
||||
}
|
||||
}
|
||||
|
||||
.snz {
|
||||
.button--primary {
|
||||
@apply bg-none bg-snz-secondary text-white;
|
||||
@apply hover:bg-none hover:bg-snz-primary/50;
|
||||
@apply hover:bg-none hover:bg-snz-primary/80;
|
||||
}
|
||||
|
||||
.button--secondary {
|
||||
@apply bg-none bg-snz-primary-dark text-white;
|
||||
@apply hover:bg-none hover:bg-snz-primary/50;
|
||||
@apply hover:bg-none hover:bg-snz-primary/80;
|
||||
}
|
||||
}
|
||||
|
||||
.ser {
|
||||
.button--primary {
|
||||
@apply bg-none bg-ser-secondary text-white;
|
||||
@apply hover:bg-none hover:bg-ser-primary/50;
|
||||
@apply hover:bg-none hover:bg-ser-primary/80;
|
||||
}
|
||||
|
||||
.button--secondary {
|
||||
@apply bg-none bg-ser-primary text-white;
|
||||
@apply hover:bg-none hover:bg-ser-primary/50;
|
||||
@apply hover:bg-none hover:bg-ser-primary/80;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
.data-table-wrapper {
|
||||
@apply overflow-x-auto lg:overflow-x-visible mb-8;
|
||||
}
|
||||
|
||||
.data-table-wrapper__inner {
|
||||
@apply align-middle inline-block min-w-full overflow-hidden lg:overflow-visible;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
@apply min-w-full;
|
||||
}
|
||||
|
||||
.data-table tbody {
|
||||
@apply bg-white;
|
||||
}
|
||||
|
||||
.data-table tr {
|
||||
@apply border border-gray-300;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
@apply px-4 py-2 bg-gray-200 text-left leading-4 font-bold text-primary align-top;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
@apply px-4 py-2 leading-5 align-top;
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
@apply hover:bg-gray-100;
|
||||
}
|
||||
@@ -14,17 +14,12 @@
|
||||
"doctrine/doctrine-bundle": "^2.13",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.3",
|
||||
"doctrine/orm": "^3.3",
|
||||
"fakerphp/faker": "^1.24",
|
||||
"flagception/flagception-bundle": "^6.1",
|
||||
"knplabs/knp-menu-bundle": "^3.7",
|
||||
"knplabs/knp-paginator-bundle": "^6.10",
|
||||
"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",
|
||||
@@ -63,7 +58,6 @@
|
||||
"twig/extra-bundle": "^2.12|^3.0",
|
||||
"twig/html-extra": "^3.15",
|
||||
"twig/intl-extra": "*",
|
||||
"twig/string-extra": "^3.24",
|
||||
"twig/twig": "^2.12|^3.0",
|
||||
"zenstruck/schedule-bundle": "^1.8"
|
||||
},
|
||||
@@ -75,7 +69,7 @@
|
||||
},
|
||||
"sort-packages": true,
|
||||
"platform": {
|
||||
"php": "8.3.30"
|
||||
"php": "8.2.28"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -98,8 +92,6 @@
|
||||
"symfony/polyfill-php81": "*"
|
||||
},
|
||||
"scripts": {
|
||||
"rector": "vendor/bin/rector process",
|
||||
"rector:dry-run": "vendor/bin/rector process --dry-run",
|
||||
"auto-scripts": {
|
||||
"cache:clear": "symfony-cmd",
|
||||
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
||||
@@ -124,11 +116,9 @@
|
||||
"require-dev": {
|
||||
"deployer/deployer": "^7.5",
|
||||
"friendsofphp/php-cs-fixer": "^3.75",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpstan/phpstan-doctrine": "^2.0",
|
||||
"phpstan/phpstan-symfony": "^2.0",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"rector/rector": "^2.4",
|
||||
"spatie/ray": "^1.43",
|
||||
"spatie/x-ray": "^1.2",
|
||||
"symfony/browser-kit": "6.4.*",
|
||||
"symfony/css-selector": "6.4.*",
|
||||
"symfony/debug-bundle": "6.4.*",
|
||||
|
||||
@@ -16,7 +16,4 @@ return [
|
||||
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
|
||||
League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true],
|
||||
Zenstruck\ScheduleBundle\ZenstruckScheduleBundle::class => ['all' => true],
|
||||
Flagception\Bundle\FlagceptionBundle\FlagceptionBundle::class => ['all' => true],
|
||||
Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true],
|
||||
Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
|
||||
];
|
||||
@@ -15,7 +15,5 @@ framework:
|
||||
#app: cache.adapter.apcu
|
||||
|
||||
# Namespaced pools use the above "app" backend by default
|
||||
pools:
|
||||
bpn.cache:
|
||||
adapter: cache.adapter.filesystem
|
||||
tags: true
|
||||
#pools:
|
||||
#my.dedicated.cache: null
|
||||
@@ -1,2 +0,0 @@
|
||||
flagception:
|
||||
features: {}
|
||||
@@ -1,17 +1,21 @@
|
||||
flysystem:
|
||||
storages:
|
||||
xml_export.storage:
|
||||
local:
|
||||
adapter: 'local'
|
||||
options:
|
||||
directory: '%env(resolve:XML_EXPORT_PATH)%'
|
||||
xml_source.storage:
|
||||
sftp:
|
||||
adapter: 'sftp'
|
||||
options:
|
||||
host: '%env(SFTP_XML_EXPORT_HOST)%'
|
||||
port: '%env(int:SFTP_XML_EXPORT_PORT)%'
|
||||
username: '%env(SFTP_XML_EXPORT_USER)%'
|
||||
password: '%env(SFTP_XML_EXPORT_PASSWORD)%'
|
||||
xml_dump.storage:
|
||||
local:
|
||||
adapter: 'local'
|
||||
options:
|
||||
directory: '%kernel.project_dir%/var/bpn'
|
||||
json_export.storage:
|
||||
local:
|
||||
adapter: 'local'
|
||||
options:
|
||||
directory: '%env(resolve:JSON_EXPORT_PATH)%'
|
||||
@@ -9,8 +9,9 @@ framework:
|
||||
# Remove or comment this section to explicitly disable session support.
|
||||
session:
|
||||
handler_id: null
|
||||
cookie_secure: true
|
||||
cookie_samesite: lax
|
||||
cookie_secure: auto
|
||||
# cookie_samesite: lax
|
||||
cookie_samesite: none # this is to make login work in staging environment. to be removed when going live!
|
||||
|
||||
#esi: true
|
||||
#fragments: true
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
knp_menu:
|
||||
twig:
|
||||
template: _partials/_menu.html.twig
|
||||
@@ -1,11 +0,0 @@
|
||||
knp_paginator:
|
||||
default_options:
|
||||
page_name: page
|
||||
sort_field_name: sort
|
||||
sort_direction_name: direction
|
||||
distinct: true
|
||||
filter_field_name: filterField
|
||||
filter_value_name: filterValue
|
||||
template:
|
||||
pagination: '_partials/_paginator_sliding.html.twig'
|
||||
sortable: '_partials/_paginator_sortable_link.html.twig'
|
||||
@@ -3,10 +3,9 @@ league_oauth2_server:
|
||||
private_key: '%env(resolve:OAUTH_PRIVATE_KEY)%'
|
||||
private_key_passphrase: null
|
||||
encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%'
|
||||
require_code_challenge_for_public_clients: true
|
||||
enable_client_credentials_grant: true
|
||||
enable_auth_code_grant: true
|
||||
enable_refresh_token_grant: true
|
||||
enable_refresh_token_grant: false
|
||||
enable_password_grant: false
|
||||
access_token_ttl: PT10M
|
||||
resource_server:
|
||||
|
||||
@@ -13,7 +13,7 @@ framework:
|
||||
max_retries: 3
|
||||
multiplier: 2
|
||||
failed: 'doctrine://default?queue_name=failed'
|
||||
sync: 'sync://'
|
||||
# sync: 'sync://'
|
||||
|
||||
default_bus: messenger.bus.default
|
||||
|
||||
@@ -21,10 +21,9 @@ framework:
|
||||
messenger.bus.default: []
|
||||
|
||||
routing:
|
||||
Symfony\Component\Mailer\Messenger\SendEmailMessage: sync
|
||||
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
|
||||
Symfony\Component\Notifier\Message\ChatMessage: async
|
||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||
App\Message\MailjetNewsletterEventMessage: async
|
||||
|
||||
# Route your messages to the transports
|
||||
# 'App\Message\YourMessage': async
|
||||
@@ -4,7 +4,6 @@ monolog:
|
||||
- bpn
|
||||
- core
|
||||
- auth
|
||||
- admin
|
||||
|
||||
when@dev:
|
||||
monolog:
|
||||
@@ -28,16 +27,11 @@ when@dev:
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.core.log"
|
||||
level: debug
|
||||
channels: ["core"]
|
||||
admin:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
|
||||
level: debug
|
||||
channels: ["admin"]
|
||||
main:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
channels: ["!event", "!bpn", "!core", "!auth", "!admin"]
|
||||
channels: ["!event", "!bpn", "!core", "!auth"]
|
||||
# uncomment to get logging in your browser
|
||||
# you may have to allow bigger header sizes in your Web server configuration
|
||||
#firephp:
|
||||
@@ -65,7 +59,7 @@ when@test:
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
|
||||
when@prod: &prod_config
|
||||
when@prod:
|
||||
monolog:
|
||||
handlers:
|
||||
database:
|
||||
@@ -90,19 +84,13 @@ when@prod: &prod_config
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.core.log"
|
||||
level: info
|
||||
channels: ["core"]
|
||||
admin:
|
||||
type: rotating_file
|
||||
max_files: 7
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
|
||||
level: info
|
||||
channels: ["admin"]
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
||||
channels: ["!bpn", "!core", "!auth", "!admin"]
|
||||
channels: ["!bpn", "!core", "!auth"]
|
||||
nested:
|
||||
type: rotating_file
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.framework.log"
|
||||
@@ -117,5 +105,3 @@ when@prod: &prod_config
|
||||
channels: [deprecation]
|
||||
path: php://stderr
|
||||
formatter: monolog.formatter.json
|
||||
|
||||
when@staging: *prod_config
|
||||
@@ -1,7 +1,6 @@
|
||||
security:
|
||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||
password_hashers:
|
||||
Symfony\Component\Security\Core\User\InMemoryUser: 'plaintext'
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
|
||||
providers:
|
||||
@@ -9,12 +8,6 @@ security:
|
||||
entity:
|
||||
class: App\Entity\User
|
||||
property: email
|
||||
mailjet_webhook_provider:
|
||||
memory:
|
||||
users:
|
||||
mailjet:
|
||||
password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%'
|
||||
roles: ['ROLE_MAILJET_WEBHOOK']
|
||||
firewalls:
|
||||
dev:
|
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||
@@ -26,15 +19,7 @@ security:
|
||||
pattern: ^/api
|
||||
security: true
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
oauth2: true
|
||||
mailjet_webhook:
|
||||
pattern: ^/webhooks/mailjet/newsletter$
|
||||
security: true
|
||||
stateless: true
|
||||
provider: mailjet_webhook_provider
|
||||
http_basic:
|
||||
realm: 'Mailjet Webhook'
|
||||
main:
|
||||
lazy: true
|
||||
provider: app_user_provider
|
||||
@@ -47,10 +32,8 @@ security:
|
||||
# Easy way to control access for large sections of your site
|
||||
# Note: Only the *first* access control that matches will be used
|
||||
access_control:
|
||||
- { path: ^/webhooks/mailjet/newsletter$, roles: ROLE_MAILJET_WEBHOOK, requires_channel: https }
|
||||
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https }
|
||||
- { path: ^/admin, roles: ROLE_ADMIN, requires_channel: https }
|
||||
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
|
||||
- { path: '^/', roles: PUBLIC_ACCESS, requires_channel: https }
|
||||
|
||||
when@test:
|
||||
security:
|
||||
@@ -59,7 +42,6 @@ when@test:
|
||||
# important to generate secure password hashes. In tests however, secure hashes
|
||||
# are not important, waste resources and increase test times. The following
|
||||
# reduces the work factor to the lowest possible values.
|
||||
Symfony\Component\Security\Core\User\InMemoryUser: 'plaintext'
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
|
||||
algorithm: auto
|
||||
cost: 4 # Lowest possible value for bcrypt
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
twig:
|
||||
file_name_pattern: '*.twig'
|
||||
form_themes: ['forms.html.twig']
|
||||
paths:
|
||||
'%kernel.project_dir%/assets/images': images
|
||||
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)%'
|
||||
insurance_revocation_url: '%env(APP_INSURANCE_REVOCATION_URL)%'
|
||||
|
||||
when@test:
|
||||
twig:
|
||||
|
||||
@@ -9,52 +9,21 @@ zenstruck_schedule:
|
||||
|
||||
schedule_extensions:
|
||||
email_on_failure:
|
||||
enabled: true
|
||||
to: [email protected]
|
||||
|
||||
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: "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"
|
||||
|
||||
- task: app:cleanup:newsletter-opt-in-requests
|
||||
frequency: "45 1 * * *"
|
||||
description: "Removes expired pending newsletter double opt-in requests"
|
||||
|
||||
- task: league:oauth2-server:clear-expired-tokens
|
||||
frequency: "0 * * * *"
|
||||
description: "Removes expired oauth2 tokens hourly"
|
||||
|
||||
- task: app:bpn:refresh-travel-snapshot --refresh-after 60
|
||||
frequency: "10 8-19 * * *"
|
||||
description: "Syncs travel data snapshots with BusPro XML data hourly during peak hours"
|
||||
|
||||
- task: app:bpn:refresh-travel-snapshot --refresh-after 180
|
||||
frequency: "10 20-23,0-7 * * *"
|
||||
description: "Syncs travel data snapshots with BusPro XML data every 3 hours outside peak hours"
|
||||
|
||||
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: "Syncs BusPro XML data every 15 min. during peak hours"
|
||||
|
||||
- task: app:bpn:xml-sync
|
||||
frequency: "0 20-23,0-7 * * *"
|
||||
description: "Syncs BusPro XML data hourly outside peak hours"
|
||||
description: "Removes outdated log entries with a retention period of 6 months"
|
||||
@@ -5,62 +5,28 @@
|
||||
# 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)%'
|
||||
newsletter_confirmation_ttl_hours: '%env(int:APP_NEWSLETTER_CONFIRMATION_TTL_HOURS)%'
|
||||
travel_snapshot_retention_buffer_days: '%env(int:APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS)%'
|
||||
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
|
||||
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
|
||||
|
||||
# MailJet list ids and their labels
|
||||
mailjet_lists:
|
||||
10321569: 'E&P Newsletter'
|
||||
10321382: 'Reisen-Alert Ahrntal'
|
||||
10321383: 'Reisen-Alert Davos'
|
||||
10321391: 'Reisen-Alert Familienreisen'
|
||||
10321384: 'Reisen-Alert Arosa-Lenzerheide'
|
||||
10321385: 'Reisen-Alert Les Deux Alpes'
|
||||
10321386: 'Reisen-Alert Montafon'
|
||||
10321387: 'Reisen-Alert Portes du Soleil'
|
||||
10321388: 'Reisen-Alert Saalbach-Hinterglemm'
|
||||
10538809: 'Reisen-Alert Scuol'
|
||||
10321389: 'Reisen-Alert Stubaital'
|
||||
10554990: 'Reisen-Alert Ski & Boarderweek'
|
||||
|
||||
# MailJet contact metadata names for name synchronization
|
||||
mailjet_contact_metadata_fields:
|
||||
firstName: 'vorname'
|
||||
lastName: 'nachname'
|
||||
|
||||
# Body dimensions ranges for BodyDimensionsType and ParticipantValidator
|
||||
body_dimension_ranges:
|
||||
height_min: 145
|
||||
height_max: 210
|
||||
weight_min: 40
|
||||
weight_max: 120
|
||||
shoe_size_min: 35
|
||||
shoe_size_max: 50
|
||||
|
||||
# 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
|
||||
# Body dimensions choices for BodyDimensionsType
|
||||
body_dimensions.height_choices:
|
||||
'bis 148cm': '-148'
|
||||
'149 - 157cm': '149-157'
|
||||
'158 - 166cm': '158-166'
|
||||
'167 - 178cm': '167-178'
|
||||
'179 - 185cm': '179-185'
|
||||
'186 - 194cm': '186-194'
|
||||
'195cm oder mehr': '195cm+'
|
||||
body_dimensions.shoe_size_min: 36
|
||||
body_dimensions.shoe_size_max: 48
|
||||
body_dimensions.weight_choices:
|
||||
'42 - 48kg': '42-48'
|
||||
'49 - 57kg': '49-57'
|
||||
'58 - 66kg': '58-66'
|
||||
'67 - 78kg': '67-78'
|
||||
'79 - 85kg': '79-85'
|
||||
'86 - 94kg': '86-94'
|
||||
'95kg oder mehr': '95k+'
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
@@ -73,7 +39,6 @@ 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%'
|
||||
|
||||
@@ -94,7 +59,7 @@ services:
|
||||
bpn_username: '%env(APP_BPN_USER)%'
|
||||
bpn_password: '%env(APP_BPN_PASSWORD)%'
|
||||
bpn_api_ip: '%env(APP_BPN_IP)%'
|
||||
bpn_api_ports: '%env(csv:APP_BPN_PORTS)%'
|
||||
bpn_api_port: '%env(APP_BPN_PORT)%'
|
||||
debug: '%env(bool:APP_BPN_DEBUG)%'
|
||||
connection_timeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
|
||||
stream_timeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
|
||||
@@ -108,65 +73,17 @@ services:
|
||||
arguments:
|
||||
$path: '%path_to_keys%'
|
||||
|
||||
App\Menu\AdminMenuBuilder:
|
||||
arguments:
|
||||
$factory: '@knp_menu.factory'
|
||||
tags:
|
||||
- name: knp_menu.menu_builder
|
||||
method: createMainMenu
|
||||
alias: admin_main
|
||||
|
||||
App\Service\BpnXmlSyncManager:
|
||||
arguments:
|
||||
$xmlSource: '@xml_source.storage'
|
||||
$xmlExport: '@xml_export.storage'
|
||||
$bpnCache: '@bpn.cache'
|
||||
|
||||
App\Command\BpnXmlCacheInvalidateCommand:
|
||||
arguments:
|
||||
$xmlExport: '@xml_export.storage'
|
||||
$bpnCache: '@bpn.cache'
|
||||
$cache: '@cache.app'
|
||||
|
||||
App\Security\Crypt:
|
||||
arguments:
|
||||
$path: '%path_to_keys%'
|
||||
|
||||
App\Service\TravelSnapshotManager:
|
||||
arguments:
|
||||
$retentionBufferDays: '%travel_snapshot_retention_buffer_days%'
|
||||
|
||||
App\Service\TravelDataProvider:
|
||||
App\Service\TravelDataService:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.core'
|
||||
$preferRemote: '%env(bool:APP_TRAVEL_PREFER_REMOTE)%'
|
||||
$enableFallback: '%env(bool:APP_TRAVEL_ENABLE_FALLBACK)%'
|
||||
|
||||
App\Service\ParticipantFormSupport:
|
||||
arguments:
|
||||
$bodyDimensionRanges: '%body_dimension_ranges%'
|
||||
|
||||
App\Service\BookingEditDataLoader:
|
||||
arguments:
|
||||
$bpnCache: '@bpn.cache'
|
||||
|
||||
App\BusProNet\XmlLoader\TravelLoader:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
|
||||
App\BusProNet\XmlLoader\HotelLoader:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
|
||||
App\BusProNet\XmlLoader\PickupLoader:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
|
||||
App\BusProNet\XmlLoader\InsuranceLoader:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
|
||||
App\Service\VoucherValidator:
|
||||
App\Service\VoucherValidationService:
|
||||
arguments:
|
||||
$cache: '@cache.app'
|
||||
$logger: '@monolog.logger.bpn'
|
||||
@@ -183,81 +100,28 @@ services:
|
||||
arguments:
|
||||
$handlers:
|
||||
# Simple handlers with no external dependencies - instantiated directly
|
||||
- 'App\Form\Service\ParticipantDateOfBirthFieldHandler'
|
||||
- 'App\Form\Service\ParticipantAssignedRoomFieldHandler'
|
||||
- 'App\Form\Service\ParticipantRemarksRoomFieldHandler'
|
||||
- 'App\Form\Service\ParticipantAdditionalServicesFieldHandler'
|
||||
- 'App\Form\Service\ParticipantCoursesFieldHandler'
|
||||
- 'App\Form\Service\ParticipantBoardFieldHandler'
|
||||
- 'App\Form\Service\ParticipantVegFieldHandler'
|
||||
- 'App\Form\Service\ParticipantRentalsFieldHandler'
|
||||
- 'App\Form\Service\ParticipantSkiPassFieldHandler'
|
||||
- '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'
|
||||
# Complex handlers with dependencies - use service references
|
||||
- '@App\Form\Service\ParticipantDateOfBirthFieldHandler'
|
||||
- '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler'
|
||||
- '@App\Form\Service\ParticipantBulkInsuranceFieldHandler'
|
||||
- '@App\Form\Service\ParticipantInsuranceFieldHandler'
|
||||
- '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler'
|
||||
- '@App\Form\Service\ParticipantPromoVoucherFieldHandler'
|
||||
|
||||
# Booking Status Rules
|
||||
App\BusProNet\Service\StatusRule\Selection1473StatusRule: ~
|
||||
App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule: ~
|
||||
|
||||
# Booking Status Rule Registry
|
||||
App\BusProNet\Service\BookingStatusRuleRegistry:
|
||||
arguments:
|
||||
$rules:
|
||||
- '@App\BusProNet\Service\StatusRule\Selection1473StatusRule'
|
||||
- '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule'
|
||||
|
||||
App\Service\CmsDataProvider:
|
||||
App\Service\CmsDataService:
|
||||
arguments:
|
||||
$httpClient: '@typo3.client'
|
||||
$apiKey: 'AbcAbc123'# dummy key, not yet implemented
|
||||
|
||||
App\Logger\RequestIdProcessor:
|
||||
tags:
|
||||
- { name: monolog.processor }
|
||||
|
||||
App\Service\NewsletterManager:
|
||||
arguments:
|
||||
$newsletterConfirmationTtlHours: '%newsletter_confirmation_ttl_hours%'
|
||||
$mailjetLists: '%mailjet_lists%'
|
||||
$defaultMailjetListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%'
|
||||
|
||||
App\Controller\Api\NewsletterSubscriptionController:
|
||||
arguments:
|
||||
$mailjetLists: '%mailjet_lists%'
|
||||
|
||||
App\Controller\Webhook\MailjetNewsletterWebhookController:
|
||||
arguments:
|
||||
$mailjetLists: '%mailjet_lists%'
|
||||
|
||||
App\Service\DomainConfigProvider:
|
||||
arguments:
|
||||
$domainConfig: '%domain_config%'
|
||||
|
||||
App\Email\Mailer:
|
||||
arguments:
|
||||
$defaults:
|
||||
from: '%default_email_from%'
|
||||
to: '%default_email_to%'
|
||||
|
||||
App\Validator\Constraints\ParticipantValidator:
|
||||
arguments:
|
||||
$bodyDimensionRanges: '%body_dimension_ranges%'
|
||||
|
||||
App\Service\MailjetApiClient:
|
||||
arguments:
|
||||
$apiKey: '%env(default::MAILJET_API_KEY)%'
|
||||
$apiSecret: '%env(default::MAILJET_API_SECRET)%'
|
||||
$apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
|
||||
$defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%'
|
||||
$contactMetadataFields: '%mailjet_contact_metadata_fields%'
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
@@ -2,7 +2,7 @@ const {defineConfig} = require("cypress");
|
||||
|
||||
module.exports = defineConfig({
|
||||
e2e: {
|
||||
baseUrl: 'https://myep.ddev.site',
|
||||
baseUrl: 'https://myep-next-booking.ddev.site',
|
||||
supportFile: false,
|
||||
},
|
||||
});
|
||||
@@ -17,6 +17,7 @@ add('shared_dirs', [
|
||||
|
||||
add('shared_files', [
|
||||
'public/.htaccess',
|
||||
'.env.local.php',
|
||||
]);
|
||||
|
||||
set('keep_releases', 3);
|
||||
@@ -32,7 +33,6 @@ $rsyncOptions = [
|
||||
'.ddev',
|
||||
'.git',
|
||||
'.github',
|
||||
'.jj',
|
||||
'node_modules',
|
||||
'.editorconfig',
|
||||
'.env.dev.local',
|
||||
@@ -46,17 +46,14 @@ $rsyncOptions = [
|
||||
'.phpunit.result.cache',
|
||||
'config/secret/',
|
||||
'CLAUDE.md',
|
||||
'cypress*',
|
||||
'deploy.php',
|
||||
'docs',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'*.http',
|
||||
'http-client*.json',
|
||||
'http-client.private.env.json',
|
||||
'postcss.config.js',
|
||||
'phpunit.xml.dist',
|
||||
'public/.htaccess',
|
||||
'ray.php',
|
||||
'tailwind.config.js',
|
||||
'webpack.config.js',
|
||||
'bin/.phpunit',
|
||||
@@ -77,31 +74,31 @@ $rsyncOptions = [
|
||||
];
|
||||
|
||||
host('prod')
|
||||
->setHostname('185.243.135.29')
|
||||
->setRemoteUser('p704161')
|
||||
->setHostname('92.205.212.39')
|
||||
->setRemoteUser('ssh-53857-root')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/home/www/p704161/html/myep')
|
||||
->setDeployPath('/kunden/tiefschnee.de/myep')
|
||||
->set('writable_mode', 'chmod')
|
||||
->set('http_user', 'p704161')
|
||||
->set('bin/php', '/usr/local/bin/php')
|
||||
->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={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{release_or_current_path}}/public/ --web-url=https://my.ep-reisen.de')
|
||||
;
|
||||
|
||||
host('staging')
|
||||
->setHostname('185.243.135.29')
|
||||
->setRemoteUser('p704161')
|
||||
->setHostname('185.243.132.170')
|
||||
->setRemoteUser('p546473')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/home/www/p704161/html/myep-staging')
|
||||
->setDeployPath('/home/www/p546473/html/myep')
|
||||
->set('writable_mode', 'chmod')
|
||||
->set('http_user', 'p704161')
|
||||
->set('bin/php', '/usr/local/bin/php')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging')
|
||||
->set('keep_releases', 5)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{release_or_current_path}}/public/ --web-url=https://myep.dreipunktnull.net')
|
||||
;
|
||||
|
||||
task('deploy', [
|
||||
@@ -113,16 +110,14 @@ task('deploy', [
|
||||
'rsync',
|
||||
'deploy:shared',
|
||||
'deploy:writable',
|
||||
'deploy:cache:clear',
|
||||
'database:migrate',
|
||||
'cachetool:clear:opcache',
|
||||
'deploy:publish',
|
||||
'cachetool:clear:opcache',
|
||||
'deploy:stop-workers',
|
||||
]);
|
||||
|
||||
task('deploy:assets', function () {
|
||||
runLocally('ddev exec npm ci');
|
||||
runLocally('ddev exec npm run build');
|
||||
runLocally('npm i && npm run build');
|
||||
});
|
||||
|
||||
task('deploy:stop-workers', function () {
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
# Symfony 7.4 Upgrade Plan
|
||||
|
||||
**Project:** MyEP Next Booking
|
||||
**Current Version:** Symfony 6.4.31
|
||||
**Target Version:** Symfony 7.4 (LTS)
|
||||
**Date:** 2026-01-05
|
||||
**Status:** Ready for Upgrade
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Estimated Effort: LOW**
|
||||
**Risk Level: LOW**
|
||||
**Status: READY FOR UPGRADE**
|
||||
|
||||
The MyEP Next Booking application is well-positioned for a Symfony 7.4 upgrade. The codebase follows modern Symfony best practices, uses PHP 8 features extensively, and avoids most deprecated patterns. All pre-upgrade fixes have been completed.
|
||||
|
||||
**Key Facts:**
|
||||
- Symfony 7.4 was released on November 27, 2025
|
||||
- Requires PHP 8.2.0+ (current: PHP 8.2.29)
|
||||
- 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)
|
||||
- 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
|
||||
- Email validation mode: Configured (html5)
|
||||
- 18 test-only deprecation warnings (cosmetic, do not affect runtime)
|
||||
|
||||
---
|
||||
|
||||
## Previous Plan Status
|
||||
|
||||
The previous upgrade plan (dated 2025-12-06) has been updated. All pre-upgrade tasks are now complete.
|
||||
|
||||
### Completed Items
|
||||
| Item | Previous Status | Current Status |
|
||||
|------|----------------|----------------|
|
||||
| OAuth2 Server Bundle | 1.0.0 | **1.1.0 installed** |
|
||||
| Stimulus Bundle | 2.31.0 | **2.32.0 installed** |
|
||||
| PHP 8.2 deprecations (ParticipantDto::$touched) | Planned | **Completed** |
|
||||
| Doctrine config (controller_resolver) | Planned | **Completed** |
|
||||
| SecurityController null safety | Needs verification | **Completed** (uses `?->` and `??`) |
|
||||
| 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
|
||||
- **Symfony 7.4 Required:** PHP 8.2.0+
|
||||
- **Status:** Compatible
|
||||
|
||||
### Installed Dependencies
|
||||
|
||||
#### Core Symfony Packages (6.4.x)
|
||||
All 33 Symfony packages currently at 6.4.x need version constraint updates to 7.4.*
|
||||
|
||||
#### Third-Party Bundles - Compatible
|
||||
| Package | Installed | Symfony 7.4 Compatible |
|
||||
|---------|-----------|------------------------|
|
||||
| `doctrine/orm` | 3.6.0 | Yes |
|
||||
| `doctrine/doctrine-bundle` | 2.18.1 | Yes |
|
||||
| `doctrine/doctrine-migrations-bundle` | 3.7.0 | Yes |
|
||||
| `league/oauth2-server-bundle` | 1.1.0 | Yes |
|
||||
| `symfony/stimulus-bundle` | 2.32.0 | Yes |
|
||||
| `symfony/webpack-encore-bundle` | 2.4.0 | Yes |
|
||||
| `symfony/monolog-bundle` | 3.11.0 | Yes |
|
||||
| `zenstruck/schedule-bundle` | 1.9.0 | Yes |
|
||||
|
||||
### Code Quality Assessment
|
||||
|
||||
The codebase implements modern Symfony/PHP patterns:
|
||||
- All controllers use PHP 8 attributes (`#[Route]`)
|
||||
- Constructor property promotion used throughout
|
||||
- Strict types enforced on all files
|
||||
- Typed properties and return type declarations
|
||||
- Entities use PHP 8 attributes for ORM mapping
|
||||
- Modern security system (AbstractLoginFormAuthenticator)
|
||||
- No deprecated annotation usage
|
||||
|
||||
---
|
||||
|
||||
## Pre-Upgrade Fixes - COMPLETED
|
||||
|
||||
### 1. Email Validator "loose" Mode Deprecation - DONE
|
||||
|
||||
**Issue:** The default mode for `#[Assert\Email]` is "loose", which is deprecated in Symfony 6.2+ and removed in Symfony 7.0.
|
||||
|
||||
**Resolution:** Framework default configured in `config/packages/validator.yaml`:
|
||||
|
||||
```yaml
|
||||
framework:
|
||||
validation:
|
||||
email_validation_mode: html5
|
||||
```
|
||||
|
||||
All Email constraints in the codebase also explicitly use `mode: 'strict'`:
|
||||
- `src/Form/Model/ParticipantDto.php` - Has `mode: 'strict'`
|
||||
- `src/Form/Model/RegistrationDto.php` - Has `mode: 'strict'`
|
||||
- `src/BusProNet/Model/Communication.php` - Has `mode: 'strict'`
|
||||
- `src/Controller/ResetPasswordController.php` - Has `mode: 'strict'`
|
||||
|
||||
### 2. Test Deprecation Warnings - COSMETIC ONLY
|
||||
|
||||
**Issue:** 18 deprecation warnings appear during test runs.
|
||||
|
||||
**Cause:** `ParticipantEditDtoTest` creates a standalone validator using `Validation::createValidatorBuilder()` which bypasses the framework's `validator.yaml` configuration.
|
||||
|
||||
**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)
|
||||
|
||||
**Note:** Warnings can be silenced with `SYMFONY_DEPRECATIONS_HELPER=disabled` if desired.
|
||||
|
||||
---
|
||||
|
||||
## Required Changes
|
||||
|
||||
### 1. Update Composer Version Constraints
|
||||
|
||||
**File:** `composer.json`
|
||||
|
||||
#### Symfony Core Packages
|
||||
Update all packages from `6.4.*` to `7.4.*`:
|
||||
|
||||
```json
|
||||
{
|
||||
"require": {
|
||||
"symfony/asset": "7.4.*",
|
||||
"symfony/console": "7.4.*",
|
||||
"symfony/doctrine-messenger": "7.4.*",
|
||||
"symfony/dom-crawler": "7.4.*",
|
||||
"symfony/dotenv": "7.4.*",
|
||||
"symfony/expression-language": "7.4.*",
|
||||
"symfony/form": "7.4.*",
|
||||
"symfony/framework-bundle": "7.4.*",
|
||||
"symfony/html-sanitizer": "7.4.*",
|
||||
"symfony/http-client": "7.4.*",
|
||||
"symfony/intl": "7.4.*",
|
||||
"symfony/lock": "7.4.*",
|
||||
"symfony/mailer": "7.4.*",
|
||||
"symfony/mime": "7.4.*",
|
||||
"symfony/notifier": "7.4.*",
|
||||
"symfony/process": "7.4.*",
|
||||
"symfony/property-access": "7.4.*",
|
||||
"symfony/property-info": "7.4.*",
|
||||
"symfony/runtime": "7.4.*",
|
||||
"symfony/security-bundle": "7.4.*",
|
||||
"symfony/serializer": "7.4.*",
|
||||
"symfony/string": "7.4.*",
|
||||
"symfony/translation": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/uid": "7.4.*",
|
||||
"symfony/validator": "7.4.*",
|
||||
"symfony/web-link": "7.4.*",
|
||||
"symfony/yaml": "7.4.*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Development Packages
|
||||
|
||||
```json
|
||||
{
|
||||
"require-dev": {
|
||||
"symfony/browser-kit": "7.4.*",
|
||||
"symfony/css-selector": "7.4.*",
|
||||
"symfony/debug-bundle": "7.4.*",
|
||||
"symfony/stopwatch": "7.4.*",
|
||||
"symfony/web-profiler-bundle": "7.4.*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Update Symfony Flex Configuration
|
||||
|
||||
**File:** `composer.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"extra": {
|
||||
"symfony": {
|
||||
"allow-contrib": true,
|
||||
"require": "7.4.*",
|
||||
"docker": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Potential Breaking Changes
|
||||
|
||||
### 1. Symfony Core Changes (Symfony 7.0 -> 7.4)
|
||||
|
||||
Symfony 7.0 removed all deprecated features from 6.4. Our codebase is fully compliant:
|
||||
|
||||
- **Native Return Types:** Already implemented
|
||||
- **Attribute-based Configuration:** Already implemented
|
||||
- **Security System:** Modern authenticator pattern in use
|
||||
- **Form System:** Modern approach in use
|
||||
- **No Annotation Usage:** All using PHP 8 attributes
|
||||
|
||||
### 2. Email Validation Mode
|
||||
|
||||
**Breaking Change:** The default `mode` for `Email` constraint changes from `loose` to `html5` in Symfony 7.0.
|
||||
|
||||
**Status:** Already handled - all constraints use explicit `mode: 'strict'` and framework default is set to `html5`.
|
||||
|
||||
### 3. Deprecation Cleanup
|
||||
|
||||
Deprecations accumulated in Symfony 7.1-7.4 will need review:
|
||||
- Check Symfony Profiler after upgrade for any new deprecation warnings
|
||||
- Review [UPGRADE-7.0.md](https://github.com/symfony/symfony/blob/7.0/UPGRADE-7.0.md)
|
||||
- Review [UPGRADE-7.4.md](https://github.com/symfony/symfony/blob/7.4/UPGRADE-7.4.md)
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Procedure
|
||||
|
||||
### Phase 1: Preparation (15 minutes)
|
||||
|
||||
1. **Create Feature Branch**
|
||||
```bash
|
||||
git checkout -b feature/symfony-7.4-upgrade
|
||||
```
|
||||
|
||||
2. **Backup Current State**
|
||||
```bash
|
||||
git tag before-symfony-7.4-upgrade
|
||||
```
|
||||
|
||||
### Phase 2: Dependency Updates (30 minutes - 1 hour)
|
||||
|
||||
1. **Update composer.json**
|
||||
- Update all Symfony package constraints to `7.4.*`
|
||||
- Update `extra.symfony.require` to `"7.4.*"`
|
||||
|
||||
2. **Run Composer Update**
|
||||
```bash
|
||||
ddev composer update
|
||||
```
|
||||
|
||||
3. **Review Recipe Updates**
|
||||
```bash
|
||||
ddev composer recipes
|
||||
```
|
||||
Review any recipe updates from Symfony Flex and apply as needed.
|
||||
|
||||
4. **Clear All Caches**
|
||||
```bash
|
||||
ddev exec bin/console cache:clear
|
||||
ddev exec bin/console cache:warmup
|
||||
ddev exec "php -r 'opcache_reset();'"
|
||||
```
|
||||
|
||||
### Phase 3: Testing & Validation (1-2 hours)
|
||||
|
||||
1. **Run PHPUnit Test Suite**
|
||||
```bash
|
||||
ddev exec ./vendor/bin/phpunit
|
||||
```
|
||||
Check for:
|
||||
- Test failures
|
||||
- Deprecation warnings
|
||||
- Fatal errors
|
||||
|
||||
2. **Run PHP-CS-Fixer**
|
||||
```bash
|
||||
ddev php vendor/bin/php-cs-fixer fix --dry-run --diff
|
||||
```
|
||||
|
||||
3. **Manual Testing - Critical Paths**
|
||||
- [ ] User authentication (BPN API login)
|
||||
- [ ] OAuth2 token generation (`/token` endpoint)
|
||||
- [ ] OAuth2 authorization flow (`/authorize` endpoint)
|
||||
- [ ] API endpoints (all `/api/*` routes)
|
||||
- [ ] Booking creation flow (Steps 1-4)
|
||||
- [ ] Participant form rendering and submission
|
||||
- [ ] Dynamic field updates (HTMX)
|
||||
- [ ] Real-time pricing calculations
|
||||
- [ ] Insurance selection and filtering
|
||||
- [ ] Transportation service selection
|
||||
- [ ] Payment method selection
|
||||
- [ ] Final booking submission to BPN API
|
||||
- [ ] Booking edit functionality
|
||||
- [ ] PDF/XML download functionality
|
||||
- [ ] User registration
|
||||
- [ ] Password reset
|
||||
- [ ] Personal data management
|
||||
|
||||
4. **Check Symfony Profiler**
|
||||
- Review deprecation warnings (should be minimal)
|
||||
- Check performance metrics
|
||||
- Verify no errors in logs
|
||||
|
||||
5. **Test Frontend Assets**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
- Verify Stimulus controllers load correctly
|
||||
- Test HTMX interactions
|
||||
- Verify Toastify notifications
|
||||
- Check TailwindCSS compilation
|
||||
|
||||
### Phase 4: Bug Fixes (0-2 hours)
|
||||
|
||||
Address any issues discovered during testing:
|
||||
|
||||
1. **Review Error Logs**
|
||||
```bash
|
||||
ddev logs
|
||||
```
|
||||
|
||||
2. **Fix Any Remaining Deprecations**
|
||||
- Use Symfony Profiler to identify deprecations
|
||||
- Update code to use modern alternatives
|
||||
|
||||
### Phase 5: Deployment (1 hour)
|
||||
|
||||
1. **Deploy to Staging**
|
||||
```bash
|
||||
ddev exec vendor/bin/dep deploy staging
|
||||
```
|
||||
|
||||
2. **Staging Validation**
|
||||
- Full regression testing
|
||||
- Performance testing
|
||||
|
||||
3. **Production Deployment**
|
||||
- Schedule maintenance window
|
||||
- Deploy during low-traffic period
|
||||
- Monitor error logs closely
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
### Immediate Rollback
|
||||
|
||||
If critical issues are discovered:
|
||||
|
||||
1. **Git Revert**
|
||||
```bash
|
||||
git checkout master
|
||||
git reset --hard before-symfony-7.4-upgrade
|
||||
```
|
||||
|
||||
2. **Restore Dependencies**
|
||||
```bash
|
||||
ddev composer install
|
||||
```
|
||||
|
||||
3. **Clear Caches**
|
||||
```bash
|
||||
ddev exec bin/console cache:clear
|
||||
```
|
||||
|
||||
### Production Rollback
|
||||
|
||||
If issues occur in production:
|
||||
|
||||
```bash
|
||||
ddev exec vendor/bin/dep rollback production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Unit Tests
|
||||
- [ ] All PHPUnit tests pass (312 tests)
|
||||
- [ ] No new deprecation warnings
|
||||
- [ ] Code coverage maintained
|
||||
|
||||
### Integration Tests
|
||||
- [ ] BusProNet API integration
|
||||
- [ ] XML parsing and data processing
|
||||
- [ ] Booking price calculations
|
||||
- [ ] Insurance assignment logic
|
||||
- [ ] Field handler system
|
||||
|
||||
### Functional Tests
|
||||
- [ ] User authentication flow
|
||||
- [ ] OAuth2 token generation
|
||||
- [ ] API endpoints respond correctly
|
||||
- [ ] Booking creation (all steps)
|
||||
- [ ] Form validation
|
||||
- [ ] HTMX partial updates
|
||||
- [ ] Dynamic field state changes
|
||||
- [ ] Real-time pricing updates
|
||||
- [ ] Toast notifications
|
||||
|
||||
### Browser Testing
|
||||
- [ ] Chrome (latest)
|
||||
- [ ] Firefox (latest)
|
||||
- [ ] Safari (latest)
|
||||
- [ ] Mobile browsers
|
||||
|
||||
---
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
| Phase | Estimated Time | Notes |
|
||||
|-------|---------------|-------|
|
||||
| **Phase 1:** Preparation | 15 min | Branch creation |
|
||||
| **Phase 2:** Dependency Updates | 30 min - 1 hour | composer.json updates, composer update |
|
||||
| **Phase 3:** Testing & Validation | 1-2 hours | Automated + manual testing |
|
||||
| **Phase 4:** Bug Fixes | 0-2 hours | Address any issues |
|
||||
| **Phase 5:** Deployment | 1 hour | Staging deployment |
|
||||
| **Total** | **2.5-6 hours** | Average: **3.5 hours** |
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations: Symfony 8.0
|
||||
|
||||
Symfony 8.0 was released alongside 7.4 (November 2025) with identical features but:
|
||||
- Requires PHP 8.4
|
||||
- No deprecation layers (all deprecated code removed)
|
||||
- Shorter support window (8 months)
|
||||
|
||||
**Recommendation:** Stay on Symfony 7.4 LTS for stability. Plan PHP 8.4 upgrade separately, then consider Symfony 8.x path.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] All PHPUnit tests pass (312 tests)
|
||||
- [ ] No deprecation warnings in Symfony Profiler
|
||||
- [ ] All critical user flows functional
|
||||
- [ ] OAuth2 authentication working
|
||||
- [ ] BPN API integration operational
|
||||
- [ ] Booking creation flow complete
|
||||
- [ ] Frontend assets compile and load
|
||||
- [ ] Performance metrics comparable or better
|
||||
- [ ] No errors in production logs after 24 hours
|
||||
|
||||
---
|
||||
|
||||
## Additional Resources
|
||||
|
||||
### Official Symfony Documentation
|
||||
- [Upgrade Major Version Guide](https://symfony.com/doc/current/setup/upgrade_major.html)
|
||||
- [UPGRADE-7.0.md](https://github.com/symfony/symfony/blob/7.0/UPGRADE-7.0.md)
|
||||
- [UPGRADE-7.4.md](https://github.com/symfony/symfony/blob/7.4/UPGRADE-7.4.md)
|
||||
- [Symfony 7.4 Release Notes](https://symfony.com/releases/7.4)
|
||||
- [Preparing for Symfony 7.4 and 8.0](https://symfony.com/blog/preparing-for-symfony-7-4-and-symfony-8-0)
|
||||
|
||||
### Bundle-Specific Documentation
|
||||
- [OAuth2 Server Bundle](https://github.com/thephpleague/oauth2-server-bundle)
|
||||
- [Doctrine ORM 3.x Documentation](https://www.doctrine-project.org/projects/orm.html)
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 5.0
|
||||
**Previous Version:** 4.0 (dated 2025-12-06)
|
||||
**Last Updated:** 2026-01-05
|
||||
**Status:** Ready for Symfony 7.4 Upgrade
|
||||
@@ -1,122 +0,0 @@
|
||||
# Body Dimensions Transition Plan (Stakeholder Draft)
|
||||
|
||||
> **STATUS (verified 2026-07-15): NOT IMPLEMENTED.** `src/Form/BodyDimensionsType.php` still declares `height`, `shoeSize`, and `weight` as `IntegerType` fields, and `src/Validator/Constraints/ParticipantValidator.php` has no regex/legacy-format handling — only numeric range checks. This plan has not been acted on.
|
||||
|
||||
## Goal
|
||||
|
||||
Prevent edit-form failures for existing bookings while we transition from legacy body-dimension values to a better long-term field model.
|
||||
|
||||
This plan introduces a temporary compatibility phase and keeps sunsetting manual and straightforward.
|
||||
|
||||
## Current Problem
|
||||
|
||||
- Existing bookings may contain legacy values such as `-148`, `149-157`, or `195+`.
|
||||
- Integer-only form fields can fail when these legacy strings are loaded.
|
||||
- Result: participant edit forms can crash before submit.
|
||||
|
||||
## Proposed Transition Strategy
|
||||
|
||||
Use **text input fields** for body dimensions during transition, with explicit server-side format validation.
|
||||
|
||||
### Key Decisions
|
||||
|
||||
- No gating by booking create date.
|
||||
- One unified behavior for all bookings.
|
||||
- Accept both legacy and numeric formats during transition.
|
||||
- Keep manual sunsetting later (no automated migration switch).
|
||||
|
||||
## Functional Scope
|
||||
|
||||
Affected fields:
|
||||
|
||||
- `height`
|
||||
- `weight`
|
||||
- `shoeSize`
|
||||
|
||||
Affected components:
|
||||
|
||||
- `src/Form/BodyDimensionsType.php`
|
||||
- `src/Validator/Constraints/ParticipantValidator.php`
|
||||
- `src/Form/BookingParticipantType.php` (option passthrough already in place)
|
||||
- `config/services.yaml` (ranges remain as validation/config source)
|
||||
|
||||
## Validation Rules During Transition
|
||||
|
||||
### Accepted formats
|
||||
|
||||
- Numeric value: `^\d+$` (e.g. `176`)
|
||||
- Legacy lower/open/range tokens:
|
||||
- `^-\d+$` (e.g. `-148`)
|
||||
- `^\d+\+$` (e.g. `195+`)
|
||||
- `^\d+\s*-\s*\d+$` (e.g. `149-157`)
|
||||
|
||||
### Requiredness
|
||||
|
||||
- Keep existing rule: body dimensions required when rentals are selected.
|
||||
|
||||
### Range checks
|
||||
|
||||
- Keep current numeric range checks based on `body_dimension_ranges`.
|
||||
- Apply range checks only to plain numeric values in transition phase.
|
||||
|
||||
## UX/Behavior Expectations
|
||||
|
||||
- Existing legacy values render without form initialization errors.
|
||||
- New numeric entries are accepted and validated.
|
||||
- Invalid free text (e.g. `abc`, `17x`) shows clear validation errors.
|
||||
- HTMX refresh behavior remains unchanged.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Form type change**
|
||||
- In `BodyDimensionsType`, replace integer fields with text fields for all three body dimensions.
|
||||
- Keep labels/help text; keep placeholders based on configured ranges.
|
||||
|
||||
2. **Validator extension**
|
||||
- Add format validation for all three fields in `ParticipantValidator`.
|
||||
- Keep existing required-when-rentals logic.
|
||||
- Keep existing range validation for numeric values.
|
||||
|
||||
3. **Message tuning**
|
||||
- Add one clear message for invalid format.
|
||||
- Retain existing range message for numeric out-of-range values.
|
||||
|
||||
4. **Testing and QA**
|
||||
- Validate old bookings with legacy values open/edit successfully.
|
||||
- Validate numeric path (valid and invalid range).
|
||||
- Validate invalid format handling.
|
||||
- Validate rental-required behavior.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- No 500 error when editing participants with legacy body-dimension values.
|
||||
- Legacy values can be loaded and submitted in transition phase.
|
||||
- Numeric values are accepted and range-validated.
|
||||
- Invalid text is rejected with a user-facing validation message.
|
||||
- Existing participant edit/refresh flows continue to work.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
- **Risk:** Free-text field semantics are less strict than integer fields.
|
||||
- **Mitigation:** strict server-side regex + range checks.
|
||||
|
||||
- **Risk:** Inconsistent data representations during transition.
|
||||
- **Mitigation:** explicit acceptance policy and manual sunset plan.
|
||||
|
||||
## Effort Estimate
|
||||
|
||||
- Implementation: 0.5 day
|
||||
- Validation/message tuning: 0.25 day
|
||||
- QA/manual testing: 0.5 day
|
||||
- **Total:** ~1 to 1.5 days
|
||||
|
||||
## Manual Sunset Plan (Later)
|
||||
|
||||
When stakeholders approve end of transition:
|
||||
|
||||
1. Replace text fields with final semantic field type(s).
|
||||
2. Remove legacy format acceptance from validator.
|
||||
3. Keep only final numeric/range validation behavior.
|
||||
4. Remove transition-specific tests and copy.
|
||||
|
||||
Expected cleanup effort: ~0.25 to 0.5 day.
|
||||
@@ -1,196 +0,0 @@
|
||||
# Booking Flow Reference
|
||||
|
||||
Last updated: 2026-07-15
|
||||
|
||||
This document traces the booking create and edit flows from controller entry point through to session/DTO state. It is intended as orientation for developers unfamiliar with the flow, not as a substitute for reading the code.
|
||||
|
||||
---
|
||||
|
||||
## Shared Concepts
|
||||
|
||||
### `BookingDto`
|
||||
|
||||
The central data object for both flows. Holds room selections, participants, payment method, and travel reference. Lives in the HTTP session between requests. Has two modes: `MODE_CREATE` and `MODE_EDIT`.
|
||||
|
||||
### `BookingSessionManager`
|
||||
|
||||
Owns all reads and writes to session-stored `BookingDto` instances. Also manages the baseline room snapshot used for change detection in the create flow.
|
||||
|
||||
### `BookingCreateContextFactory`
|
||||
|
||||
Assembles the view model (`BookingCreateContext`) passed to create-flow templates. Handles room grouping (by-pax vs. by-room) and wires summary sidebar data.
|
||||
|
||||
---
|
||||
|
||||
## Create Flow
|
||||
|
||||
```
|
||||
Step 1 (room selection)
|
||||
→ Step 2 (participants)
|
||||
→ Step 2 sub-form (per-participant, HTMX)
|
||||
→ Step 3 (payment + API pre-validation)
|
||||
→ Step 4 (confirmation + final submission)
|
||||
→ Success
|
||||
```
|
||||
|
||||
### Entry / Bootstrap
|
||||
|
||||
`AbstractBookingCreateController::loadBookingCreateDto()` is called by every step controller and delegates to `BookingSessionManager::getOrCreateBookingCreateDto()`. If no DTO exists in session, `BookingConfigurator::startFreshBooking()` loads travel data and initialises a fresh `BookingDto`.
|
||||
|
||||
### Step 1 — Room Selection
|
||||
|
||||
**Controller:** `Create/Step1Controller`
|
||||
**Route:** `GET|POST /bookings/create/rooms`
|
||||
|
||||
1. Load or create DTO via `getOrCreateBookingCreateDto()`
|
||||
2. `BookingSessionManager::getOrCreateBaselineSnapshot()` — saves the current room selection as a comparison point for change detection
|
||||
3. Render `BookingCreateStep1Type` form
|
||||
4. On submit:
|
||||
- If room selection changed from baseline: `BookingDto::resetParticipantAssignments()` — clears stale assignments
|
||||
- `BookingConfigurator::updateBookingStatusFromRoomSelection()` — may switch booking to inquiry mode if capacity constraints require it
|
||||
- `BookingSessionManager::saveBookingDto()`, advance `currentStep` to 2
|
||||
- `BookingSessionManager::clearBaselineSnapshot()`
|
||||
- Redirect to Step 2
|
||||
|
||||
HTMX refresh (`/bookings/create/refresh`): processes the form without validation and returns updated room selection + summary sidebar blocks.
|
||||
|
||||
### Step 2 — Participants (Card Overview)
|
||||
|
||||
**Controller:** `Create/Step2Controller`
|
||||
**Route:** `GET|POST /bookings/create/participants`
|
||||
|
||||
1. Load DTO from session (or redirect to Step 1 if missing)
|
||||
2. `TravelDataProvider::enrichWithFreshAvailabilities()` — refreshes room availability
|
||||
3. `ensureCorrectNumberOfParticipants()` — syncs the `BookingDto::participants` array length with the room selections (e.g., 2× double room → 4 participants). Preserves existing participant data for slots that still exist.
|
||||
4. `ParticipantDataPrefiller::shouldPrefillApplicant()` + `prefillApplicantFromUser()` — prefills participant[0] from user profile on first visit
|
||||
5. `RoomAssigner::validateAndResetInvalidAssignments()` — clears any room assignments that no longer match the current room selection (handles back-navigation from Step 2 → Step 1 → Step 2)
|
||||
6. `RoomAssigner::assignRoomsIfNeeded()` — auto-assigns participants to rooms
|
||||
7. `BookingConfigurator::preselectDefaultServices()` — selects mandatory and auto-book services for all participants
|
||||
8. `BookingConfigurator::applyCreateBookingStatusRules()` — re-evaluates booking status (book / inquiry / not-bookable)
|
||||
9. `BookingSessionManager::saveBookingDto()`
|
||||
10. On form submit (all participants valid): advance to Step 3, redirect
|
||||
|
||||
### Step 2 — Individual Participant Form (HTMX)
|
||||
|
||||
**Controller:** `Create/Step2ParticipantController`
|
||||
**Routes:** `GET|POST /bookings/create/participants/{index}` and `/participants/{index}/refresh`
|
||||
|
||||
Each participant card lazy-loads its form via HTMX. The controller:
|
||||
|
||||
1. Verifies the participant slot exists
|
||||
2. Refreshes availability
|
||||
3. Handles dummy data fill (`ParticipantDataPrefiller::isDummyDataFillRequested()` / `fillDummyParticipant()`)
|
||||
4. On submit: re-applies status rules, saves to session, redirects to card overview
|
||||
5. `ParticipantFormSupport` is used for form creation options and notification draining
|
||||
|
||||
### Step 3 — Payment + API Pre-Validation
|
||||
|
||||
**Controller:** `Create/Step3Controller`
|
||||
**Route:** `GET|POST /bookings/create/payment`
|
||||
|
||||
1. Load DTO from session
|
||||
2. `BookingConfigurator::applyCreateBookingStatusRules()`
|
||||
3. On submit:
|
||||
- `ApiClient::createBookingInquiry()` — submits booking to API for validation without committing it
|
||||
- If inquiry fails with "nicht möglich" + "Anfrage" in message: auto-switch to inquiry mode, advance to Step 4
|
||||
- If inquiry fails otherwise: show error, stay on step
|
||||
- Price verification: `BookingPriceCalculator::calculateGrandTotal()` is compared against the API response total (accounting for promotional/goodwill voucher discounts and API-applied group discounts). Mismatch triggers `BookingPriceMismatchAnalyzer` for detailed logging.
|
||||
- On success: advance `currentStep` to 4, redirect
|
||||
|
||||
### Step 4 — Confirmation + Final Submission
|
||||
|
||||
**Controller:** `Create/Step4Controller`
|
||||
**Route:** `GET|POST /bookings/create/confirmation`
|
||||
|
||||
1. Load DTO from session
|
||||
2. Newsletter subscription check for opt-in display
|
||||
3. On submit:
|
||||
- `BookingConfigurator::applyCreateBookingStatusRules()`
|
||||
- `ApiClient::createBooking()` — final booking submission (already validated in Step 3)
|
||||
- Newsletter double-opt-in if selected
|
||||
- On success: store booking number + total in flash (for analytics), clear travel data cache, `BookingSessionManager::clearBookingDto()`, redirect to success page
|
||||
|
||||
---
|
||||
|
||||
## Edit Flow
|
||||
|
||||
```
|
||||
Entry (start)
|
||||
→ Load (session or API + draft restore)
|
||||
→ Participant sub-forms (HTMX, per-participant)
|
||||
→ Submission
|
||||
```
|
||||
|
||||
### Entry
|
||||
|
||||
**Controller:** `Edit/IndexController::start()`
|
||||
**Route:** `GET /bookings/{id}/edit/start`
|
||||
|
||||
Clears any existing session DTO for edit mode and invalidates the API booking cache, then redirects to the edit page. This ensures a clean load from API on every intentional re-entry.
|
||||
|
||||
### Load
|
||||
|
||||
**Controller:** `Edit/IndexController::index()`
|
||||
**Route:** `GET|POST /bookings/{id}/edit`
|
||||
|
||||
Loading is delegated entirely to `BookingEditDataLoader::loadFormData()`:
|
||||
|
||||
**If session DTO exists and matches the requested booking ID:**
|
||||
- `TravelDataProvider::enrichWithFreshAvailabilities()` — refreshes room availability
|
||||
- `TravelDataProvider::getMutabilityData()` + `patchMutability()` — applies cached mutability state to DTO
|
||||
|
||||
**If no session DTO (first load or after `start`):**
|
||||
|
||||
`initializeFromApi()`:
|
||||
1. `fetchBookingData()` — `ApiClient::getBooking()` with 5-minute cache (tagged by user ID for bulk invalidation)
|
||||
2. `TravelDataProvider::getTravelData()` — loads travel data (XML or snapshot)
|
||||
3. `TravelDataProvider::getAvailabilityData(forceRefresh: true)` + `patchAvailabilities()` — force-refreshes availability at edit start, populates cache for subsequent loads within the session
|
||||
4. `TravelDataProvider::getMutabilityData(cached: true)` + `patchMutability()` — applies which service categories are currently editable
|
||||
5. `BookingDataProcessor::createBookingDtoFromBooking()` — hydrates `BookingDto` from API booking entity
|
||||
6. Agency code resolved via `AgencyLoader` (used by field state conditions)
|
||||
7. `BookingChangeTracker::generateFingerprint()` — sets `originalFingerprint` on DTO **before** draft is applied, so dirty detection compares against original API data
|
||||
8. `BookingEditDraftManager::findDraft()` + `applyDraftToDto()` — if draft exists, merges user edits on top of fresh API data. Sets `draftWasRestored = true` if successful.
|
||||
9. `BookingSessionManager::saveBookingDto()`
|
||||
|
||||
Back in the controller: shows "draft restored" flash if applicable, fetches booking data for surcharge/status display, renders the card overview with `BookingEditContextFactory::createOverviewContext()`.
|
||||
|
||||
### Individual Participant Form (HTMX)
|
||||
|
||||
**Controller:** `Edit/ParticipantController`
|
||||
**Routes:** `GET|POST /bookings/{id}/edit/participants/{index}` and `/{index}/refresh`
|
||||
|
||||
Per-participant forms are lazy-loaded, matching the create flow pattern.
|
||||
|
||||
1. `BookingEditContextFactory::prepareBookingDto()` — refreshes availabilities on the DTO travel object
|
||||
2. `ParticipantFormSupport` — form creation, notification draining
|
||||
3. On submit: `BookingSessionManager::saveBookingDto()` + `BookingEditDraftManager::saveDraft()` — persists participant edits to both session and draft
|
||||
|
||||
### Submission
|
||||
|
||||
**Service:** `BookingEditSubmitter::handleSubmission()`
|
||||
Called from `Edit/IndexController` on valid form submit.
|
||||
|
||||
1. Invalidate API cache, fetch fresh booking data
|
||||
2. Force-refresh mutability data (`TravelDataProvider::getMutabilityData(forceRefresh: true)`) and patch DTO travel
|
||||
3. `BookingEditSubmitGuard::reconcileImmutableCategories()` — reverts any DTO fields that have become immutable since the session was loaded. Shows flash if anything was reverted.
|
||||
4. `ApiClient::updateBooking()` — submit
|
||||
5. On success: invalidate cache again, `BookingSessionManager::clearBookingDto()`, `BookingEditDraftManager::deleteDraft()`
|
||||
|
||||
### Other Edit Routes
|
||||
|
||||
| Route | Action |
|
||||
|-------|--------|
|
||||
| `GET /bookings/{id}/edit/reload` | Shows confirmation modal |
|
||||
| `POST /bookings/{id}/edit/reload` | Clears session + **deletes draft**, reloads from API |
|
||||
| `GET /bookings/{id}/edit/cancel` | Clears session, **preserves draft**, redirects to booking list |
|
||||
|
||||
The distinction between reload and cancel is intentional: cancel keeps the draft so the user can return to their changes later; reload discards everything.
|
||||
|
||||
---
|
||||
|
||||
## Key Invariants
|
||||
|
||||
- `currentStep` on the DTO gates forward navigation in create mode; controllers redirect to the current step if a higher step is requested directly.
|
||||
- The original fingerprint is set before draft application in edit mode. Dirty detection (`BookingChangeTracker::isDirty()`) always compares the live session DTO against the original API state, not against the draft.
|
||||
- Mutability is always patched from fresh or cached data before rendering. The DTO travel object is the authoritative source of which fields are editable at render time.
|
||||
- Drafts survive cancel but not reload, and are deleted on successful submission.
|
||||
@@ -7,8 +7,8 @@
|
||||
"Grant Type": "Authorization Code",
|
||||
"Client ID": "{{oauth2_client_id}}",
|
||||
"Client Secret": "{{oauth2_client_secret}}",
|
||||
"Auth URL": "https://myep.ddev.site/authorize",
|
||||
"Token URL": "https://myep.ddev.site/token",
|
||||
"Auth URL": "https://myep-next-booking.ddev.site/authorize",
|
||||
"Token URL": "https://myep-next-booking.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.ddev.site/authorize",
|
||||
"Token URL": "https://myep.ddev.site/token",
|
||||
"Auth URL": "https://myep-next-booking.ddev.site/authorize",
|
||||
"Token URL": "https://myep-next-booking.ddev.site/token",
|
||||
"Redirect URL": "https://myep-team.ddev.site/auth/check",
|
||||
"Scope": "email profile"
|
||||
},
|
||||
@@ -27,61 +27,8 @@
|
||||
"Grant Type": "Client Credentials",
|
||||
"Client ID": "{{oauth2_api_client_id}}",
|
||||
"Client Secret": "{{oauth2_api_client_secret}}",
|
||||
"Auth URL": "https://myep.ddev.site/authorize",
|
||||
"Token URL": "https://myep.ddev.site/token",
|
||||
"Scope": "api"
|
||||
},
|
||||
"oauth2_newsletter": {
|
||||
"Type": "OAuth2",
|
||||
"Grant Type": "Client Credentials",
|
||||
"Client ID": "{{oauth2_api_client_id}}",
|
||||
"Client Secret": "{{oauth2_api_client_secret}}",
|
||||
"Auth URL": "https://myep.ddev.site/authorize",
|
||||
"Token URL": "https://myep.ddev.site/token",
|
||||
"Scope": "api"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"staging": {
|
||||
"Security": {
|
||||
"Auth": {
|
||||
"oauth2_email": {
|
||||
"Type": "OAuth2",
|
||||
"Grant Type": "Authorization Code",
|
||||
"Client ID": "{{oauth2_client_id}}",
|
||||
"Client Secret": "{{oauth2_client_secret}}",
|
||||
"Auth URL": "https://my.ep-reisen.net/authorize",
|
||||
"Token URL": "https://my.ep-reisen.net/token",
|
||||
"Redirect URL": "https://myep-team.ep-reisen.de/auth/check",
|
||||
"Scope": "email"
|
||||
},
|
||||
"oauth2_profile": {
|
||||
"Type": "OAuth2",
|
||||
"Grant Type": "Authorization Code",
|
||||
"Client ID": "{{oauth2_client_id}}",
|
||||
"Client Secret": "{{oauth2_client_secret}}",
|
||||
"Auth URL": "https://my.ep-reisen.net/authorize",
|
||||
"Token URL": "https://my.ep-reisen.net/token",
|
||||
"Redirect URL": "https://myep-team.ep-reisen.de/auth/check",
|
||||
"Scope": "email profile"
|
||||
},
|
||||
"oauth2_api": {
|
||||
"Type": "OAuth2",
|
||||
"Grant Type": "Client Credentials",
|
||||
"Client ID": "{{oauth2_api_client_id}}",
|
||||
"Client Secret": "{{oauth2_api_client_secret}}",
|
||||
"Auth URL": "https://my.ep-reisen.net/authorize",
|
||||
"Token URL": "https://my.ep-reisen.net/token",
|
||||
"Scope": "api"
|
||||
},
|
||||
"oauth2_newsletter": {
|
||||
"Type": "OAuth2",
|
||||
"Grant Type": "Client Credentials",
|
||||
"Client ID": "{{oauth2_api_client_id}}",
|
||||
"Client Secret": "{{oauth2_api_client_secret}}",
|
||||
"Auth URL": "https://my.ep-reisen.net/authorize",
|
||||
"Token URL": "https://my.ep-reisen.net/token",
|
||||
"Auth URL": "https://myep-next-booking.ddev.site/authorize",
|
||||
"Token URL": "https://myep-next-booking.ddev.site/token",
|
||||
"Scope": "api"
|
||||
}
|
||||
}
|
||||
@@ -95,8 +42,8 @@
|
||||
"Grant Type": "Authorization Code",
|
||||
"Client ID": "{{oauth2_client_id}}",
|
||||
"Client Secret": "{{oauth2_client_secret}}",
|
||||
"Auth URL": "https://my.ep-reisen.de/authorize",
|
||||
"Token URL": "https://my.ep-reisen.de/token",
|
||||
"Auth URL": "https://myep.ep-reisen.de/authorize",
|
||||
"Token URL": "https://myep.ep-reisen.de/token",
|
||||
"Redirect URL": "https://myep-team.ep-reisen.de/auth/check",
|
||||
"Scope": "email"
|
||||
},
|
||||
@@ -105,8 +52,8 @@
|
||||
"Grant Type": "Authorization Code",
|
||||
"Client ID": "{{oauth2_client_id}}",
|
||||
"Client Secret": "{{oauth2_client_secret}}",
|
||||
"Auth URL": "https://my.ep-reisen.de/authorize",
|
||||
"Token URL": "https://my.ep-reisen.de/token",
|
||||
"Auth URL": "https://myep.ep-reisen.de/authorize",
|
||||
"Token URL": "https://myep.ep-reisen.de/token",
|
||||
"Redirect URL": "https://myep-team.ep-reisen.de/auth/check",
|
||||
"Scope": "email profile"
|
||||
},
|
||||
@@ -115,17 +62,8 @@
|
||||
"Grant Type": "Client Credentials",
|
||||
"Client ID": "{{oauth2_api_client_id}}",
|
||||
"Client Secret": "{{oauth2_api_client_secret}}",
|
||||
"Auth URL": "https://my.ep-reisen.de/authorize",
|
||||
"Token URL": "https://my.ep-reisen.de/token",
|
||||
"Scope": "api"
|
||||
},
|
||||
"oauth2_newsletter": {
|
||||
"Type": "OAuth2",
|
||||
"Grant Type": "Client Credentials",
|
||||
"Client ID": "{{oauth2_api_client_id}}",
|
||||
"Client Secret": "{{oauth2_api_client_secret}}",
|
||||
"Auth URL": "https://my.ep-reisen.de/authorize",
|
||||
"Token URL": "https://my.ep-reisen.de/token",
|
||||
"Auth URL": "https://myep.ep-reisen.de/authorize",
|
||||
"Token URL": "https://myep.ep-reisen.de/token",
|
||||
"Scope": "api"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
###
|
||||
# Mailjet API test requests for IntelliJ HTTP Client
|
||||
#
|
||||
# 1) Fill in variables below (or move them to http-client.private.env.json).
|
||||
# 2) Set `mailjet_api_key` and `mailjet_api_secret` in your env file.
|
||||
# IntelliJ HTTP Client will inject them into the Authorization header below.
|
||||
#
|
||||
# Required variables:
|
||||
# - mailjet_base_url (example: https://api.mailjet.com/v3/REST)
|
||||
# - mailjet_api_key
|
||||
# - mailjet_api_secret
|
||||
# - mailjet_contact_email
|
||||
# - mailjet_list_id
|
||||
#
|
||||
# @no-cookie-jar
|
||||
|
||||
### Mailjet health check (authenticated)
|
||||
# @no-cookie-jar
|
||||
GET {{mailjet_base_url}}/apikey
|
||||
Accept: application/json
|
||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
||||
|
||||
|
||||
### Find contact by email
|
||||
# @no-cookie-jar
|
||||
GET {{mailjet_base_url}}/contact?Email={{mailjet_contact_email}}
|
||||
Accept: application/json
|
||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
||||
|
||||
> {%
|
||||
if (response.body && response.body.Data && response.body.Data.length > 0) {
|
||||
client.global.set("contact_id", String(response.body.Data[0].ID));
|
||||
}
|
||||
%}
|
||||
|
||||
|
||||
### Create contact (run if not found)
|
||||
# @no-cookie-jar
|
||||
POST {{mailjet_base_url}}/contact
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
||||
|
||||
{
|
||||
"Email": "{{mailjet_contact_email}}"
|
||||
}
|
||||
|
||||
> {%
|
||||
if (response.body && response.body.Data && response.body.Data.length > 0) {
|
||||
client.global.set("contact_id", String(response.body.Data[0].ID));
|
||||
}
|
||||
%}
|
||||
|
||||
|
||||
### Get contact by ID (after find/create)
|
||||
# @no-cookie-jar
|
||||
GET {{mailjet_base_url}}/contact/{{contact_id}}
|
||||
Accept: application/json
|
||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
||||
|
||||
|
||||
### Get contact metadata by ID (after find/create)
|
||||
# @no-cookie-jar
|
||||
GET {{mailjet_base_url}}/contactdata/{{contact_id}}
|
||||
Accept: application/json
|
||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
||||
|
||||
### Get newsletter list by ID
|
||||
# @no-cookie-jar
|
||||
GET {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}
|
||||
Accept: application/json
|
||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
||||
|
||||
|
||||
### List all available contact lists
|
||||
# @no-cookie-jar
|
||||
GET {{mailjet_base_url}}/contactslist?Limit=1000
|
||||
Accept: application/json
|
||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
||||
|
||||
|
||||
### Check list recipient status (before actions)
|
||||
# @no-cookie-jar
|
||||
GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}}
|
||||
Accept: application/json
|
||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
||||
|
||||
> {%
|
||||
if (response.body && response.body.Data && response.body.Data.length > 0) {
|
||||
client.global.set("listrecipient_id", String(response.body.Data[0].ID));
|
||||
}
|
||||
%}
|
||||
|
||||
|
||||
### Subscribe to list (addnoforce)
|
||||
# @no-cookie-jar
|
||||
POST {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}/managecontact
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
||||
|
||||
{
|
||||
"Action": "addnoforce",
|
||||
"Email": "{{mailjet_contact_email}}"
|
||||
}
|
||||
|
||||
|
||||
### Verify list recipient after addnoforce
|
||||
# @no-cookie-jar
|
||||
GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}}
|
||||
Accept: application/json
|
||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
||||
|
||||
|
||||
### Unsubscribe from list (unsub)
|
||||
# @no-cookie-jar
|
||||
POST {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}/managecontact
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
||||
|
||||
{
|
||||
"Action": "unsub",
|
||||
"Email": "{{mailjet_contact_email}}"
|
||||
}
|
||||
|
||||
|
||||
### Verify list recipient after unsub
|
||||
# @no-cookie-jar
|
||||
GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}}
|
||||
Accept: application/json
|
||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
||||
|
||||
|
||||
### Re-subscribe attempt with addnoforce (expected to keep unsubscribed state)
|
||||
# @no-cookie-jar
|
||||
POST {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}/managecontact
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
||||
|
||||
{
|
||||
"Action": "addnoforce",
|
||||
"Email": "{{mailjet_contact_email}}"
|
||||
}
|
||||
|
||||
|
||||
### Verify list recipient after addnoforce re-subscribe attempt
|
||||
# @no-cookie-jar
|
||||
GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}}
|
||||
Accept: application/json
|
||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
||||
|
||||
|
||||
### Re-subscribe with addforce (expected to re-subscribe)
|
||||
# @no-cookie-jar
|
||||
POST {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}/managecontact
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
||||
|
||||
{
|
||||
"Action": "addforce",
|
||||
"Email": "{{mailjet_contact_email}}"
|
||||
}
|
||||
|
||||
|
||||
### Verify list recipient after addforce
|
||||
# @no-cookie-jar
|
||||
GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}}
|
||||
Accept: application/json
|
||||
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260318113000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create newsletter_opt_in_confirmation table for double opt-in confirmations';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql("CREATE TABLE newsletter_opt_in_confirmation (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(255) NOT NULL, token_hash VARCHAR(64) NOT NULL, expires_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', confirmed_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', UNIQUE INDEX UNIQ_NEWSLETTER_OPT_IN_TOKEN_HASH (token_hash), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE newsletter_opt_in_confirmation');
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260320071724 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add error_code column and index to log_entry table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE log_entry ADD error_code VARCHAR(12) DEFAULT NULL');
|
||||
$this->addSql('CREATE INDEX IDX_LOG_ENTRY_ERROR_CODE ON log_entry (error_code)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX IDX_LOG_ENTRY_ERROR_CODE ON log_entry');
|
||||
$this->addSql('ALTER TABLE log_entry DROP error_code');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260321120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create travel_snapshot table for DB-primary travel persistence';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE travel_snapshot (id INT AUTO_INCREMENT NOT NULL, date_id INT NOT NULL, hotel_id INT NOT NULL, date_code VARCHAR(64) DEFAULT NULL, label VARCHAR(255) DEFAULT NULL, product_id INT DEFAULT NULL, product_code VARCHAR(64) DEFAULT NULL, hotel_code VARCHAR(64) DEFAULT NULL, hotel_label VARCHAR(255) DEFAULT NULL, date_from DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', payload LONGTEXT NOT NULL, payload_hash VARCHAR(64) NOT NULL, captured_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', extended_refreshed_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX uniq_snapshot_date_hotel (date_id, hotel_id), INDEX idx_snapshot_date_code (date_code), INDEX idx_snapshot_product_id (product_id), INDEX idx_snapshot_date_to (date_to), INDEX idx_snapshot_extended_refreshed_at (extended_refreshed_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE travel_snapshot');
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<?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 Version20260323182524 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 CHANGE form_data form_data JSON NOT NULL');
|
||||
$this->addSql('ALTER TABLE log_entry CHANGE context context JSON DEFAULT NULL, CHANGE extra extra JSON DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE newsletter_opt_in_confirmation RENAME INDEX uniq_newsletter_opt_in_token_hash TO UNIQ_70505214B3BC57DA');
|
||||
$this->addSql('ALTER TABLE user CHANGE roles roles JSON NOT NULL, CHANGE hotel_codes hotel_codes JSON NOT NULL');
|
||||
$this->addSql('DROP INDEX IDX_75EA56E0FB7336F0 ON messenger_messages');
|
||||
$this->addSql('DROP INDEX IDX_75EA56E0E3BD61CE ON messenger_messages');
|
||||
$this->addSql('DROP INDEX IDX_75EA56E016BA31DB ON messenger_messages');
|
||||
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE newsletter_opt_in_confirmation RENAME INDEX uniq_70505214b3bc57da TO UNIQ_NEWSLETTER_OPT_IN_TOKEN_HASH');
|
||||
$this->addSql('ALTER TABLE user CHANGE roles roles JSON NOT NULL COLLATE `utf8mb4_bin`, CHANGE hotel_codes hotel_codes JSON NOT NULL COLLATE `utf8mb4_bin`');
|
||||
$this->addSql('DROP INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages');
|
||||
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)');
|
||||
$this->addSql('CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)');
|
||||
$this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)');
|
||||
$this->addSql('ALTER TABLE log_entry CHANGE context context JSON DEFAULT NULL COLLATE `utf8mb4_bin`, CHANGE extra extra JSON DEFAULT NULL COLLATE `utf8mb4_bin`');
|
||||
$this->addSql('ALTER TABLE booking_edit_draft CHANGE form_data form_data JSON NOT NULL COLLATE `utf8mb4_bin`');
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Changes travel_snapshot.payload from TEXT to LONGTEXT so large
|
||||
* plain JSON snapshot payloads can be stored safely.
|
||||
*/
|
||||
final class Version20260324120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Change travel_snapshot.payload from TEXT to LONGTEXT for plain JSON snapshot payloads';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE travel_snapshot CHANGE payload payload LONGTEXT NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE travel_snapshot CHANGE payload payload TEXT NOT NULL');
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260422120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Store requested Mailjet list IDs on newsletter opt-in confirmations';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql("ALTER TABLE newsletter_opt_in_confirmation ADD mailjet_list_ids JSON DEFAULT NULL COMMENT '(DC2Type:json)'");
|
||||
$this->addSql('UPDATE newsletter_opt_in_confirmation SET mailjet_list_ids = JSON_ARRAY(10321569) WHERE mailjet_list_ids IS NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE newsletter_opt_in_confirmation DROP mailjet_list_ids');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260423120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Store optional first and last names on newsletter opt-in confirmations';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql("ALTER TABLE newsletter_opt_in_confirmation ADD first_name VARCHAR(255) DEFAULT NULL, ADD last_name VARCHAR(255) DEFAULT NULL");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE newsletter_opt_in_confirmation DROP first_name, DROP last_name');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260424110732 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Track newsletter revocations on opt-in confirmations';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql("ALTER TABLE newsletter_opt_in_confirmation ADD revoked_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)'");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE newsletter_opt_in_confirmation DROP revoked_at');
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260429120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create per-list newsletter consent table and backfill confirmed opt-ins';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql("CREATE TABLE newsletter_consent (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(255) NOT NULL, mailjet_list_id INT NOT NULL, confirmed_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', revoked_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', first_name VARCHAR(255) DEFAULT NULL, last_name VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', updated_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', UNIQUE INDEX UNIQ_NEWSLETTER_CONSENT_EMAIL_LIST (email, mailjet_list_id), INDEX IDX_NEWSLETTER_CONSENT_EMAIL (email), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB");
|
||||
$this->addSql("INSERT IGNORE INTO newsletter_consent (email, mailjet_list_id, confirmed_at, revoked_at, first_name, last_name, created_at, updated_at) SELECT email, 10321569, confirmed_at, revoked_at, first_name, last_name, created_at, COALESCE(confirmed_at, created_at) FROM newsletter_opt_in_confirmation WHERE confirmed_at IS NOT NULL AND (mailjet_list_ids IS NULL OR JSON_LENGTH(mailjet_list_ids) = 0)");
|
||||
$this->addSql("INSERT IGNORE INTO newsletter_consent (email, mailjet_list_id, confirmed_at, revoked_at, first_name, last_name, created_at, updated_at) SELECT c.email, CAST(j.list_id AS UNSIGNED), c.confirmed_at, c.revoked_at, c.first_name, c.last_name, c.created_at, COALESCE(c.confirmed_at, c.created_at) FROM newsletter_opt_in_confirmation c JOIN JSON_TABLE(c.mailjet_list_ids, '$[*]' COLUMNS (list_id VARCHAR(32) PATH '$')) j WHERE c.confirmed_at IS NOT NULL");
|
||||
$this->addSql('DELETE FROM newsletter_opt_in_confirmation WHERE confirmed_at IS NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE newsletter_consent');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260429130000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Rename newsletter opt-in confirmation table to newsletter opt-in request';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('RENAME TABLE newsletter_opt_in_confirmation TO newsletter_opt_in_request');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('RENAME TABLE newsletter_opt_in_request TO newsletter_opt_in_confirmation');
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"@babel/preset-env": "^7.16.0",
|
||||
"@hotwired/stimulus": "^3.0.0",
|
||||
"@symfony/stimulus-bridge": "^3.2.0",
|
||||
"@symfony/webpack-encore": "^6.0.0",
|
||||
"@symfony/webpack-encore": "^5.0.0",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"autoprefixer": "^10.4.20",
|
||||
@@ -20,7 +20,8 @@
|
||||
"tippy.js": "^6.3.7",
|
||||
"toastify-js": "^1.12.0",
|
||||
"webpack": "^5.74.0",
|
||||
"webpack-cli": "^6.0.1"
|
||||
"webpack-cli": "^5.1.0",
|
||||
"webpack-notifier": "^1.15.0"
|
||||
},
|
||||
"license": "WTFPL",
|
||||
"private": true,
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
includes:
|
||||
- vendor/phpstan/phpstan-symfony/extension.neon
|
||||
- vendor/phpstan/phpstan-doctrine/extension.neon
|
||||
|
||||
parameters:
|
||||
level: 6
|
||||
paths:
|
||||
- src
|
||||
tmpDir: var/phpstan
|
||||
parallel:
|
||||
maximumNumberOfProcesses: 1
|
||||
symfony:
|
||||
containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
|
||||
doctrine:
|
||||
objectManagerLoader: tests/doctrine-object-manager.php
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 995 B |
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" },
|
||||
{ "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'host' => 'host.orb.internal',
|
||||
'port' => 23517,
|
||||
'remote_path' => '/var/www/html',
|
||||
'local_path' => '/Users/fromme/Development/Symfony/myep-next-booking',
|
||||
];
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Rector\Config\RectorConfig;
|
||||
|
||||
return RectorConfig::configure()
|
||||
->withPaths([
|
||||
__DIR__.'/src',
|
||||
__DIR__.'/tests',
|
||||
])
|
||||
->withImportNames(
|
||||
importShortClasses: false,
|
||||
removeUnusedImports: true,
|
||||
)
|
||||
->withComposerBased(
|
||||
doctrine: true,
|
||||
symfony: true,
|
||||
)
|
||||
->withSymfonyContainerXml(
|
||||
__DIR__.'/var/cache/dev/App_KernelDevDebugContainer.xml'
|
||||
)
|
||||
->withFluentCallNewLine()
|
||||
->withCache(__DIR__.'/var/rector');
|
||||
@@ -4,44 +4,39 @@ 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\Agency;
|
||||
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\ExtendedServiceAvailabilityResponse;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\BusProNet\Model\PersonalDataUpdateResponse;
|
||||
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';
|
||||
public const TYPE_MUTABLE_DATA = 'MOEGLICHEAENDERUNGEN';
|
||||
public const TYPE_AVAILABILITY = 'VERFUEGBARKEIT';
|
||||
public const TYPE_AVAILABILITY_EXTENDED = 'VERFUEGBARKEIT2';
|
||||
public const TYPE_AVAILABILITY_HOTEL = 'VERFUEGBARKEITHOTEL';
|
||||
public const TYPE_BOOKING_UPDATE = 'BUCHUNGAENDERUNG';
|
||||
public const TYPE_BOOKING = 'BUCHUNG';
|
||||
@@ -51,21 +46,14 @@ class ApiClient
|
||||
public const TYPE_PURCHASE_VOUCHER = 'GUTSCHEINPRUEFUNGEINLOESUNG';
|
||||
public const TYPE_PROMO_VOUCHER = 'AKTIONSGUTSCHEIN';
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $config;
|
||||
private float $operationStartTime;
|
||||
private int $selectedPort;
|
||||
private int $requestCounter = 0;
|
||||
|
||||
/** @param array<string, mixed> $options */
|
||||
public function __construct(
|
||||
private readonly SerializerInterface $serializer,
|
||||
private readonly ApiResponseParser $responseParser,
|
||||
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);
|
||||
@@ -91,14 +79,21 @@ class ApiClient
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function register(RegistrationDto $registrationData): Notification|RegistrationResponse
|
||||
public function register(RegistrationDto $registrationData): 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' => $registrationData->toPayload(),
|
||||
'adressdaten' => [
|
||||
'geschlecht' => $registrationData->gender,
|
||||
'vorname' => $registrationData->firstName,
|
||||
'name' => $registrationData->name,
|
||||
'kommunikation' => [
|
||||
'email' => $registrationData->email,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
|
||||
@@ -128,7 +123,7 @@ class ApiClient
|
||||
string $password,
|
||||
PersonalData $personalData,
|
||||
bool $debug = false,
|
||||
): Notification|PersonalDataUpdateResponse {
|
||||
): Notification|PersonalData {
|
||||
$data = [
|
||||
'user' => $this->config['bpn_username'],
|
||||
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
|
||||
@@ -149,13 +144,13 @@ class ApiClient
|
||||
public function createAddress(
|
||||
PersonalData $personalData,
|
||||
bool $debug = false,
|
||||
): RegistrationResponse|Notification {
|
||||
): ContactFormResponse|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(false),
|
||||
'adressdaten' => $personalData->toPayload(),
|
||||
'ohnemailversand' => 'True',
|
||||
];
|
||||
|
||||
@@ -321,21 +316,6 @@ class ApiClient
|
||||
return $this->sendRequest(static::TYPE_AVAILABILITY, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getAvailabilitiesExtended(int $dateId): Notification|ExtendedServiceAvailabilityResponse
|
||||
{
|
||||
$data = [
|
||||
'user' => $this->config['bpn_username'],
|
||||
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY_EXTENDED),
|
||||
'satz' => ['@typ' => static::TYPE_AVAILABILITY_EXTENDED],
|
||||
'idreise' => $dateId,
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_AVAILABILITY_EXTENDED, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
@@ -417,30 +397,6 @@ 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
|
||||
*/
|
||||
@@ -533,11 +489,10 @@ class ApiClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends raw XML to the BPN API with key regeneration and automatic retry.
|
||||
* Sends raw XML to the BPN API with key regeneration.
|
||||
*
|
||||
* 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)
|
||||
@@ -548,18 +503,7 @@ class ApiClient
|
||||
*/
|
||||
public function sendRawXml(string $xml, bool $debug = false): string
|
||||
{
|
||||
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();
|
||||
$requestId = date(DATE_ATOM).uniqid();
|
||||
|
||||
$doc = new \DOMDocument();
|
||||
if (false === @$doc->loadXML($xml)) {
|
||||
@@ -590,19 +534,25 @@ 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->logger->info('Sending raw XML request to BPN API', [
|
||||
'requestId' => $requestId,
|
||||
'type' => $type,
|
||||
'port' => $this->selectedPort,
|
||||
]);
|
||||
$this->send($socket, $body);
|
||||
$response = $this->receive($socket);
|
||||
$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']);
|
||||
$this->disconnect($socket);
|
||||
|
||||
$responseXml = substr($response, 10);
|
||||
@@ -617,81 +567,9 @@ class ApiClient
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, mixed> $additionalArgs
|
||||
*/
|
||||
private function sendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed
|
||||
{
|
||||
return $this->executeWithRetry(fn () => $this->doSendRequest($type, $data, $additionalArgs, $debug), $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
/** @param array<string, mixed> $data */
|
||||
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
|
||||
*/
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, mixed> $additionalArgs
|
||||
*/
|
||||
private function doSendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed
|
||||
{
|
||||
$requestId = $this->getRequestId();
|
||||
$requestId = date(DATE_ATOM).uniqid();
|
||||
|
||||
$body = $this
|
||||
->serializer
|
||||
@@ -701,33 +579,30 @@ 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->logger->info('Sending request to BPN API', [
|
||||
'requestId' => $requestId,
|
||||
'type' => $data['satz']['@typ'],
|
||||
'port' => $this->selectedPort,
|
||||
]);
|
||||
|
||||
$this->send($socket, $body);
|
||||
$response = $this->receive($socket);
|
||||
$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']);
|
||||
$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);
|
||||
}
|
||||
@@ -745,46 +620,6 @@ class ApiClient
|
||||
throw new ApiClientException('Unexpected response received from API');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @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 {
|
||||
@@ -793,14 +628,6 @@ 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');
|
||||
@@ -808,175 +635,6 @@ class ApiClient
|
||||
return md5($username.$password.$date.$type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return resource
|
||||
*
|
||||
* @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'],
|
||||
'port' => $this->selectedPort,
|
||||
]);
|
||||
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,
|
||||
'port' => $this->selectedPort,
|
||||
]);
|
||||
++$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,
|
||||
'port' => $this->selectedPort,
|
||||
]);
|
||||
throw new ApiClientException('Unable to open socket');
|
||||
}
|
||||
|
||||
return $socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param resource $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,
|
||||
'port' => $this->selectedPort,
|
||||
]);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param resource $socket
|
||||
*
|
||||
* @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,
|
||||
'port' => $this->selectedPort,
|
||||
]);
|
||||
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,
|
||||
'port' => $this->selectedPort,
|
||||
]);
|
||||
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,
|
||||
'port' => $this->selectedPort,
|
||||
]);
|
||||
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,
|
||||
'port' => $this->selectedPort,
|
||||
]);
|
||||
throw new ImmediateConnectionCloseException('Server closed connection without sending data');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/** @param resource $socket */
|
||||
private function disconnect($socket): void
|
||||
{
|
||||
@fclose($socket);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resolveOptions(array $options): array
|
||||
{
|
||||
$optionsResolver = new OptionsResolver();
|
||||
@@ -984,17 +642,14 @@ class ApiClient
|
||||
'bpn_username',
|
||||
'bpn_password',
|
||||
'bpn_api_ip',
|
||||
'bpn_api_ports',
|
||||
'bpn_api_port',
|
||||
]);
|
||||
$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,7 +14,6 @@ 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'];
|
||||
@@ -38,7 +37,6 @@ 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',
|
||||
@@ -57,7 +55,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_OPTION = 'O'; // Optionsbuchung
|
||||
public const BOOKING_STATUS_OPEN = 'O'; // Optionsbuchung - not relevant for current implementation
|
||||
|
||||
// Payment methods
|
||||
public const PAYMENT_METHOD_TRANSFER = 'transfer';
|
||||
@@ -70,7 +68,4 @@ 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';
|
||||
}
|
||||
@@ -6,17 +6,14 @@ namespace App\BusProNet\DataProcessor;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\Utility\DirectionMapper;
|
||||
use App\BusProNet\XmlLoader\AgencyLoader;
|
||||
use App\Form\Model\BankAccountDto;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Model\RoomSelectionDto;
|
||||
use App\Service\BookingPriceCalculator;
|
||||
use App\Service\InsuranceManager;
|
||||
use function Symfony\Component\String\u;
|
||||
use App\Service\BookingPriceCalculatorService;
|
||||
use App\Service\InsuranceService;
|
||||
|
||||
/**
|
||||
* Processes booking form data and converts it into BusProNet API payload format.
|
||||
@@ -28,8 +25,8 @@ use function Symfony\Component\String\u;
|
||||
class BookingDataProcessor
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InsuranceManager $insuranceService,
|
||||
private readonly BookingPriceCalculator $priceCalculatorService,
|
||||
private readonly InsuranceService $insuranceService,
|
||||
private readonly BookingPriceCalculatorService $priceCalculatorService,
|
||||
private readonly ServiceMappingCollector $mappingCollector,
|
||||
private readonly ParticipantServiceProcessor $serviceProcessor,
|
||||
private readonly BookingPayloadBuilder $payloadBuilder,
|
||||
@@ -45,12 +42,11 @@ class BookingDataProcessor
|
||||
*
|
||||
* IMPORTANT: Address handling for edit mode
|
||||
* - ParticipantDto::fromPersonalData() clones address objects to prevent shared references
|
||||
* - For normal bookings, participant[0]'s missing address/dimensions are filled from the
|
||||
* applicant (same person in normal bookings, but BPN may omit fields in <teilnehmer>)
|
||||
* - For internal agency bookings, this copy is skipped: the applicant is a customer, not
|
||||
* the first participant, so copying applicant data into participant[0] would be incorrect
|
||||
* - First participant's address is NOT auto-populated from applicant
|
||||
* - Template uses placeholders to show applicant's address as hints
|
||||
* - This prevents address data from being inadvertently modified during form binding
|
||||
*/
|
||||
public function createBookingDtoFromBooking(Booking $booking, Travel $travel, bool $isInternalAgencyBooking = false): BookingDto
|
||||
public function createBookingDtoFromBooking(Booking $booking, Travel $travel): BookingDto
|
||||
{
|
||||
$dto = new BookingDto($travel, $booking->hotelId);
|
||||
|
||||
@@ -72,18 +68,19 @@ class BookingDataProcessor
|
||||
$participantData = ParticipantDto::fromPersonalData($participant);
|
||||
$participantData->index = $index;
|
||||
|
||||
// For normal bookings, applicant === participant[0]: copy address and body dimensions
|
||||
// from applicant when BPN returns full data only in <anmelder> and minimal data in
|
||||
// <teilnehmer id="1">. Skipped for internal agency bookings where the applicant is
|
||||
// a customer (a different entity from the first traveler).
|
||||
if (0 === $index && false === $isInternalAgencyBooking) {
|
||||
// First participant (applicant): copy data from applicant if participant data is empty
|
||||
// BPN API may return full data only in <anmelder> but minimal/empty data in <teilnehmer id="1">
|
||||
if (0 === $index) {
|
||||
// Copy address if first participant has no street (indicating empty/incomplete address)
|
||||
$isEmpty = null === $participantData->address
|
||||
|| null === $participantData->address->street
|
||||
|| u($participantData->address->street)->trim()->isEmpty();
|
||||
// This allows applicant and first participant to be different people with different addresses
|
||||
if (null !== $booking->applicant->address) {
|
||||
$isEmpty = null === $participantData->address
|
||||
|| null === $participantData->address->street
|
||||
|| '' === trim($participantData->address->street);
|
||||
|
||||
if ($isEmpty) {
|
||||
$participantData->address = clone $booking->applicant->address;
|
||||
if ($isEmpty) {
|
||||
$participantData->address = clone $booking->applicant->address;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy body dimensions from applicant if not present in participant
|
||||
@@ -110,9 +107,6 @@ class BookingDataProcessor
|
||||
$participantData->board = $booking
|
||||
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_BOARD);
|
||||
|
||||
$vegServices = $booking->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_VEG);
|
||||
$participantData->veg = false === empty($vegServices) ? reset($vegServices) : null;
|
||||
|
||||
$participantData->rentals = $booking
|
||||
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_RENTALS);
|
||||
|
||||
@@ -130,19 +124,9 @@ class BookingDataProcessor
|
||||
// Pickup location
|
||||
$participantData->pickup = $booking->getPickupForParticipant($index);
|
||||
|
||||
// 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;
|
||||
}
|
||||
// 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;
|
||||
|
||||
// License plate (not stored in booking entity)
|
||||
$participantData->licensePlate = null;
|
||||
@@ -157,9 +141,6 @@ class BookingDataProcessor
|
||||
// Enrich services with data from travel (especially prices)
|
||||
$this->enrichParticipantServicesFromTravel($participantData, $travel);
|
||||
|
||||
// Normalize loaded participant data so the edit flow sees canonical values.
|
||||
$participantData->normalizeLoadedData();
|
||||
|
||||
$dto->participants[$index] = $participantData;
|
||||
}
|
||||
|
||||
@@ -175,8 +156,6 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -222,11 +201,6 @@ class BookingDataProcessor
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich veg
|
||||
if (null !== $participant->veg && isset($travel->additionalServices[$participant->veg->id])) {
|
||||
$participant->veg = $travel->additionalServices[$participant->veg->id];
|
||||
}
|
||||
|
||||
// Enrich rentals
|
||||
foreach ($participant->rentals as $key => $rental) {
|
||||
if (isset($travel->additionalServices[$rental->id])) {
|
||||
@@ -239,11 +213,6 @@ 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];
|
||||
@@ -253,33 +222,9 @@ class BookingDataProcessor
|
||||
$participant->transportationInbound = $travel->transportationServices[$participant->transportationInbound->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 pickup
|
||||
if (null !== $participant->pickup && isset($travel->pickupsOutbound[$participant->pickup->id])) {
|
||||
$participant->pickup = $travel->pickupsOutbound[$participant->pickup->id];
|
||||
}
|
||||
|
||||
// Enrich insurance
|
||||
@@ -298,12 +243,12 @@ class BookingDataProcessor
|
||||
*
|
||||
* @param BookingDto|null $formData The booking edit form data containing updated participant and service selections
|
||||
*
|
||||
* @return array<string, mixed> The structured payload array ready for BusProNet API submission
|
||||
* @return array The structured payload array ready for BusProNet API submission
|
||||
*/
|
||||
public function createUpdateRequestPayload(?BookingDto $formData): array
|
||||
{
|
||||
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
||||
$this->applyApplicantInsuranceToParticipants($formData);
|
||||
// Apply bulk insurance if enabled (modifies DTO in place)
|
||||
$this->applyBulkInsuranceIfActive($formData);
|
||||
|
||||
$bookingData = $formData->booking;
|
||||
$travelData = $formData->travel;
|
||||
@@ -321,18 +266,13 @@ class BookingDataProcessor
|
||||
}
|
||||
|
||||
$this->serviceProcessor->removeUnusedServices($bookingData);
|
||||
$this->personalDataSynchronizer->updateParticipantPersonalData(
|
||||
$formData->participants,
|
||||
$bookingData,
|
||||
$formData->isInternalAgencyBooking()
|
||||
);
|
||||
$this->personalDataSynchronizer->updateParticipantPersonalData($formData->participants, $bookingData);
|
||||
|
||||
$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);
|
||||
@@ -359,135 +299,37 @@ class BookingDataProcessor
|
||||
* @param BookingDto $bookingDto The booking creation form data
|
||||
* @param string $bookingType Either 'Anfrage' (inquiry) or 'Buchung' (final booking)
|
||||
*
|
||||
* @return array<string, mixed> The structured payload array for BusProNet API submission
|
||||
* @return array The structured payload array for BusProNet API submission
|
||||
*/
|
||||
public function createBookingRequestPayload(BookingDto $bookingDto, string $bookingType): array
|
||||
{
|
||||
// Re-validate each participant's insurance against current eligibility (price tier,
|
||||
// family constraint, etc.) before submission - selections can go stale between the
|
||||
// moment they were chosen and final submission, since users can freely revisit
|
||||
// earlier steps and change composition/services without ever re-opening the
|
||||
// insurance field. Must run before the applicant-to-dependents propagation below,
|
||||
// so that propagation is based on an already-corrected applicant selection.
|
||||
$this->reconcileInsuranceEligibility($bookingDto);
|
||||
|
||||
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
||||
$this->applyApplicantInsuranceToParticipants($bookingDto);
|
||||
// Apply bulk insurance if enabled (modifies DTO in place)
|
||||
$this->applyBulkInsuranceIfActive($bookingDto);
|
||||
|
||||
return $this->payloadBuilder->buildCreatePayload($bookingDto, $bookingType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-validates every participant's currently selected insurance against present-day
|
||||
* eligibility (price tier, family constraint, age, travel/booking dates, duration) and
|
||||
* corrects any selection that's gone stale.
|
||||
* Applies bulk insurance assignment if the applicant has enabled it.
|
||||
*
|
||||
* Insurance eligibility is normally re-checked when a participant's own card is
|
||||
* resubmitted (see ParticipantInsuranceFieldHandler::processField()), but nothing forces
|
||||
* that to happen again if the user changes composition or services on an earlier step
|
||||
* after insurance was already chosen, then jumps straight to submission. Left unchecked,
|
||||
* a stale insurance ID (e.g. a family-tariff price tier that no longer matches the
|
||||
* current total booking price) would reach the BPN API and be rejected as a mismatch.
|
||||
* When bulk insurance is active (applicant's bulkInsuranceBooking = true), this method
|
||||
* assigns the applicant's insurance TYPE to all dependent participants with automatic
|
||||
* price tier adjustment based on each participant's total cost.
|
||||
*
|
||||
* Mirrors the reassign-or-clear logic already established in
|
||||
* ParticipantInsuranceFieldHandler for the resubmission case, applied here across all
|
||||
* participants right before the payload is built.
|
||||
*/
|
||||
private function reconcileInsuranceEligibility(BookingDto $bookingDto): bool
|
||||
{
|
||||
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
|
||||
$changed = false;
|
||||
|
||||
foreach ($bookingDto->getParticipants() as $index => $participant) {
|
||||
$currentInsurance = $participant->insurance;
|
||||
if (null === $currentInsurance || $currentInsurance->isNoInsurance()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$travelPrice = $this->priceCalculatorService->resolveInsuranceTravelPrice($bookingDto, $index, $currentInsurance);
|
||||
$eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice);
|
||||
|
||||
if ($this->isInsuranceInList($currentInsurance, $eligibleInsurances)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reassignedInsurance = $this->insuranceService->reassignInsuranceForPriceChange(
|
||||
$selectableInsurances,
|
||||
$currentInsurance,
|
||||
$participant,
|
||||
$bookingDto,
|
||||
$travelPrice
|
||||
);
|
||||
|
||||
if (null !== $reassignedInsurance) {
|
||||
$participant->addNotification(
|
||||
'warning',
|
||||
sprintf('Versicherung vor der Übermittlung automatisch angepasst: %s', $reassignedInsurance->label)
|
||||
);
|
||||
} else {
|
||||
$participant->addNotification(
|
||||
'warning',
|
||||
'Die gewählte Versicherung ist für diese Buchung nicht mehr verfügbar und wurde entfernt. Bitte erneut auswählen.'
|
||||
);
|
||||
}
|
||||
|
||||
$participant->insurance = $reassignedInsurance;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific insurance exists in a list of insurances (by id).
|
||||
*
|
||||
* @param array<Insurance> $insuranceList
|
||||
*/
|
||||
private function isInsuranceInList(Insurance $targetInsurance, array $insuranceList): bool
|
||||
{
|
||||
foreach ($insuranceList as $insurance) {
|
||||
if ($insurance->id === $targetInsurance->id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the applicant's insurance to other participants if their selection requires it.
|
||||
*
|
||||
* Runs when either:
|
||||
* - Bulk insurance is active (applicant's bulkInsuranceBooking = true): assigns the
|
||||
* applicant's insurance TYPE to all dependent participants with automatic price tier
|
||||
* adjustment based on each participant's individual cost.
|
||||
* - The applicant selected a family insurance: family insurance covers the whole family
|
||||
* under a single policy, so dependents must never hold their own insurance, regardless
|
||||
* of whether the bulk checkbox was checked.
|
||||
*
|
||||
* IMPORTANT: In edit mode, non-family bulk insurance only applies to participants who:
|
||||
* IMPORTANT: In edit mode, bulk insurance only applies to participants who:
|
||||
* 1. Currently have NO insurance assigned (insurance === null)
|
||||
* 2. OR whose insurance was already assigned via previous bulk operation
|
||||
*
|
||||
* This prevents overriding individually selected insurances that are locked. Family
|
||||
* insurance is exempt from this protection: dependents can never legitimately hold
|
||||
* their own policy alongside it, so their insurance is always cleared, in edit mode too.
|
||||
* This prevents overriding individually selected insurances that are locked.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking DTO (create or edit flow)
|
||||
*/
|
||||
private function applyApplicantInsuranceToParticipants(BookingDto $bookingDto): void
|
||||
private function applyBulkInsuranceIfActive(BookingDto $bookingDto): void
|
||||
{
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
|
||||
if (null === $applicant || null === $applicant->insurance) {
|
||||
return;
|
||||
}
|
||||
|
||||
$isFamilyInsurance = true === $applicant->insurance->familyInsurance;
|
||||
|
||||
// Check if bulk insurance is enabled OR the applicant's selection is a family
|
||||
// insurance (which always applies to dependents, independent of the bulk checkbox)
|
||||
if (false === $applicant->bulkInsuranceBooking && false === $isFamilyInsurance) {
|
||||
// Check if bulk insurance is enabled and applicant has selected an insurance
|
||||
if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -500,7 +342,7 @@ class BookingDataProcessor
|
||||
$participantPrices[$index] = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $index);
|
||||
}
|
||||
|
||||
// Use InsuranceManager for proper type-based assignment with price tier matching
|
||||
// Use InsuranceService for proper type-based assignment with price tier matching
|
||||
$assignments = $this->insuranceService->batchAssignInsuranceToParticipants(
|
||||
$selectableInsurances,
|
||||
$applicant->insurance,
|
||||
@@ -508,11 +350,10 @@ class BookingDataProcessor
|
||||
$participantPrices
|
||||
);
|
||||
|
||||
// Apply assignments to all participants; for non-family insurance skip the applicant
|
||||
// (they already hold the insurance they selected themselves)
|
||||
// Apply assignments to dependent participants (skip applicant at index 0)
|
||||
foreach ($assignments as $index => $insurance) {
|
||||
if (0 === $index && false === $isFamilyInsurance) {
|
||||
continue;
|
||||
if (0 === $index) {
|
||||
continue; // Skip applicant
|
||||
}
|
||||
|
||||
$participant = $bookingDto->getParticipant($index);
|
||||
@@ -520,10 +361,9 @@ class BookingDataProcessor
|
||||
continue;
|
||||
}
|
||||
|
||||
// In edit mode: non-family bulk assignment must not overwrite an individually
|
||||
// locked insurance. Family insurance always overrides, since dependents can
|
||||
// never legitimately keep their own policy alongside it.
|
||||
if (BookingDto::MODE_EDIT === $bookingDto->getMode() && false === $isFamilyInsurance && null !== $participant->insurance) {
|
||||
// In edit mode: only apply bulk insurance if participant has no insurance
|
||||
// This respects the rule that once assigned, insurance cannot be changed
|
||||
if (BookingDto::MODE_EDIT === $bookingDto->getMode() && null !== $participant->insurance) {
|
||||
continue; // Skip participants with existing insurance assignment
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\BusProNet\DataProcessor;
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
|
||||
/**
|
||||
* Builds API payload structures for BusProNet booking requests.
|
||||
@@ -17,8 +16,6 @@ use App\Form\Model\ParticipantDto;
|
||||
*/
|
||||
class BookingPayloadBuilder
|
||||
{
|
||||
private const DEFAULT_PARTICIPANT_STATUS = 'F';
|
||||
|
||||
public function __construct(
|
||||
private readonly ServiceMappingCollector $mappingCollector,
|
||||
) {
|
||||
@@ -31,7 +28,7 @@ class BookingPayloadBuilder
|
||||
*
|
||||
* @param Booking $bookingData The booking data object
|
||||
*
|
||||
* @return array<string, mixed> The base payload structure
|
||||
* @return array The base payload structure
|
||||
*/
|
||||
public function buildBasePayload(Booking $bookingData): array
|
||||
{
|
||||
@@ -67,8 +64,8 @@ class BookingPayloadBuilder
|
||||
*
|
||||
* Bank account information is required for direct debit payments.
|
||||
*
|
||||
* @param array<string, mixed> $payload The payload array to modify
|
||||
* @param Booking $bookingData The booking data object
|
||||
* @param array $payload The payload array to modify
|
||||
* @param Booking $bookingData The booking data object
|
||||
*/
|
||||
public function addBankAccountToPayload(array &$payload, Booking $bookingData): void
|
||||
{
|
||||
@@ -87,9 +84,9 @@ class BookingPayloadBuilder
|
||||
*
|
||||
* Includes status, personal data, and wishes (room remarks, license plate) for each participant.
|
||||
*
|
||||
* @param array<string, mixed> $payload The payload array to modify
|
||||
* @param Booking $bookingData The booking data object
|
||||
* @param array<int, ParticipantDto> $participantDtos The participant DTOs from the form (for wishes data)
|
||||
* @param array $payload The payload array to modify
|
||||
* @param Booking $bookingData The booking data object
|
||||
* @param array $participantDtos The participant DTOs from the form (for wishes data)
|
||||
*/
|
||||
public function buildParticipantPayload(array &$payload, Booking $bookingData, array $participantDtos): void
|
||||
{
|
||||
@@ -142,39 +139,36 @@ class BookingPayloadBuilder
|
||||
* Includes additional services, transportation services, and accommodation details
|
||||
* with participant mappings and quantities.
|
||||
*
|
||||
* @param array<string, mixed> $payload The payload array to modify
|
||||
* @param Booking $bookingData The booking data object
|
||||
* @param array $payload The payload array to modify
|
||||
* @param Booking $bookingData The booking data object
|
||||
*/
|
||||
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($uniqueMapping),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
|
||||
'@anzahl' => count($service->mapping),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $service->mapping)),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($bookingData->transportationServices as $service) {
|
||||
$uniqueMapping = array_unique($service->mapping);
|
||||
$payload['beförderungen']['beförderung'][] = [
|
||||
'@idleistung' => $service->id,
|
||||
'@anzahl' => count($uniqueMapping),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
|
||||
'@anzahl' => count($service->mapping),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $service->mapping)),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($bookingData->rooms as $room) {
|
||||
$uniqueMapping = array_unique($room->mapping);
|
||||
$payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [
|
||||
'@idzimmer' => $room->id,
|
||||
'@kategorie' => $room->category,
|
||||
'@idverpflegung' => $room->boardId,
|
||||
'@anreise' => $room->dateFrom?->format('d.m.Y'),
|
||||
'@abreise' => $room->dateTo?->format('d.m.Y'),
|
||||
'@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, $uniqueMapping)),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $room->mapping)),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -182,12 +176,11 @@ class BookingPayloadBuilder
|
||||
if (false === empty($bookingData->insurances)) {
|
||||
$payload['versicherungen']['versicherung'] = [];
|
||||
foreach ($bookingData->insurances as $insurance) {
|
||||
$uniqueMapping = array_unique($insurance->mapping);
|
||||
if (count($uniqueMapping) > 0) {
|
||||
if (count($insurance->mapping) > 0) {
|
||||
$payload['versicherungen']['versicherung'][] = [
|
||||
'@idversicherung' => $insurance->id,
|
||||
'@anzahl' => count($uniqueMapping),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $uniqueMapping)),
|
||||
'@anzahl' => count($insurance->mapping),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $insurance->mapping)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -199,44 +192,18 @@ class BookingPayloadBuilder
|
||||
*
|
||||
* Only included in payload if there are actual pickup assignments for bus transportation.
|
||||
*
|
||||
* @param array<string, mixed> $payload The payload array to modify
|
||||
* @param Booking $bookingData The booking data object
|
||||
* @param array $payload The payload array to modify
|
||||
* @param Booking $bookingData The booking data object
|
||||
*/
|
||||
public function buildPickupPayload(array &$payload, Booking $bookingData): void
|
||||
{
|
||||
$payload['zustiege'] = [];
|
||||
|
||||
if (0 < count($bookingData->pickups)) {
|
||||
if (0 < count($bookingData->pickupsOutbound)) {
|
||||
$payload['zustiege']['zustieg'] = [];
|
||||
foreach ($bookingData->pickups as $pickup) {
|
||||
$uniqueMapping = array_unique($pickup->mapping);
|
||||
foreach ($bookingData->pickupsOutbound as $pickup) {
|
||||
$payload['zustiege']['zustieg'][] = [
|
||||
'@idzustieg' => $pickup->id,
|
||||
'@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<string, mixed> $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)),
|
||||
'@anzahl' => count($pickup->mapping),
|
||||
'@zuordnung' => implode(',', array_map(fn ($index) => $index + 1, $pickup->mapping)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -250,7 +217,7 @@ class BookingPayloadBuilder
|
||||
* @param BookingDto $bookingDto The booking creation form data
|
||||
* @param string $bookingType Either 'Anfrage' (inquiry) or 'Buchung' (final booking)
|
||||
*
|
||||
* @return array<string, mixed> The structured payload array for BusProNet API submission
|
||||
* @return array The structured payload array for BusProNet API submission
|
||||
*/
|
||||
public function buildCreatePayload(BookingDto $bookingDto, string $bookingType): array
|
||||
{
|
||||
@@ -303,7 +270,6 @@ class BookingPayloadBuilder
|
||||
foreach ($bookingDto->participants as $index => $participant) {
|
||||
$participantData = [
|
||||
'@id' => $index + 1,
|
||||
'status' => self::DEFAULT_PARTICIPANT_STATUS,
|
||||
'name' => $participant->lastName,
|
||||
'vorname' => $participant->firstName,
|
||||
'geschlecht' => $participant->gender ?? '',
|
||||
@@ -393,13 +359,6 @@ 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)
|
||||
@@ -440,11 +399,11 @@ class BookingPayloadBuilder
|
||||
* Generic helper method that converts service ID => participant IDs mappings
|
||||
* into XML payload structure.
|
||||
*
|
||||
* @param array<string, mixed> $payload The payload array to modify
|
||||
* @param string $sectionKey The section key (e.g., 'beförderungen', 'versicherungen')
|
||||
* @param string $itemKey The item key (e.g., 'beförderung', 'versicherung')
|
||||
* @param string $idAttributeName The ID attribute name (e.g., '@idleistung', '@idversicherung')
|
||||
* @param array<int|string, int[]> $serviceMap Map of service ID to participant IDs
|
||||
* @param array $payload The payload array to modify
|
||||
* @param string $sectionKey The section key (e.g., 'beförderungen', 'versicherungen')
|
||||
* @param string $itemKey The item key (e.g., 'beförderung', 'versicherung')
|
||||
* @param string $idAttributeName The ID attribute name (e.g., '@idleistung', '@idversicherung')
|
||||
* @param array $serviceMap Map of service ID to participant IDs
|
||||
*/
|
||||
public function addServicesFromMap(
|
||||
array &$payload,
|
||||
@@ -456,11 +415,10 @@ 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($uniqueParticipantIds),
|
||||
'@zuordnung' => implode(',', $uniqueParticipantIds),
|
||||
'@anzahl' => count($participantIds),
|
||||
'@zuordnung' => implode(',', $participantIds),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -476,9 +434,9 @@ class BookingPayloadBuilder
|
||||
* - abreise (departure date)
|
||||
* - anzahl (number of rooms of this type booked)
|
||||
*
|
||||
* @param array<string, mixed> $payload The payload array to modify
|
||||
* @param array<int|string, int[]> $roomMap Map of room ID to participant IDs
|
||||
* @param BookingDto $bookingDto The booking data for accessing room details and quantities
|
||||
* @param array $payload The payload array to modify
|
||||
* @param array $roomMap Map of room ID to participant IDs
|
||||
* @param BookingDto $bookingDto The booking data for accessing room details and quantities
|
||||
*/
|
||||
public function addRoomMappingsToPayload(array &$payload, array $roomMap, BookingDto $bookingDto): void
|
||||
{
|
||||
@@ -504,7 +462,6 @@ class BookingPayloadBuilder
|
||||
}
|
||||
|
||||
$quantity = $roomQuantities[$roomId] ?? 1;
|
||||
$uniqueParticipantIds = array_unique($participantIds);
|
||||
|
||||
$payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [
|
||||
'@idzimmer' => $room->id,
|
||||
@@ -513,7 +470,7 @@ class BookingPayloadBuilder
|
||||
'@anreise' => $bookingDto->travel->dateFrom->format('d.m.Y'),
|
||||
'@abreise' => $bookingDto->travel->dateTo->format('d.m.Y'),
|
||||
'@anzahl' => $quantity,
|
||||
'@zuordnung' => implode(',', $uniqueParticipantIds),
|
||||
'@zuordnung' => implode(',', $participantIds),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -521,8 +478,8 @@ class BookingPayloadBuilder
|
||||
/**
|
||||
* Adds purchase vouchers to the payload.
|
||||
*
|
||||
* @param array<string, mixed> $payload The payload array to modify
|
||||
* @param BookingDto $bookingDto The booking data
|
||||
* @param array $payload The payload array to modify
|
||||
* @param BookingDto $bookingDto The booking data
|
||||
*/
|
||||
public function addPurchaseVouchersToPayload(array &$payload, BookingDto $bookingDto): void
|
||||
{
|
||||
|
||||
@@ -5,10 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\BusProNet\DataProcessor;
|
||||
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\Service;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Processes participant service selections for booking updates.
|
||||
@@ -19,11 +17,6 @@ use Psr\Log\LoggerInterface;
|
||||
*/
|
||||
class ParticipantServiceProcessor
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all existing participant-to-service mappings to start with a clean slate.
|
||||
*
|
||||
@@ -37,8 +30,8 @@ class ParticipantServiceProcessor
|
||||
$servicesToReset = [
|
||||
...$bookingData->additionalServices,
|
||||
...$bookingData->transportationServices,
|
||||
...$bookingData->pickups,
|
||||
...$bookingData->dropOffs,
|
||||
...$bookingData->pickupsOutbound,
|
||||
...$bookingData->pickupsInbound,
|
||||
...$bookingData->rooms,
|
||||
...$bookingData->insurances,
|
||||
];
|
||||
@@ -67,7 +60,6 @@ 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);
|
||||
}
|
||||
@@ -94,15 +86,15 @@ class ParticipantServiceProcessor
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($bookingData->pickups as $pickup) {
|
||||
foreach ($bookingData->pickupsOutbound as $pickup) {
|
||||
if (0 === count($pickup->mapping)) {
|
||||
unset($bookingData->pickups[$pickup->id]);
|
||||
unset($bookingData->pickupsOutbound[$pickup->id]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($bookingData->dropOffs as $dropOff) {
|
||||
if (0 === count($dropOff->mapping)) {
|
||||
unset($bookingData->dropOffs[$dropOff->id]);
|
||||
foreach ($bookingData->pickupsInbound as $pickup) {
|
||||
if (0 === count($pickup->mapping)) {
|
||||
unset($bookingData->pickupsInbound[$pickup->id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,11 +109,11 @@ class ParticipantServiceProcessor
|
||||
* Collects additional services from a participant for mapping.
|
||||
*
|
||||
* Extracts all additional services (courses, board, rentals, ski pass, rental insurance,
|
||||
* parking, additional services) from a participant into a flat array of Service objects.
|
||||
* additional services) from a participant into a flat array of Service objects.
|
||||
*
|
||||
* @param ParticipantDto $participant The participant data
|
||||
*
|
||||
* @return array<Service> Array of services selected by this participant
|
||||
* @return array<\App\BusProNet\Model\Service> Array of services selected by this participant
|
||||
*/
|
||||
private function collectParticipantAdditionalServices(ParticipantDto $participant): array
|
||||
{
|
||||
@@ -132,11 +124,6 @@ class ParticipantServiceProcessor
|
||||
...$participant->rentals,
|
||||
];
|
||||
|
||||
// Veg is encoded as an additional service in the edit/update payload.
|
||||
if (null !== $participant->veg) {
|
||||
$services[] = $participant->veg;
|
||||
}
|
||||
|
||||
// Add ski pass if selected (single service, not an array)
|
||||
if (null !== $participant->skiPass) {
|
||||
$services[] = $participant->skiPass;
|
||||
@@ -147,11 +134,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -172,18 +154,11 @@ class ParticipantServiceProcessor
|
||||
foreach ($servicesToMap as $service) {
|
||||
if (false === isset($bookingData->additionalServices[$service->id])) {
|
||||
$serviceToAdd = $travelData->additionalServices[$service->id] ?? null;
|
||||
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;
|
||||
if (null !== $serviceToAdd) {
|
||||
$bookingData->additionalServices[$service->id] = $serviceToAdd;
|
||||
$bookingData->additionalServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
|
||||
}
|
||||
$bookingData->additionalServices[$service->id] = $serviceToAdd;
|
||||
$bookingData->additionalServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
|
||||
}
|
||||
|
||||
$bookingData->additionalServices[$service->id]->mapping[] = $participant->index;
|
||||
}
|
||||
}
|
||||
@@ -201,24 +176,13 @@ 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) {
|
||||
$this->logger->warning('Transportation service not found in travel data, skipping mapping', [
|
||||
'bookingId' => $bookingData->id,
|
||||
'serviceId' => $service->id,
|
||||
'participantIndex' => $participant->index,
|
||||
]);
|
||||
continue;
|
||||
if (null !== $serviceToAdd) {
|
||||
$bookingData->transportationServices[$service->id] = $serviceToAdd;
|
||||
$bookingData->transportationServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
|
||||
}
|
||||
$bookingData->transportationServices[$service->id] = $serviceToAdd;
|
||||
$bookingData->transportationServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
|
||||
}
|
||||
|
||||
$bookingData->transportationServices[$service->id]->mapping[] = $participant->index;
|
||||
}
|
||||
}
|
||||
@@ -239,34 +203,10 @@ class ParticipantServiceProcessor
|
||||
$hasInboundBus = null !== $participant->transportationInbound && 'BUS' === $participant->transportationInbound->subType;
|
||||
|
||||
if (($hasOutboundBus || $hasInboundBus) && null !== $selectedPickup = $participant->pickup) {
|
||||
if (false === isset($bookingData->pickups[$selectedPickup->id])) {
|
||||
$bookingData->pickups[$selectedPickup->id] = $selectedPickup;
|
||||
if (false === isset($bookingData->pickupsOutbound[$selectedPickup->id])) {
|
||||
$bookingData->pickupsOutbound[$selectedPickup->id] = $selectedPickup;
|
||||
}
|
||||
$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;
|
||||
$bookingData->pickupsOutbound[$selectedPickup->id]->mapping[] = $participant->index;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace App\BusProNet\DataProcessor;
|
||||
|
||||
use App\BusProNet\Model\Address;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\BusProNet\Model\Communication;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
|
||||
/**
|
||||
@@ -20,53 +20,28 @@ class PersonalDataSynchronizer
|
||||
/**
|
||||
* Updates participant personal data from form input.
|
||||
*
|
||||
* Processes all participants regardless of status, including canceled participants (status 'S').
|
||||
* Processes all active participants (status 'F' or 'A'). Skips 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 bool $isInternalAgencyBooking Whether to fill default names for participants with empty names
|
||||
* @param array<ParticipantDto> $participants The participants array from the form
|
||||
* @param Booking $bookingData The booking data object to update
|
||||
*/
|
||||
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);
|
||||
|
||||
public function updateParticipantPersonalData(array $participants, Booking $bookingData): void
|
||||
{
|
||||
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;
|
||||
}
|
||||
// Skip canceled participants (status 'S')
|
||||
if ('S' === $participant->status) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bookingData->participants[$participant->index]->firstName = $participant->firstName;
|
||||
$bookingData->participants[$participant->index]->name = $participant->lastName;
|
||||
$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;
|
||||
@@ -85,61 +60,12 @@ class PersonalDataSynchronizer
|
||||
}
|
||||
|
||||
if ($participant->email || $participant->mobile) {
|
||||
if (null === $bookingData->participants[$participant->index]->communication) {
|
||||
$bookingData->participants[$participant->index]->communication = new Communication();
|
||||
}
|
||||
$bookingData->participants[$participant->index]->communication->email = $participant->email;
|
||||
$bookingData->participants[$participant->index]->communication->mobile = $participant->mobile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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->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{city: string, location: string, datetime: string, busNumber: string}>>
|
||||
* @return array<string, array<int, array<string, string>>>
|
||||
*/
|
||||
public function transform(array $payload): array
|
||||
{
|
||||
@@ -27,25 +27,26 @@ class PickupPlanningTransformer
|
||||
continue;
|
||||
}
|
||||
|
||||
$code = $item['Reisecode'] ?? '';
|
||||
$travelCodes = $item['Reisecodes'] ?? [];
|
||||
|
||||
if ('' === $code) {
|
||||
if (0 === count($travelCodes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dateTime = \DateTimeImmutable::createFromFormat('d.m.Y H:i', $item['Zeit'] ?? '');
|
||||
|
||||
$pickup = [
|
||||
'city' => $item['Zustieg/Ausstieg Ort'] ?? '',
|
||||
'location' => $item['Zustieg/Ausstieg Strasse'] ?? '',
|
||||
'datetime' => false !== $dateTime ? $dateTime->format('Y-m-d H:i') : '',
|
||||
'busNumber' => $item['BusNummer'] ?? '',
|
||||
];
|
||||
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') : '',
|
||||
];
|
||||
|
||||
$key = sprintf('%s|%s|%s|%s', $pickup['city'], $pickup['location'], $pickup['datetime'], $pickup['busNumber']);
|
||||
$key = sprintf('%s|%s|%s', $pickup['city'], $pickup['location'], $pickup['datetime']);
|
||||
|
||||
if (false === isset($result[$code][$key])) {
|
||||
$result[$code][$key] = $pickup;
|
||||
if (false === isset($result[$code][$key])) {
|
||||
$result[$code][$key] = $pickup;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class ServiceMappingCollector
|
||||
*
|
||||
* Groups participants by their assigned room ID.
|
||||
*
|
||||
* @return array<int, list<int>> Map of room ID to participant IDs
|
||||
* @return array<string, array<int>> Map of room ID to participant IDs
|
||||
*/
|
||||
public function collectRoomMappings(BookingDto $bookingDto): array
|
||||
{
|
||||
@@ -57,11 +57,6 @@ 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;
|
||||
@@ -143,28 +138,6 @@ 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.
|
||||
*
|
||||
|
||||
@@ -14,7 +14,6 @@ class CountryDataProvider
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array<string, Country> */
|
||||
public function getAll(): array
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<?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
|
||||
{
|
||||
}
|
||||
@@ -10,7 +10,6 @@ use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/** @extends AbstractType<string> */
|
||||
class CountryType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly CountryDataProvider $countries)
|
||||
|
||||
@@ -4,9 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use function Symfony\Component\String\u;
|
||||
|
||||
/**
|
||||
* Represents a physical address with street, postal code, city, and country information.
|
||||
*
|
||||
@@ -16,35 +13,16 @@ use function Symfony\Component\String\u;
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* Normalizes legacy string data loaded from API/session sources.
|
||||
*
|
||||
* Trims whitespace and converts empty strings to null so load-time audits
|
||||
* and form hydration see canonical values.
|
||||
*/
|
||||
public function normalize(): void
|
||||
{
|
||||
$this->street = $this->normalizeNullableString($this->street);
|
||||
$this->postCode = $this->normalizeNullableString($this->postCode);
|
||||
$this->city = $this->normalizeNullableString($this->city);
|
||||
$this->district = $this->normalizeNullableString($this->district);
|
||||
$this->country = $this->normalizeNullableString($this->country);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the address to API payload format.
|
||||
*
|
||||
@@ -63,15 +41,4 @@ class Address
|
||||
'land' => $this->country,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeNullableString(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = u($value)->trim()->toString();
|
||||
|
||||
return '' === $trimmed ? null : $trimmed;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ namespace App\BusProNet\Model;
|
||||
*/
|
||||
class AgeConstraintResult
|
||||
{
|
||||
/** @param array<string, mixed> $metadata */
|
||||
public function __construct(
|
||||
public readonly string $type,
|
||||
public readonly ?int $ageFrom = null,
|
||||
|
||||
@@ -12,7 +12,6 @@ namespace App\BusProNet\Model;
|
||||
*/
|
||||
class BaseData
|
||||
{
|
||||
/** @param array<array-key, mixed> $items */
|
||||
public function __construct(private readonly array $items)
|
||||
{
|
||||
}
|
||||
@@ -20,7 +19,7 @@ class BaseData
|
||||
/**
|
||||
* Retrieves all items in the base data collection.
|
||||
*
|
||||
* @return array<array-key, mixed> The complete items array
|
||||
* @return array The complete items array
|
||||
*/
|
||||
public function getItems(): array
|
||||
{
|
||||
|
||||
@@ -15,8 +15,6 @@ use App\BusProNet\Constants;
|
||||
*/
|
||||
class Booking
|
||||
{
|
||||
public const string STATUS_CANCELED = 'S';
|
||||
|
||||
public ?int $id = null;
|
||||
public ?int $agencyId = null;
|
||||
public ?int $bookingNumber = null;
|
||||
@@ -38,50 +36,14 @@ class Booking
|
||||
public ?string $paymentType = null;
|
||||
public ?string $paymentLabel = null;
|
||||
public ?BankAccount $bankAccount = null;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $participantsStatus = [];
|
||||
|
||||
/**
|
||||
* @var array<int, PersonalData>
|
||||
*/
|
||||
public array $participants = [];
|
||||
|
||||
/**
|
||||
* @var array<int, Service>
|
||||
*/
|
||||
public array $transportationServices = [];
|
||||
|
||||
/**
|
||||
* @var array<int, Service>
|
||||
*/
|
||||
public array $additionalServices = [];
|
||||
|
||||
/**
|
||||
* @var array<int, Room>
|
||||
*/
|
||||
public array $rooms = [];
|
||||
|
||||
/**
|
||||
* @var array<int, Pickup>
|
||||
*/
|
||||
public array $pickups = [];
|
||||
|
||||
/**
|
||||
* @var array<int, Pickup>
|
||||
*/
|
||||
public array $dropOffs = [];
|
||||
|
||||
/**
|
||||
* @var array<int, Surcharge>
|
||||
*/
|
||||
public array $pickupsOutbound = [];
|
||||
public array $pickupsInbound = [];
|
||||
public array $surcharges = [];
|
||||
|
||||
/**
|
||||
* @var array<string|int, Insurance>
|
||||
*/
|
||||
public array $insurances = [];
|
||||
public ?int $invoiceNumber = null;
|
||||
public ?float $totalPrice = null;
|
||||
@@ -174,7 +136,7 @@ class Booking
|
||||
* Retrieves pickup service for a specific participant.
|
||||
*
|
||||
* Finds the pickup service assigned to the specified participant
|
||||
* from the pickup services.
|
||||
* from the outbound pickup services.
|
||||
*
|
||||
* @param int $participantIndex The participant index to search for
|
||||
*
|
||||
@@ -182,7 +144,7 @@ class Booking
|
||||
*/
|
||||
public function getPickupForParticipant(int $participantIndex): ?Pickup
|
||||
{
|
||||
foreach ($this->pickups as $pickup) {
|
||||
foreach ($this->pickupsOutbound as $pickup) {
|
||||
if (in_array($participantIndex, $pickup->mapping)) {
|
||||
return $pickup;
|
||||
}
|
||||
@@ -191,27 +153,6 @@ 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.
|
||||
*
|
||||
@@ -222,7 +163,7 @@ class Booking
|
||||
public function getInsuranceForParticipant(int $participantIndex): ?Insurance
|
||||
{
|
||||
foreach ($this->insurances as $insurance) {
|
||||
if (in_array($participantIndex, $insurance->mapping, true)) {
|
||||
if (in_array($participantIndex, $insurance->mapping ?? [], true)) {
|
||||
return $insurance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,20 +15,16 @@ namespace App\BusProNet\Model;
|
||||
*/
|
||||
class BookingResponse
|
||||
{
|
||||
public const string BOOKING_POSSIBLE = 'möglich';
|
||||
public const string BOOKING_IMPOSSIBLE = 'nicht möglich';
|
||||
public const string BOOKING_PROCESSED = 'erfolgt';
|
||||
|
||||
/**
|
||||
* @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)
|
||||
* @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)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $status,
|
||||
public readonly ?int $bookingNumber = null,
|
||||
public readonly ?string $transactionNumber = null,
|
||||
public readonly array $priceItems = [],
|
||||
public readonly ?float $totalPrice = null,
|
||||
public readonly ?PaymentTerms $paymentTerms = null,
|
||||
@@ -41,7 +37,7 @@ class BookingResponse
|
||||
*/
|
||||
public function isInquiryValid(): bool
|
||||
{
|
||||
return self::BOOKING_POSSIBLE === $this->status;
|
||||
return 'möglich' === $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +45,7 @@ class BookingResponse
|
||||
*/
|
||||
public function isBookingSuccessful(): bool
|
||||
{
|
||||
return self::BOOKING_PROCESSED === $this->status;
|
||||
return 'erfolgt' === $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,48 +102,4 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
class RegistrationResponse
|
||||
class ContactFormResponse
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $addressId,
|
||||
@@ -12,23 +12,8 @@ namespace App\BusProNet\Model;
|
||||
*/
|
||||
class CrmAttributes
|
||||
{
|
||||
/**
|
||||
* @var array<int, CrmSelectionGroup>|null
|
||||
*/
|
||||
public ?array $selectionGroups = null;
|
||||
|
||||
/**
|
||||
* @var array<int, CrmAction>|null
|
||||
*/
|
||||
public ?array $crmActions = null;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $hotelCodes = [];
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $roles = [];
|
||||
}
|
||||
@@ -15,15 +15,15 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
*/
|
||||
class CrmSelection
|
||||
{
|
||||
#[Groups(['api:single', 'snapshot'])]
|
||||
#[Groups(['api:single'])]
|
||||
public ?int $id = null;
|
||||
|
||||
#[Groups(['api:single', 'snapshot'])]
|
||||
#[Groups(['api:single'])]
|
||||
public ?string $label = null;
|
||||
|
||||
#[Groups(['api:single', 'snapshot'])]
|
||||
#[Groups(['api:single'])]
|
||||
public bool $mutable = false;
|
||||
|
||||
#[Groups(['api:single', 'snapshot'])]
|
||||
#[Groups(['api:single'])]
|
||||
public bool $selected = false;
|
||||
}
|
||||