Compare commits
1
Commits
master
..
fe655d4444
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe655d4444 |
No files matched your search
@@ -1,4 +0,0 @@
|
||||
# Tailwind 4's floor: browsers without cascade layers, or Chrome before 111, can't render the
|
||||
# app's CSS, so the JS isn't transpiled for them either.
|
||||
defaults and fully supports css-cascade-layers
|
||||
not chrome < 111
|
||||
+34
-48
@@ -1,25 +1,21 @@
|
||||
name: myep
|
||||
type: php
|
||||
docroot: public
|
||||
php_version: "8.5"
|
||||
php_version: "8.3"
|
||||
webserver_type: apache-fpm
|
||||
xdebug_enabled: false
|
||||
additional_hostnames:
|
||||
- mysnz
|
||||
- mysbw
|
||||
- myser
|
||||
additional_fqdns: [ ]
|
||||
- mysnz
|
||||
- mysbw
|
||||
- myser
|
||||
additional_fqdns: []
|
||||
database:
|
||||
type: mariadb
|
||||
version: "11.8"
|
||||
webimage_extra_packages: [ ripgrep, bubblewrap, socat ]
|
||||
type: mysql
|
||||
version: "8.4"
|
||||
use_dns_when_possible: true
|
||||
timezone: Europe/Berlin
|
||||
composer_version: "2"
|
||||
web_environment: [ ]
|
||||
nodejs_version: "24"
|
||||
web_environment: []
|
||||
corepack_enable: false
|
||||
omit_containers: [ddev-ssh-agent]
|
||||
nodejs_version: "22"
|
||||
|
||||
# Key features of DDEV's config.yaml:
|
||||
|
||||
@@ -28,16 +24,16 @@ omit_containers: [ddev-ssh-agent]
|
||||
# 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> # asterios, backdrop, cakephp, codeigniter, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, drupal12, generic, joomla, laravel, magento, magento2, maho, modx, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock
|
||||
# type: <projecttype> # backdrop, cakephp, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, generic, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress
|
||||
# See https://docs.ddev.com/en/stable/users/quickstart/ for more
|
||||
# information on the different project types
|
||||
|
||||
# docroot: <relative_path> # Relative path to the directory containing index.php.
|
||||
|
||||
# php_version: "8.4" # PHP version to use, "5.6" through "8.5"
|
||||
# php_version: "8.3" # PHP version to use, "5.6" through "8.5"
|
||||
|
||||
# You can explicitly specify the webimage but this
|
||||
# is not recommended, as the images are often closely tied to DDEV's behavior,
|
||||
# is not recommended, as the images are often closely tied to DDEV's' behavior,
|
||||
# so this can break upgrades.
|
||||
|
||||
# webimage: <docker_image>
|
||||
@@ -47,18 +43,10 @@ omit_containers: [ddev-ssh-agent]
|
||||
# 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, 12.3
|
||||
# MySQL versions can be 5.5-8.0, 8.4, 9.7
|
||||
# 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
|
||||
|
||||
# You can explicitly specify the dbimage but this
|
||||
# is not recommended, as the images are often closely tied to DDEV's behavior,
|
||||
# so this can break upgrades.
|
||||
|
||||
# dbimage: <docker_image>
|
||||
# It’s unusual to change this option, and we don’t recommend it without Docker experience and a good reason.
|
||||
# Typically, this means additions to the existing db image using a .ddev/db-build/Dockerfile.*
|
||||
|
||||
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
|
||||
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
|
||||
|
||||
@@ -78,7 +66,8 @@ omit_containers: [ddev-ssh-agent]
|
||||
# bind_all_ports is used (normally with router disabled)
|
||||
|
||||
# xhprof_mode: [prepend|xhgui|global]
|
||||
# Default is "xhgui"
|
||||
# Set to "xhgui" to enable XHGui features
|
||||
# "xhgui" will become default in a future major release
|
||||
|
||||
# webserver_type: nginx-fpm, apache-fpm, generic
|
||||
|
||||
@@ -96,24 +85,22 @@ omit_containers: [ddev-ssh-agent]
|
||||
# commands are executed.
|
||||
|
||||
# composer_version: "2"
|
||||
# You can set it to "" or "2" (default) for Composer v2
|
||||
# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1
|
||||
# to use the latest major version available at the time your container is built.
|
||||
# It is also possible to use any other Composer version channel. This includes:
|
||||
# It is also possible to use each other Composer version channel. This includes:
|
||||
# - 2.2 (latest Composer LTS version)
|
||||
# - stable
|
||||
# - preview
|
||||
# - snapshot
|
||||
# Alternatively, an explicit Composer version may be specified, for example "2.2.18".
|
||||
# To reinstall Composer after the image was built, run "ddev utility rebuild".
|
||||
# To reinstall Composer after the image was built, run "ddev debug rebuild".
|
||||
|
||||
# nodejs_root: <relative_path>
|
||||
# Relative path to the directory containing the Node.js version file from the
|
||||
# project root. Only used with "nodejs_version: auto" or "nodejs_version: engine".
|
||||
|
||||
# nodejs_version: "24"
|
||||
# nodejs_version: "22"
|
||||
# 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
|
||||
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation.
|
||||
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation,
|
||||
# Note that using of 'ddev nvm' is discouraged because "nodejs_version" is much easier to use,
|
||||
# can specify any version, and is more robust than using 'nvm'.
|
||||
|
||||
# corepack_enable: false
|
||||
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
|
||||
@@ -173,7 +160,9 @@ omit_containers: [ddev-ssh-agent]
|
||||
# - "global": uses the value from the global config.
|
||||
# - "none": disables performance optimization for this project.
|
||||
# - "mutagen": enables Mutagen for this project.
|
||||
# - "nfs": enables NFS for this project.
|
||||
#
|
||||
# See https://docs.ddev.com/en/stable/users/install/performance/#nfs
|
||||
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
|
||||
|
||||
# fail_on_hook_fail: False
|
||||
@@ -203,10 +192,10 @@ omit_containers: [ddev-ssh-agent]
|
||||
# The mailpit port is not normally bound on the host at all, instead being routed
|
||||
# through ddev-router, but it can be bound directly to localhost if specified here.
|
||||
|
||||
# webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
|
||||
# webimage_extra_packages: [php7.4-tidy, php-bcmath]
|
||||
# Extra Debian packages that are needed in the webimage can be added here
|
||||
|
||||
# dbimage_extra_packages: [netcat, telnet, sudo]
|
||||
# dbimage_extra_packages: [telnet,netcat]
|
||||
# Extra Debian packages that are needed in the dbimage can be added here
|
||||
|
||||
# use_dns_when_possible: true
|
||||
@@ -218,15 +207,12 @@ omit_containers: [ddev-ssh-agent]
|
||||
# project_tld: ddev.site
|
||||
# The top-level domain used for project URLs
|
||||
# The default "ddev.site" allows DNS lookup via a wildcard
|
||||
# If you prefer you can change this to "ddev.local" to preserve
|
||||
# pre-v1.9 behavior.
|
||||
|
||||
# share_default_provider: ngrok
|
||||
# The default share provider to use for "ddev share"
|
||||
# Defaults to global configuration, usually "ngrok"
|
||||
# Can be "ngrok" or "cloudflared" or the name of a custom provider from .ddev/share-providers/
|
||||
|
||||
# share_provider_args: --basic-auth username:pass1234
|
||||
# Provide extra flags to the share provider script
|
||||
# See https://docs.ddev.com/en/stable/users/configuration/config/#share_provider_args
|
||||
# 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"
|
||||
|
||||
# disable_settings_management: false
|
||||
# If true, DDEV will not create CMS-specific settings files like
|
||||
@@ -288,7 +274,7 @@ omit_containers: [ddev-ssh-agent]
|
||||
# override_config: false
|
||||
# By default, config.*.yaml files are *merged* into the configuration
|
||||
# But this means that some things can't be overridden
|
||||
# For example, if you have 'use_dns_when_possible: true' you can't override it with a merge
|
||||
# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge
|
||||
# and you can't erase existing hooks or all environment variables.
|
||||
# However, with "override_config: true" in a particular config.*.yaml file,
|
||||
# 'use_dns_when_possible: false' can override the existing values, and
|
||||
@@ -298,7 +284,7 @@ omit_containers: [ddev-ssh-agent]
|
||||
# web_environment: []
|
||||
# or
|
||||
# additional_hostnames: []
|
||||
# can have their intended effect. 'override_config' affects only behavior of the
|
||||
# can have their intended affect. 'override_config' affects only behavior of the
|
||||
# config.*.yaml file it exists in.
|
||||
|
||||
# Many DDEV commands can be extended to run tasks before or after the
|
||||
|
||||
@@ -25,7 +25,7 @@ APP_SECRET=642ead390b8a38f0a017ec84d5362129
|
||||
#
|
||||
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
|
||||
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
|
||||
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=mariadb-11.8.9&charset=utf8mb4"
|
||||
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.4.0&charset=utf8mb4"
|
||||
# DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
@@ -44,9 +44,6 @@ MAILJET_API_KEY=
|
||||
MAILJET_API_SECRET=
|
||||
MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST
|
||||
MAILJET_DEFAULT_LIST_ID=
|
||||
# Sub-account for the travel mailing lists
|
||||
MAILJET_TRAVEL_MAILING_API_KEY=
|
||||
MAILJET_TRAVEL_MAILING_API_SECRET=
|
||||
# Development default for HTTP Basic Auth password; override in production secrets.
|
||||
MAILJET_WEBHOOK_BASIC_PASSWORD=secret
|
||||
APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1
|
||||
@@ -85,12 +82,6 @@ APP_LEGAL_NOTICE_URL="https://www.ep-reisen.de/unternehmen/impressum-skireisever
|
||||
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/"
|
||||
# TYPO3 API key (X-Api-Key), sent by the typo3.client scoped client and by app:content:sync;
|
||||
# the real value per stage goes in .env.local
|
||||
APP_CMS_API_KEY=
|
||||
# Bearer tokens for the chatbot MCP endpoints (/mcp, /mcp/trips), one per client: "name:token,name:token"
|
||||
# (e.g. "n8n:…,claude-desktop:…"); empty refuses every request
|
||||
APP_MCP_TOKENS=
|
||||
APP_WEBSITE_BASE_URL="https://www.ep-reisen.de/"
|
||||
APP_BASE_URL="https://my.ep-reisen.de"
|
||||
APP_CUSTOMER_SERVICE_EMAIL="[email protected]"
|
||||
@@ -101,17 +92,10 @@ APP_TRAVEL_PREFER_REMOTE=false
|
||||
APP_TRAVEL_ENABLE_FALLBACK=true
|
||||
APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
|
||||
|
||||
# Off turns every navigation into a full page load, which the web debug toolbar needs: its loader
|
||||
# script never runs on a boosted body swap. Explicit hx-get/hx-post requests are unaffected.
|
||||
# Only for local debugging; prod and the boosted code paths (HxRedirectResponse, error modal) need it on.
|
||||
APP_HTMX_BOOST=true
|
||||
|
||||
# Emails
|
||||
APP_DEFAULT_EMAIL_FROM=[email protected]
|
||||
APP_DEFAULT_EMAIL_TO=[email protected]
|
||||
ACCOMMODATION_INQUIRY_EMAIL=[email protected]
|
||||
# Comma-separated; recipients of the role nomination notification. Empty means disable notification.
|
||||
APP_ROLE_NOMINATION_EMAILS=
|
||||
|
||||
# Global common defaults
|
||||
APP_SEASON_WINTER_FROM=2026-10-01
|
||||
@@ -128,11 +112,19 @@ APP_UNDERSUBSCRIPTION_SURCHARGE_40_CHF=2.5
|
||||
# Use 'O' during beta phase, switch to 'F' for production
|
||||
DEFAULT_BOOKING_STATUS=F
|
||||
|
||||
SFTP_XML_SOURCE_HOST=
|
||||
SFTP_XML_SOURCE_PORT=
|
||||
SFTP_XML_SOURCE_USER=
|
||||
SFTP_XML_SOURCE_PASSWORD=
|
||||
SFTP_XML_SOURCE_ROOT=
|
||||
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=
|
||||
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
|
||||
@@ -145,8 +137,3 @@ OAUTH_ENCRYPTION_KEY=580084fd179e67399467f59ee96658ac
|
||||
# postgresql+advisory://db_user:db_password@localhost/db_name
|
||||
LOCK_DSN=flock
|
||||
###< symfony/lock ###
|
||||
|
||||
MAINTENANCE_MODE_ENABLED=false
|
||||
MAINTENANCE_MODE_IP_WHITELIST=188.245.62.21
|
||||
MAINTENANCE_MODE_ROUTE_WHITELIST=
|
||||
MAINTENANCE_MODE_PATH_WHITELIST=/api,/token
|
||||
+5
-5
@@ -8,9 +8,13 @@
|
||||
/vendor/
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> phpunit/phpunit ###
|
||||
/phpunit.xml
|
||||
.phpunit.result.cache
|
||||
###< phpunit/phpunit ###
|
||||
|
||||
###> symfony/phpunit-bridge ###
|
||||
.phpunit.result.cache
|
||||
.phpunit.cache
|
||||
/phpunit.xml
|
||||
###< symfony/phpunit-bridge ###
|
||||
|
||||
@@ -33,7 +37,3 @@ yarn-error.log
|
||||
###> phpstan/phpstan ###
|
||||
phpstan.neon
|
||||
###< phpstan/phpstan ###
|
||||
|
||||
###> config reference ###
|
||||
/config/reference.php
|
||||
###< config reference ###
|
||||
@@ -1,22 +0,0 @@
|
||||
import {Controller} from '@hotwired/stimulus'
|
||||
|
||||
/*
|
||||
* Keeps buttons disabled until the given fields pass the browser's constraint validation
|
||||
* (required, min, max, step, …), which the form types render from the same rules the server
|
||||
* validates. A text field holding only whitespace counts as empty, as NotBlank does.
|
||||
*
|
||||
* Works on forms with novalidate too: that only stops the browser from blocking the submit.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ['field', 'button']
|
||||
|
||||
connect() {
|
||||
this.update()
|
||||
}
|
||||
|
||||
update() {
|
||||
const invalid = this.fieldTargets.some(field => !field.checkValidity()
|
||||
|| ('radio' !== field.type && 'checkbox' !== field.type && '' === field.value.trim()))
|
||||
this.buttonTargets.forEach(button => button.disabled = invalid)
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,10 @@ export default class extends Controller {
|
||||
|
||||
connect() {
|
||||
this.itemsCountValue = this.fieldTargets.length
|
||||
// A running counter rather than the current count: after a remove, count + 1 can be an
|
||||
// index a remaining entry still holds, and the two would submit into the same slot.
|
||||
this.nextIndex = this.fieldTargets.length
|
||||
}
|
||||
|
||||
addItem() {
|
||||
const index = this.nextIndex++
|
||||
const index = this.fieldTargets.length + 1
|
||||
const prototype = JSON.parse(this.prototypeValue)
|
||||
const newField = prototype.replace(/__name__/g, index)
|
||||
this.fieldsTarget.insertAdjacentHTML('beforeend', newField)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import {Controller} from '@hotwired/stimulus'
|
||||
|
||||
/*
|
||||
* Shows an indicator over an iframe until its content has loaded.
|
||||
*
|
||||
* The controller sets the frame's src itself, after the load listener is attached, so a fast
|
||||
* response cannot finish before anybody listens.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ['frame', 'indicator']
|
||||
static values = {src: String}
|
||||
|
||||
connect() {
|
||||
this.loaded = this.loaded.bind(this)
|
||||
this.frameTarget.addEventListener('load', this.loaded)
|
||||
this.frameTarget.src = this.srcValue
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.frameTarget.removeEventListener('load', this.loaded)
|
||||
}
|
||||
|
||||
loaded() {
|
||||
this.indicatorTarget.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -90,7 +90,7 @@ 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.
|
||||
alert('Die Anfrage hat zu lange gedauert. Bitte versuche es erneut. Falls das Problem weiterhin besteht, kontaktiere bitte unseren Support.')
|
||||
alert('Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut. Falls das Problem weiterhin besteht, kontaktieren Sie bitte unseren Support.')
|
||||
}
|
||||
|
||||
function handleBeforeHistorySave() {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
@import "./components/button.css";
|
||||
@import "./components/forms.css";
|
||||
@import "./components/gallery.css";
|
||||
@import "./components/pagination.css";
|
||||
@import "./components/price_calendar.css";
|
||||
@import "./components/toast.css";
|
||||
@import "./components/tooltip.css";
|
||||
@import "./components/typography.css";
|
||||
@import "./components/rte-content.css";
|
||||
@import "components/button.css";
|
||||
@import "components/forms.css";
|
||||
@import "components/gallery.css";
|
||||
@import "components/pagination.css";
|
||||
@import "components/price_calendar.css";
|
||||
@import "components/toast.css";
|
||||
@import "components/tooltip.css";
|
||||
@import "components/typography.css";
|
||||
@import "components/rte-content.css";
|
||||
@@ -1,10 +1,10 @@
|
||||
@import "./components/autocomplete.css";
|
||||
@import "./components/badge.css";
|
||||
@import "./components/button.css";
|
||||
@import "./components/datatable.css";
|
||||
@import "./components/datepicker.css";
|
||||
@import "./components/forms.css";
|
||||
@import "./components/pagination.css";
|
||||
@import "./components/toast.css";
|
||||
@import "./components/tooltip.css";
|
||||
@import "./components/typography.css";
|
||||
@import "components/autocomplete.css";
|
||||
@import "components/badge.css";
|
||||
@import "components/button.css";
|
||||
@import "components/datatable.css";
|
||||
@import "components/datepicker.css";
|
||||
@import "components/forms.css";
|
||||
@import "components/pagination.css";
|
||||
@import "components/toast.css";
|
||||
@import "components/tooltip.css";
|
||||
@import "components/typography.css";
|
||||
@@ -1,66 +0,0 @@
|
||||
/* Shared by app.css and admin.css, imported right after tailwindcss */
|
||||
@source "../../templates";
|
||||
@source "../../src";
|
||||
@source "../../assets";
|
||||
@source inline("rotate-90");
|
||||
|
||||
@plugin "@tailwindcss/forms";
|
||||
|
||||
@theme {
|
||||
--font-sans: Lato, sans-serif;
|
||||
|
||||
--color-primary: #3d8ccb;
|
||||
--color-secondary: #f9c700;
|
||||
--color-primary-bg: #ebf3fa;
|
||||
--color-primary-light: #0070e0;
|
||||
--color-primary-medium: #165883;
|
||||
--color-primary-dark: #18527b;
|
||||
--color-pink: #fa1a8c;
|
||||
|
||||
--color-sbw-primary: #B3820F;
|
||||
--color-sbw-secondary: #E91C26;
|
||||
--color-sbw-primary-dark: #8D7207;
|
||||
--color-sbw-overlay: #c20000;
|
||||
|
||||
--color-snz-primary: #82c3c6;
|
||||
--color-snz-secondary: #f9c33d;
|
||||
--color-snz-primary-dark: #82c3c6;
|
||||
|
||||
--color-ser-primary: #006fb6;
|
||||
--color-ser-secondary: #DE2C1B;
|
||||
--color-ser-primary-light: #04A4CC;
|
||||
|
||||
--background-image-badge-gradient: linear-gradient(120deg, rgba(150,1,103,1) 0%, rgba(158,3,106,1) 40%, rgba(250,26,140,1) 100%);
|
||||
--background-image-brand-gradient: linear-gradient(to top right, #009fff, #165883, #18527b);
|
||||
}
|
||||
|
||||
/*
|
||||
* Tailwind 3 defaults that Tailwind 4 changed: borders were gray-200 instead of currentColor,
|
||||
* and buttons had a pointer cursor.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Thin scrollbars without a track for inner scrolling areas. The page scrollbar takes its
|
||||
* style from <html>, which body * leaves out. Firefox and Chrome/Edge 121+ honour these;
|
||||
* Safari keeps its own overlay scrollbars.
|
||||
*/
|
||||
@layer base {
|
||||
body * {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-gray-300) transparent;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
/* Reserve the scrollbar gutter so content does not shift width when a
|
||||
scrollbar appears (e.g. when a collapsible section is opened). */
|
||||
@utility scroll-stable {
|
||||
scrollbar-gutter: stable;
|
||||
@layer utilities {
|
||||
/* Reserve the scrollbar gutter so content does not shift width when a
|
||||
scrollbar appears (e.g. when a collapsible section is opened). */
|
||||
.scroll-stable {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
@import "tailwindcss" source(none);
|
||||
@import "./_theme.css";
|
||||
@import "./_base.css" layer(base);
|
||||
@import "./_components_admin.css" layer(components);
|
||||
@import "tailwindcss/base";
|
||||
@import "_base.css";
|
||||
|
||||
@import "tailwindcss/components";
|
||||
@import "_components_admin.css";
|
||||
|
||||
@import "tailwindcss/utilities";
|
||||
@@ -1,5 +1,8 @@
|
||||
@import "tailwindcss" source(none);
|
||||
@import "./_theme.css";
|
||||
@import "./_base.css" layer(base);
|
||||
@import "./_components.css" layer(components);
|
||||
@import "./_utilities.css";
|
||||
@import "tailwindcss/base";
|
||||
@import "_base.css";
|
||||
|
||||
@import "tailwindcss/components";
|
||||
@import "_components.css";
|
||||
|
||||
@import "tailwindcss/utilities";
|
||||
@import "_utilities.css";
|
||||
@@ -1,22 +1,16 @@
|
||||
.button {
|
||||
/* Same box as the @tailwindcss/forms inputs (1px border, py-2, 24px line), so a button lines up with a field beside it.
|
||||
The line height is set with each font size (text-sm/6), because text-sm's own line height would override a plain leading-6. */
|
||||
@apply inline-flex items-center justify-center space-x-2 px-4 md:px-8 py-2 border border-transparent bg-origin-border cursor-pointer;
|
||||
@apply uppercase text-center text-sm/6 md:text-base/6;
|
||||
@apply transition-colors outline-hidden focus:outline-2 focus:outline-offset-2 focus:outline-primary-dark;
|
||||
@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 rounded-md;
|
||||
/* At least one line tall even when the content is only a smaller icon: line + padding + border */
|
||||
min-height: calc(1lh + 2 * --spacing(2) + 2px);
|
||||
}
|
||||
|
||||
.button--small {
|
||||
@apply px-2 py-1 md:px-4 text-xs/6 sm:text-sm/6;
|
||||
min-height: calc(1lh + 2 * --spacing(1) + 2px);
|
||||
@apply px-2 py-1 md:px-4 text-xs sm:text-sm;
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
/* Only the image: the background shorthand would reset .button's background-origin */
|
||||
background-image: linear-gradient(120deg, #960167 0%, #9e036a 40%, #fa1a8c 100%);
|
||||
background: linear-gradient(120deg, #960167 0%, #9e036a 40%, #fa1a8c 100%);
|
||||
@apply text-white;
|
||||
@apply hover:bg-none hover:bg-pink/80;
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
|
||||
[data-vc-theme=light] .vc-date[data-vc-date-selected] .vc-date__btn,
|
||||
[data-vc-theme=light] .vc-date[data-vc-date-selected] .vc-date__btn:hover {
|
||||
background-color: var(--color-secondary);
|
||||
background-color: theme('colors.secondary');
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
.pc-day {
|
||||
@apply relative w-full text-center py-1 px-0.5 text-sm leading-tight rounded-sm text-white cursor-pointer;
|
||||
@apply relative w-full text-center py-1 px-0.5 text-sm leading-tight rounded text-white cursor-pointer;
|
||||
}
|
||||
|
||||
.pc-day--out-of-month {
|
||||
@@ -11,11 +11,11 @@
|
||||
}
|
||||
|
||||
.pc-day[data-status="blocked-to-ok"] {
|
||||
background: linear-gradient(to bottom right, var(--color-gray-400) 50%, var(--color-emerald-500) 50%);
|
||||
background: linear-gradient(to bottom right, theme('colors.gray.400') 50%, theme('colors.emerald.500') 50%);
|
||||
}
|
||||
|
||||
.pc-day[data-status="checkout-only"] {
|
||||
background: linear-gradient(to bottom right, var(--color-emerald-500) 50%, var(--color-gray-400) 50%);
|
||||
background: linear-gradient(to bottom right, theme('colors.emerald.500') 50%, theme('colors.gray.400') 50%);
|
||||
}
|
||||
|
||||
.pc-day[data-status="past"] {
|
||||
@@ -33,7 +33,7 @@
|
||||
.pc-day.pc-day--pending-start,
|
||||
.pc-day.pc-day--selected-start,
|
||||
.pc-day.pc-day--selected-end {
|
||||
@apply bg-none outline-hidden bg-secondary text-gray-700;
|
||||
@apply bg-none outline-none bg-secondary text-gray-700;
|
||||
}
|
||||
|
||||
.pc-day.pc-day--in-range {
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
@import "toastify-js/src/toastify.css";
|
||||
|
||||
.toastify {
|
||||
@apply shadow-lg rounded-sm;
|
||||
@apply shadow-lg rounded;
|
||||
}
|
||||
|
||||
.toastify--success {
|
||||
@apply text-gray-100;
|
||||
background: var(--color-emerald-600);
|
||||
background: theme('colors.emerald.600');
|
||||
}
|
||||
|
||||
.toastify--warning {
|
||||
@apply text-gray-100;
|
||||
background: var(--color-red-600);
|
||||
background: theme('colors.red.600');
|
||||
}
|
||||
|
||||
.toastify--error {
|
||||
@apply text-gray-100;
|
||||
background: var(--color-red-700);
|
||||
background: theme('colors.red.700');
|
||||
}
|
||||
|
||||
.toastify--info {
|
||||
@apply text-gray-100;
|
||||
background: var(--color-primary);
|
||||
background: theme('colors.primary');
|
||||
}
|
||||
@@ -1,71 +1,71 @@
|
||||
@import "tippy.js/dist/tippy.css";
|
||||
|
||||
.tippy-box {
|
||||
background-color: var(--color-primary);
|
||||
background-color: theme('colors.primary');
|
||||
}
|
||||
.tippy-box[data-placement^='top'] > .tippy-arrow::before {
|
||||
border-top-color: var(--color-primary);
|
||||
border-top-color: theme('colors.primary');
|
||||
}
|
||||
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
|
||||
border-bottom-color: var(--color-primary);
|
||||
border-bottom-color: theme('colors.primary');
|
||||
}
|
||||
.tippy-box[data-placement^='left'] > .tippy-arrow::before {
|
||||
border-left-color: var(--color-primary);
|
||||
border-left-color: theme('colors.primary');
|
||||
}
|
||||
.tippy-box[data-placement^='right'] > .tippy-arrow::before {
|
||||
border-right-color: var(--color-primary);
|
||||
border-right-color: theme('colors.primary');
|
||||
}
|
||||
|
||||
.sbw {
|
||||
.tippy-box {
|
||||
background-color: var(--color-sbw-secondary);
|
||||
background-color: theme('colors.sbw.secondary');
|
||||
}
|
||||
.tippy-box[data-placement^='top'] > .tippy-arrow::before {
|
||||
border-top-color: var(--color-sbw-secondary);
|
||||
border-top-color: theme('colors.sbw.secondary');
|
||||
}
|
||||
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
|
||||
border-bottom-color: var(--color-sbw-secondary);
|
||||
border-bottom-color: theme('colors.sbw.secondary');
|
||||
}
|
||||
.tippy-box[data-placement^='left'] > .tippy-arrow::before {
|
||||
border-left-color: var(--color-sbw-secondary);
|
||||
border-left-color: theme('colors.sbw.secondary');
|
||||
}
|
||||
.tippy-box[data-placement^='right'] > .tippy-arrow::before {
|
||||
border-right-color: var(--color-sbw-secondary);
|
||||
border-right-color: theme('colors.sbw.secondary');
|
||||
}
|
||||
}
|
||||
|
||||
.snz {
|
||||
.tippy-box {
|
||||
background-color: var(--color-snz-primary-dark);
|
||||
background-color: theme('colors.snz.primary-dark');
|
||||
}
|
||||
.tippy-box[data-placement^='top'] > .tippy-arrow::before {
|
||||
border-top-color: var(--color-snz-primary-dark);
|
||||
border-top-color: theme('colors.snz.primary-dark');
|
||||
}
|
||||
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
|
||||
border-bottom-color: var(--color-snz-primary-dark);
|
||||
border-bottom-color: theme('colors.snz.primary-dark');
|
||||
}
|
||||
.tippy-box[data-placement^='left'] > .tippy-arrow::before {
|
||||
border-left-color: var(--color-snz-primary-dark);
|
||||
border-left-color: theme('colors.snz.primary-dark');
|
||||
}
|
||||
.tippy-box[data-placement^='right'] > .tippy-arrow::before {
|
||||
border-right-color: var(--color-snz-primary-dark);
|
||||
border-right-color: theme('colors.snz.primary-dark');
|
||||
}
|
||||
}
|
||||
|
||||
.ser {
|
||||
.tippy-box {
|
||||
background-color: var(--color-ser-secondary);
|
||||
background-color: theme('colors.ser.secondary');
|
||||
}
|
||||
.tippy-box[data-placement^='top'] > .tippy-arrow::before {
|
||||
border-top-color: var(--color-ser-secondary);
|
||||
border-top-color: theme('colors.ser.secondary');
|
||||
}
|
||||
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
|
||||
border-bottom-color: var(--color-ser-secondary);
|
||||
border-bottom-color: theme('colors.ser.secondary');
|
||||
}
|
||||
.tippy-box[data-placement^='left'] > .tippy-arrow::before {
|
||||
border-left-color: var(--color-ser-secondary);
|
||||
border-left-color: theme('colors.ser.secondary');
|
||||
}
|
||||
.tippy-box[data-placement^='right'] > .tippy-arrow::before {
|
||||
border-right-color: var(--color-ser-secondary);
|
||||
border-right-color: theme('colors.ser.secondary');
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 131 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 176 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 134 KiB |
@@ -1,4 +0,0 @@
|
||||
### list custom fields
|
||||
GET {{clickup_baseurl}}/list/{{clickup_list_id}}/field?include_applied_objects=true
|
||||
Authorization: {{clickup_api_token}}
|
||||
|
||||
+39
-42
@@ -4,7 +4,7 @@
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.5",
|
||||
"php": ">=8.1",
|
||||
"ext-ctype": "*",
|
||||
"ext-dom": "*",
|
||||
"ext-iconv": "*",
|
||||
@@ -21,50 +21,47 @@
|
||||
"knplabs/knp-paginator-bundle": "^6.10",
|
||||
"league/flysystem-bundle": "^3.4",
|
||||
"league/flysystem-sftp-v3": "^3.29",
|
||||
"league/html-to-markdown": "^5.1",
|
||||
"league/oauth2-server-bundle": "^1.0",
|
||||
"maennchen/zipstream-php": "^3.2",
|
||||
"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",
|
||||
"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/asset": "6.4.*",
|
||||
"symfony/console": "6.4.*",
|
||||
"symfony/doctrine-messenger": "6.4.*",
|
||||
"symfony/dom-crawler": "6.4.*",
|
||||
"symfony/dotenv": "6.4.*",
|
||||
"symfony/expression-language": "6.4.*",
|
||||
"symfony/flex": "^2",
|
||||
"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/mcp-bundle": "^0.14.1",
|
||||
"symfony/mime": "7.4.*",
|
||||
"symfony/form": "6.4.*",
|
||||
"symfony/framework-bundle": "6.4.*",
|
||||
"symfony/html-sanitizer": "6.4.*",
|
||||
"symfony/http-client": "6.4.*",
|
||||
"symfony/intl": "6.4.*",
|
||||
"symfony/lock": "6.4.*",
|
||||
"symfony/mailer": "6.4.*",
|
||||
"symfony/mime": "6.4.*",
|
||||
"symfony/monolog-bundle": "^3.0",
|
||||
"symfony/notifier": "7.4.*",
|
||||
"symfony/process": "7.4.*",
|
||||
"symfony/property-access": "7.4.*",
|
||||
"symfony/property-info": "7.4.*",
|
||||
"symfony/rate-limiter": "7.4.*",
|
||||
"symfony/runtime": "7.4.*",
|
||||
"symfony/security-bundle": "7.4.*",
|
||||
"symfony/serializer": "7.4.*",
|
||||
"symfony/notifier": "6.4.*",
|
||||
"symfony/process": "6.4.*",
|
||||
"symfony/property-access": "6.4.*",
|
||||
"symfony/property-info": "6.4.*",
|
||||
"symfony/runtime": "6.4.*",
|
||||
"symfony/security-bundle": "6.4.*",
|
||||
"symfony/serializer": "6.4.*",
|
||||
"symfony/stimulus-bundle": "^2.21",
|
||||
"symfony/string": "7.4.*",
|
||||
"symfony/translation": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/uid": "7.4.*",
|
||||
"symfony/string": "6.4.*",
|
||||
"symfony/translation": "6.4.*",
|
||||
"symfony/twig-bundle": "6.4.*",
|
||||
"symfony/uid": "6.4.*",
|
||||
"symfony/ux-twig-component": "^2.36",
|
||||
"symfony/validator": "7.4.*",
|
||||
"symfony/web-link": "7.4.*",
|
||||
"symfony/validator": "6.4.*",
|
||||
"symfony/web-link": "6.4.*",
|
||||
"symfony/webpack-encore-bundle": "^2.2",
|
||||
"symfony/yaml": "7.4.*",
|
||||
"symfony/yaml": "6.4.*",
|
||||
"twig/extra-bundle": "^2.12|^3.0",
|
||||
"twig/html-extra": "^3.15",
|
||||
"twig/intl-extra": "*",
|
||||
@@ -80,7 +77,7 @@
|
||||
},
|
||||
"sort-packages": true,
|
||||
"platform": {
|
||||
"php": "8.5.0"
|
||||
"php": "8.3.30"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -122,7 +119,7 @@
|
||||
"extra": {
|
||||
"symfony": {
|
||||
"allow-contrib": true,
|
||||
"require": "7.4.*",
|
||||
"require": "6.4.*",
|
||||
"docker": true
|
||||
}
|
||||
},
|
||||
@@ -132,14 +129,14 @@
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpstan/phpstan-doctrine": "^2.0",
|
||||
"phpstan/phpstan-symfony": "^2.0",
|
||||
"phpunit/phpunit": "^12",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"rector/rector": "^2.4",
|
||||
"symfony/browser-kit": "7.4.*",
|
||||
"symfony/css-selector": "7.4.*",
|
||||
"symfony/debug-bundle": "7.4.*",
|
||||
"symfony/browser-kit": "6.4.*",
|
||||
"symfony/css-selector": "6.4.*",
|
||||
"symfony/debug-bundle": "6.4.*",
|
||||
"symfony/maker-bundle": "^1.0",
|
||||
"symfony/phpunit-bridge": "^7.4",
|
||||
"symfony/stopwatch": "7.4.*",
|
||||
"symfony/web-profiler-bundle": "7.4.*"
|
||||
"symfony/phpunit-bridge": "^7.1",
|
||||
"symfony/stopwatch": "6.4.*",
|
||||
"symfony/web-profiler-bundle": "6.4.*"
|
||||
}
|
||||
}
|
||||
Generated
+2150
-3246
File diff suppressed because it is too large.
Load diff
@@ -20,5 +20,4 @@ return [
|
||||
Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true],
|
||||
Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
|
||||
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
|
||||
Symfony\AI\McpBundle\McpBundle::class => ['all' => true],
|
||||
];
|
||||
@@ -9,7 +9,8 @@ doctrine:
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
use_savepoints: true
|
||||
orm:
|
||||
enable_native_lazy_objects: true
|
||||
auto_generate_proxy_classes: true
|
||||
enable_lazy_ghost_objects: true
|
||||
validate_xml_mapping: true
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||
auto_mapping: true
|
||||
@@ -32,6 +33,8 @@ when@test:
|
||||
when@prod:
|
||||
doctrine:
|
||||
orm:
|
||||
auto_generate_proxy_classes: false
|
||||
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
|
||||
query_cache_driver:
|
||||
type: pool
|
||||
pool: doctrine.system_cache_pool
|
||||
|
||||
@@ -2,17 +2,16 @@ flysystem:
|
||||
storages:
|
||||
xml_export.storage:
|
||||
local:
|
||||
directory: '%kernel.project_dir%/var/xmlexport'
|
||||
directory: '%env(resolve:XML_EXPORT_PATH)%'
|
||||
xml_source.storage:
|
||||
sftp:
|
||||
host: '%env(SFTP_XML_SOURCE_HOST)%'
|
||||
port: '%env(int:SFTP_XML_SOURCE_PORT)%'
|
||||
username: '%env(SFTP_XML_SOURCE_USER)%'
|
||||
password: '%env(SFTP_XML_SOURCE_PASSWORD)%'
|
||||
root: '%env(SFTP_XML_SOURCE_ROOT)%'
|
||||
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:
|
||||
directory: '%kernel.project_dir%/var/bpn'
|
||||
json_export.storage:
|
||||
local:
|
||||
directory: '%kernel.project_dir%/var/jsonexport'
|
||||
directory: '%env(resolve:JSON_EXPORT_PATH)%'
|
||||
@@ -1,6 +1,7 @@
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
annotations: false
|
||||
http_method_override: false
|
||||
handle_all_throwables: true
|
||||
|
||||
@@ -16,20 +17,12 @@ framework:
|
||||
php_errors:
|
||||
log: true
|
||||
|
||||
rate_limiter:
|
||||
# Per MCP client. A chatbot turn makes a few tool calls; this catches runaway loops.
|
||||
chatbot_mcp:
|
||||
policy: sliding_window
|
||||
limit: 60
|
||||
interval: '1 minute'
|
||||
|
||||
http_client:
|
||||
scoped_clients:
|
||||
typo3.client:
|
||||
base_uri: '%env(resolve:APP_CMS_API_BASE_URL)%'
|
||||
headers:
|
||||
accept: 'text/json'
|
||||
X-Api-Key: '%env(APP_CMS_API_KEY)%'
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
services:
|
||||
Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory'
|
||||
|
||||
http_discovery.psr17_factory:
|
||||
class: Http\Discovery\Psr17Factory
|
||||
@@ -19,9 +19,6 @@ league_oauth2_server:
|
||||
doctrine: null
|
||||
client:
|
||||
allow_plaintext_secrets: false
|
||||
# App\Entity\OAuth2Client adds the required_roles column that
|
||||
# App\EventListener\AuthorizationCodeListener gates authorization on.
|
||||
classname: App\Entity\OAuth2Client
|
||||
|
||||
when@test:
|
||||
league_oauth2_server:
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# The website chatbot's MCP server, called by the n8n AI Agent through its MCP Client Tool
|
||||
# node. Read-only tools over the mirrored TYPO3 content and our own trip data; see
|
||||
# docs/chatbot-mcp.md. Authentication is the "mcp" firewall in security.yaml.
|
||||
mcp:
|
||||
servers:
|
||||
chatbot:
|
||||
name: 'MyE&P chatbot'
|
||||
version: '1.0.0'
|
||||
instructions: 'Answers questions about the trips and website content of E&P Reisen and its brands. Every result carries a url to cite. Prices and availability are a snapshot as of dataAsOf; the booking page is binding. The structured trip fields of search_trips and get_trip (dates, rooms, prices, pickups, inclusions) come from the booking system and override anything editorial text says about them: product descriptions, FAQs and search_content/get_document results can be out of date.'
|
||||
http:
|
||||
path: /mcp
|
||||
# DNS rebinding protection guards unauthenticated local servers. This one is
|
||||
# public, behind a bearer token, and never called from a browser.
|
||||
allowed_hosts: false
|
||||
registry:
|
||||
tools: ['App\Chatbot\Mcp\Tool\']
|
||||
# The same trip tools without the website's editorial text: no search_content and
|
||||
# get_document, and a get_trip that leaves out the product and hotel page texts.
|
||||
# Same tokens as /mcp; a client picks the variant by the path it is pointed at.
|
||||
chatbot_trips:
|
||||
name: 'MyE&P chatbot (trips only)'
|
||||
version: '1.0.0'
|
||||
instructions: 'Answers questions about the trips of E&P Reisen and its brands: dates, rooms, prices, availability, pickups and inclusions, all from the booking system. Every result carries a url to cite. Prices and availability are a snapshot as of dataAsOf; the booking page is binding. Website texts such as descriptions, FAQs and terms are not available here: for anything the tools do not answer, refer the user to the url of the trip.'
|
||||
http:
|
||||
path: /mcp/trips
|
||||
allowed_hosts: false
|
||||
registry:
|
||||
tools:
|
||||
- 'App\Chatbot\Mcp\Tool\SearchTripsTool'
|
||||
- 'App\Chatbot\Mcp\TripsOnly\'
|
||||
@@ -26,7 +26,6 @@ framework:
|
||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||
App\Message\MailjetNewsletterEventMessage: async
|
||||
App\Message\CreateClickUpBookingTaskMessage: async
|
||||
App\Message\RoleNominationMessage: async
|
||||
|
||||
# Route your messages to the transports
|
||||
# 'App\Message\YourMessage': async
|
||||
@@ -5,7 +5,6 @@ monolog:
|
||||
- core
|
||||
- auth
|
||||
- admin
|
||||
- chatbot
|
||||
|
||||
when@dev:
|
||||
monolog:
|
||||
@@ -34,16 +33,11 @@ when@dev:
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
|
||||
level: debug
|
||||
channels: ["admin"]
|
||||
chatbot:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.chatbot.log"
|
||||
level: debug
|
||||
channels: ["chatbot"]
|
||||
main:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
channels: ["!event", "!bpn", "!core", "!auth", "!admin", "!chatbot"]
|
||||
channels: ["!event", "!bpn", "!core", "!auth", "!admin"]
|
||||
# uncomment to get logging in your browser
|
||||
# you may have to allow bigger header sizes in your Web server configuration
|
||||
#firephp:
|
||||
@@ -102,20 +96,13 @@ when@prod: &prod_config
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
|
||||
level: info
|
||||
channels: ["admin"]
|
||||
# MCP tool calls, file only: the log for judging search quality later.
|
||||
chatbot:
|
||||
type: rotating_file
|
||||
max_files: 30
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.chatbot.log"
|
||||
level: info
|
||||
channels: ["chatbot"]
|
||||
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", "!chatbot"]
|
||||
channels: ["!bpn", "!core", "!auth", "!admin"]
|
||||
nested:
|
||||
type: rotating_file
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.framework.log"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
framework:
|
||||
property_info:
|
||||
with_constructor_extractor: true
|
||||
@@ -2,12 +2,9 @@ framework:
|
||||
router:
|
||||
utf8: true
|
||||
|
||||
# How URLs are generated where there is no request to take the host from: scheduled
|
||||
# commands and, notably, the Messenger workers that send mail off the request path.
|
||||
# Without it those links would point at http://localhost. Only ever consulted in a
|
||||
# non-HTTP context, so web requests keep using their own host — which matters here,
|
||||
# since one codebase serves four brands.
|
||||
default_uri: '%env(APP_BASE_URL)%'
|
||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||
#default_uri: http://localhost
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
|
||||
@@ -15,19 +15,10 @@ security:
|
||||
mailjet:
|
||||
password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%'
|
||||
roles: [ ROLE_MAILJET_WEBHOOK ]
|
||||
# Never consulted: McpAccessTokenHandler builds the user from the client name its
|
||||
# token belongs to. Named only because a firewall must have a provider.
|
||||
mcp_provider:
|
||||
memory:
|
||||
users: []
|
||||
|
||||
role_hierarchy:
|
||||
# ROLE_CUSTOMER_EXPERT gates the customer-service surfaces (booking drafts, travel
|
||||
# mailing). Inheriting it here is what keeps them open to administrators: the booking
|
||||
# drafts used to be ROLE_ADMIN only before their #[IsGranted] moved to the narrower role.
|
||||
ROLE_ADMIN:
|
||||
- ROLE_GROUPS_ADMIN
|
||||
- ROLE_CUSTOMER_EXPERT
|
||||
ROLE_GROUPS_ADMIN:
|
||||
- ROLE_GROUPS_MANAGER
|
||||
firewalls:
|
||||
@@ -50,12 +41,6 @@ security:
|
||||
provider: mailjet_webhook_provider
|
||||
http_basic:
|
||||
realm: 'Mailjet Webhook'
|
||||
mcp:
|
||||
pattern: ^/mcp(/trips)?$
|
||||
stateless: true
|
||||
provider: mcp_provider
|
||||
access_token:
|
||||
token_handler: App\Security\McpAccessTokenHandler
|
||||
main:
|
||||
lazy: true
|
||||
provider: app_user_provider
|
||||
@@ -69,7 +54,6 @@ security:
|
||||
# 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: ^/mcp(/trips)?$, roles: ROLE_CHATBOT_MCP, requires_channel: https }
|
||||
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https }
|
||||
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ twig:
|
||||
'%kernel.project_dir%/assets/images': images
|
||||
globals:
|
||||
htmx_change_trigger: 'change delay:100ms'
|
||||
htmx_boost: '%env(bool:APP_HTMX_BOOST)%'
|
||||
website_base_url: '%env(APP_WEBSITE_BASE_URL)%'
|
||||
customer_service_email: '%env(APP_CUSTOMER_SERVICE_EMAIL)%'
|
||||
legal_notice_url: '%env(APP_LEGAL_NOTICE_URL)%'
|
||||
|
||||
@@ -14,6 +14,4 @@ when@test:
|
||||
intercept_redirects: false
|
||||
|
||||
framework:
|
||||
profiler:
|
||||
collect: false
|
||||
collect_serializer_data: true
|
||||
profiler: { collect: false }
|
||||
@@ -3,14 +3,13 @@ zenstruck_schedule:
|
||||
|
||||
mailer:
|
||||
service: mailer
|
||||
default_to: [email protected]
|
||||
default_from: noreply@ep-reisen.de
|
||||
default_to: [email protected]
|
||||
default_from: info@ep-reisen.de
|
||||
subject_prefix: "[MyE&P]"
|
||||
|
||||
schedule_extensions:
|
||||
email_on_failure:
|
||||
enabled: true
|
||||
to: [email protected]
|
||||
|
||||
tasks:
|
||||
- task: app:cleanup:xml-dumps
|
||||
@@ -29,10 +28,6 @@ zenstruck_schedule:
|
||||
frequency: "45 1 * * *"
|
||||
description: "Removes expired pending newsletter double opt-in requests"
|
||||
|
||||
- task: app:cleanup:travel-mailing-lists
|
||||
frequency: "50 1 * * *"
|
||||
description: "Deletes Mailjet travel mailing lists and their orphaned contacts 30 days after the travel started"
|
||||
|
||||
- task: league:oauth2-server:clear-expired-tokens
|
||||
frequency: "0 * * * *"
|
||||
description: "Removes expired oauth2 tokens hourly"
|
||||
@@ -71,15 +66,3 @@ when@prod:
|
||||
- task: app:bpn:sync-contingents
|
||||
frequency: "5 20-23,0-7 * * *"
|
||||
description: "Syncs the full contingent horizon hourly outside peak hours"
|
||||
|
||||
- task: app:content:sync
|
||||
frequency: "0 * * * *"
|
||||
description: "Mirrors changes in the TYPO3 content feed for the chatbot MCP server"
|
||||
|
||||
- task: app:content:sync --refresh
|
||||
frequency: "5 1 * * 0"
|
||||
description: "Refetches TYPO3 content stored more than 7 days ago, within the night window"
|
||||
|
||||
- task: app:content:check-freshness --max-age 150
|
||||
frequency: "10 * * * *"
|
||||
description: "Fails when the TYPO3 content mirror has missed more than one hourly sync"
|
||||
@@ -1,3 +0,0 @@
|
||||
mcp:
|
||||
resource: .
|
||||
type: mcp
|
||||
+19
-138
@@ -1,4 +1,3 @@
|
||||
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
||||
# This file is the entry point to configure your own services.
|
||||
# Files in the packages/ subdirectory configure your dependencies.
|
||||
|
||||
@@ -15,27 +14,6 @@ parameters:
|
||||
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
|
||||
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
|
||||
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
|
||||
role_nomination_notification_emails: '%env(csv:APP_ROLE_NOMINATION_EMAILS)%'
|
||||
# Promo codes issued before the "R-" prefix was introduced, with the last day each is recognised
|
||||
# as a promo code (VoucherCodeClassifier). Remove this parameter and the classifier branch
|
||||
# reading it once the last date has passed.
|
||||
legacy_promo_voucher_codes:
|
||||
BEWERTUNG25: '2026-10-31'
|
||||
ANNA50: '2026-12-20'
|
||||
SIDNEY50: '2026-12-20'
|
||||
LENA20: '2027-03-04'
|
||||
BOTSCHAFTER2627: '2027-04-30'
|
||||
|
||||
# Named travel periods of the website search ("Reisezeitraum"), offered by the chatbot's
|
||||
# search_trips. Duplicated from the TYPO3 search configuration on purpose: update both each
|
||||
# season. Periods may overlap; one whose end has passed is no longer offered.
|
||||
chatbot_travel_periods:
|
||||
- { label: 'Vorsaison', from: '2026-11-01', to: '2026-12-20' }
|
||||
- { label: 'um Weihnachten', from: '2026-12-20', to: '2026-12-28' }
|
||||
- { label: 'Silvester', from: '2026-12-26', to: '2027-01-03' }
|
||||
- { label: '1 Januarwoche', from: '2027-01-02', to: '2027-01-09' }
|
||||
- { label: 'Nachsaison', from: '2027-03-20', to: '2027-05-01' }
|
||||
- { label: 'Ostern', from: '2027-03-20', to: '2027-04-03' }
|
||||
|
||||
# MailJet list ids and their labels
|
||||
mailjet_lists:
|
||||
@@ -52,12 +30,6 @@ parameters:
|
||||
10321389: 'Reisen-Alert Stubaital'
|
||||
10554990: 'Reisen-Alert Ski & Boarderweek'
|
||||
|
||||
# Email domains whose accounts are members of staff. BusPro has no CRM selection expressing
|
||||
# "works here", so ROLE_EMPLOYEE is derived from the address instead. Matched exactly on the
|
||||
# domain part, never as a suffix.
|
||||
employee_email_domains:
|
||||
- 'ep-reisen.de'
|
||||
|
||||
# BusProNet "Hausleitung {CODE}" CRM selections, by selection id.
|
||||
# DEPLOYMENT-CRITICAL: roles and hotel codes are synced on every login, so an id missing
|
||||
# here does not merely fail to nominate a Hausleitung — it revokes the role and the hotel
|
||||
@@ -148,22 +120,18 @@ services:
|
||||
- '../src/Model'
|
||||
- '../src/Kernel.php'
|
||||
|
||||
App\BusProNet\Transport\XmlChannel:
|
||||
App\BusProNet\ApiClient:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.bpn'
|
||||
$options:
|
||||
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)%'
|
||||
debug: '%env(bool:APP_BPN_DEBUG)%'
|
||||
|
||||
App\BusProNet\Transport\SocketTransport:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.bpn'
|
||||
$host: '%env(APP_BPN_IP)%'
|
||||
$ports: '%env(csv:APP_BPN_PORTS)%'
|
||||
$connectionTimeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
|
||||
$streamTimeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
|
||||
$totalTimeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%'
|
||||
connection_timeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
|
||||
stream_timeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
|
||||
total_timeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%'
|
||||
|
||||
App\Twig\AppRuntime:
|
||||
arguments:
|
||||
@@ -181,6 +149,14 @@ services:
|
||||
method: createMainMenu
|
||||
alias: admin_main
|
||||
|
||||
App\Menu\GroupsMenuBuilder:
|
||||
arguments:
|
||||
$factory: '@knp_menu.factory'
|
||||
tags:
|
||||
- name: knp_menu.menu_builder
|
||||
method: createMainMenu
|
||||
alias: groups_main
|
||||
|
||||
App\Service\BpnXmlSyncManager:
|
||||
arguments:
|
||||
$xmlSource: '@xml_source.storage'
|
||||
@@ -219,10 +195,6 @@ services:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
|
||||
App\BusProNet\Utility\CountryCodeMapper:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
App\BusProNet\XmlLoader\HotelLoader:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
@@ -235,49 +207,17 @@ services:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
|
||||
App\Service\TravelExportBuilder:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
App\Service\ProductExportBuilder:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
App\Service\VoucherValidator:
|
||||
arguments:
|
||||
$cache: '@cache.app'
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
App\Service\VoucherCodeClassifier:
|
||||
arguments:
|
||||
$legacyPromoCodes: '%legacy_promo_voucher_codes%'
|
||||
|
||||
# Voucher purchase flow (/gutschein)
|
||||
App\Service\VoucherTemplateProvider:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
App\Service\VoucherThumbnailResolver:
|
||||
arguments:
|
||||
$manifestPath: '%kernel.project_dir%/public/build/manifest.json'
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
App\Controller\Voucher\Step1Controller:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
App\Controller\Voucher\Step3Controller:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
# Field Handlers with dependencies
|
||||
App\Form\Service\ParticipantInsuranceFieldHandler: ~
|
||||
App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~
|
||||
App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler: ~
|
||||
App\Form\Service\ParticipantVoucherFieldHandler: ~
|
||||
App\Form\Service\ParticipantPurchaseVoucherFieldHandler: ~
|
||||
App\Form\Service\ParticipantPromoVoucherFieldHandler: ~
|
||||
|
||||
# Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services
|
||||
App\Form\Service\ParticipantFieldHandlerRegistry:
|
||||
@@ -303,7 +243,8 @@ services:
|
||||
- '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler'
|
||||
- '@App\Form\Service\ParticipantBulkInsuranceFieldHandler'
|
||||
- '@App\Form\Service\ParticipantInsuranceFieldHandler'
|
||||
- '@App\Form\Service\ParticipantVoucherFieldHandler'
|
||||
- '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler'
|
||||
- '@App\Form\Service\ParticipantPromoVoucherFieldHandler'
|
||||
- '@App\Form\Service\ParticipantParkingFieldHandler'
|
||||
|
||||
# Booking Status Rules
|
||||
@@ -317,37 +258,10 @@ services:
|
||||
- '@App\BusProNet\Service\StatusRule\Selection1473StatusRule'
|
||||
- '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule'
|
||||
|
||||
# Dashboard Widgets
|
||||
App\Dashboard\Widget\StuckBookingDraftsWidgetProvider: ~
|
||||
App\Dashboard\Widget\OpenGroupBookingsWidgetProvider: ~
|
||||
App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider: ~
|
||||
App\Dashboard\Widget\RecentLogEntriesWidgetProvider: ~
|
||||
|
||||
# Dashboard Widget Registry
|
||||
App\Dashboard\DashboardWidgetRegistry:
|
||||
arguments:
|
||||
$providers:
|
||||
- '@App\Dashboard\Widget\StuckBookingDraftsWidgetProvider'
|
||||
- '@App\Dashboard\Widget\OpenGroupBookingsWidgetProvider'
|
||||
- '@App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider'
|
||||
- '@App\Dashboard\Widget\RecentLogEntriesWidgetProvider'
|
||||
|
||||
App\ContentFeed\ContentFeedClient:
|
||||
arguments:
|
||||
$baseUrl: '%env(APP_CMS_API_BASE_URL)%'
|
||||
$apiKey: '%env(APP_CMS_API_KEY)%'
|
||||
|
||||
App\Chatbot\TravelPeriods:
|
||||
arguments:
|
||||
$periods: '%chatbot_travel_periods%'
|
||||
|
||||
App\Security\McpAccessTokenHandler:
|
||||
arguments:
|
||||
$tokens: '%env(APP_MCP_TOKENS)%'
|
||||
|
||||
App\Service\CmsDataProvider:
|
||||
arguments:
|
||||
$httpClient: '@typo3.client'
|
||||
$apiKey: 'AbcAbc123'# dummy key, not yet implemented
|
||||
|
||||
App\Logger\RequestIdProcessor:
|
||||
tags:
|
||||
@@ -371,14 +285,6 @@ services:
|
||||
arguments:
|
||||
$houseManagerIds: '%bpn_crm_house_manager_ids%'
|
||||
|
||||
App\Security\EmployeeDomainMatcher:
|
||||
arguments:
|
||||
$domains: '%employee_email_domains%'
|
||||
|
||||
App\MessageHandler\RoleNominationHandler:
|
||||
arguments:
|
||||
$notificationRecipients: '%role_nomination_notification_emails%'
|
||||
|
||||
App\Service\DomainConfigProvider:
|
||||
arguments:
|
||||
$domainConfig: '%domain_config%'
|
||||
@@ -423,9 +329,6 @@ services:
|
||||
App\BpnConnect\ContingentsClient:
|
||||
parent: App\BpnConnect\AbstractApiClient
|
||||
|
||||
App\BpnConnect\TravelsClient:
|
||||
parent: App\BpnConnect\AbstractApiClient
|
||||
|
||||
App\ClickUp\ApiClient:
|
||||
arguments:
|
||||
$apiToken: '%env(default::CLICKUP_API_TOKEN)%'
|
||||
@@ -440,25 +343,3 @@ services:
|
||||
$apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
|
||||
$defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%'
|
||||
$contactMetadataFields: '%mailjet_contact_metadata_fields%'
|
||||
|
||||
# Travel mailing lists live in a Mailjet sub-account, which has its own key pair and keeps
|
||||
# its contacts and lists apart from the newsletter on the main account.
|
||||
app.mailjet.travel_mailing_client:
|
||||
class: App\Mailjet\ApiClient
|
||||
arguments:
|
||||
$apiKey: '%env(default::MAILJET_TRAVEL_MAILING_API_KEY)%'
|
||||
$apiSecret: '%env(default::MAILJET_TRAVEL_MAILING_API_SECRET)%'
|
||||
$apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
|
||||
$contactMetadataFields: '%mailjet_contact_metadata_fields%'
|
||||
|
||||
App\Service\TravelMailingListManager:
|
||||
arguments:
|
||||
$mailjet: '@app.mailjet.travel_mailing_client'
|
||||
|
||||
App\EventListener\MaintenanceModeListener:
|
||||
arguments:
|
||||
$options:
|
||||
enabled: '%env(bool:MAINTENANCE_MODE_ENABLED)%'
|
||||
whitelisted_ips: '%env(csv:MAINTENANCE_MODE_IP_WHITELIST)%'
|
||||
whitelisted_routes: '%env(csv:MAINTENANCE_MODE_ROUTE_WHITELIST)%'
|
||||
whitelisted_paths: '%env(csv:MAINTENANCE_MODE_PATH_WHITELIST)%'
|
||||
@@ -5,7 +5,7 @@ parameters:
|
||||
theme: base
|
||||
gtm_id: GTM-56LWRF
|
||||
cmp_url: https://consent.cookiefirst.com/sites/my.ep-reisen.de-28eabc35-7085-4d8f-b450-522bd1a5247c/consent.js
|
||||
mysbw.ddev.site:
|
||||
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
|
||||
|
||||
+38
-34
@@ -9,6 +9,7 @@ require 'recipe/symfony.php';
|
||||
add('shared_dirs', [
|
||||
'config/secret',
|
||||
'var/bpn',
|
||||
'var/log',
|
||||
'var/sessions',
|
||||
'var/jsonexport',
|
||||
'var/xmlexport',
|
||||
@@ -34,13 +35,10 @@ $rsyncOptions = [
|
||||
'.jj',
|
||||
'node_modules',
|
||||
'.editorconfig',
|
||||
'.env.local',
|
||||
'.env.local.php',
|
||||
'.env.dev.local',
|
||||
'.env.dev.local.php',
|
||||
'.env.test',
|
||||
'.env.test.local',
|
||||
'.browserslistrc',
|
||||
'.gitignore',
|
||||
'.nvmrc',
|
||||
'.php-cs-fixer.cache',
|
||||
@@ -59,7 +57,8 @@ $rsyncOptions = [
|
||||
'phpunit.xml.dist',
|
||||
'public/.htaccess',
|
||||
'ray.php',
|
||||
'webpack.config.mjs',
|
||||
'tailwind.config.js',
|
||||
'webpack.config.js',
|
||||
'bin/.phpunit',
|
||||
'temp/',
|
||||
'tests/',
|
||||
@@ -77,70 +76,75 @@ $rsyncOptions = [
|
||||
'timeout' => 300,
|
||||
];
|
||||
|
||||
// Shared by all hosts.
|
||||
set('bin/php', '/usr/bin/php');
|
||||
set('http_user', 'myepsf');
|
||||
// Must stay here: contrib/rsync.php sets the same key, but its __DIR__ is the vendor dir.
|
||||
set('rsync_src', __DIR__);
|
||||
set('rsync', $rsyncOptions);
|
||||
|
||||
host('prod')
|
||||
->setHostname('dedi10193.your-server.de')
|
||||
->setDeployPath('/usr/www/users/myepsf/prod')
|
||||
->setRemoteUser('myepsf')
|
||||
->setHostname('185.243.135.29')
|
||||
->setRemoteUser('p704161')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/home/www/p704161/html/myep')
|
||||
->set('writable_mode', 'chmod')
|
||||
->set('http_user', 'p704161')
|
||||
->set('bin/php', '/usr/local/bin/php')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
|
||||
;
|
||||
|
||||
host('staging')
|
||||
->setHostname('dedi10193.your-server.de')
|
||||
->setDeployPath('/usr/www/users/myepsf/staging')
|
||||
->setRemoteUser('myepsf')
|
||||
->setHostname('185.243.135.29')
|
||||
->setRemoteUser('p704161')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/home/www/p704161/html/myep-staging')
|
||||
->set('writable_mode', 'chmod')
|
||||
->set('http_user', 'p704161')
|
||||
->set('bin/php', '/usr/local/bin/php')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging')
|
||||
->add('shared_files', [
|
||||
'public/.htaccess',
|
||||
'public/.htpasswd',
|
||||
])
|
||||
;
|
||||
|
||||
host('develop')
|
||||
->setHostname('185.243.135.29')
|
||||
->setRemoteUser('p704161')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/home/www/p704161/html/myep-develop')
|
||||
->set('writable_mode', 'chmod')
|
||||
->set('http_user', 'p704161')
|
||||
->set('bin/php', '/usr/local/bin/php')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://mydev.ep-reisen.net --web-basic-auth=myep:develop')
|
||||
->add('shared_files', [
|
||||
'public/.htpasswd',
|
||||
])
|
||||
;
|
||||
|
||||
task('deploy', [
|
||||
'deploy:info',
|
||||
'deploy:assets',
|
||||
'deploy:setup',
|
||||
'deploy:lock',
|
||||
'deploy:release',
|
||||
'deploy:assets',
|
||||
'rsync',
|
||||
'deploy:shared',
|
||||
'deploy:writable',
|
||||
'deploy:cache:warmup',
|
||||
'deploy:cache:clear',
|
||||
'database:migrate',
|
||||
// Must run before the symlink flip: the cachetool probe file is only reachable through
|
||||
// the release the docroot currently resolves to. It still pays off, because it drops the
|
||||
// opcache entries keyed under current/public/*.
|
||||
'cachetool:clear:opcache',
|
||||
'deploy:publish',
|
||||
'deploy:stop-workers',
|
||||
]);
|
||||
|
||||
// Purely local, so it runs before anything is created on the remote. once(), so
|
||||
// deploying multiple hosts at the same time builds the bundle only once.
|
||||
task('deploy:assets', function () {
|
||||
runLocally('ddev exec npm ci');
|
||||
runLocally('ddev exec npm run build');
|
||||
})->once();
|
||||
|
||||
// The recipe's deploy:cache:clear only does something when composer ran with
|
||||
// --no-scripts, which never happens here: vendor/ is rsynced, composer never runs
|
||||
// remotely. A fresh release has no var/cache to clear, so warm it up directly.
|
||||
// Runs after deploy:writable so the default ACLs are already in place.
|
||||
task('deploy:cache:warmup', function () {
|
||||
run('{{bin/console}} cache:warmup {{console_options}}');
|
||||
});
|
||||
|
||||
// Workers keep running the previous release's code until they are told to stop.
|
||||
task('deploy:stop-workers', function () {
|
||||
run('{{bin/console}} messenger:stop-workers');
|
||||
});
|
||||
|
||||
+21
-411
@@ -10,10 +10,7 @@ A Symfony application exposing a read-mostly JSON API under the `/api` prefix. A
|
||||
|
||||
- Token endpoint: `POST /token` (unauthenticated).
|
||||
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
|
||||
- Access token TTL is **10 minutes** for every grant.
|
||||
Do not hardcode it — read `expires_in` from the token response, it is authoritative and may be tuned.
|
||||
A token can therefore expire part-way through a long page walk of `GET /api/exports/*`; re-request one on
|
||||
401 and resume at the page you were on. At the default `perPage=500` either export is a single page today.
|
||||
- Access token TTL: **10 minutes** — clients must refresh/re-request frequently.
|
||||
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
|
||||
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
|
||||
- Send the token as `Authorization: Bearer <access_token>`.
|
||||
@@ -50,9 +47,7 @@ There is no single error envelope; three shapes occur:
|
||||
- `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code.
|
||||
- `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter).
|
||||
|
||||
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/exports/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
|
||||
|
||||
`GET /api/exports/*` deliberately uses real status codes throughout and never the 200-with-an-`error`-key shape: a failure there is a 5xx, so retry logic can key on the status code alone.
|
||||
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
|
||||
|
||||
The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them.
|
||||
|
||||
@@ -64,23 +59,11 @@ The HTTP methods listed below are the intended ones. Several read routes do not
|
||||
|
||||
#### `GET /api/hotels` — scope `api`
|
||||
|
||||
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`,
|
||||
`costUnit`, `countryCode`.
|
||||
|
||||
- `country` is the **raw** upstream token, unnormalised, and is the join key into `GET /api/countries[].token`.
|
||||
It mixes conventions: ISO-2 (`CH`, `NL`), European vehicle registration codes (`D`, `A`, `F`, `S`, `FL`) and
|
||||
ISO-3 (`AUT`, `PRT`, `HRV`), sometimes several for the same country.
|
||||
- `countryCode` (`string|null`) is that token mapped to a lowercase ISO 3166-1 alpha-2 code — use this one to pick
|
||||
a flag. A token with no confident mapping yields **`null`**, never a guess and never `""`, so treat null as
|
||||
"unknown" rather than as an error. New tokens appear upstream from time to time; they surface as null here until
|
||||
the mapping table is extended.
|
||||
- `costUnit` (`string|null`) is the raw upstream cost centre. It is a **string, not a number**: most hotels have
|
||||
none, and some carry a space-separated list such as `"84 85"`. Store it as text and do not parse it.
|
||||
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`.
|
||||
|
||||
#### `GET /api/hotels/{id}` — scope `api`
|
||||
|
||||
Single hotel by numeric id, same field set as the list, including `costUnit` and `countryCode`.
|
||||
`404 {"message":"Not found"}` if unknown.
|
||||
Single hotel by numeric id, same field set as the list. `404 {"message":"Not found"}` if unknown.
|
||||
|
||||
#### `GET /api/countries` — scope `api`
|
||||
|
||||
@@ -92,9 +75,7 @@ Product master data from the upstream BusProNet system, cached server-side for 1
|
||||
|
||||
#### `GET /api/last-update` — scope `api`
|
||||
|
||||
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}` in `Europe/Berlin` local time.
|
||||
If the export marker is missing or unreadable (for instance mid-sync), the answer is `{"timestamp": null}` with
|
||||
status 200 — treat null as "unknown", not as "changed".
|
||||
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}`. Note: if no export marker file exists the current implementation errors (500) rather than returning null — treat a non-JSON/500 response as "unknown".
|
||||
|
||||
---
|
||||
|
||||
@@ -141,272 +122,6 @@ This is a full overwrite, not a merge.
|
||||
|
||||
---
|
||||
|
||||
### Bulk exports
|
||||
|
||||
#### `GET /api/exports/travels` — scope `api`
|
||||
|
||||
Bulk export of every travel date × hotel pair with its boarding points resolved, for clients that mirror the
|
||||
disposition locally. One request replaces a walk over `GET /api/travels` plus a per-pair detail call.
|
||||
|
||||
The two `/api/exports/*` endpoints are the only paginated ones and the only ones with a `meta` envelope.
|
||||
|
||||
It sits under `/api/exports`, not `/api/travels`, on purpose: `/api/travels/{dateCode}` accepts an
|
||||
arbitrary string, so an export hung off that collection would share a namespace with every date code.
|
||||
`/api/exports` never takes a parameterised segment, so there is nothing for it to collide with.
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Parameter | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `page` | int ≥ 1 | `1` | Out of range is **not** clamped — see below |
|
||||
| `perPage` | int 1–1000 | `500` | |
|
||||
| `changedSince` | — | — | **Reserved, not implemented.** Sending it today is ignored; it is named here only so it is not claimed for anything else. |
|
||||
|
||||
A `page`/`perPage` outside those bounds returns **400** with Symfony's constraint-violation body (send
|
||||
`Accept: application/json` to get it as JSON rather than an HTML error page). A `page` past the last one is *not*
|
||||
an error: it returns `200` with an empty `data` array and the usual `meta`.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"busProId": 12794,
|
||||
"code": "PCJSER261226",
|
||||
"productCode": "PCJSER",
|
||||
"product": "Semesterende - Portes du Soleil - Joliment",
|
||||
"dateFrom": "2026-12-26",
|
||||
"dateTo": "2026-12-30",
|
||||
"hotel": {
|
||||
"busProId": 225444,
|
||||
"code": "PCJSER1",
|
||||
"name": "NEU SER Jolimont 1",
|
||||
"costUnit": "863",
|
||||
"country": "CH",
|
||||
"countryCode": "ch"
|
||||
},
|
||||
"pickups": [
|
||||
{
|
||||
"busProId": 18,
|
||||
"code": "ESS-Route",
|
||||
"city": "Essen",
|
||||
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
|
||||
"time": "20:15",
|
||||
"datetime": "2026-12-25T20:15:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"page": 1,
|
||||
"perPage": 500,
|
||||
"total": 415,
|
||||
"totalPages": 1,
|
||||
"skipped": 0,
|
||||
"pickupsWithoutMasterData": 0,
|
||||
"sourceGeneratedAt": "2026-09-29T11:11:01+02:00",
|
||||
"generatedAt": "2026-09-29T12:10:06+02:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Row identity.** `(busProId, hotel.busProId)` is unique across the whole export — upsert on that pair. `busProId`
|
||||
is always an int and never null; `hotel` is always an object and never null; `pickups` is always an array, possibly
|
||||
empty, never null.
|
||||
|
||||
**`productCode`** is the code of the travel (product) the date belongs to, e.g. `DKIWE`. Key on it to
|
||||
recognise dates of the same product. Do not derive it from the date `code` by cutting off the date: some
|
||||
products run dates under a different code (`DWWMP` runs `DWWMW…`), and group travels carry each group's own
|
||||
code on the date. It is `null` for the few travels that have no code upstream.
|
||||
|
||||
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`,
|
||||
`sourceGeneratedAt` and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the
|
||||
rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total / perPage)`.
|
||||
|
||||
- `skipped` — (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent
|
||||
from the hotel master data. Expect `0`. A non-zero value means upstream data is inconsistent, not that you have
|
||||
lost rows you previously had.
|
||||
- `pickupsWithoutMasterData` — count of **distinct** pickup ids referenced by a travel but absent from
|
||||
`GET /api/pickups`. Those entries are still emitted, with `code`, `city` and `street` null: a departure point
|
||||
with no name is more useful to a disposition view than one that silently vanished.
|
||||
|
||||
**Pickup times.** `pickups` is in **source order** — the order the boarding list is driven in — not sorted by time.
|
||||
|
||||
- `time` is `"HH:MM"` for display; `datetime` is the same reading with its date resolved, as
|
||||
`YYYY-MM-DDTHH:MM:SS` **without an offset**. **Prefer `datetime`.**
|
||||
- Both are upstream's local clock readings, not instants: coaches are timetabled in plain clock times. A stop at
|
||||
`02:45` on the night the clocks go forward comes back as `…T02:45:00`, although that hour does not exist in
|
||||
`Europe/Berlin`, and a stop in the hour repeated when the clocks go back passes through unchanged. Do not attach
|
||||
an offset to compare or sort them; they already sort correctly as strings.
|
||||
- Upstream states a boarding time either as a full date and time, taken verbatim, or as a bare time. A bare time
|
||||
takes the departure date (`dateFrom`), and moves to the following day if it would otherwise fall before the
|
||||
latest full date and time **anywhere** in the same boarding list, whatever its position. So on a departure
|
||||
dated 26.12. whose list also dates a stop `26.12. 23:59`, a bare `02:15` comes back as `…12-27T02:15:00`.
|
||||
`datetime` carries that resolution for bare-time sources too.
|
||||
- A boarding point with no time upstream comes back with **both** `time` and `datetime` null. They are always
|
||||
null together.
|
||||
- Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some
|
||||
lists date every stop to the evening before departure, including stops after midnight, so an entry can appear a
|
||||
day earlier than the running order implies. This is upstream data, not a transformation applied here.
|
||||
|
||||
**Consistency.** The export is built from the same cached master data as `GET /api/hotels` and `GET /api/pickups`,
|
||||
under one cache tag, so all three move together on an upstream sync rather than drifting between your calls.
|
||||
It is cached for 3 hours; `generatedAt` is when the cached copy was assembled, not when you requested it.
|
||||
|
||||
**Page consistency.** `sourceGeneratedAt` is the timestamp of the upstream export the rows were built from — the
|
||||
same value `GET /api/last-update` reports, as ISO-8601 — or `null` if it could not be read. An upstream sync can
|
||||
replace the cached copy while you are walking the pages; if `sourceGeneratedAt` differs from the value you saw on
|
||||
page 1, you have stitched two exports together. Discard what you have and restart from page 1.
|
||||
|
||||
#### `GET /api/exports/products` — scope `api`
|
||||
|
||||
Bulk export of every travel date × hotel pair with its **whole record**, for clients that import the product
|
||||
catalogue: rooms with prices and availability, the pseudo price, all selection groups, the three service lists,
|
||||
boarding points with prices, and the guide. Same grain, paging, query parameters, `meta` envelope, status codes and
|
||||
page-consistency rule as `GET /api/exports/travels` above; only the rows differ.
|
||||
|
||||
**Unfiltered on purpose.** Nothing is dropped or rewritten for you. Return-leg (`RUECK`) services, `EIN` services,
|
||||
`Buchungsstop` rooms and services, rooms without a price and boarding points with id `0` are all emitted. Which of
|
||||
them a website shows, and how, is the client's rule, not this endpoint's.
|
||||
|
||||
At current volumes the export is ~420 rows and ~3.8 MB of JSON (a row is 4–24 kB), so the default `perPage=500`
|
||||
returns it in one page.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"product": { "code": "DKIWE", "busProId": 1127 },
|
||||
"date": {
|
||||
"code": "DKIWE041226",
|
||||
"busProId": 12437,
|
||||
"from": "2026-12-04",
|
||||
"to": "2026-12-06",
|
||||
"type": "F"
|
||||
},
|
||||
"hotel": { "code": "DKIWEKT", "busProId": 222931 },
|
||||
"pseudoPrice": null,
|
||||
"selectionGroups": [
|
||||
{
|
||||
"busProId": 7,
|
||||
"label": "Leistungen Skipass",
|
||||
"selections": [ { "busProId": 92, "label": "3 Tage Skipass" } ]
|
||||
}
|
||||
],
|
||||
"transportServices": [
|
||||
{ "busProId": 193804, "subType": "PKW", "label": "Eigene Anreise mit Rabatt", "price": -50, "status": "Frei", "direction": "HIN" }
|
||||
],
|
||||
"dateServices": [
|
||||
{ "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null }
|
||||
],
|
||||
"hotelServices": [
|
||||
{ "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null }
|
||||
],
|
||||
"rooms": [
|
||||
{
|
||||
"busProId": 74,
|
||||
"code": "MBZET",
|
||||
"label": "Bett im Mehrbettzimmer Etagendusche/-WC",
|
||||
"category": "GR",
|
||||
"minPax": 1,
|
||||
"maxPax": 1,
|
||||
"nights": 2,
|
||||
"price": 229,
|
||||
"available": 24,
|
||||
"status": "Frei",
|
||||
"boardCode": "Lt. Auss.",
|
||||
"boardBusProId": 2
|
||||
}
|
||||
],
|
||||
"pickups": [
|
||||
{
|
||||
"busProId": 4,
|
||||
"code": "FRA-Route",
|
||||
"city": "Frankfurt",
|
||||
"street": "Flughafen (P36 zwischen Terminal 1 und 2)",
|
||||
"price": 5.9,
|
||||
"returnLeg": true,
|
||||
"returnPrice": 5.9,
|
||||
"datetime": "2026-12-03T23:30:00"
|
||||
}
|
||||
],
|
||||
"guide": null
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"page": 1,
|
||||
"perPage": 500,
|
||||
"total": 419,
|
||||
"totalPages": 1,
|
||||
"skipped": 0,
|
||||
"pickupsWithoutMasterData": 0,
|
||||
"sourceGeneratedAt": "2026-09-30T11:11:01+02:00",
|
||||
"generatedAt": "2026-09-30T14:05:00+02:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Row identity.** `(date.busProId, hotel.busProId)` is unique across the whole export — upsert on that pair.
|
||||
`product.code` and `hotel.code` are verbatim and suitable for matching against your own records. No row depends on
|
||||
hotel master data, so `skipped` counts only travel dates whose dates could not be parsed.
|
||||
|
||||
**Types.**
|
||||
|
||||
- Every price is a JSON **number** in euros, never a formatted string: `229`, `5.4`, `-50`. A price the source does
|
||||
not state is `null`, and `0` means a stated zero ("included"). Do not treat the two alike.
|
||||
- `selectionGroups`, `selections`, `transportServices`, `dateServices`, `hotelServices`, `rooms` and `pickups` are
|
||||
always arrays, possibly empty, never null. `pseudoPrice` and `guide` are nullable.
|
||||
- All arrays are in **source order**. Sort for display yourself; do not depend on the order.
|
||||
|
||||
**Rooms.** One entry per room offered on the date at that hotel.
|
||||
|
||||
- `code` is trimmed. `available` is a count of **rooms**, not persons — person capacity is `available × maxPax`.
|
||||
- `status` is passed through verbatim: `Frei`, `Anfrage` or `Buchungsstop`.
|
||||
- `available`, `price`, `minPax`, `maxPax`, `nights` and `boardBusProId` are `null` where the source leaves them
|
||||
empty.
|
||||
|
||||
**`pseudoPrice`.** A struck-through comparison price for the date, carried upstream by a pseudo room with code
|
||||
`PDGS`. It is lifted onto the row and never appears in `rooms`. It is `null` in most rows.
|
||||
|
||||
**Services.** Three lists, deliberately kept apart:
|
||||
|
||||
- `transportServices` — the date's transport services (bus, own arrival, train …). Both directions.
|
||||
- `dateServices` — the date's general services.
|
||||
- `hotelServices` — the services listed on this hotel for this date. Upstream repeats most date-level services
|
||||
here, possibly with hotel-specific prices, so expect overlap with `dateServices`. If you merge the two, key by
|
||||
`busProId`: it identifies a service across both lists.
|
||||
|
||||
`subType` is the upstream service subtype (`BUS`, `PKW`, `BAH`, `SON`, `SPA`, `VER`, `EIN`, …), passed through.
|
||||
`direction` is `HIN` or `RUECK` on transport services and `null` on general ones. A negative `price` is a discount,
|
||||
such as for arriving by car.
|
||||
|
||||
**Selection groups.** Every group on the date, not only the `Leistungen *` ones. Match groups by `busProId`, not by
|
||||
`label`: labels are editorial and can be renamed upstream. The ids of the included-services groups are stable:
|
||||
|
||||
| `busProId` | Label |
|
||||
|---|---|
|
||||
| 7 | Leistungen Skipass |
|
||||
| 8 | Leistungen Sonstige |
|
||||
| 71 | Leistungen Verpflegung |
|
||||
| 85 | Leistungen Übernachtung |
|
||||
| 86 | Leistungen Programm |
|
||||
| 87 | Leistungen Kurse |
|
||||
| 89 | Leistungen Events |
|
||||
| 90 | Leistungen Transport |
|
||||
|
||||
**Pickups.** Outbound boarding points only, in source order. `datetime` is resolved and formatted exactly as in
|
||||
`GET /api/exports/travels` — a clock reading without an offset, `null` where upstream states no time — including
|
||||
rolling a bare time past midnight onto the next day. There is no separate `time` field; format `datetime` for
|
||||
display. `price` is the outbound boarding surcharge exactly as upstream states it: `null` where upstream states
|
||||
none. The surcharge is charged **per leg**: `returnLeg` says whether the point is also served on the way back, and
|
||||
`returnPrice` is its return surcharge, again verbatim and `null` where upstream states none. Neither price is levelled
|
||||
against the other. A point served on the return leg only is not emitted. An id absent from
|
||||
`GET /api/pickups` is emitted with `code`, `city` and `street` null and counted in `pickupsWithoutMasterData`. An id
|
||||
of `0` is emitted as-is but not counted.
|
||||
|
||||
**`guide`.** `{ "name": …, "phone": … }` for the first outbound bus service that has a guide assigned, else `null`.
|
||||
|
||||
---
|
||||
|
||||
### Travels
|
||||
|
||||
#### `GET /api/travels` — scope `api`
|
||||
@@ -465,24 +180,16 @@ Prices are **decimal major units** (converted from integer minor units server-si
|
||||
|
||||
#### `GET /api/contingents/calendar?hotelCode=…&dateFrom=…&dateTo=…` — scope `api`
|
||||
|
||||
Per-day availability enriched with prices.
|
||||
|
||||
`dateFrom` and `dateTo` are **optional, and must be supplied together** — one without the other is a `400`. Both are `Y-m-d` and **inclusive** (`dateTo` is the last night, not the checkout day); when supplied, the range may not exceed 366 days and `dateTo` must not precede `dateFrom`.
|
||||
|
||||
**Omit both to get the full priced span.** The range is then derived from the accommodation's persisted prices, `MIN(dateFrom)` to `MAX(dateTo)`, and never starts before today. This is the way to fetch everything a hotel sells in one call, and it is not subject to the 366-day cap — a hotel priced over two seasons returns well over a year of entries.
|
||||
|
||||
Full-span responses are contiguous, exactly like ranged ones: every day between the derived bounds gets an entry, including days no price covers. Those come back `BLOCKED` with `null` price fields — a hotel closed over winter reports the whole closure day by day rather than skipping it, so a consumer keying off `status` always finds one.
|
||||
|
||||
The response is `[]` — a `200`, not a `502` — when the hotel has no prices at all, or when every priced period has already ended.
|
||||
Per-day availability enriched with prices. Both dates are `Y-m-d` and **inclusive** (`dateTo` is the last night, not the checkout day); the range may not exceed 366 days and `dateTo` must not precede `dateFrom`.
|
||||
|
||||
Availability is served from a **local snapshot** refreshed over a 24-month horizon — every 15 minutes during the day, hourly overnight — not fetched from the upstream contingent service per request. The endpoint therefore responds in single-digit milliseconds and stays available during an upstream outage, at the cost of being at most one sync interval stale.
|
||||
|
||||
The response format is unchanged from the previous upstream-backed implementation: same fields, same order, same types, same `status` values. One behavioural note — the endpoint returns **one entry per day of the range**, whether that range came from `dateFrom`/`dateTo` or was derived from the prices. Previously days the upstream service did not mention were simply absent, so a response may contain days it would not have contained before. It is a superset, never a different shape.
|
||||
The response format is unchanged from the previous upstream-backed implementation: same fields, same order, same types, same `status` values. One behavioural note — the endpoint now returns **one entry per requested day**. Previously days the upstream service did not mention were simply absent, so a response may now contain days it would not have contained before. It is a superset, never a different shape.
|
||||
|
||||
Two kinds of day resolve to `BLOCKED` regardless of the underlying contingent:
|
||||
|
||||
- **days in the past** — the snapshot is maintained from today forward only (`prices` likewise refuses past ranges)
|
||||
- **days beyond the synced horizon** — currently 24 months out. Prices are often maintained further ahead than the contingent is synced, so a full-span response can end in a run of `BLOCKED` days that simply have no availability data yet.
|
||||
- **days beyond the synced horizon** — currently 24 months out
|
||||
|
||||
Each entry:
|
||||
|
||||
@@ -501,80 +208,27 @@ Each entry:
|
||||
}
|
||||
```
|
||||
|
||||
Important rule: **a day with no maintained price is forced to `Blocked`** regardless of what the upstream contingent says. Price fields may be `null` in that case. This applies to both modes.
|
||||
Important rule: **a day with no maintained price is forced to `Blocked`** regardless of what the upstream contingent says. Price fields may be `null` in that case.
|
||||
|
||||
Errors: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations for invalid parameters.
|
||||
|
||||
`502 {"error":"Failed to fetch contingent data."}` is still returned — with the same body as before — but it now means **"no usable local snapshot for this hotel"** rather than "the upstream call failed": either the hotel has never synced successfully, or its last successful sync is more than 6 hours old. Treat it as before: the data is unavailable, not empty. This matters because the alternative would be a `200` reporting every day as `BLOCKED`, which is indistinguishable from a genuinely fully-booked hotel.
|
||||
|
||||
#### `GET /api/contingents/availability?hotelCode=…&dateFrom=…&dateTo=…` — scope `api`
|
||||
|
||||
The raw contingent snapshot per day, without prices. Built for the iCal feed that external booking portals import, which blocks only what the contingent itself reports as unavailable.
|
||||
|
||||
Parameters are the same as for `calendar`: `hotelCode` is required, `dateFrom` and `dateTo` are optional, `Y-m-d`, inclusive, and must be supplied together (at most 366 days). **Omit both to get every snapshot day from today to the end of the snapshot** (the 24-month sync horizon). Prices play no part in that span.
|
||||
|
||||
Each entry, ordered by date:
|
||||
|
||||
```json
|
||||
{"date": "2027-01-30", "status": "BLOCKED"}
|
||||
```
|
||||
|
||||
`status` is `OK`, `BLOCKED` or `ON_REQUEST`.
|
||||
|
||||
Two differences from `calendar`:
|
||||
|
||||
- **No price rule.** A day without a maintained price keeps the status the contingent reports; it is not forced to `BLOCKED`.
|
||||
- **Days the snapshot lacks are left out**, not returned as `BLOCKED`. The response is not contiguous: a day without an entry has no availability data, and the feed shows it as free, as it did when it read the upstream service directly.
|
||||
|
||||
- **Not limited to accommodations.** `hotelCode` may be any synced hotel code: an accommodation's calendar code, or the calendar hotel of a visible TYPO3 product (TYPO3's `/api/calendar-hotels`). Winter-only hotels without an accommodation are therefore served here, while `calendar` and `prices` still need an accommodation, since they need its prices.
|
||||
|
||||
Errors are the same as for `calendar`: `400 {"error":"Hotel not found for hotelCode."}` when the code is not synced, `400` with violations for invalid parameters, and `502 {"error":"Failed to fetch contingent data."}` when there is no usable snapshot. For this endpoint the `502` is the important one: an empty list would show every day as free, so a consumer should answer with an error and let the portals keep their last import.
|
||||
|
||||
#### `GET /api/contingents/status` — scope `api`
|
||||
|
||||
One row per synced hotel code, sorted by code, for an overview of which hotel supports which calendar:
|
||||
|
||||
```json
|
||||
{
|
||||
"hotelCode": "DGS",
|
||||
"name": "Sportclub Spinabad",
|
||||
"sources": ["accommodation", "typo3"],
|
||||
"accommodation": {"name": "Davos Glaris - Gruppenhaus Spinabad", "cmsCode": "DGSGRP"},
|
||||
"pricedFrom": "2027-06-01",
|
||||
"pricedTo": "2027-09-30",
|
||||
"syncedAt": "2026-10-29T10:15:00+01:00",
|
||||
"lastError": null,
|
||||
"failureCount": 0,
|
||||
"available": true
|
||||
}
|
||||
```
|
||||
|
||||
- `sources`: where the code comes from, `accommodation` and/or `typo3`.
|
||||
- `name`: the hotel's name in TYPO3's calendar hotel list, `null` for a code TYPO3 does not list.
|
||||
- `accommodation`: `null` without one; `cmsCode` is the code the accommodation is known by in TYPO3, which may differ from `hotelCode`.
|
||||
- `pricedFrom`/`pricedTo`: the span of the accommodation's prices, `null` without prices. `calendar` and `prices` work only for hotels that have them.
|
||||
- `syncedAt`, `lastError`, `failureCount`: the last successful sync (`null` if none) and the failures since.
|
||||
- `available`: whether `availability` currently serves the hotel, i.e. it has synced within the last 6 hours. `false` means that endpoint answers `502`.
|
||||
|
||||
---
|
||||
|
||||
### Operating the snapshot (deployment)
|
||||
|
||||
The snapshot is filled only by the scheduler, so on an empty database (a new environment, or after the snapshot tables were truncated) every hotel returns `502` until the first successful sync — deliberately, so no consumer caches a fully-blocked calendar as if it were real availability.
|
||||
The snapshot is a new table populated by the scheduler, so it is **empty immediately after a deploy that runs the migration**. Until the first successful sync every hotel returns `502` — deliberately, so no consumer caches a fully-blocked calendar as if it were real availability.
|
||||
|
||||
Bringing up an environment:
|
||||
Rollout order:
|
||||
|
||||
1. **Confirm `BPN_CONNECT_API_KEY` is valid for the environment.** If it is rejected the sync cannot populate anything and the endpoints stay at `502`.
|
||||
2. Deploy; `database:migrate` creates the tables.
|
||||
3. Run `bin/console app:bpn:sync-contingents -v` on the host promptly, ideally off-peak — a consumer that page-caches whatever it renders from a `502` may hold that for the length of its own cache period.
|
||||
4. Verify with `GET /api/contingents/status`: every row `available: true`, `failureCount` 0.
|
||||
1. **Confirm `BPN_CONNECT_API_KEY` is valid for the target environment before deploying.** If it is rejected the sync cannot populate anything and the endpoint stays at `502`.
|
||||
2. Deploy; `database:migrate` creates the empty tables.
|
||||
3. Run `bin/console app:bpn:sync-contingents` on the host, promptly and ideally off-peak — a consumer that page-caches whatever it renders from a `502` may hold that for the length of its own cache period.
|
||||
4. Verify: `contingent_sync_state.synced_at` populated and `failure_count` at 0 for every hotel, then spot-check one calendar response.
|
||||
|
||||
Thereafter the scheduler keeps it current; a sync failing for one hotel puts only that hotel into `502` and leaves the rest serving normally.
|
||||
|
||||
**Which hotels are synced.** Every accommodation's calendar code plus every code in TYPO3's `/api/calendar-hotels` (same `APP_CMS_API_KEY` as the other CMS calls). Each run fetches TYPO3's list first and stores it in `calendar_hotel`; when TYPO3 errors or answers with an empty list, the run logs a warning and uses the stored list, so TYPO3 being down never drops its hotels. `--hotel=<code>` accepts any code from either source. A full run deletes the snapshot of codes that are in neither source any more; they answer `400` from then on, and come back with the next run if they reappear.
|
||||
|
||||
**TYPO3's codes must be BusPro calendar codes.** The sync sends every code to bpn-connect as is. A TYPO3 hotel whose code is a CMS code rather than the BusPro hotel code (group houses often have both, e.g. calendar code `DKS`, CMS code `DKSGRP`) is answered with a `404` by bpn-connect: that code fails on every run, logs two errors each time, and shows up in `contingents/status` with `lastError` set and `available: false`. The task itself keeps succeeding, since only a total failure fails it. The fix belongs in TYPO3, by pointing the product at a hotel whose code is the BusPro code; MyE&P does not translate CMS codes. `contingents/status` is the place to spot such codes, and `accommodation.cmsCode` there shows both codes side by side for group houses.
|
||||
|
||||
### Freshness and propagation
|
||||
|
||||
There is deliberately **no change-notification endpoint**. Availability changes slowly, and the measured cost of simply refetching on a TTL is low enough that a polling/revision mechanism was not worth the extra moving parts on either side.
|
||||
@@ -614,35 +268,14 @@ Single booking by UUID. `404 {"message":"Not found"}` if unknown.
|
||||
"accommodationDiscount": null,
|
||||
"boardServiceDiscount": null,
|
||||
"additionalServicesDiscount": null,
|
||||
"totalDiscountAmount": null,
|
||||
"totalDiscountLabel": null,
|
||||
"totalPrice": 123456,
|
||||
"pricingCurrency": "EUR",
|
||||
"pricingVersion": 2,
|
||||
"pricingVersion": 3,
|
||||
"priceBreakdown": {}
|
||||
}
|
||||
```
|
||||
|
||||
`totalPrice` and `totalDiscountAmount` are integer **minor units** (cents) — unlike the contingent endpoints. The three `*Discount` fields are whole **percentages** (1–100) or `null`, not amounts. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure.
|
||||
|
||||
The first three discounts each apply to one section of the breakdown: `accommodationDiscount` to `basePrice + additionalPersonsPrice`, `boardServiceDiscount` to `boardPrice`, `additionalServicesDiscount` to `servicesPrice`. Surcharges, running costs and the lines of split-off periods sit in `total` but in no section, so the section discounts never reach them; only `totalDiscountAmount` does.
|
||||
|
||||
`dateFrom`/`dateTo`/`nights` span the whole stay. The office can split nights off its start or end, for example for an advance team arriving before the group. Such a split-off period has no headcount, board or services of its own; it is priced as free-form lines instead. `paxCount`, `minorsCount`, `childrenCount`, `boardService` and `additionalServices` describe the **group**, which stays for what remains between the split-off periods. The split-off periods are listed in `priceBreakdown.periods`, below. A booking nobody split has an empty list there, and its group stays for the whole stay.
|
||||
|
||||
`totalDiscountAmount` is a freely named discount on the whole price, labelled by `totalDiscountLabel`. It is a fixed sum, not a percentage, and is subtracted **after** the three section discounts have reduced `total`. It is capped at what is left of that subtotal, so `discountedTotal` is never negative and the amount shown in the breakdown may be smaller than `totalDiscountAmount` itself. The section discounts are each rounded to the nearest cent as they are applied.
|
||||
|
||||
`priceBreakdown` carries the resulting rows, all in minor units:
|
||||
|
||||
| field | meaning |
|
||||
| --- | --- |
|
||||
| `nights` | the group's nights, which are fewer than the booking's `nights` when periods were split off |
|
||||
| `periods` | list of `{position, label, dateFrom, dateTo, nights, lines, total}`, at most one per side, in date order; `position` is `leading` (before the group) or `trailing` (after it), `label` is the office's name for the period or `null`, `lines` is a list of `{label, amount}`, and `total` is their sum |
|
||||
| `periodsTotal` | the sum of all `periods[].total` |
|
||||
| `total` | the price before discounts, the group's rows plus `periodsTotal` |
|
||||
| `discounts` | list of `{label, percent, amount}` for the section discounts; entries rounding to `0` are omitted, and `label` already includes its `Rabatt ` prefix |
|
||||
| `discountSubtotal` | `total` minus the section discounts — present only when `totalDiscountAmount` applies *and* at least one section discount did, otherwise `null` |
|
||||
| `totalDiscountDetails` | `{label, amount}` for the total discount, or `null`; `amount` is the capped figure actually subtracted |
|
||||
| `discountedTotal` | the final price, equal to the booking's `totalPrice` |
|
||||
`totalPrice` and the `*Discount` fields are integer **minor units** (cents) — unlike the contingent endpoints. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure.
|
||||
|
||||
#### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api`
|
||||
|
||||
@@ -723,9 +356,9 @@ Responses:
|
||||
|
||||
OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes:
|
||||
|
||||
- always: `sub` (UUID string, see below), `email`
|
||||
- always: `email`
|
||||
- scope `id`: `person_id`, `address_id`
|
||||
- scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`)
|
||||
- scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/user-roles.md`)
|
||||
- scope `profile`: `profile` object:
|
||||
|
||||
```json
|
||||
@@ -738,30 +371,7 @@ OIDC-style claims for the authenticated user. The response contains only the cla
|
||||
}
|
||||
```
|
||||
|
||||
##### Identifying the account: match on `sub`
|
||||
|
||||
A BusPro address record holds several email addresses (private and business, say), and BusPro
|
||||
accepts **any** of them as a login. Each one is a **separate MyE&P account with its own roles** —
|
||||
that is intended, not a duplicate: administrative roles are reserved for the company address, so
|
||||
the same person signing in privately is a Teamer:in or Kund:in and signing in with their
|
||||
`@ep-reisen.de` address is an administrator.
|
||||
|
||||
All of those accounts report the **same** `person_id` and `address_id`, and the same
|
||||
`profile.communication.email` — that value is the first contact address on the BusPro record and
|
||||
is **not necessarily** the one signed in with. So:
|
||||
|
||||
- **`sub`** — a **UUID (v4, RFC 4122, 36 characters)**, stable for the life of the account and
|
||||
unique across accounts. The only claim that identifies an account. Match your local user on it
|
||||
and store it. Size the column at 36 characters and treat the value as opaque: it is random, it
|
||||
carries no timestamp and no ordering, and nothing about it should be parsed or derived from.
|
||||
- **`email`** — the address this session actually authenticated with. Distinct per account, but
|
||||
treat it as a display and contact value; it is not the account key.
|
||||
- **`person_id` / `address_id`** — the BusPro person behind the account. Shared between that
|
||||
person's accounts, so they identify a *human*, never an account. Do not match on them.
|
||||
|
||||
Do not derive staff status from an email domain. MyE&P decides who is a member of staff and
|
||||
exports the result as `ROLE_EMPLOYEE` in the `roles` claim; re-deriving it from `email` or from
|
||||
`profile.communication.email` will disagree with MyE&P in both directions.
|
||||
Note: `profile.communication.email` comes from the CRM address record and is **not necessarily** the login email — a single address can hold several contacts. Use the top-level `email` claim for identity.
|
||||
|
||||
`400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials.
|
||||
|
||||
@@ -773,7 +383,7 @@ CRM selection attributes/segments for the authenticated user. `400 {"message": "
|
||||
|
||||
## Client implementation notes
|
||||
|
||||
1. **Token lifetime comes from `expires_in`** (currently 1 hour for client_credentials, 10 minutes for authorization_code) — cache the token for that long, refresh with a safety margin, and retry once on 401.
|
||||
1. **Token lifetime is 10 minutes** — implement refresh/re-fetch with a safety margin and retry once on 401.
|
||||
2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint.
|
||||
3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary.
|
||||
4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries.
|
||||
|
||||
@@ -577,10 +577,6 @@ Known role mapping from the XML parser:
|
||||
- admin users also receive default hotel code `SSL`
|
||||
- no matched role means `ROLE_CUSTOMER`
|
||||
|
||||
These are the CRM's claims. The portal filters them before storing anything (staff-only
|
||||
roles, approval markers), so a proxy must return the claims unfiltered — see
|
||||
`docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`.
|
||||
|
||||
Candidate SQL for selected customer selections:
|
||||
|
||||
```sql
|
||||
|
||||
@@ -1,477 +0,0 @@
|
||||
# BusProNet XML API — working reference
|
||||
|
||||
Condensed from the vendor PDF `docs/bpn_docs.pdf` (41 pages, German), keeping what this project
|
||||
uses or plausibly could. The PDF stays authoritative; a few of its sentences are mangled by a
|
||||
broken font encoding, so anything unclear there is flagged rather than guessed at.
|
||||
|
||||
> **The PDF lags the live API.** The vendor has shipped elements since it was written —
|
||||
> `<ausstiege>` (return boarding points) is one confirmed example. So *absent from the PDF* means
|
||||
> "not in this snapshot", never "unsupported". Treat the document as a floor on what the API
|
||||
> accepts, not a ceiling, and prefer a live `buchungsart=Anfrage` probe over an argument from
|
||||
> silence.
|
||||
|
||||
Read this alongside `buspro-database-reference.md` (what BusPro actually stores) — the two answer
|
||||
different questions, and where they disagree the schema is the record of what happened.
|
||||
|
||||
**Use it for:** what an element is called and what it may contain; which codes a failure can
|
||||
return; which documented capabilities we are not using; and which elements we send that the
|
||||
vendor never documented — those are the ones that can break without warning.
|
||||
|
||||
## Envelope
|
||||
|
||||
Every request is `<anfrage>` with `<user>`, `<key>` and `<satz typ="…"/>`.
|
||||
|
||||
```
|
||||
key = md5( user + password + date("YYYYMMDD") + satz-typ )
|
||||
```
|
||||
|
||||
Per satz type and per day. `XmlChannel::createKey()` implements it, and `XmlChannel::replay()` regenerates
|
||||
the key before sending, so a hand-built payload can carry a placeholder.
|
||||
|
||||
## Satz types
|
||||
|
||||
**Documented:** `ADRESSFELDER`, `ADRESSFELDERGUTSCHEIN`, `ADRESSFELDERKATALOGANFRAGE`,
|
||||
`AGENTUREN`, `AGENTURKONTO`, `AKTIONSGUTSCHEIN`, `BUCHUNG`, `BUCHUNGAENDERUNG`, `CRM`,
|
||||
`CRMKATALOGANFRAGE`, `GUTSCHEINBUCHUNG`, `GUTSCHEINDRUCK`, `GUTSCHEINPRUEFUNGEINLOESUNG`,
|
||||
`GUTSCHEINVORLAGEN`, `GUTSCHEINVORSCHAU`, `GUTSCHEINZAHLUNGSARTEN`, `KATALOGANFRAGE`,
|
||||
`KUNDENKONTO`, `KUNDENLISTE`, `MOEGLICHEAENDERUNGEN`, `PRODUKTDATEN`, `PRODUKTE`, `SITZPLATZ`,
|
||||
`STAMMHOTELS`, `STAMMLAENDER`, `STAMMVERSICHERUNGEN`, `STAMMZUSTIEGE`, `VERFUEGBARKEIT`,
|
||||
`VERFUEGBARKEITHOTEL`, `ZAHLUNG`, `ZAHLUNG2`, `ZAHLUNGSARTEN`, `ZAHLUNGSARTEN2`.
|
||||
`SITZPLATZAUSWAHL` and `ZAHLUNG4` are named in prose without their own section.
|
||||
|
||||
**Used here but undocumented:** `VERFUEGBARKEIT2` (`MessageType::AvailabilityExtended`) — the
|
||||
extended availability call returning `buchungstatusmoeglich` and per-service `pflicht`/`frei`.
|
||||
Its contract is observed, not specified.
|
||||
|
||||
**Documented but unused here:** `SITZPLATZ`/`SITZPLATZAUSWAHL`, `KUNDENLISTE`, `ZAHLUNG2`,
|
||||
`ADRESSFELDER`, `STAMMHOTELS`, `STAMMVERSICHERUNGEN`, `STAMMZUSTIEGE`, `KATALOGANFRAGE`.
|
||||
|
||||
## BUCHUNG — the create contract
|
||||
|
||||
`<buchungsart>` decides what the call does:
|
||||
|
||||
| Value | Effect |
|
||||
|---|---|
|
||||
| `Anfrage` | Enquiry. Validates and **returns the full price breakdown**. Nothing is stored. |
|
||||
| `Buchung` | Stores the booking. |
|
||||
|
||||
`<status>` at the root: `F` Festbuchung · `O` Optionsbuchung · `A` Anfragebuchung. Gated by
|
||||
error `962` ("Buchungsstatus #1# kann nicht verarbeitet werden, nur Status #2# möglich"), and by
|
||||
`982`/`983` when the travel forbids options or enquiries outright.
|
||||
|
||||
### Two accommodation models
|
||||
|
||||
The base payload books accommodation as an ordinary service:
|
||||
|
||||
```xml
|
||||
<unterbringungen>
|
||||
<unterbringung idleistung="22341" anzahl="1" zuordnung="1,2"/>
|
||||
</unterbringungen>
|
||||
```
|
||||
|
||||
For the **Ferienziel** domain — what this app books — `unterbringungen` is *replaced* by
|
||||
`ferienzielunterbringungen`, **and** a root `<idpartner>` added:
|
||||
|
||||
```xml
|
||||
<ferienzielunterbringungen>
|
||||
<ferienzielunterbringung idzimmer="2" kategorie="Ik" idverpflegung="2"
|
||||
anreise="30.01.2018" abreise="06.02.2018"
|
||||
anzahl="1" zuordnung="1,2"/>
|
||||
</ferienzielunterbringungen>
|
||||
…
|
||||
<idpartner>78687</idpartner>
|
||||
```
|
||||
|
||||
> `<idpartner>` is introduced as the element you add when switching to the Ferienziel model, and
|
||||
> `914 Partner-ID fehlt` guards it. Note it is **not** confined to bookings that have a room: a
|
||||
> teamer booking with zero `BuchungTNFerienziel` rows still returns `<idpartner>222925</idpartner>`
|
||||
> from `Vorgang_Details`. Where that comes from is **unresolved** — `Buchung` has no partner FK, so
|
||||
> either it is stored on `Buchung.IDAdresse_FS_ExtraFerienzielPartner`, or it is derived from the
|
||||
> travel. That booking's product has exactly one hotel, so the response cannot distinguish the two;
|
||||
> one read-only query settles it.
|
||||
|
||||
Accommodation is **mandatory** — `913 Unterbringungen fehlen` / `916 Ferienziel-Unterbringungen
|
||||
fehlen`. Confirmed empirically on the test system 2026-09-02, not just documented: see
|
||||
"Validation gate order" below. Room dates are cross-checked against the transport legs (`921`,
|
||||
`923`).
|
||||
|
||||
### Full documented request shape
|
||||
|
||||
```xml
|
||||
<anfrage>
|
||||
<user/><key/><satz typ="BUCHUNG"/>
|
||||
<buchungsart>Anfrage|Buchung</buchungsart>
|
||||
<status>F|O|A</status>
|
||||
<idagentur/> <idcrm/> <idreise/> <idpartner/>
|
||||
<fremdvorgang/> <!-- your own reference, stored as Buchung.FremdVorgang -->
|
||||
<datenschutztfmp/> <!-- ad consent, combinable: T Telefon, F Telefax, M EMail, P Post -->
|
||||
<anmelder>
|
||||
name, vorname, anrede, titel, geschlecht, nationalitaet, geburtsdatum,
|
||||
<anschrift>strasse, plz, ort, ortsteil, land</anschrift>
|
||||
<kommunikation>telefonmobil, email, telefonprivat</kommunikation>
|
||||
<bemerkung/>
|
||||
</anmelder>
|
||||
<teilnehmerliste>
|
||||
<teilnehmer id="1">
|
||||
…same personal fields…
|
||||
<wünsche><unterbringungswunsch/><beförderungswunsch/></wünsche>
|
||||
<aktionscode/>
|
||||
</teilnehmer>
|
||||
</teilnehmerliste>
|
||||
<beförderungen><beförderung idleistung anzahl zuordnung/></beförderungen>
|
||||
<ferienzielunterbringungen>…</ferienzielunterbringungen>
|
||||
<zusatzleistungen><zusatzleistung idleistung anzahl zuordnung/></zusatzleistungen>
|
||||
<zustiege><zustieg idzustieg anzahl zuordnung/></zustiege>
|
||||
<sitzpläne><sitzplan idleistung idsitzplan sitzplätze="1B;1A"/></sitzpläne>
|
||||
<versicherungen><versicherung idversicherung anzahl zuordnung/></versicherungen>
|
||||
<gutscheine><gutschein gutscheinnr einloesecode/></gutscheine>
|
||||
<zuschlaege>
|
||||
<zuschlag text betrag anzahl zuordnung
|
||||
provision altersrabatt gruppenrabatt frühbucherrabatt bzberechnung/>
|
||||
</zuschlaege>
|
||||
<zahlung idzahlungsart bezeichnung art>
|
||||
<bankverbindung kreditinstitut bic iban kontoinhaber/>
|
||||
</zahlung>
|
||||
</anfrage>
|
||||
```
|
||||
|
||||
`zuordnung` is a comma-joined list of 1-based `teilnehmer/@id` values throughout.
|
||||
|
||||
### Responses
|
||||
|
||||
`buchungsart=Anfrage` → `<buchung>möglich</buchung>` plus `<preise>`: one `<preis>` per line with
|
||||
`position`, `art`, `unterart`, `bezeichnung`, `terminvon`/`terminbis`, `anzahl`, `zuordnung`,
|
||||
`preis`, `gesamtpreis`, `id`; then `<gesamtpreis>` and `<zahlungsbedingungen>` with `<anzahlung>`
|
||||
and `<restzahlung>` (each `betrag` + `termin`). `art` values seen: `BEF` transport, `UNT`
|
||||
accommodation, `SON` other, `ERM` discount, `VER` insurance.
|
||||
|
||||
`buchungsart=Buchung` → `<buchung>erfolgt</buchung>`, `<vorgang>`, `<rechnung>`, `<idbuchung>`,
|
||||
`<email>`, `<pdfpasswort>`.
|
||||
|
||||
### Validation gate order (measured)
|
||||
|
||||
Established with `buchungsart=Anfrage` against the test system on 2026-09-02, by mirroring a real
|
||||
room-less teamer booking (Vorgang 110628) element for element:
|
||||
|
||||
1. **Accommodation.** No `ferienzielunterbringungen` → `916`, regardless of everything else being
|
||||
valid. Nothing further is evaluated.
|
||||
2. **Personal data**, once a room is present → `650` with the field list *"Fehlende
|
||||
Anmelder/Teilnehmer Daten … Nachname, Vorname, Strasse, Plz, Ort, Telefon-Mobil, Mail-Adresse,
|
||||
Geburtsdatum, Nationalität"*, for the Anmelder **and every participant**.
|
||||
|
||||
So **sending `idadresse`/`idadresseperson` alone is not enough on create** — BusPro validates the
|
||||
personal fields whether or not it could resolve them from the ids. Echoing the record back
|
||||
(`idadresse` + `idadresseperson` + full data) narrows the complaint to whatever that record is
|
||||
genuinely missing, which is a useful way to audit an address: the placeholder account 19465:41003
|
||||
fails on `Telefon-Mobil` and `Nationalität` because its `Vorgang_Details` record has only
|
||||
`telefonprivat` and no `nationalitaet`.
|
||||
|
||||
**Participants are held to less than the Anmelder** (measured 2026-09-16 on 2036, travel 12500, from a
|
||||
real step-3 `Anfrage`). `telefonmobil` and an empty `anschrift` pass for participants. The field list
|
||||
above (Strasse, Plz, Ort, Telefon-Mobil for every participant) was measured on the placeholder records and
|
||||
is stricter than what BusPro applies to ordinary participants.
|
||||
|
||||
**The participant `Mail-Adresse` is BusPro configuration, not a fixed rule.** Until 2026-10-13 every
|
||||
`teilnehmer` needed one once a room was present, children included: without it the answer was
|
||||
`650 … 1.Teilnehmer-Mail-Adresse, 2.Teilnehmer-Mail-Adresse`, while an email identical to the Anmelder's
|
||||
was accepted. On 2026-10-15 the requirement was switched off on the test system, and the same requests
|
||||
(no `email`, no `kommunikation`, an empty `<email/>`, adults and children, with and without a travelling
|
||||
Anmelder) all pass with an unchanged price. A committed `Buchung` (status `A`) with an email-less child
|
||||
goes through as well, both with a travelling Anmelder and with one who does not travel. BusPro now accepts a participant without an email at any age,
|
||||
so the rule MyE&P applies (required from 16, and for the applicant at any age) is enforced only by
|
||||
`ParticipantDto` and `BookingDto` validation. Production needs the same configuration before under-16s
|
||||
without an email can be booked there.
|
||||
|
||||
**The Anmelder does not have to travel.** An `<anmelder>` that matches no `teilnehmer` (a parent booking
|
||||
for their children) returns `möglich` with the same price as the control where the Anmelder is
|
||||
`teilnehmer 1`. Whether the committed booking binds to the Anmelder's existing customer record has not
|
||||
been measured yet.
|
||||
|
||||
Note `650` again: both of those are hard rejections delivered under the code
|
||||
`Notification::isError()` treats as success.
|
||||
|
||||
## BUCHUNGAENDERUNG — the edit contract
|
||||
|
||||
Structurally identical to `BUCHUNG` plus `<idbuchung>`, and the responses follow `buchungsart`
|
||||
the same way. Ask `MOEGLICHEAENDERUNGEN` first (`<idreise>`), which answers:
|
||||
|
||||
```xml
|
||||
<änderungen>
|
||||
<änderung art="anzahl_teilnehmer" möglich="False"/>
|
||||
<änderung art="beförderung" möglich="False"/>
|
||||
<änderung art="zustieg" möglich="False"/>
|
||||
<änderung art="sitztplatz" möglich="False"/> <!-- vendor's typo -->
|
||||
<änderung art="unterbringung" möglich="False"/>
|
||||
<änderung art="zusatzleistung" möglich="True" möglichbiszum="23.02.2020"/>
|
||||
<änderung art="teilnehmerdaten" möglich="False"/>
|
||||
</änderungen>
|
||||
```
|
||||
|
||||
## How this app maps onto the contract
|
||||
|
||||
`BookingPayloadBuilder::buildCreatePayload()` builds create; `buildBasePayload()` + the
|
||||
`addTo…Payload()` helpers build edit; `BookingClient::createBooking()`/`updateBooking()` wrap them.
|
||||
|
||||
| Documented element | Sent on create | Sent on edit | Notes |
|
||||
|---|---|---|---|
|
||||
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`BookingClient::updateBooking()`) |
|
||||
| `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below |
|
||||
| `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room |
|
||||
| `anmelder` | yes | yes | Create builds it from participant[0], or from `BookingDto::$applicant` when the applicant does not travel; edit uses `PersonalData::toPayload()`. BusPro accepts an Anmelder who is not a participant |
|
||||
| `anmelder/bemerkung` | — | yes | Via `PersonalData::toPayload()` |
|
||||
| `teilnehmerliste` | yes | yes | |
|
||||
| `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | |
|
||||
| `aktionscode` | yes | yes | Promo code, paired with `AKTIONSGUTSCHEIN` |
|
||||
| `beförderungen`, `zusatzleistungen`, `zustiege` | yes | yes | Built by `ServiceMappingCollector` |
|
||||
| `ferienzielunterbringungen` | yes | yes | Create omits the node entirely when no room maps; edit always emits the empty skeleton |
|
||||
| `versicherungen` | yes | yes | |
|
||||
| `gutscheine` | yes | yes | Paired with `GUTSCHEINPRUEFUNGEINLOESUNG` |
|
||||
| `zahlung` + `bankverbindung` | yes | yes | |
|
||||
| `idcrm` | no | no | |
|
||||
| `fremdvorgang` | no | no | Would give our own bookings a stable external reference |
|
||||
| `datenschutztfmp` | no | no | Advertising consent — we track newsletter consent locally instead |
|
||||
| `sitzpläne` | no | no | See Seats |
|
||||
| `zuschlaege` | no | no | Free-text manual surcharge or discount with its own amount |
|
||||
|
||||
### Newer than the PDF
|
||||
|
||||
These are absent from the document but work against the live API — vendor additions that
|
||||
post-date it. Listed so nobody mistakes the PDF's silence for a contract violation, and so the
|
||||
gaps in this reference are known.
|
||||
|
||||
| Element | Where | Note |
|
||||
|---|---|---|
|
||||
| `<ausstiege><ausstieg idzustieg …/></ausstiege>` | create and edit | Return boarding points. Confirmed as a recent vendor addition; the PDF documents only `<zustiege>`. |
|
||||
| `<status>` inside `<teilnehmer>` | create (hardcoded `F`) and edit | Not in the PDF in either direction, though `Vorgang_Details` returns `status_teilnehmer`. |
|
||||
| `<login>J</login>` on `KUNDENKONTO` | probe command | Passwordless read escape hatch; see `bpn-login-j-passwordless-reads`. |
|
||||
| `VERFUEGBARKEIT2` | availability path | Whole satz type post-dates the PDF. Returns `buchungstatusmoeglich` and per-service `pflicht`. |
|
||||
|
||||
When one of these needs pinning down, the authoritative move is a live `Anfrage` call, not a
|
||||
reading of this file.
|
||||
|
||||
### Caveat: the edit flow replays a derived status
|
||||
|
||||
`buildBasePayload()` sends `'status' => $bookingData->status`, and `Booking::$status` is parsed
|
||||
from the response's root `<status>` (`BookingParser.php:38`). But `Buchung` has **no status
|
||||
column** — the root value is an aggregate over `BuchungTN.Status`, the only real store
|
||||
(`<status>F</status>` beside `<status_teilnehmer>F/F/…/F</status_teilnehmer>` in
|
||||
`tests/Resources/booking_data.xml`). So on a booking whose participants do *not* all share one
|
||||
status, the edit flow sends an aggregate back as a directive. Worth confirming against a mixed
|
||||
booking before relying on it.
|
||||
|
||||
### Gap: edit never dry-runs
|
||||
|
||||
Create is two-phase — `Anfrage` to validate and price, then `Buchung`. Edit sends
|
||||
`buchungsart=Buchung` directly (`BookingClient::updateBooking()`), even though `BUCHUNGAENDERUNG` supports
|
||||
`Anfrage` identically. An enquiry pass would surface `913`/`924`/`962` and return the recalculated
|
||||
price *before* mutating a live booking.
|
||||
|
||||
## Seats
|
||||
|
||||
`SITZPLATZ` asks BusPro to pick seats:
|
||||
|
||||
```xml
|
||||
<satz typ="SITZPLATZ"/>
|
||||
<idreise/><idleistung_hin/><idleistung_rueck/><anzahl_personen/>
|
||||
<wunsch_vornemittehinten>V|M|H</wunsch_vornemittehinten>
|
||||
<wunsch_linksrechts>L|R</wunsch_linksrechts>
|
||||
<wunsch_fenstergang>F|G</wunsch_fenstergang>
|
||||
```
|
||||
|
||||
Response: one `<sitzplan>` per direction with `sitzplaetze="0D;1D;1C"`, `idsitzplan`,
|
||||
`richtung="H|R"`, `bezeichnung`, `idleistung`, and `grafik` (base64 PNG of the seat map). Where
|
||||
seats carry surcharges the response adds `<zuschläge>` (`id`, `bezeichnung`, `preis`, colours) and
|
||||
the request must then carry `idzuschlag`. `SITZPLATZAUSWAHL` returns the raw plan structure
|
||||
instead of a picture.
|
||||
|
||||
Chosen seats go back into a booking via `<sitzpläne>`, landing in
|
||||
`BuchungTNLeistungZusatz.Sitzplatz` / `IDSitzplan_FS`. Guarded by `933 Sitzplan mit ID #1# bei
|
||||
Leistung #2# nicht freigegeben` and `304 Es konnte nicht für alle Teilnehmer ein Sitzplatz
|
||||
ermittelt werden`.
|
||||
|
||||
## KUNDENKONTO
|
||||
|
||||
`<art>`: `Adressdaten`, `Adressdaten_Ändern`, `Adresse_Neu`, `SelektionCRM`,
|
||||
`SelektionCRM_Ändern`, `Vorgänge`, `Vorgangdruck`, `Voucherdruck`, `Dokumentdruck`,
|
||||
`Passwort_Anfrage`, `Newsletter`, `Adressdokument`, `Vorgang_Details`, `Prüfung`, `PrüfungEmail`,
|
||||
`Ausweisdaten_Ändern`, `Stornoanfrage`, `Storno`, `Versicherungspolice`.
|
||||
|
||||
`Stornoanfrage` answers `stornierung_moeglich` + `stornopreis`; `Storno` answers `storniert` —
|
||||
the documented way to cancel, including cleaning up a test booking. `Ausweisdaten_Ändern` takes a
|
||||
`<personen>` list with `<ausweis>` data and answers `änderung`. `Versicherungspolice` returns
|
||||
per-policy document URLs.
|
||||
|
||||
## AGENTURKONTO
|
||||
|
||||
`<art>`: `Vorgänge`, `Vorgangdruck`, `Rechnungdruck`, `Hotelgutscheine`, `Hotelgutscheindruck`,
|
||||
`OptionInFest` — the last converts a held option into a firm booking, answering
|
||||
`<optioninfestumgewandelt>true</optioninfestumgewandelt>`. `Vorgangdruck` takes `idbuchung`,
|
||||
`Rechnungdruck` takes `idrechnung`, `Hotelgutscheindruck` takes `idgutschein`; print arts return
|
||||
base64 PDFs.
|
||||
|
||||
## Availability and payment
|
||||
|
||||
`VERFUEGBARKEIT` takes `<idreise>` plus a `<leistungen><leistung id="…"/></leistungen>` filter and
|
||||
answers `<reise id status>` plus per-service `status` ("buchbar"), `frei`, `preis`,
|
||||
`preisfaellig`. `VERFUEGBARKEIT2`, which we actually use, additionally reports
|
||||
`buchungstatusmoeglich` and `pflicht` but is undocumented.
|
||||
|
||||
`ZAHLUNGSARTEN2` lists payment methods; `ZAHLUNG2` records a payment against a booking
|
||||
(`vorgang`, `idbuchung`, `datum`, `betrag`, `idzahlungsart`, `zahlungstext` — all mandatory).
|
||||
`ZAHLUNG` reports a credit-card authorisation after booking; the amount must equal the confirmed
|
||||
`anzahlung` or errors `1011`-`1018` follow.
|
||||
|
||||
## Selling vouchers (Kaufgutschein)
|
||||
|
||||
Used by the `/gutschein` flow, which replaces the BusProNet-hosted shop
|
||||
(`maske_gutschein.php`). `VoucherClient`, `MessageType::Voucher*`; not to be confused with
|
||||
`MessageType::PurchaseVoucher` (`GUTSCHEINPRUEFUNGEINLOESUNG`), which redeems a voucher in a booking.
|
||||
|
||||
| Satz | Request | Response |
|
||||
|---|---|---|
|
||||
| `GUTSCHEINVORLAGEN` | — | `gutscheinminbetrag`, `gutscheinmaxbetrag` (**undocumented**, measured), then `<gutscheine><gutschein idgutscheinvorlage="3"><gutscheinart>K</gutscheinart><bezeichnung>Allgemein</bezeichnung><hinweis/>` |
|
||||
| `GUTSCHEINZAHLUNGSARTEN` | — | `<zahlungsarten><zahlungsart id="2" bezeichnung="Überweisung" art="UEBERW"/>` (measured; the attributes are echoed as `<zahlung>` below) |
|
||||
| `GUTSCHEINVORSCHAU` | `idgutscheinvorlage`, `kaeufer`, `empfaenger`, `kaufpreis`, `text` | `<gutscheinpdf>data:application/pdf;base64,…` (measured, about 430 KB) |
|
||||
| `ADRESSFELDERGUTSCHEIN` | — | which address fields to show and which are mandatory; not used, the flow fixes the live shop's fields |
|
||||
| `GUTSCHEINBUCHUNG` | see below | `buchung=erfolgt`, `idgutschein`, `gutscheinnummer`, `einloesecode`, `preis`, `rechnungsnummer`, `email` |
|
||||
| `GUTSCHEINDRUCK` | `idgutschein`, `einloesecode` | the voucher PDF; not used, BusPro mails the voucher itself once the payment has arrived |
|
||||
|
||||
```xml
|
||||
<satz typ="GUTSCHEINBUCHUNG"/>
|
||||
<buchungsart>Anfrage|Buchung</buchungsart>
|
||||
<gutscheinart>K</gutscheinart>
|
||||
<idgutscheinvorlage>3</idgutscheinvorlage>
|
||||
<kaeufer>…</kaeufer> <!-- "von", printed on the voucher -->
|
||||
<empfaenger>…</empfaenger> <!-- "für", printed on the voucher -->
|
||||
<kaufpreis>25</kaufpreis>
|
||||
<text/> <!-- Zusatztext; the live shop sends it empty -->
|
||||
<rechnungsempfänger>
|
||||
<name/><vorname/><anrede/><titel/><geschlecht/><geburtsdatum/>
|
||||
<anschrift><strasse/><plz/><ort/><ortsteil/><land>D</land></anschrift>
|
||||
<kommunikation><telefonprivat/><email/><telefonmobil/></kommunikation>
|
||||
</rechnungsempfänger>
|
||||
<zahlung idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"/>
|
||||
```
|
||||
|
||||
`<preiszusaetze>` is documented but not sent; the total is the voucher value. `telefonmobil` is not
|
||||
in the vendor's example, its name is the one `BUCHUNG` uses, and the test system accepts it. The
|
||||
live shop also collects a business phone, whose element name is not documented anywhere, so the
|
||||
flow does not collect it.
|
||||
|
||||
### Measured on the test system (2026-10-01)
|
||||
|
||||
`buchungsart=Anfrage` is a full dry run: a valid request answers `<buchung>möglich</buchung>` and
|
||||
nothing else (no `idgutschein`, `gutscheinnummer`, `einloesecode`, `rechnungsnummer`), so nothing is
|
||||
issued. `idagentur` is not required (619/620 never fired).
|
||||
|
||||
| Variant | Answer |
|
||||
|---|---|
|
||||
| Valid (control), 25 € | `möglich` |
|
||||
| `kaufpreis` 6000 | `608 Gutscheinbetrag darf nicht größer als 5.000,00 Euro sein` |
|
||||
| `kaufpreis` 5 | `607 Gutscheinbetrag darf nicht kleiner als 10,00 Euro sein` |
|
||||
| `kaufpreis` 5000 | `möglich` (bounds inclusive) |
|
||||
| `kaufpreis` 25,50 | `möglich` (cents accepted; the flow offers whole euros only) |
|
||||
| `empfaenger` missing | `605 Empfängername fehlt` |
|
||||
| `idgutscheinvorlage` 999 | `602 Gutscheine mit ID 999 nicht gefunden` (not 615 as documented) |
|
||||
| `email` empty | `650 Fehlende Adress Daten. Bitte folgende Eingabefelder prüfen: Mail-Adresse` |
|
||||
| `strasse` empty | `650 … Strasse` |
|
||||
| `anrede` Herr, `telefonmobil` set | `möglich` |
|
||||
|
||||
Missing address data is a `650` and a hard rejection, as on `BUCHUNG`. The flow therefore treats
|
||||
every `HINWEIS` on this path as a rejection and sends it back to the step that owns it
|
||||
(`VoucherSaleRejectionMapper`). The design ids (3, 4, 5) match the thumbnail file names of the old
|
||||
shop, which suggests they are shared with production; confirm on the first production read.
|
||||
|
||||
## Error codes
|
||||
|
||||
Returned as `<satz typ="HINWEIS"><nr>…</nr><text>…</text></satz>`. `#1#`/`#2#` are substituted at
|
||||
runtime.
|
||||
|
||||
> **`650` is not a success code.** Its documented text is a bare `#1#` — a generic message
|
||||
> carrier whose entire content is substituted. `Notification::isError()`
|
||||
> (`src/BusProNet/Model/Notification.php:33-36`) treats it as success, which happens to hold on
|
||||
> the `KUNDENKONTO` paths but not on `BUCHUNG`/`BUCHUNGAENDERUNG`/`GUTSCHEINBUCHUNG`, where the same code delivers
|
||||
> real rejections such as *"Es wurde nicht jedem Teilnehmer eine Pflichtleistung zugeordnet"*.
|
||||
> `BookingEditSubmitter.php:81` therefore reports some genuine failures as INFO.
|
||||
|
||||
**Envelope / auth** — 100 Anfrageknoten fehlt · 101 Satzknoten fehlt · 102 Ungültiger Satztyp ·
|
||||
103/104 User fehlt/fehlerhaft · 105/106 Key fehlt/fehlerhaft · 999 Systemfehler
|
||||
|
||||
**Lookup** — 200 Keine Einträge · 201/202 Partner · 203 Ungültiger Termin · 300 IDReise fehlt ·
|
||||
301 Reise nicht gefunden · 302 IDLeistung (Hin & Rück) fehlt · 303 IDLeistung passt nicht zu
|
||||
IDReise · 304 Kein Sitzplatz für alle Teilnehmer ermittelbar · 305 Anzahl Personen fehlt ·
|
||||
306/307 Leistungen · 308 Keine Zahlungsarten · 400 IDPartner fehlt · 401 Partner nicht gefunden ·
|
||||
402 Bis-Termin fehlt · 403 Keine Unterbringungsleistungen gefunden
|
||||
|
||||
**Customer / address** — 500 Art fehlt · 502 Keine Kunden · 840-842 Personen · 850 IDAdresse und
|
||||
EMail fehlen · 851 Passwort fehlt · 852 Art fehlt · 853 ID, EMail oder Passwort falsch · 855 Keine
|
||||
Daten für Adresse · 860 Adressdaten fehlen · 862-867 Selektionsmerkmale / CRM-Aktionen ·
|
||||
870-875 Newsletter · 880-882 Dokumente · 885-891 Pflichtfelder (Name, Vorname, Geschlecht, EMail,
|
||||
PLZ, Straße) · 889 Kunde bereits angelegt (ID #1#)
|
||||
|
||||
**Booking (create and edit)** — 900 Buchungsart fehlt · 901 Buchungsart falsch · 902 Status
|
||||
fehlt · 903 Status falsch · 904/905 Agentur · 906 Reise-ID fehlt · 907 Reise nicht gefunden ·
|
||||
908 Reise ist storniert · **909 Beförderungen fehlen** · 910 Leistung gehört nicht zur Reise ·
|
||||
911 Leistung gehört nicht zum Produkt · 912 Beförderungsleistung fehlt · **913 Unterbringungen
|
||||
fehlen** · **914 Partner-ID fehlt** · 915 Partner nicht gefunden · **916 Ferienziel-Unterbringungen
|
||||
fehlen** · 917 Zimmer-IDZ fehlt · 918 Kategorie fehlt · 919 Verpflegungs-ID fehlt · 920 Anreise
|
||||
fehlt · **921 Anreise passt nicht zur Beförderungsleistung (Hinfahrt)** · 922 Abreise fehlt ·
|
||||
**923 Abreise passt nicht zur Beförderungsleistung (Rückfahrt)** · **924 Ferienziel: Keine Preise
|
||||
zu den Daten gefunden** · 925 Preis zu den Daten nicht gefunden · 930 Zustiege fehlen ·
|
||||
931 Zustieg nicht gefunden · 932 Zustieg bei Leistung nicht freigegeben · 933 Sitzplan bei
|
||||
Leistung nicht freigegeben · 935-938 Versicherungen · 940-942 Zahlungsart · 950 Anmelder fehlt ·
|
||||
951 Teilnehmerliste fehlt · 952 Teilnehmer-ID fehlt · 953 Keine Teilnehmer übergeben ·
|
||||
960 Preisfehler · 961 Buchung konnte nicht gespeichert werden · **962 Buchungsstatus #1# kann
|
||||
nicht verarbeitet werden, nur Status #2# möglich** · 970 CRM-Merkmal nicht gültig · 980 Reise ist
|
||||
fürs Internet gesperrt · 981 Reise ist nicht mehr buchbar (#1#) · **982 Optionsbuchungen nicht
|
||||
zugelassen** · 983 Anfragebuchung nicht zugelassen · 984 Leistung nicht fürs Internet buchbar ·
|
||||
985 Zustieg nicht fürs Internet buchbar · 986/987 Rechnung · 990-994 Agenturkonto ·
|
||||
995/996 Gutschein · 997/998 Buchung-ID · 1001 Reisedatum fehlerhaft · 1002 Buchungsdatum
|
||||
fehlerhaft · 1003 Status im Vorgang nicht korrekt · 1011-1018 Zahlungsabgleich
|
||||
|
||||
**Vouchers** — 600-608 Gutscheine (602 Vorlage nicht gefunden, measured; 604 Käufername ·
|
||||
605 Empfängername · 606 Betrag fehlt · 607/608 Betrag kleiner/größer als #1# Euro) ·
|
||||
610/611 Buchungsart · 612/613 Gutscheinart · 614/615 Gutscheinstamm · 616 Kapazität nicht
|
||||
ausreichend · 617 Rechnungsempfänger fehlt · 618 Zahlungsart fehlt · 619/620 Agentur-ID ·
|
||||
621 Gutscheindaten fehlen · 622 Einlösecode fehlt · 630 Aktionsgutschein fehlt ·
|
||||
631 Aktionsgutschein mit Code #1# nicht gefunden · 632 Aktionsgutschein für geprüfte Reise ungültig ·
|
||||
**633 Aktionsgutschein vom Buchungszeitraum ungültig** · **634 Aktionsgutschein vom Reisezeitraum
|
||||
ungültig**
|
||||
|
||||
633 and 634 are not in the PDF; both were measured on `AKTIONSGUTSCHEIN` on 2026-10-06. 633 means
|
||||
the request falls outside the promo's booking period (`GutscheinAktion.BuchungDtVon`/`BuchungDtBis`):
|
||||
a test promo whose booking period was being edited answered 633 and was accepted once the period
|
||||
covered the request date. 634 refers to the travel period (`ReiseDtVon`/`ReiseDtBis`). It was
|
||||
returned on prod for a promo whose stored travel period did contain the travel's `TerminVon`/
|
||||
`TerminBis`. What it actually compares has not been established.
|
||||
|
||||
## Applying this: the teamer placeholder booking
|
||||
|
||||
**Answered 2026-09-02: `BUCHUNG` cannot create it.** A payload mirroring the real teamer booking
|
||||
(travel 12429, hotel 222925, agency 77876, both bus legs, ski pass, Ortstaxe, Keycard-Pfand,
|
||||
pickup and drop-off, root and participant status `O`, ids-only identity, no room) returns
|
||||
`916 Ferienziel-Unterbringungen fehlen`. Adding a room to the identical payload clears that gate
|
||||
and moves on to `650`, which proves accommodation was the only structural objection.
|
||||
|
||||
Two blockers that were *not* the problem:
|
||||
|
||||
- **Status `O` is fine here.** Travel 12429 reports `buchungstatusmoeglich="F, O, A"`. The `982`
|
||||
refusal seen earlier is per travel, not a global setting.
|
||||
- **`NurFahrt` never came up** — the request never got far enough for it to matter.
|
||||
|
||||
What the real booking shows about how the office builds one, worth reproducing if the work moves
|
||||
to a direct write:
|
||||
|
||||
- `<idpartner>` **is** present despite there being no accommodation — 222925 is the ordinary
|
||||
hotel of the travel (`Adresse.IDAdresse`, "NEU Sportclub Weissfluh Midweeks und Wochenreisen",
|
||||
the sole hotel of product 1429, with real bookable rooms), not a special no-accommodation
|
||||
partner. Whether the booking stores it or the response derives it is still open.
|
||||
- Each teamer seat is its **own `AdressePerson`** (407725, 407726) under the shared address 41003,
|
||||
with an empty `<anschrift/>`.
|
||||
- `<wünsche><beförderungswunsch>` carries the role label — "Reiseleitung + Busbegleitung".
|
||||
- Per participant, `<optionsdatum>` sets the option expiry.
|
||||
- The total is driven to zero with a **manual `<zuschlag>`**: Ortstaxe 44,00 + Keycard-Pfand 10,80
|
||||
+ Abfahrtsortzuschlag 25,80 = 80,60, offset by an unnamed −80,60 line. `<zuschlaege>` is part of
|
||||
the documented `BUCHUNG` payload, so this part *is* expressible over XML.
|
||||
|
||||
See `.ddev/plans/teamer-placeholder-booking-plan.md`.
|
||||
Binary file not shown.
@@ -225,13 +225,6 @@ JOIN dbo.ZustiegPreis zp ON zp.IDArtZustiegPreis_FS=lb.IDArtZustiegPreis_FS
|
||||
WHERE l.IDLeistung IN (@OutboundServiceId,@ReturnServiceId);
|
||||
```
|
||||
|
||||
**V 2026-10-02 — an unpriced return leg is free.** The export can state a `preis` on the
|
||||
outbound `<zustieg>` but none on the matching `<zustieg_rueck>` (seen on products 701 and
|
||||
2435). The booking engine bills a return `Abfahrtsortzuschlag` only where the return entry
|
||||
states a price, so the export is read verbatim, per leg. A missing return price is a catalog
|
||||
omission for the office to fix, not something to compensate locally: pricing the return leg
|
||||
from the outbound one makes the booking inquiry fail with a price mismatch.
|
||||
|
||||
**N — the departure surcharge is computed.** Booking responses carry a position
|
||||
`art="SON" unterart="TRA"` (*Abfahrtsortzuschlag*) with no matching row type: `ArtZuschlag`
|
||||
— the lookup behind `BuchungTNZuschlag` — holds only discounts and fees
|
||||
|
||||
@@ -9,7 +9,7 @@ Companion to `travel-queries.md` (static catalog) and `live-api-queries.md` (liv
|
||||
socket endpoints). This one covers the `KUNDENKONTO` / `art=SelektionCRM` response —
|
||||
the document that drives the portal's entire authorization model.
|
||||
|
||||
Request side: `AccountClient::getCrmAttributes()` (`src/BusProNet/Client/AccountClient.php`) sends
|
||||
Request side: `ApiClient::getCrmAttributes()` (`src/BusProNet/ApiClient.php`) sends
|
||||
`satz typ="KUNDENKONTO"` + `art=SelektionCRM` + the customer's email/password.
|
||||
Response side: `CrmAttributesResponseParser`
|
||||
(`src/BusProNet/XmlParser/CrmAttributesResponseParser.php`) turns selection ids into
|
||||
@@ -18,48 +18,16 @@ Symfony roles:
|
||||
| `IDSelektionsstamm` | Role |
|
||||
| --- | --- |
|
||||
| `1070` | `ROLE_TEAMER` |
|
||||
| `1292` | `ROLE_ADMIN` |
|
||||
| `1292` | `ROLE_ADMIN` (+ `ROLE_HOUSE_MANAGER`, + hotel code `SSL`) |
|
||||
| `1293` | `ROLE_MANAGER` |
|
||||
| `1477` | `ROLE_GROUPS_MANAGER` |
|
||||
| `1478` | `ROLE_GROUPS_ADMIN` |
|
||||
| `1483` | `ROLE_CUSTOMER_EXPERT` |
|
||||
| `1484` | `ROLE_TEAM_ADMIN` (not consumed here — exported through `/api/userinfo` for myep-team) |
|
||||
| `1487` | `ROLE_SUPERVISOR` (not consumed here — exported through `/api/userinfo` for ephub) |
|
||||
| label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` |
|
||||
| nothing matched | `ROLE_CUSTOMER` |
|
||||
|
||||
Only selections with `auswahl="True"` count. Every `Admin/*` controller is gated on
|
||||
one of these roles.
|
||||
|
||||
These are *claims*, not grants. What an account ends up holding is decided by
|
||||
`Role::sync()` (`src/Security/Role.php`) on every login — see
|
||||
[From claim to role](#from-claim-to-role).
|
||||
|
||||
---
|
||||
|
||||
## From claim to role
|
||||
|
||||
BusPro backend users can edit their own CRM selections, so the claims above are
|
||||
filtered before anything is stored:
|
||||
|
||||
| Role | Claimed without a staff address | Claimed with a staff address |
|
||||
| --- | --- | --- |
|
||||
| `ROLE_TEAMER` | granted | granted |
|
||||
| `ROLE_HOUSE_MANAGER` | nominated | nominated |
|
||||
| `ROLE_ADMIN`, `ROLE_TEAM_ADMIN`, `ROLE_MANAGER`, `ROLE_GROUPS_ADMIN`, `ROLE_GROUPS_MANAGER`, `ROLE_CUSTOMER_EXPERT`, `ROLE_SUPERVISOR` (`Role::EMPLOYEE_ONLY`) | **ignored** | nominated |
|
||||
|
||||
- **Staff address**: the login email's domain is listed in `employee_email_domains`
|
||||
(`config/services.yaml`, exact match, never a suffix). Such an account also gets
|
||||
`ROLE_EMPLOYEE`, which is derived from the address and not from the CRM.
|
||||
- **Nominated**: stored as the marker `ROLE_X_PENDING`, which grants nothing until an
|
||||
administrator approves it in `/admin/user`. Nominations are announced to
|
||||
`role_nomination_notification_emails`.
|
||||
- **Ignored**: treated exactly like a selection that is not set. No marker is stored,
|
||||
and a role approved earlier is **revoked** on the next login. A leftover marker from
|
||||
before the rule cannot be approved and disappears on that login too.
|
||||
- Anything the CRM stops claiming is revoked, approved or not.
|
||||
- An account left without any effective role gets `ROLE_CUSTOMER`.
|
||||
|
||||
---
|
||||
|
||||
## The four tables
|
||||
@@ -230,7 +198,7 @@ Three conclusions:
|
||||
`dbo.AgenturSelektionenKunde` is a bare two-column link table,
|
||||
`(IDAdresse_FS, IDSelektionsstamm_FS)` — literally *"agency's customer selections"*. The
|
||||
portal authenticates against BusPro as an **agency** account (`bpn_username` in
|
||||
`XmlChannel`), so a per-agency whitelist of which customer selections that agency may see
|
||||
`ApiClient`), so a per-agency whitelist of which customer selections that agency may see
|
||||
and/or edit is exactly the right shape. It is the only candidate found so far that could
|
||||
explain the `1478` omission **and** `aenderbar` through one mechanism.
|
||||
|
||||
@@ -429,11 +397,11 @@ BusProNet host is the fix, and no schema change is needed.
|
||||
|
||||
## Portal side: a correct XML is not enough
|
||||
|
||||
`BpnAuthenticator::syncFromCrm()` writes the roles onto the local `User` entity at login,
|
||||
through `Role::sync()` (see [From claim to role](#from-claim-to-role)), and the hotel codes
|
||||
verbatim. Roles are **not** re-read per request. So after fixing BusPro the affected user
|
||||
must **log out and log in again**, and even then `ROLE_GROUPS_ADMIN` only takes effect once
|
||||
the account has a staff address and an administrator has approved the nomination.
|
||||
`BpnAuthenticator::createOrUpdateLocalUser()` snapshots the roles onto the local `User`
|
||||
entity at login (`setRoles($crmAttributes->roles)` /
|
||||
`setHotelCodes($crmAttributes->hotelCodes)`). Roles are **not** re-read per request. So
|
||||
after fixing BusPro the affected user must **log out and log in again** before
|
||||
`ROLE_GROUPS_ADMIN` takes effect — an active session keeps the stale role set.
|
||||
|
||||
---
|
||||
|
||||
@@ -462,7 +430,7 @@ and they are long shots. Prefer the escalation path below.
|
||||
Escalation path, cheapest first:
|
||||
|
||||
1. **Re-fetch and confirm staleness.** Our side does not cache this call — the
|
||||
`xml-sync` cache tag covers the XML *export* sync, and `AccountClient::getCrmAttributes()`
|
||||
`xml-sync` cache tag covers the XML *export* sync, and `ApiClient::getCrmAttributes()`
|
||||
has no cache layer — so a fresh login hits BusPro directly. If `1478` is still absent
|
||||
from a response captured after the insert, the staleness is on the BusProNet host and
|
||||
an application-pool/service restart there is the test.
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
> per-endpoint migration verdicts and the `frei` discovery SQL.
|
||||
|
||||
This complements `travel-queries.md`. That doc replaced the **XML export** (static
|
||||
catalog). This one covers the **live socket API** methods of the clients in
|
||||
`src/BusProNet/Client/` and classifies how replicable each is by direct MSSQL
|
||||
catalog). This one covers the **live socket API** methods on
|
||||
`src/BusProNet/ApiClient.php` and classifies how replicable each is by direct MSSQL
|
||||
query in the Go proxy.
|
||||
|
||||
**Verdict in one line:** the *structural* parts replicate cleanly; the *live "free"
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
# Chatbot MCP server
|
||||
|
||||
The website chatbot runs in n8n. Its AI Agent answers from a read-only MCP server in this app instead
|
||||
of scraping the websites. The server combines two sources:
|
||||
|
||||
- **Editorial content from TYPO3**: the content feed of all public sites (ep-reisen, ser,
|
||||
ski-boarderweek, ski-wochenenden, snowzone), mirrored into `content_document` by `app:content:sync`.
|
||||
- **Trip data of our own**: dates, rooms, prices, availability and pickups from the product export
|
||||
(`ProductExportBuilder`, the data behind `GET /api/exports/products`).
|
||||
|
||||
TYPO3 sends no dates or prices, and nothing flows back to TYPO3.
|
||||
|
||||
## Endpoint
|
||||
|
||||
`POST /mcp` (Streamable HTTP; `GET`/`DELETE` are part of the transport), served by `symfony/mcp-bundle`
|
||||
from `config/packages/mcp.yaml`. Tools only, no resources or prompts. `POST /mcp/trips` is the same
|
||||
server without the editorial content, see [Trips only](#trips-only-mcptrips).
|
||||
|
||||
Authentication is a static bearer token per client, `Authorization: Bearer <token>`, checked by
|
||||
`App\Security\McpAccessTokenHandler` on the `mcp` firewall. `APP_MCP_TOKENS` lists the clients as
|
||||
`name:token` pairs separated by commas, e.g. `APP_MCP_TOKENS="n8n:3f9c…,claude-desktop:a71e…"`. Give every
|
||||
system its own token, so one can be revoked by removing its entry without touching the others. Use tokens
|
||||
without commas, e.g. `openssl rand -hex 32`. A malformed list (no name, a duplicate name, a token shared by
|
||||
two clients) fails loudly with a 500 on `/mcp` rather than locking a client out silently. If the list is
|
||||
empty, every request is refused. It is not the OAuth2 client-credentials flow of `/api`: n8n's MCP Client Tool node sends a fixed
|
||||
header and cannot renew a token every 10 minutes.
|
||||
|
||||
The client name becomes the authenticated user. Tool calls are rate-limited per client (`chatbot_mcp`
|
||||
limiter, 60 per minute), so testing from Claude Desktop does not eat into n8n's budget. The limit is there to stop
|
||||
runaway agent loops. Each call is logged on the `chatbot` channel (`var/log/<env>.chatbot.log`, file
|
||||
only) with the client, tool, arguments, result count and duration. Nothing identifies the person chatting.
|
||||
|
||||
### n8n
|
||||
|
||||
MCP Client Tool node: endpoint `https://<host>/mcp`, transport *HTTP Streamable*, authentication *Bearer*
|
||||
with n8n's own token from the stage's `APP_MCP_TOKENS`. The agent prompt should tell the bot to cite each answer's `url`, and
|
||||
that prices and availability are a snapshot (`dataAsOf`) and only the booking page is binding.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Use | Returns |
|
||||
|---|---|---|
|
||||
| `search_trips` | The website's travel search: which trips exist, when, where, how much | One result per trip × hotel × travel date (default 10, max 25), with trip `code` and `hotelCode`, "ab" price, nights, inclusions, room types, availability, link; `total` and `dataAsOf` |
|
||||
| `get_trip` | One trip by product `code`, or one date of it by date code such as `SSLMP061226` (optionally `hotelCode`) | Product text with FAQs, hotels with facts, all upcoming dates with bookable rooms, pickups for the first 3 dates |
|
||||
| `search_content` | Questions not about dates or prices | Up to 8 hits with a ~600-character excerpt |
|
||||
| `get_document` | The full text of one `search_content` hit, by `url` | Markdown body, truncated at 20 000 characters |
|
||||
|
||||
### Trips only: `/mcp/trips`
|
||||
|
||||
A second server (`chatbot_trips` in `config/packages/mcp.yaml`) for clients that must not answer from
|
||||
editorial website text, which can contradict the booking system, most visibly on prices. A client picks
|
||||
the variant by the path it is pointed at; tokens, rate limit and log are those of `/mcp`.
|
||||
|
||||
- Only `search_trips` and `get_trip`. `search_content` and `get_document` do not exist here.
|
||||
- `get_trip` (`App\Chatbot\Mcp\TripsOnly\GetTripTool`) leaves out the product `description`, which
|
||||
includes the FAQs, and each hotel's `description`.
|
||||
- What remains from TYPO3 is structured: trip and hotel names, `url`, `site`, destination, concept and
|
||||
the hotel `facts` (beds, altitude, distances, sauna, wifi, …; no prices). Which trips exist is still
|
||||
decided by TYPO3's products and their `searchable` flag, and the free-text `query` of `search_trips`
|
||||
still falls back to the page texts to find a trip, without returning any of it.
|
||||
- The server instructions tell the model to refer to the trip's `url` for anything the tools don't answer.
|
||||
|
||||
To give a client the editorial content back, point it at `/mcp` again.
|
||||
|
||||
Editorial text (product descriptions, FAQs, pages) is maintained by hand in TYPO3 and can lag behind BusPro, e.g. an
|
||||
old price or a changed inclusion. The server instructions and the tool descriptions therefore tell the model that the
|
||||
structured trip fields of `search_trips` and `get_trip` override whatever the text says about dates, prices, pickups
|
||||
and inclusions.
|
||||
|
||||
Every result carries the public `url`. Output is compact JSON with Markdown in the text fields.
|
||||
An empty result is `{"results": []}`. Invalid input produces a tool error the model can read.
|
||||
|
||||
Trip rules (`App\Chatbot\TripCatalog`):
|
||||
|
||||
- TYPO3 products are joined to the export on `product.code`. Several products can share a code, and
|
||||
each is listed as its own trip. Hotels are joined on any entry of the TYPO3 hotel's `codes` (BusPro
|
||||
uses the matchcode aliases). A code that is one hotel's own and another's alias resolves to its owner.
|
||||
- BusPro has hotel names of its own (`hotel.xml`, joined on the hotel id). They are internal wording
|
||||
("NEU Sportclub Lederer Wochenenden und Kurztrips", "Vacanceole Résidence L'Edelweiss"), so a hotel is
|
||||
shown under its TYPO3 title, and under BusPro's name only when TYPO3 has no hotel for its code.
|
||||
`query` searches both. Trips have no name in BusPro's export, only a code; their names are TYPO3's.
|
||||
- A room is bookable unless its status is `Buchungsstop`. The baby row (room code `Baby`) is not a room.
|
||||
`available` counts rooms, so the places of a date at a hotel are `Σ available × maxPax` over its
|
||||
bookable rooms. The tools never output room counts, only `frei` or `auf Anfrage`.
|
||||
- `search_trips` only offers products that TYPO3 lists in its own website search (`searchable`) and does
|
||||
not mark as unbookable. Closed group trips and staff trainings are `searchable: false`. `get_trip` still
|
||||
answers when someone names their code.
|
||||
- Pickup surcharges are per person and per leg, so each pickup carries `surchargeOutbound`, `surchargeReturn` and
|
||||
their sum `surchargeRoundTrip`. A single figure was read by the model as the whole price. A leg listed without a
|
||||
price is `0`, because BusPro bills only priced legs; `surchargeReturn` is absent only where the point is not
|
||||
served on the way back.
|
||||
- The export's `guide` (name, phone) and all BusPro ids are never output.
|
||||
|
||||
`search_trips` mirrors the search page (`/skireisen/suche/`), whose data is TYPO3's import of our own
|
||||
product export. Its rules follow TYPO3's `DateImportService` and search repository, derived per date and
|
||||
hotel in `App\Model\Chatbot\TravelDateFacts`:
|
||||
|
||||
- **"ab" price:** the cheapest bookable room with places left, otherwise the cheapest bookable room.
|
||||
`priceMin`/`priceMax` apply to it. The site's price bands are presets of this.
|
||||
- **Travel period:** `dateFrom`/`dateTo` match by containment (start on or after, end on or before), as on
|
||||
the site, not by overlap. `period` names one of the site's presets, from the `chatbot_travel_periods`
|
||||
parameter in `config/services.yaml`. It's duplicated from the TYPO3 search configuration on purpose, so
|
||||
update both each season. Expired periods are no longer offered.
|
||||
- **`persons`:** the site's condition (a room takes groups this small: smallest `minPax` ≤ persons) **and**
|
||||
enough places left. The site checks only the first.
|
||||
- **Room type:** capacity, as TYPO3's `RoomTypeResolver` (`App\Enum\RoomType`): `maxPax` 1–8, 9 or more
|
||||
is `9er+`. A label containing "bett im" or "mit ander" is `mit anderen` (a shared bed, sold per bed).
|
||||
- **Accommodation:** the TYPO3 hotel `type` (1 Ferienwohnung, 2 Sportclub, 3 Apartment, 4 Gästehaus,
|
||||
5 Pension), `App\Enum\HotelType`.
|
||||
- **Board:** the items of selection group 71 ("4x Abendessen", "Frühstück inkl. … Lunchpaket", "Vollpension",
|
||||
…), matched by word against any item. The site keys on the first item only. "Halbpension" occurs in no
|
||||
label, so dinner is asked as "Abendessen".
|
||||
- **Inclusions:** selection groups 7 (Skipass), 71 (board), 86 (Programm), 87 (Kurse), 89 (Events), and the
|
||||
outbound bus when its price is 0. `bus: true` asks for any bookable outbound bus.
|
||||
- **Nights:** days between start and end. The site's "short" is ≤ 4 and its "long" is ≥ 5.
|
||||
- **`query`:** the site has no free text. A code or a name is taken at its word, so that naming a trip or
|
||||
hotel returns that one and not every page mentioning it. The first of these that finds anything decides
|
||||
(`TripCatalog::resolveQuery()`, `App\Chatbot\Search\NameQuery`):
|
||||
1. a product code, a hotel code or alias, or a date code;
|
||||
2. names holding every word of the query, compared without accents, case and German stopwords, each
|
||||
word as a prefix: "Lederer" is "Sportclub Lederer", "Chaviere" is "Val Chavière". Names are those of
|
||||
products, hotels (TYPO3's and BusPro's), cities, regions, countries and concepts;
|
||||
3. names the query spells out among other words ("Hotel Chamois d'Or in Val Thorens"), where the most
|
||||
specific kind wins: product or hotel, then city, region, country or concept;
|
||||
4. the page texts of those records, every term required, then any term.
|
||||
|
||||
A matching hotel selects its own travel dates, not all hotels of its trips. A destination or concept
|
||||
selects its trips. Matches of one step are combined, so "Österreich Sportclub" is everything in Austria
|
||||
plus every Sportclub; `country` and `concept` narrow instead.
|
||||
- **Order:** by start date, then "ab" price. The site sorts by concept first, which the feed doesn't carry.
|
||||
- Results link to the product page. Links with the hotel preselected and the site's "Restplätze" badge need
|
||||
two fields the TYPO3 content feed doesn't send yet (`hotelUrls`, `lowContingent`).
|
||||
|
||||
Search (`App\Chatbot\Search\ContentSearch`) is MariaDB full text in boolean mode over
|
||||
`title, breadcrumb, body_md`. Each term becomes a prefix match, because InnoDB has no German stemming,
|
||||
and German stopwords are dropped in PHP. Relevance is weighted by type: products and hotels, then
|
||||
destinations and concepts, then pages, then blog posts.
|
||||
|
||||
## Content sync
|
||||
|
||||
```bash
|
||||
php bin/console app:content:sync # delta: what the manifest says changed
|
||||
php bin/console app:content:sync --refresh # also refetch items older than 7 days, oldest first
|
||||
php bin/console app:content:sync --ignore-window # deliberate bulk run outside 01:00–05:00
|
||||
php bin/console app:content:check-freshness # fails if the last good manifest is > 60 min old
|
||||
```
|
||||
|
||||
Scheduled on prod (`config/packages/zenstruck_schedule.yaml`): an hourly delta sync,
|
||||
`--refresh` on Sunday at 01:05, and the freshness check hourly at :10 with `--max-age 150`, so that
|
||||
`email_on_failure` reports a stalled sync but not a single missed run. Website content changes
|
||||
rarely, and an hour's delay is acceptable for the chatbot; trip data and availability do not come
|
||||
from this mirror. The refresh is off the quarter hour because a run that
|
||||
finds another one holding the lock exits successfully without doing anything.
|
||||
|
||||
The feed is read with `X-Api-Key: <APP_CMS_API_KEY>` from `APP_CMS_API_BASE_URL`. The manifest lists
|
||||
every site whichever host serves it. The key is only sent to hosts of the sites the manifest names.
|
||||
|
||||
**Load rules.** These are agreed with the TYPO3 side, so do not tune them:
|
||||
|
||||
- One request at a time, and at most one page fetch per second.
|
||||
- A response slower than 2 s makes the next request wait 5 s. Three slow pages in a row end the page
|
||||
phase for the run.
|
||||
- A `503` is TYPO3's kill switch. The run stops, and `Retry-After` pauses later runs (`content_sync_state.paused_until`).
|
||||
Three consecutive 5xx responses or timeouts (10 s) also stop the run. Nothing is retried in a loop.
|
||||
- Outside 01:00–05:00 Europe/Berlin a run fetches at most 50 pages and leaves the rest for later.
|
||||
|
||||
**Robustness.**
|
||||
|
||||
- A failed, invalid or empty manifest changes nothing.
|
||||
- A record that is requested but absent from the records response is deleted, and so is an item that
|
||||
is missing from the manifest.
|
||||
- A page answering 404 or 5xx is remembered as `failed`, and one that converts to no text as `empty`.
|
||||
Each is stored with its `modified` and not refetched until that changes or a `--refresh` reaches it.
|
||||
- A timeout is retried on the next run.
|
||||
|
||||
**Document model.** One `content_document` row per `(type, uid, language)`. A record's structured
|
||||
payload is kept in `data`, and HTML is converted to Markdown (`HtmlToMarkdownConverter`). A page whose
|
||||
`detailOf` names a record is appended to that record's `body_md` and kept out of the index
|
||||
(`merged_into`), so a product is never found twice. Merges are recomputed on every run. A record without
|
||||
a reachable page (`site`/`url` null) is not shown in search results, unless a detail page lends it its URL.
|
||||
|
||||
After a complete run that changed something, the codes the trip join cannot place are logged on `core`
|
||||
(`Content feed: unmatched codes`).
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `APP_CMS_API_BASE_URL` | TYPO3 base URL per stage (also used by `CmsDataProvider`) |
|
||||
| `APP_CMS_API_KEY` | TYPO3 `X-Api-Key`: the content feed and its `contentUrl` pages, and `typo3.client` (`CmsDataProvider`) |
|
||||
| `APP_MCP_TOKENS` | Bearer tokens for `/mcp` and `/mcp/trips`, one per client: `name:token,name:token` |
|
||||
+3
-15
@@ -41,11 +41,7 @@
|
||||
"Scope": "api"
|
||||
}
|
||||
}
|
||||
},
|
||||
"clickup_baseurl": "https://api.clickup.com/api/v2",
|
||||
"clickup_api_token": "abcdef123456789",
|
||||
"clickup_list_id": "12345",
|
||||
"clickup_template_id": "t-12345"
|
||||
}
|
||||
},
|
||||
"staging": {
|
||||
"Security": {
|
||||
@@ -89,11 +85,7 @@
|
||||
"Scope": "api"
|
||||
}
|
||||
}
|
||||
},
|
||||
"clickup_baseurl": "https://api.clickup.com/api/v2",
|
||||
"clickup_api_token": "abcdef123456789",
|
||||
"clickup_list_id": "12345",
|
||||
"clickup_template_id": "t-12345"
|
||||
}
|
||||
},
|
||||
"prod": {
|
||||
"Security": {
|
||||
@@ -137,10 +129,6 @@
|
||||
"Scope": "api"
|
||||
}
|
||||
}
|
||||
},
|
||||
"clickup_baseurl": "https://api.clickup.com/api/v2",
|
||||
"clickup_api_token": "abcdef123456789",
|
||||
"clickup_list_id": "12345",
|
||||
"clickup_template_id": "t-12345"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +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 Version20260903153641 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE accommodation ADD product_code VARCHAR(16) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE accommodation DROP product_code');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260908145156 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add a generic labelled discount on the accommodation booking total';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE accommodation_booking ADD total_discount INT DEFAULT NULL, ADD total_discount_label VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE accommodation_booking DROP total_discount, DROP total_discount_label');
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* The percentage column is dropped rather than converted: it shipped with Version20260908145156
|
||||
* but no booking was ever given a value, so there is nothing to carry over.
|
||||
*/
|
||||
final class Version20260908161500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Store the accommodation booking total discount as an absolute amount in minor units instead of a percentage';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE accommodation_booking DROP total_discount, ADD total_discount_amount INT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE accommodation_booking DROP total_discount_amount, ADD total_discount INT DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260911170000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drops contingent_sync_state.content_hash: the rolling sync window makes a snapshot digest unusable as a change signal.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE contingent_sync_state DROP content_hash');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE contingent_sync_state ADD content_hash VARCHAR(64) NOT NULL');
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Adds the per-client role requirement that App\EventListener\AuthorizationCodeListener
|
||||
* gates the authorization code grant on.
|
||||
*
|
||||
* The column defaults to an empty list and an empty list denies, so every client is
|
||||
* refused until its roles are set. The two clients in actual use are therefore set here
|
||||
* rather than by hand after the deploy: it closes the window in which both staff tools
|
||||
* would be live but unable to authorize anyone, and it keeps the role strings somewhere
|
||||
* review can see them.
|
||||
*
|
||||
* Matched on name, not identifier: client identifiers are generated per environment and
|
||||
* differ between local, staging and production, while the names do not.
|
||||
*
|
||||
* Deliberately left empty, and so denied: myepapp (the mobile app) and myep (the Keycloak
|
||||
* broker), both R&D only and not in use. The client_credentials clients never reach the
|
||||
* authorization endpoint and are unaffected either way.
|
||||
*/
|
||||
final class Version20260923120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds oauth2_client.required_roles and sets it for the two staff clients.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Added nullable and backfilled before being tightened: a NOT NULL column with no
|
||||
// default leaves existing rows holding an empty string, which is not valid JSON and
|
||||
// fails to hydrate -- the row cannot even reach the listener that would deny it.
|
||||
$this->addSql("ALTER TABLE oauth2_client ADD required_roles JSON DEFAULT NULL COMMENT '(DC2Type:json)'");
|
||||
$this->addSql("UPDATE oauth2_client SET required_roles = '[]'");
|
||||
$this->addSql("ALTER TABLE oauth2_client MODIFY required_roles JSON NOT NULL COMMENT '(DC2Type:json)'");
|
||||
|
||||
// mirrors ephub's App\Security\Role::ELIGIBLE
|
||||
$this->addSql('UPDATE oauth2_client SET required_roles = :roles WHERE name = :name', [
|
||||
'roles' => json_encode(['ROLE_EMPLOYEE', 'ROLE_ADMIN'], JSON_THROW_ON_ERROR),
|
||||
'name' => 'EPHub',
|
||||
]);
|
||||
|
||||
// mirrors myep-team's App\Security\MyEpAuthenticator::ELIGIBLE_ROLES
|
||||
$this->addSql('UPDATE oauth2_client SET required_roles = :roles WHERE name = :name', [
|
||||
'roles' => json_encode(['ROLE_TEAM_ADMIN', 'ROLE_TEAMER', 'ROLE_MANAGER', 'ROLE_HOUSE_MANAGER'], JSON_THROW_ON_ERROR),
|
||||
'name' => 'MyEPTeam',
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE oauth2_client DROP required_roles');
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* Gives every account a uuid, which replaces the primary key as the OAuth2 `sub` claim.
|
||||
*
|
||||
* The UUIDs are generated here in PHP rather than with MariaDB's UUID(): that function
|
||||
* returns v1, which encodes a timestamp and the server's node id, so a whole backfill
|
||||
* would come out time-ordered and share a suffix. Guessing one account's identifier from
|
||||
* another's is the property this column exists to remove, so the backfill has to be as
|
||||
* random as the ones App\Entity\User generates from now on.
|
||||
*
|
||||
* One statement per row, which is why it is emitted rather than written out: the values
|
||||
* cannot exist before the migration runs. Added nullable and tightened afterwards, since
|
||||
* there is no single default that could satisfy a unique column.
|
||||
*/
|
||||
final class Version20260923150000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds user.uuid and backfills it; the OAuth2 sub claim moves off the primary key.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE user ADD uuid VARCHAR(36) DEFAULT NULL');
|
||||
|
||||
foreach ($this->connection->fetchFirstColumn('SELECT id FROM user') as $id) {
|
||||
$this->addSql('UPDATE user SET uuid = :uuid WHERE id = :id', [
|
||||
'uuid' => Uuid::v4()->toRfc4122(),
|
||||
'id' => $id,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->addSql('ALTER TABLE user MODIFY uuid VARCHAR(36) NOT NULL');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649D17F50A6 ON user (uuid)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX UNIQ_8D93D649D17F50A6 ON user');
|
||||
$this->addSql('ALTER TABLE user DROP uuid');
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Split-off periods of an accommodation booking. No backfill: a booking without periods is
|
||||
* priced exactly as before.
|
||||
*/
|
||||
final class Version20260930150445 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds accommodation_booking_period for nights split off the start or end of a stay.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE accommodation_booking_period (id INT AUTO_INCREMENT NOT NULL, booking_id INT NOT NULL, position VARCHAR(10) NOT NULL, date_from DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', label VARCHAR(255) DEFAULT NULL, price_lines JSON NOT NULL COMMENT \'(DC2Type:json)\', INDEX IDX_ABFCF6B33301C60 (booking_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('ALTER TABLE accommodation_booking_period ADD CONSTRAINT FK_ABFCF6B33301C60 FOREIGN KEY (booking_id) REFERENCES accommodation_booking (id) ON DELETE CASCADE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE accommodation_booking_period DROP FOREIGN KEY FK_ABFCF6B33301C60');
|
||||
$this->addSql('DROP TABLE accommodation_booking_period');
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* The mirror of the TYPO3 content feed behind the chatbot MCP server. No backfill: the
|
||||
* first app:content:sync fills it.
|
||||
*/
|
||||
final class Version20261008081401 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds content_document and content_sync_state for the chatbot content mirror.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE content_document (id INT AUTO_INCREMENT NOT NULL, type VARCHAR(16) NOT NULL, uid INT NOT NULL, language VARCHAR(8) NOT NULL, site VARCHAR(32) DEFAULT NULL, url VARCHAR(768) DEFAULT NULL, title VARCHAR(255) NOT NULL, breadcrumb VARCHAR(512) DEFAULT NULL, body_md MEDIUMTEXT NOT NULL, data JSON DEFAULT NULL COMMENT \'(DC2Type:json)\', modified DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', fetched_modified DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', fetch_state VARCHAR(16) NOT NULL, synced_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', merged_into VARCHAR(32) DEFAULT NULL, indexed TINYINT(1) NOT NULL, INDEX content_document_indexed_site (indexed, site), INDEX content_document_url (url), FULLTEXT INDEX content_document_fulltext (title, breadcrumb, body_md), UNIQUE INDEX content_document_identity (type, uid, language), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('CREATE TABLE content_sync_state (id INT AUTO_INCREMENT NOT NULL, manifest_generated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', last_run_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', last_error LONGTEXT DEFAULT NULL, paused_until DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE content_document');
|
||||
$this->addSql('DROP TABLE content_sync_state');
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20261029120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Keys the contingent snapshot by hotel code instead of accommodation, and adds calendar_hotel for TYPO3\'s calendar hotel list.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE calendar_hotel (id INT AUTO_INCREMENT NOT NULL, code VARCHAR(16) NOT NULL, name VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_6F075DB777153098 (code), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
|
||||
// Backfill from the linked accommodation. accommodation_id stays, nullable and no longer
|
||||
// written, until a later migration drops it; that keeps this step reversible.
|
||||
$this->addSql('ALTER TABLE contingent_day ADD hotel_code VARCHAR(16) DEFAULT NULL, CHANGE accommodation_id accommodation_id INT DEFAULT NULL');
|
||||
$this->addSql('UPDATE contingent_day cd INNER JOIN accommodation a ON a.id = cd.accommodation_id SET cd.hotel_code = a.calendar_code');
|
||||
$this->addSql('DELETE FROM contingent_day WHERE hotel_code IS NULL OR hotel_code = \'\'');
|
||||
$this->addSql('ALTER TABLE contingent_day CHANGE hotel_code hotel_code VARCHAR(16) NOT NULL');
|
||||
$this->addSql('DROP INDEX uniq_contingent_day_accommodation_date ON contingent_day');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_contingent_day_hotel_code_date ON contingent_day (hotel_code, date)');
|
||||
|
||||
$this->addSql('ALTER TABLE contingent_sync_state ADD hotel_code VARCHAR(16) DEFAULT NULL, CHANGE accommodation_id accommodation_id INT DEFAULT NULL');
|
||||
$this->addSql('UPDATE contingent_sync_state s INNER JOIN accommodation a ON a.id = s.accommodation_id SET s.hotel_code = a.calendar_code');
|
||||
$this->addSql('DELETE FROM contingent_sync_state WHERE hotel_code IS NULL OR hotel_code = \'\'');
|
||||
$this->addSql('ALTER TABLE contingent_sync_state CHANGE hotel_code hotel_code VARCHAR(16) NOT NULL');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_737F167CC0D0E610 ON contingent_sync_state (hotel_code)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Rows of hotel codes without an accommodation cannot be expressed in the old schema.
|
||||
// The snapshot is a cache, so the next sync of the old code rebuilds whatever is lost.
|
||||
$this->addSql('UPDATE contingent_day cd INNER JOIN accommodation a ON a.calendar_code = cd.hotel_code SET cd.accommodation_id = a.id');
|
||||
$this->addSql('DELETE cd FROM contingent_day cd LEFT JOIN accommodation a ON a.calendar_code = cd.hotel_code WHERE a.id IS NULL');
|
||||
$this->addSql('DROP INDEX uniq_contingent_day_hotel_code_date ON contingent_day');
|
||||
$this->addSql('ALTER TABLE contingent_day DROP hotel_code, CHANGE accommodation_id accommodation_id INT NOT NULL');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_contingent_day_accommodation_date ON contingent_day (accommodation_id, date)');
|
||||
|
||||
$this->addSql('UPDATE contingent_sync_state s INNER JOIN accommodation a ON a.calendar_code = s.hotel_code SET s.accommodation_id = a.id');
|
||||
$this->addSql('DELETE s FROM contingent_sync_state s LEFT JOIN accommodation a ON a.calendar_code = s.hotel_code WHERE a.id IS NULL');
|
||||
$this->addSql('DROP INDEX UNIQ_737F167CC0D0E610 ON contingent_sync_state');
|
||||
$this->addSql('ALTER TABLE contingent_sync_state DROP hotel_code, CHANGE accommodation_id accommodation_id INT NOT NULL');
|
||||
|
||||
$this->addSql('DROP TABLE calendar_hotel');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20261104142531 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds travel_mailing_list to track the Mailjet contact lists created per travel until their deletion.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE travel_mailing_list (id INT AUTO_INCREMENT NOT NULL, travel_code VARCHAR(32) NOT NULL, mailjet_list_id INT NOT NULL, delete_after DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_68DC6E28BC4DD335 (travel_code), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE travel_mailing_list');
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20261109074654 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Allows a bus-only travel mailing list next to the full one: adds bus_leg and the list name, which replaces travel_code as the unique key.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE travel_mailing_list ADD bus_leg VARCHAR(20) DEFAULT NULL, ADD name VARCHAR(64) DEFAULT NULL');
|
||||
// Every existing list holds all participants and is named by its travel code alone.
|
||||
$this->addSql('UPDATE travel_mailing_list SET name = travel_code');
|
||||
$this->addSql('ALTER TABLE travel_mailing_list MODIFY name VARCHAR(64) NOT NULL');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_68DC6E285E237E06 ON travel_mailing_list (name)');
|
||||
$this->addSql('ALTER TABLE travel_mailing_list DROP INDEX UNIQ_68DC6E28BC4DD335, ADD INDEX IDX_68DC6E28BC4DD335 (travel_code)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Fails while a travel has two lists; delete the bus-only one first.
|
||||
$this->addSql('ALTER TABLE travel_mailing_list DROP INDEX IDX_68DC6E28BC4DD335, ADD UNIQUE INDEX UNIQ_68DC6E28BC4DD335 (travel_code)');
|
||||
$this->addSql('DROP INDEX UNIQ_68DC6E285E237E06 ON travel_mailing_list');
|
||||
$this->addSql('ALTER TABLE travel_mailing_list DROP bus_leg, DROP name');
|
||||
}
|
||||
}
|
||||
Generated
+3096
-1809
File diff suppressed because it is too large.
Load diff
+12
-14
@@ -1,29 +1,31 @@
|
||||
{
|
||||
"name": "myep",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^8.0.7",
|
||||
"@babel/preset-env": "^8.0.7",
|
||||
"@babel/core": "^7.17.0",
|
||||
"@babel/preset-env": "^7.16.0",
|
||||
"@hotwired/stimulus": "^3.0.0",
|
||||
"@symfony/stimulus-bridge": "^3.2.0",
|
||||
"@symfony/webpack-encore": "^7.2.0",
|
||||
"@symfony/webpack-encore": "^6.0.0",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"babel-plugin-polyfill-corejs3": "^1.0.0",
|
||||
"core-js": "^3.51.0",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"core-js": "^3.38.0",
|
||||
"cypress": "^15.5.0",
|
||||
"htmx.org": "^2.0.11",
|
||||
"lightningcss": "^1.33.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"htmx.org": "^1.9.12",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"regenerator-runtime": "^0.13.9",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"tippy.js": "^6.3.7",
|
||||
"toastify-js": "^1.12.0",
|
||||
"webpack": "^5.74.0",
|
||||
"webpack-cli": "^7.2.3"
|
||||
"webpack-cli": "^6.0.1"
|
||||
},
|
||||
"license": "WTFPL",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev-server": "encore dev-server",
|
||||
"dev": "encore dev",
|
||||
"watch": "encore dev --watch",
|
||||
"build": "encore production --progress",
|
||||
@@ -35,9 +37,5 @@
|
||||
"sortablejs": "^1.15.7",
|
||||
"stimulus-use": "^0.52.3",
|
||||
"vanilla-calendar-pro": "^3.1.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"cypress": true,
|
||||
"core-js": false
|
||||
}
|
||||
}
|
||||
+10
-7
@@ -1,20 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!-- https://docs.phpunit.de/en/12.5/configuration.html -->
|
||||
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
backupGlobals="false"
|
||||
colors="true"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
displayDetailsOnTestsThatTriggerDeprecations="true"
|
||||
displayDetailsOnPhpunitDeprecations="true"
|
||||
convertDeprecationsToExceptions="false"
|
||||
>
|
||||
<php>
|
||||
<ini name="display_errors" value="1" />
|
||||
<ini name="error_reporting" value="-1" />
|
||||
<server name="APP_ENV" value="test" force="true" />
|
||||
<server name="SHELL_VERBOSITY" value="-1" />
|
||||
<server name="SYMFONY_PHPUNIT_REMOVE" value="" />
|
||||
<server name="SYMFONY_PHPUNIT_VERSION" value="9.5" />
|
||||
</php>
|
||||
|
||||
<testsuites>
|
||||
@@ -23,13 +23,16 @@
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source>
|
||||
<coverage processUncoveredFiles="true">
|
||||
<include>
|
||||
<directory suffix=".php">src</directory>
|
||||
</include>
|
||||
</source>
|
||||
</coverage>
|
||||
|
||||
<listeners>
|
||||
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
|
||||
</listeners>
|
||||
|
||||
<extensions>
|
||||
<bootstrap class="Symfony\Bridge\PhpUnit\SymfonyExtension" />
|
||||
</extensions>
|
||||
</phpunit>
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
'postcss-import': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect\Model;
|
||||
|
||||
readonly class TravelMailingRecipient
|
||||
{
|
||||
public function __construct(
|
||||
public string $email,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect\Model;
|
||||
|
||||
use App\Enum\BusLeg;
|
||||
|
||||
readonly class TravelMailingResponse
|
||||
{
|
||||
/**
|
||||
* @param TravelMailingRecipient[] $data
|
||||
*/
|
||||
public function __construct(
|
||||
public string $travelCode,
|
||||
public array $data,
|
||||
public ?BusLeg $busLeg = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect;
|
||||
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\BpnConnect\Model\TravelMailingRecipient;
|
||||
use App\BpnConnect\Model\TravelMailingResponse;
|
||||
use App\Enum\BusLeg;
|
||||
|
||||
class TravelsClient extends AbstractApiClient
|
||||
{
|
||||
private const string MAILING_PATH = '/api/v1/travels/mailing';
|
||||
|
||||
/**
|
||||
* An unknown travel code yields an empty list, not an error: bpn-connect cannot tell it
|
||||
* apart from a travel without confirmed participants.
|
||||
*
|
||||
* @param ?BusLeg $busLeg only participants who booked this bus leg, plus their bookings' applicants
|
||||
*/
|
||||
public function getMailingRecipients(string $travelCode, ?BusLeg $busLeg = null): TravelMailingResponse
|
||||
{
|
||||
$query = ['travelCode' => $travelCode];
|
||||
if (null !== $busLeg) {
|
||||
$query['busLeg'] = $busLeg->value;
|
||||
}
|
||||
|
||||
$payload = $this->request(self::MAILING_PATH, $query);
|
||||
|
||||
// A bpn-connect that predates the filter ignores the parameter and returns everyone, which
|
||||
// must not end up on a list meant for bus passengers only.
|
||||
$appliedBusLeg = $payload['meta']['bus_leg'] ?? null;
|
||||
if ($appliedBusLeg !== $busLeg?->value) {
|
||||
$this->logger->error('BpnConnect ignored the travel mailing bus filter', [
|
||||
'travel_code' => $travelCode,
|
||||
'requested_bus_leg' => $busLeg?->value,
|
||||
'applied_bus_leg' => $appliedBusLeg,
|
||||
]);
|
||||
|
||||
throw new BpnConnectException('BpnConnect did not apply the requested bus leg filter.');
|
||||
}
|
||||
|
||||
$recipients = array_map(
|
||||
static fn (array $item) => new TravelMailingRecipient(
|
||||
email: $item['email'],
|
||||
firstName: $item['first_name'] ?? '',
|
||||
lastName: $item['last_name'] ?? '',
|
||||
),
|
||||
$payload['data'] ?? [],
|
||||
);
|
||||
|
||||
return new TravelMailingResponse($payload['meta']['travel_code'] ?? $travelCode, $recipients, $busLeg);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,119 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\BusProNet\Client;
|
||||
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\MessageType;
|
||||
use App\BusProNet\Model\CrmAttributes;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\BusProNet\Model\PersonalDataUpdateResponse;
|
||||
use App\BusProNet\Model\RegistrationResponse;
|
||||
use App\BusProNet\Transport\XmlChannel;
|
||||
use App\Form\Model\RegistrationDto;
|
||||
|
||||
/**
|
||||
* A customer's BusPro account: login, address data, newsletter, CRM selections.
|
||||
*/
|
||||
class AccountClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly XmlChannel $channel,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getPersonalData(string $email, string $password): Notification|PersonalData
|
||||
{
|
||||
return $this->channel->request(MessageType::CustomerData, [
|
||||
'art' => 'Adressdaten',
|
||||
'email' => $email,
|
||||
'passwort' => $password,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function register(RegistrationDto $registrationData): Notification|RegistrationResponse
|
||||
{
|
||||
return $this->channel->request(MessageType::CustomerData, [
|
||||
'art' => 'Adresse_Neu',
|
||||
'adressdaten' => $registrationData->toPayload(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function resetPassword(string $email): Notification
|
||||
{
|
||||
return $this->channel->request(MessageType::CustomerData, [
|
||||
'art' => 'Passwort_Anfrage',
|
||||
'email' => $email,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function updatePersonalData(
|
||||
string $email,
|
||||
string $password,
|
||||
PersonalData $personalData,
|
||||
bool $debug = false,
|
||||
): Notification|PersonalDataUpdateResponse {
|
||||
return $this->channel->request(MessageType::CustomerData, [
|
||||
'art' => 'Adressdaten_Ändern',
|
||||
'email' => $email,
|
||||
'passwort' => $password,
|
||||
'idadresse' => $personalData->addressId,
|
||||
'adressdaten' => $personalData->toPayload(),
|
||||
], [], $debug);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function createAddress(
|
||||
PersonalData $personalData,
|
||||
bool $debug = false,
|
||||
): RegistrationResponse|Notification {
|
||||
return $this->channel->request(MessageType::CustomerData, [
|
||||
'art' => 'Adresse_Neu',
|
||||
'adressdaten' => $personalData->toPayload(false),
|
||||
'ohnemailversand' => 'True',
|
||||
], [], $debug);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function updateNewsletterRegistration(string $email, string $password, PersonalData $personalData): Notification|PersonalData
|
||||
{
|
||||
return $this->channel->request(MessageType::CustomerData, [
|
||||
'art' => 'Newsletter',
|
||||
'email' => $email,
|
||||
'passwort' => $password,
|
||||
'idadresse' => $personalData->addressId,
|
||||
'newsletter' => [
|
||||
'email' => $email,
|
||||
'anmeldung' => $personalData->communication->newsletter ? 'True' : 'False',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getCrmAttributes(string $email, string $password): Notification|CrmAttributes
|
||||
{
|
||||
return $this->channel->request(MessageType::CustomerData, [
|
||||
'art' => 'SelektionCRM',
|
||||
'email' => $email,
|
||||
'passwort' => $password,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\BusProNet\Client;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\MessageType;
|
||||
use App\BusProNet\Model\BaseData;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\BookingResponse;
|
||||
use App\BusProNet\Model\BookingUpdate;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Transport\XmlChannel;
|
||||
use App\Form\Model\BookingDto;
|
||||
|
||||
/**
|
||||
* A customer's bookings: listing, details and documents, creating and changing them.
|
||||
*/
|
||||
class BookingClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly XmlChannel $channel,
|
||||
private readonly BookingDataProcessor $bookingDataProcessor,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getBookings(string $email, string $password): Notification|BaseData
|
||||
{
|
||||
return $this->channel->request(MessageType::CustomerData, [
|
||||
'art' => 'Vorgänge',
|
||||
'email' => $email,
|
||||
'passwort' => $password,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getBooking(string $email, string $password, int $id): Notification|Booking
|
||||
{
|
||||
return $this->channel->request(MessageType::CustomerData, [
|
||||
'art' => 'Vorgang_Details',
|
||||
'email' => $email,
|
||||
'passwort' => $password,
|
||||
'idbuchung' => $id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getDocuments(string $email, string $password, int $bookingId, string $type): mixed
|
||||
{
|
||||
return $this->channel->request(MessageType::CustomerData, [
|
||||
'art' => $type,
|
||||
'email' => $email,
|
||||
'passwort' => $password,
|
||||
'idbuchung' => $bookingId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function updateBooking(BookingDto $formData, bool $debug = false): Notification|BookingUpdate
|
||||
{
|
||||
return $this->channel->request(MessageType::BookingUpdate, [
|
||||
'buchungsart' => Constants::BOOKING_TYPE_BOOKING,
|
||||
...$this->bookingDataProcessor->createUpdateRequestPayload($formData),
|
||||
], [], $debug);
|
||||
}
|
||||
|
||||
/**
|
||||
* First phase of the two-phase booking process: validates the booking and returns the
|
||||
* prices without creating it.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function createBookingInquiry(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse
|
||||
{
|
||||
return $this->sendBooking($bookingDto, Constants::BOOKING_TYPE_INQUIRY, $debug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Second phase of the two-phase booking process: creates the booking after a successful
|
||||
* inquiry.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function createBooking(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse
|
||||
{
|
||||
return $this->sendBooking($bookingDto, Constants::BOOKING_TYPE_BOOKING, $debug);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
private function sendBooking(BookingDto $bookingDto, string $bookingType, bool $debug): Notification|BookingResponse
|
||||
{
|
||||
return $this->channel->request(MessageType::Booking, $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, $bookingType), [], $debug);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\BusProNet\Client;
|
||||
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\MessageType;
|
||||
use App\BusProNet\Model\Agency;
|
||||
use App\BusProNet\Model\BaseData;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Transport\XmlChannel;
|
||||
|
||||
/**
|
||||
* Lists that change rarely and that callers cache for long periods.
|
||||
*/
|
||||
class ReferenceDataClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly XmlChannel $channel,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Agency[]|Notification
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getAgencies(): array|Notification
|
||||
{
|
||||
return $this->channel->request(MessageType::Agencies);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getBaseData(MessageType $type): Notification|BaseData
|
||||
{
|
||||
return $this->channel->request($type);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\BusProNet\Client;
|
||||
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\MessageType;
|
||||
use App\BusProNet\Model\BaseData;
|
||||
use App\BusProNet\Model\ExtendedServiceAvailabilityResponse;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\Transport\XmlChannel;
|
||||
|
||||
/**
|
||||
* Travels from the live API. Most reads belong in TravelDataProvider, which arbitrates
|
||||
* between this, the XML export and the snapshots.
|
||||
*/
|
||||
class TravelClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly XmlChannel $channel,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getTravelData(int $travelId, ?int $hotelId = null): Notification|Travel
|
||||
{
|
||||
return $this->channel->request(MessageType::ProductData, ['idprodukt' => $travelId], ['hotelId' => $hotelId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the unparsed response, which has the structure of BusPro's XML export and is
|
||||
* stored in the export directory as is.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getTravelDataXml(int $travelId): string
|
||||
{
|
||||
return $this->channel->requestRaw(MessageType::ProductData, ['idprodukt' => $travelId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getProducts(): Notification|BaseData
|
||||
{
|
||||
return $this->channel->request(MessageType::Products);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getMutableData(int $dateId): Notification|BaseData
|
||||
{
|
||||
return $this->channel->request(MessageType::MutableData, ['idreise' => $dateId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getAvailabilities(int $dateId): Notification|ServiceAvailabilityResponse
|
||||
{
|
||||
return $this->channel->request(MessageType::Availability, ['idreise' => $dateId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getAvailabilitiesExtended(int $dateId): Notification|ExtendedServiceAvailabilityResponse
|
||||
{
|
||||
return $this->channel->request(MessageType::AvailabilityExtended, ['idreise' => $dateId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getHotelAvailability(int $dateId, int $hotelId, \DateTimeInterface $dateTo): Notification|BaseData
|
||||
{
|
||||
return $this->channel->request(MessageType::AvailabilityHotel, [
|
||||
'idreise' => $dateId,
|
||||
'idpartner' => $hotelId,
|
||||
'terminbis' => $dateTo->format('d.m.Y'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\BusProNet\Client;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\DataProcessor\VoucherSalePayloadBuilder;
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\MessageType;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\PromoVoucher;
|
||||
use App\BusProNet\Model\PurchaseVoucher;
|
||||
use App\BusProNet\Model\VoucherPaymentMethod;
|
||||
use App\BusProNet\Model\VoucherPreview;
|
||||
use App\BusProNet\Model\VoucherSaleResponse;
|
||||
use App\BusProNet\Model\VoucherTemplates;
|
||||
use App\BusProNet\Transport\XmlChannel;
|
||||
use App\Form\Model\VoucherPurchaseDto;
|
||||
|
||||
/**
|
||||
* Redeeming vouchers in a booking, and selling purchase vouchers (Kaufgutscheine).
|
||||
*/
|
||||
class VoucherClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly XmlChannel $channel,
|
||||
private readonly VoucherSalePayloadBuilder $voucherSalePayloadBuilder,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up a purchase voucher (Gutschein) by its redemption code. Purchase vouchers, regular
|
||||
* or goodwill (Kulanz), carry a balance that each redemption reduces.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function validatePurchaseVoucher(string $redemptionCode): Notification|PurchaseVoucher
|
||||
{
|
||||
return $this->channel->request(MessageType::PurchaseVoucher, ['einloesecode' => $redemptionCode]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a promo voucher (Aktionsgutschein) for a travel and participant price. Promo
|
||||
* vouchers are absolute discounts, per person ("P") or per booking ("B") as pro_buchung_person
|
||||
* says.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function validatePromoVoucher(string $promoCode, int $travelId, float $participantPrice): Notification|PromoVoucher
|
||||
{
|
||||
return $this->channel->request(MessageType::PromoVoucher, [
|
||||
'aktionsgutschein' => $promoCode,
|
||||
'idreise' => $travelId,
|
||||
'preis' => number_format($participantPrice, 2, ',', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the voucher designs on sale and the amount bounds.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getVoucherTemplates(): Notification|VoucherTemplates
|
||||
{
|
||||
return $this->channel->request(MessageType::VoucherTemplates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the payment methods offered for voucher purchases.
|
||||
*
|
||||
* @return list<VoucherPaymentMethod>|Notification
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getVoucherPaymentMethods(): array|Notification
|
||||
{
|
||||
return $this->channel->request(MessageType::VoucherPaymentMethods);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the PDF preview of a voucher design with the entered data.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getVoucherPreview(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherPreview
|
||||
{
|
||||
return $this->channel->request(MessageType::VoucherPreview, $this->voucherSalePayloadBuilder->buildPreviewPayload($voucherPurchaseDto));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry run of a voucher purchase: full validation, nothing is issued.
|
||||
*
|
||||
* Every HINWEIS is a rejection on this path, including 650 (missing address data), so the
|
||||
* result must not be judged by Notification::isError().
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function createVoucherSaleInquiry(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherSaleResponse
|
||||
{
|
||||
return $this->sendVoucherSale($voucherPurchaseDto, Constants::BOOKING_TYPE_INQUIRY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buys the voucher. BusPro mails it to the buyer once the payment has arrived.
|
||||
*
|
||||
* Every HINWEIS is a rejection on this path, including 650.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function createVoucherSale(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherSaleResponse
|
||||
{
|
||||
return $this->sendVoucherSale($voucherPurchaseDto, Constants::BOOKING_TYPE_BOOKING);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
private function sendVoucherSale(VoucherPurchaseDto $voucherPurchaseDto, string $saleType): Notification|VoucherSaleResponse
|
||||
{
|
||||
return $this->channel->request(MessageType::VoucherSale, $this->voucherSalePayloadBuilder->buildSalePayload($voucherPurchaseDto, $saleType));
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,13 @@ 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\ApplicantInsuranceCascade;
|
||||
use App\Service\BookingPriceCalculator;
|
||||
use App\Service\InsuranceManager;
|
||||
|
||||
use function Symfony\Component\String\u;
|
||||
|
||||
/**
|
||||
@@ -35,7 +34,6 @@ class BookingDataProcessor
|
||||
private readonly ParticipantServiceProcessor $serviceProcessor,
|
||||
private readonly BookingPayloadBuilder $payloadBuilder,
|
||||
private readonly PersonalDataSynchronizer $personalDataSynchronizer,
|
||||
private readonly ApplicantInsuranceCascade $applicantInsuranceCascade,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -49,9 +47,8 @@ class BookingDataProcessor
|
||||
* - 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>)
|
||||
* - The copy is skipped wherever participant[0] is NOT the applicant: internal agency
|
||||
* bookings (the applicant is a customer) and split bookings (the applicant does not
|
||||
* travel), where it would write one person's address onto another
|
||||
* - 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
|
||||
*/
|
||||
public function createBookingDtoFromBooking(Booking $booking, Travel $travel, bool $isInternalAgencyBooking = false): BookingDto
|
||||
{
|
||||
@@ -75,6 +72,32 @@ 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) {
|
||||
// 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();
|
||||
|
||||
if ($isEmpty) {
|
||||
$participantData->address = clone $booking->applicant->address;
|
||||
}
|
||||
|
||||
// Copy body dimensions from applicant if not present in participant
|
||||
if (null === $participantData->height && null !== $booking->applicant->height) {
|
||||
$participantData->height = $booking->applicant->height;
|
||||
}
|
||||
if (null === $participantData->weight && null !== $booking->applicant->weight) {
|
||||
$participantData->weight = $booking->applicant->weight;
|
||||
}
|
||||
if (null === $participantData->shoeSize && null !== $booking->applicant->shoeSize) {
|
||||
$participantData->shoeSize = $booking->applicant->shoeSize;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract service selections from booking and assign to participant DTO
|
||||
$participantData->courses = $booking
|
||||
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES);
|
||||
@@ -153,57 +176,14 @@ class BookingDataProcessor
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
// Runs once the participants are in place, because the decision needs the assembled DTO.
|
||||
// The explicit flag covers internal agency bookings, whose applicant is a customer; the
|
||||
// DTO predicate covers a split booking, whose participant[0] is a different person.
|
||||
if (false === $isInternalAgencyBooking && $dto->isFirstParticipantApplicant()) {
|
||||
$this->fillFirstParticipantFromApplicant($dto, $booking);
|
||||
}
|
||||
|
||||
return $dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills gaps in participant[0] from the applicant, who is the same person.
|
||||
*
|
||||
* BPN routinely returns the full record in <anmelder> and only a minimal one in
|
||||
* <teilnehmer id="1">. Only ever called where participant[0] IS the applicant: on a
|
||||
* split booking or an internal agency booking this would write somebody else's address
|
||||
* onto a traveller at the next edit submit.
|
||||
*/
|
||||
private function fillFirstParticipantFromApplicant(BookingDto $dto, Booking $booking): void
|
||||
{
|
||||
$participantData = $dto->getParticipant(0);
|
||||
|
||||
if (null === $participantData) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
if ($isEmpty) {
|
||||
$participantData->address = clone $booking->applicant->address;
|
||||
}
|
||||
|
||||
// Copy body dimensions from applicant if not present in participant
|
||||
if (null === $participantData->height && null !== $booking->applicant->height) {
|
||||
$participantData->height = $booking->applicant->height;
|
||||
}
|
||||
if (null === $participantData->weight && null !== $booking->applicant->weight) {
|
||||
$participantData->weight = $booking->applicant->weight;
|
||||
}
|
||||
if (null === $participantData->shoeSize && null !== $booking->applicant->shoeSize) {
|
||||
$participantData->shoeSize = $booking->applicant->shoeSize;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches participant service selections with data from travel model.
|
||||
*
|
||||
@@ -323,7 +303,7 @@ class BookingDataProcessor
|
||||
public function createUpdateRequestPayload(?BookingDto $formData): array
|
||||
{
|
||||
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
||||
$this->applicantInsuranceCascade->apply($formData);
|
||||
$this->applyApplicantInsuranceToParticipants($formData);
|
||||
|
||||
$bookingData = $formData->booking;
|
||||
$travelData = $formData->travel;
|
||||
@@ -340,7 +320,6 @@ class BookingDataProcessor
|
||||
$this->serviceProcessor->processParticipantServices($participant, $bookingData, $travelData);
|
||||
}
|
||||
|
||||
$this->serviceProcessor->removeCanceledParticipantsFromRooms($bookingData);
|
||||
$this->serviceProcessor->removeUnusedServices($bookingData);
|
||||
$this->personalDataSynchronizer->updateParticipantPersonalData(
|
||||
$formData->participants,
|
||||
@@ -393,7 +372,7 @@ class BookingDataProcessor
|
||||
$this->reconcileInsuranceEligibility($bookingDto);
|
||||
|
||||
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
||||
$this->applicantInsuranceCascade->apply($bookingDto);
|
||||
$this->applyApplicantInsuranceToParticipants($bookingDto);
|
||||
|
||||
return $this->payloadBuilder->buildCreatePayload($bookingDto, $bookingType);
|
||||
}
|
||||
@@ -474,4 +453,81 @@ class BookingDataProcessor
|
||||
|
||||
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:
|
||||
* 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.
|
||||
*
|
||||
* @param BookingDto $bookingDto The booking DTO (create or edit flow)
|
||||
*/
|
||||
private function applyApplicantInsuranceToParticipants(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) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get selectable (non-complementary) insurances with caching
|
||||
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
|
||||
|
||||
// Calculate travel prices for all participants (excluding insurance)
|
||||
$participantPrices = [];
|
||||
foreach ($bookingDto->getParticipants() as $index => $participant) {
|
||||
$participantPrices[$index] = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $index);
|
||||
}
|
||||
|
||||
// Use InsuranceManager for proper type-based assignment with price tier matching
|
||||
$assignments = $this->insuranceService->batchAssignInsuranceToParticipants(
|
||||
$selectableInsurances,
|
||||
$applicant->insurance,
|
||||
$bookingDto,
|
||||
$participantPrices
|
||||
);
|
||||
|
||||
// Apply assignments to all participants; for non-family insurance skip the applicant
|
||||
// (they already hold the insurance they selected themselves)
|
||||
foreach ($assignments as $index => $insurance) {
|
||||
if (0 === $index && false === $isFamilyInsurance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$participant = $bookingDto->getParticipant($index);
|
||||
if (null === $participant) {
|
||||
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) {
|
||||
continue; // Skip participants with existing insurance assignment
|
||||
}
|
||||
|
||||
$participant->insurance = $insurance;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ class BookingPayloadBuilder
|
||||
foreach ($bookingData->participants as $index => $participant) {
|
||||
$participantPayload = [
|
||||
'@id' => $index + 1,
|
||||
'status' => $bookingData->participantsStatus[$index] ?? null,
|
||||
'status' => $bookingData->participantsStatus[$index],
|
||||
...$participant->toPayload(),
|
||||
];
|
||||
|
||||
@@ -254,11 +254,7 @@ class BookingPayloadBuilder
|
||||
*/
|
||||
public function buildCreatePayload(BookingDto $bookingDto, string $bookingType): array
|
||||
{
|
||||
// The applicant is participant[0] on an ordinary booking, and a person of their own
|
||||
// when they do not travel along.
|
||||
$applicantSource = $bookingDto->applicantTravels
|
||||
? $bookingDto->participants[0]
|
||||
: ($bookingDto->applicant ?? $bookingDto->participants[0]);
|
||||
$firstParticipant = $bookingDto->participants[0];
|
||||
|
||||
$payload = [
|
||||
'buchungsart' => $bookingType,
|
||||
@@ -268,10 +264,36 @@ class BookingPayloadBuilder
|
||||
'idagentur' => $bookingDto->agencyId,
|
||||
];
|
||||
|
||||
// Add applicant (first participant data)
|
||||
$payload['anmelder'] = [
|
||||
'name' => $firstParticipant->lastName,
|
||||
'vorname' => $firstParticipant->firstName,
|
||||
'geschlecht' => $firstParticipant->gender ?? '',
|
||||
'nationalitaet' => $firstParticipant->nationality ?? '',
|
||||
];
|
||||
|
||||
// DO NOT include personId or addressId in create mode
|
||||
// BPN will automatically match existing customers by exact personal data (name, DOB, address)
|
||||
// Including IDs would prevent automatic matching and could cause data inconsistencies
|
||||
$payload['anmelder'] = $this->buildPersonalDataPayload($applicantSource);
|
||||
|
||||
if (null !== $firstParticipant->dateOfBirth) {
|
||||
$payload['anmelder']['geburtsdatum'] = $firstParticipant->dateOfBirth->format('d.m.Y');
|
||||
}
|
||||
|
||||
// Add address for applicant
|
||||
if (null !== $firstParticipant->address) {
|
||||
$payload['anmelder']['anschrift'] = $firstParticipant->address->toPayload();
|
||||
}
|
||||
|
||||
if (null !== $firstParticipant->email || null !== $firstParticipant->mobile) {
|
||||
$payload['anmelder']['kommunikation'] = [];
|
||||
if (null !== $firstParticipant->email) {
|
||||
$payload['anmelder']['kommunikation']['email'] = $firstParticipant->email;
|
||||
}
|
||||
if (null !== $firstParticipant->mobile) {
|
||||
$payload['anmelder']['kommunikation']['telefonmobil'] = $firstParticipant->mobile;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if this is an inquiry booking for voucher handling
|
||||
$isInquiryBooking = 'A' === $bookingDto->bookingStatus;
|
||||
@@ -279,13 +301,42 @@ class BookingPayloadBuilder
|
||||
// Add participants
|
||||
$payload['teilnehmerliste']['teilnehmer'] = [];
|
||||
foreach ($bookingDto->participants as $index => $participant) {
|
||||
// DO NOT include personId or addressId in create mode
|
||||
// BPN will automatically match existing customers by exact personal data (name, DOB, address)
|
||||
// Including IDs would prevent automatic matching and could cause data inconsistencies
|
||||
$participantData = [
|
||||
'@id' => $index + 1,
|
||||
'status' => self::DEFAULT_PARTICIPANT_STATUS,
|
||||
] + $this->buildPersonalDataPayload($participant, true);
|
||||
'name' => $participant->lastName,
|
||||
'vorname' => $participant->firstName,
|
||||
'geschlecht' => $participant->gender ?? '',
|
||||
'nationalitaet' => $participant->nationality ?? '',
|
||||
];
|
||||
|
||||
// DO NOT include personId or addressId in create mode
|
||||
// BPN will automatically match existing customers by exact personal data (name, DOB, address)
|
||||
// Including IDs would prevent automatic matching and could cause data inconsistencies
|
||||
|
||||
if (null !== $participant->dateOfBirth) {
|
||||
$participantData['geburtsdatum'] = $participant->dateOfBirth->format('d.m.Y');
|
||||
}
|
||||
|
||||
// Add address (always include structure, even if empty)
|
||||
$participantData['anschrift'] = $participant->address?->toPayload() ?? [
|
||||
'strasse' => null,
|
||||
'plz' => null,
|
||||
'ort' => null,
|
||||
'ortsteil' => null,
|
||||
'land' => null,
|
||||
];
|
||||
|
||||
// Add contact info for all participants
|
||||
if (null !== $participant->email || null !== $participant->mobile) {
|
||||
$participantData['kommunikation'] = [];
|
||||
if (null !== $participant->email) {
|
||||
$participantData['kommunikation']['email'] = $participant->email;
|
||||
}
|
||||
if (null !== $participant->mobile) {
|
||||
$participantData['kommunikation']['telefonmobil'] = $participant->mobile;
|
||||
}
|
||||
}
|
||||
|
||||
// Add body dimensions
|
||||
if (null !== $participant->height) {
|
||||
@@ -383,57 +434,6 @@ class BookingPayloadBuilder
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one person's personal data onto the payload shape shared by anmelder and teilnehmer.
|
||||
*
|
||||
* @param ParticipantDto $person The applicant or participant to map
|
||||
* @param bool $alwaysIncludeAddress Participants always carry the anschrift structure
|
||||
* even when empty; the anmelder only when set
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildPersonalDataPayload(ParticipantDto $person, bool $alwaysIncludeAddress = false): array
|
||||
{
|
||||
$data = [
|
||||
'name' => $person->lastName,
|
||||
'vorname' => $person->firstName,
|
||||
'geschlecht' => $person->gender ?? '',
|
||||
'nationalitaet' => $person->nationality ?? '',
|
||||
];
|
||||
|
||||
if (null !== $person->dateOfBirth) {
|
||||
$data['geburtsdatum'] = $person->dateOfBirth->format('d.m.Y');
|
||||
}
|
||||
|
||||
if (null !== $person->address) {
|
||||
$data['anschrift'] = $person->address->toPayload();
|
||||
} elseif ($alwaysIncludeAddress) {
|
||||
$data['anschrift'] = [
|
||||
'strasse' => null,
|
||||
'plz' => null,
|
||||
'ort' => null,
|
||||
'ortsteil' => null,
|
||||
'land' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// A participant may come without an email (under-16s, see ParticipantDto::$email), and
|
||||
// BusPro accepts that, so a blank is sent as a blank rather than borrowing the applicant's.
|
||||
$email = null !== $person->email && '' !== trim($person->email) ? $person->email : null;
|
||||
|
||||
if (null !== $email || null !== $person->mobile) {
|
||||
$data['kommunikation'] = [];
|
||||
if (null !== $email) {
|
||||
$data['kommunikation']['email'] = $email;
|
||||
}
|
||||
if (null !== $person->mobile) {
|
||||
$data['kommunikation']['telefonmobil'] = $person->mobile;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds services from a mapping to the payload.
|
||||
*
|
||||
|
||||
@@ -14,9 +14,8 @@ use Psr\Log\LoggerInterface;
|
||||
* Processes participant service selections for booking updates.
|
||||
*
|
||||
* Handles the assignment and mapping of services (additional, transportation,
|
||||
* pickup, insurance) from participant DTOs to the booking model during the
|
||||
* update request workflow. Rooms are not taken from the DTOs: they cannot be
|
||||
* changed in edit mode, so the booking's own room assignments are sent back.
|
||||
* pickup, room, insurance) from participant DTOs to the booking model during
|
||||
* the update request workflow.
|
||||
*/
|
||||
class ParticipantServiceProcessor
|
||||
{
|
||||
@@ -29,7 +28,7 @@ class ParticipantServiceProcessor
|
||||
* Resets all existing participant-to-service mappings to start with a clean slate.
|
||||
*
|
||||
* This ensures that service assignments are rebuilt from scratch based on current form selections.
|
||||
* Room mappings are left alone, see removeCanceledParticipantsFromRooms().
|
||||
* Includes resetting room mappings to allow room reassignments during edit.
|
||||
*
|
||||
* @param Booking $bookingData The booking data object containing services to reset
|
||||
*/
|
||||
@@ -40,6 +39,7 @@ class ParticipantServiceProcessor
|
||||
...$bookingData->transportationServices,
|
||||
...$bookingData->pickups,
|
||||
...$bookingData->dropOffs,
|
||||
...$bookingData->rooms,
|
||||
...$bookingData->insurances,
|
||||
];
|
||||
|
||||
@@ -52,7 +52,7 @@ class ParticipantServiceProcessor
|
||||
* Processes all services for a single participant.
|
||||
*
|
||||
* This orchestrator method handles the complete service assignment workflow for one participant,
|
||||
* including additional services, transportation services, pickup locations, and insurance.
|
||||
* including additional services, transportation services, pickup locations, and room assignments.
|
||||
*
|
||||
* @param ParticipantDto $participant The participant data from the form
|
||||
* @param Booking $bookingData The booking data object to update
|
||||
@@ -68,6 +68,7 @@ class ParticipantServiceProcessor
|
||||
$this->processTransportationServices($participant, $bookingData, $travelData);
|
||||
$this->processPickupLocations($participant, $bookingData);
|
||||
$this->processDropOffLocations($participant, $bookingData);
|
||||
$this->processRoomAssignment($participant, $bookingData);
|
||||
$this->processInsurance($participant, $bookingData, $travelData);
|
||||
}
|
||||
|
||||
@@ -270,23 +271,27 @@ class ParticipantServiceProcessor
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes canceled participants from the booking's room assignments.
|
||||
* Processes room assignment for a participant.
|
||||
*
|
||||
* Room assignments cannot be changed in edit mode, so they are sent back as BusPro reports
|
||||
* them on the booking reloaded at submit, together with its anzahl. Rebuilding them from the
|
||||
* session would undo any reassignment made in BusPro while the edit was open, and send a
|
||||
* zuordnung that no longer matches anzahl. Canceled participants are left out, as for every
|
||||
* other service.
|
||||
* Maps the participant to their assigned room. Allows room reassignment during
|
||||
* booking edits while maintaining the constraint that participants can only be
|
||||
* assigned to room types that have already been booked.
|
||||
*
|
||||
* @param Booking $bookingData The booking data object to update
|
||||
* @param ParticipantDto $participant The participant data from the form
|
||||
* @param Booking $bookingData The booking data object to update
|
||||
*/
|
||||
public function removeCanceledParticipantsFromRooms(Booking $bookingData): void
|
||||
private function processRoomAssignment(ParticipantDto $participant, Booking $bookingData): void
|
||||
{
|
||||
if (null === $participant->assignedRoomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the room in the existing booking by ID
|
||||
foreach ($bookingData->rooms as $room) {
|
||||
$room->mapping = array_values(array_filter(
|
||||
$room->mapping,
|
||||
fn (int $index) => 'S' !== ($bookingData->participantsStatus[$index] ?? null)
|
||||
));
|
||||
if ($room->id === $participant->assignedRoomId) {
|
||||
$room->mapping[] = $participant->index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\DataProcessor;
|
||||
|
||||
use App\Form\Model\VoucherPurchaseDto;
|
||||
|
||||
/**
|
||||
* Builds the GUTSCHEINBUCHUNG and GUTSCHEINVORSCHAU payloads from the voucher purchase DTO.
|
||||
*
|
||||
* The buyer goes into <rechnungsempfänger> and nothing else: linking the purchase to a customer
|
||||
* record is BusPro's business. The <zahlung> attributes are echoed from GUTSCHEINZAHLUNGSARTEN.
|
||||
*/
|
||||
class VoucherSalePayloadBuilder
|
||||
{
|
||||
/**
|
||||
* @param string $saleType Constants::BOOKING_TYPE_INQUIRY for the dry run, Constants::BOOKING_TYPE_BOOKING for the purchase
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildSalePayload(VoucherPurchaseDto $dto, string $saleType): array
|
||||
{
|
||||
if (null === $dto->template || null === $dto->paymentMethod) {
|
||||
throw new \LogicException('A voucher sale needs a template and a payment method.');
|
||||
}
|
||||
|
||||
$communication = [
|
||||
'telefonprivat' => $dto->phone,
|
||||
'email' => $dto->email,
|
||||
];
|
||||
// Not in the vendor's GUTSCHEINBUCHUNG example; the element name is the one BUCHUNG uses.
|
||||
if (null !== $dto->mobile && '' !== trim($dto->mobile)) {
|
||||
$communication['telefonmobil'] = $dto->mobile;
|
||||
}
|
||||
|
||||
return [
|
||||
'buchungsart' => $saleType,
|
||||
'gutscheinart' => $dto->template->type,
|
||||
'idgutscheinvorlage' => $dto->template->id,
|
||||
'kaeufer' => $dto->from,
|
||||
'empfaenger' => $dto->to,
|
||||
'kaufpreis' => $dto->amount,
|
||||
'text' => '',
|
||||
'rechnungsempfänger' => [
|
||||
'name' => $dto->lastName,
|
||||
'vorname' => $dto->firstName,
|
||||
'anrede' => $dto->salutation,
|
||||
'titel' => $dto->title,
|
||||
'geschlecht' => '',
|
||||
'geburtsdatum' => '',
|
||||
'anschrift' => $dto->address->toPayload(),
|
||||
'kommunikation' => $communication,
|
||||
],
|
||||
'zahlung' => [
|
||||
'@idzahlungsart' => $dto->paymentMethod->id,
|
||||
'@bezeichnung' => $dto->paymentMethod->label,
|
||||
'@art' => $dto->paymentMethod->type,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildPreviewPayload(VoucherPurchaseDto $dto): array
|
||||
{
|
||||
if (null === $dto->template) {
|
||||
throw new \LogicException('A voucher preview needs a template.');
|
||||
}
|
||||
|
||||
return [
|
||||
'idgutscheinvorlage' => $dto->template->id,
|
||||
'kaeufer' => $dto->from,
|
||||
'empfaenger' => $dto->to,
|
||||
'kaufpreis' => $dto->amount,
|
||||
'text' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
namespace App\BusProNet\DataProvider;
|
||||
|
||||
use App\BusProNet\Client\ReferenceDataClient;
|
||||
use App\BusProNet\MessageType;
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Model\Country;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
@@ -11,7 +10,7 @@ use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
class CountryDataProvider
|
||||
{
|
||||
public function __construct(private readonly ReferenceDataClient $referenceDataClient, private readonly CacheInterface $cache)
|
||||
public function __construct(private readonly ApiClient $apiClient, private readonly CacheInterface $cache)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -23,8 +22,8 @@ class CountryDataProvider
|
||||
$item->expiresAfter(6 * 60 * 60);
|
||||
|
||||
return $this
|
||||
->referenceDataClient
|
||||
->getBaseData(MessageType::Countries)
|
||||
->apiClient
|
||||
->getBaseData(ApiClient::TYPE_BASE_DATA_COUNTRIES)
|
||||
->getItems()
|
||||
;
|
||||
});
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\BusProNet;
|
||||
|
||||
/**
|
||||
* The `typ` of BusPro's `<satz>`: the command on a request, the kind of answer on a response.
|
||||
*/
|
||||
enum MessageType: string
|
||||
{
|
||||
// Only ever a response: BusPro's refusal or remark, whatever was asked.
|
||||
case Notification = 'HINWEIS';
|
||||
case CustomerData = 'KUNDENKONTO';
|
||||
case Countries = 'STAMMLAENDER';
|
||||
case MutableData = 'MOEGLICHEAENDERUNGEN';
|
||||
case Availability = 'VERFUEGBARKEIT';
|
||||
case AvailabilityExtended = 'VERFUEGBARKEIT2';
|
||||
case AvailabilityHotel = 'VERFUEGBARKEITHOTEL';
|
||||
case BookingUpdate = 'BUCHUNGAENDERUNG';
|
||||
case Booking = 'BUCHUNG';
|
||||
case Products = 'PRODUKTE';
|
||||
case ProductData = 'PRODUKTDATEN';
|
||||
case Agencies = 'AGENTUREN';
|
||||
case PurchaseVoucher = 'GUTSCHEINPRUEFUNGEINLOESUNG';
|
||||
case PromoVoucher = 'AKTIONSGUTSCHEIN';
|
||||
// Selling Kaufgutscheine. Not to be confused with PurchaseVoucher, which redeems one in a booking.
|
||||
case VoucherTemplates = 'GUTSCHEINVORLAGEN';
|
||||
case VoucherPaymentMethods = 'GUTSCHEINZAHLUNGSARTEN';
|
||||
case VoucherPreview = 'GUTSCHEINVORSCHAU';
|
||||
case VoucherSale = 'GUTSCHEINBUCHUNG';
|
||||
}
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
use function Symfony\Component\String\u;
|
||||
|
||||
/**
|
||||
|
||||
@@ -143,31 +143,6 @@ class Booking
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a participant already holds a service in this booking.
|
||||
*
|
||||
* This is the BusPro-side baseline, not the working edit selection: it answers
|
||||
* "did the participant have this service when the booking was last read back".
|
||||
* Services already held may be kept regardless of their current status or
|
||||
* contingent - withdrawing one produces "Anzahl Leistung stimmt nicht mit
|
||||
* Teilnehmerzuordnung überein" on the next update.
|
||||
*
|
||||
* @param int $participantIndex The participant index to search for
|
||||
* @param int $serviceId The service ID to look for
|
||||
*
|
||||
* @return bool True if the participant already holds the service
|
||||
*/
|
||||
public function hasServiceForParticipant(int $participantIndex, int $serviceId): bool
|
||||
{
|
||||
foreach ([...$this->additionalServices, ...$this->transportationServices] as $service) {
|
||||
if ($service->id === $serviceId && in_array($participantIndex, $service->mapping)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves transportation service for a specific participant and direction.
|
||||
*
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
use App\Validator\Constraints as AppAssert;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
@@ -16,47 +15,17 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
*/
|
||||
class Communication
|
||||
{
|
||||
#[Assert\When(
|
||||
expression: 'this.isPhoneNewlyEntered()',
|
||||
constraints: [new AppAssert\PhoneNumber()],
|
||||
groups: ['personal_data'],
|
||||
)]
|
||||
public ?string $phone = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||
#[Assert\When(
|
||||
expression: 'this.isMobileNewlyEntered()',
|
||||
constraints: [new AppAssert\PhoneNumber()],
|
||||
groups: ['personal_data'],
|
||||
)]
|
||||
public ?string $mobile = null;
|
||||
|
||||
// The numbers as loaded from BusPro, which go back unchanged and are exempt from PhoneNumber.
|
||||
public ?string $loadedPhone = null;
|
||||
public ?string $loadedMobile = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_identity'])]
|
||||
#[Assert\Email(message: 'Bitte eine gültige Adresse angeben', mode: 'strict', groups: ['personal_identity'])]
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||
#[Assert\Email(message: 'Bitte eine gültige Adresse angeben', mode: 'strict', groups: ['personal_data'])]
|
||||
public ?string $email = null;
|
||||
|
||||
public bool $newsletter = false;
|
||||
|
||||
public function rememberLoadedNumbers(): void
|
||||
{
|
||||
$this->loadedPhone = $this->phone;
|
||||
$this->loadedMobile = $this->mobile;
|
||||
}
|
||||
|
||||
public function isPhoneNewlyEntered(): bool
|
||||
{
|
||||
return AppAssert\PhoneNumber::isNewlyEntered($this->phone, $this->loadedPhone);
|
||||
}
|
||||
|
||||
public function isMobileNewlyEntered(): bool
|
||||
{
|
||||
return AppAssert\PhoneNumber::isNewlyEntered($this->mobile, $this->loadedMobile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the communication data to API payload format.
|
||||
*
|
||||
|
||||
@@ -35,15 +35,4 @@ class Hotel
|
||||
|
||||
#[Groups(['api:list', 'api:single', 'snapshot'])]
|
||||
public ?string $country = null;
|
||||
|
||||
/**
|
||||
* Raw `kostenstelle`, deliberately a string: coverage is thin and some hotels carry a
|
||||
* space-separated list such as "84 85", so it is an opaque label, not a number.
|
||||
*/
|
||||
#[Groups(['api:list', 'api:single', 'snapshot'])]
|
||||
public ?string $costUnit = null;
|
||||
|
||||
/** {@see \App\BusProNet\Utility\CountryCodeMapper} — lowercase ISO-2, null when unmappable. */
|
||||
#[Groups(['api:list', 'api:single', 'snapshot'])]
|
||||
public ?string $countryCode = null;
|
||||
}
|
||||
@@ -23,12 +23,10 @@ class PersonalData
|
||||
public bool $mutable = false;
|
||||
public ?string $status = null;
|
||||
|
||||
// personal_identity holds what the user cannot edit in MyE&P (name and the login e-mail), so the
|
||||
// personal-data form can validate everything else while the completeness check still sees it.
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_identity'])]
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_data'])]
|
||||
public ?string $name = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_identity'])]
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_data'])]
|
||||
public ?string $firstName = '';
|
||||
|
||||
public ?string $salutation = null;
|
||||
@@ -55,7 +53,7 @@ class PersonalData
|
||||
#[Assert\Valid(groups: ['personal_data'])]
|
||||
public Address $address;
|
||||
|
||||
#[Assert\Valid(groups: ['personal_data', 'personal_identity'])]
|
||||
#[Assert\Valid(groups: ['personal_data'])]
|
||||
public Communication $communication;
|
||||
|
||||
/** @var list<string> */
|
||||
@@ -63,13 +61,6 @@ class PersonalData
|
||||
/** @var list<string> */
|
||||
public array $hotelCodes = [];
|
||||
|
||||
// The authenticated account, not BusPro's idea of this person. One BusPro person may sign in
|
||||
// under any of the addresses on its record, and each of those is a separate local account with
|
||||
// its own roles, so neither the BusPro ids nor the communication email identifies one. Patched
|
||||
// on by the caller in the same way as the roles and hotel codes above.
|
||||
public ?string $subject = null;
|
||||
public ?string $loginEmail = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->address = new Address();
|
||||
@@ -141,21 +132,14 @@ class PersonalData
|
||||
* Creates a structured array containing user profile information
|
||||
* suitable for JWT claims or user session data.
|
||||
*
|
||||
* `sub` and `email` describe the account that authenticated and are read from the patched-on
|
||||
* fields, never from BusPro: `person_id`/`address_id` are shared by every account of the same
|
||||
* person, and `profile.communication.email` is the first contact address on the BusPro record,
|
||||
* which is not necessarily the one signed in with. Deliberately without a fallback to that
|
||||
* address — a caller that forgets to patch them gets null rather than a wrong identity.
|
||||
*
|
||||
* @return array<string, mixed> The claims array with user profile data
|
||||
*/
|
||||
public function getClaims(): array
|
||||
{
|
||||
return [
|
||||
'sub' => $this->subject,
|
||||
'person_id' => $this->personId,
|
||||
'address_id' => $this->addressId,
|
||||
'email' => $this->loginEmail,
|
||||
'email' => $this->communication->email,
|
||||
'roles' => $this->roles,
|
||||
'profile' => [
|
||||
'first_name' => $this->firstName,
|
||||
|
||||
@@ -20,12 +20,6 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
class Travel
|
||||
{
|
||||
use SortByPriceTrait;
|
||||
|
||||
/**
|
||||
* CRM selection that offers the "I am not travelling myself" choice on a travel date.
|
||||
*/
|
||||
private const NON_TRAVELLING_APPLICANT_SELECTION_ID = 1488;
|
||||
|
||||
#[Groups(['api:list', 'api:single', 'snapshot'])]
|
||||
public ?int $id = null;
|
||||
|
||||
@@ -246,15 +240,6 @@ class Travel
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a booking for this travel date may be placed by an applicant who does not travel
|
||||
* along, such as a parent booking for their children.
|
||||
*/
|
||||
public function allowsNonTravellingApplicant(): bool
|
||||
{
|
||||
return $this->hasSelectionId(self::NON_TRAVELLING_APPLICANT_SELECTION_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves rooms filtered by their IDs.
|
||||
*
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
/**
|
||||
* A payment method for voucher purchases (GUTSCHEINZAHLUNGSARTEN).
|
||||
*
|
||||
* Echoed unchanged as the <zahlung> attributes of GUTSCHEINBUCHUNG.
|
||||
*/
|
||||
final readonly class VoucherPaymentMethod
|
||||
{
|
||||
public const string TYPE_TRANSFER = 'UEBERW';
|
||||
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public string $label,
|
||||
public string $type,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isTransfer(): bool
|
||||
{
|
||||
return self::TYPE_TRANSFER === $this->type;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
/**
|
||||
* The GUTSCHEINVORSCHAU response, decoded from its data URI to the raw PDF bytes.
|
||||
*/
|
||||
final readonly class VoucherPreview
|
||||
{
|
||||
public function __construct(
|
||||
public string $pdf,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
/**
|
||||
* A GUTSCHEINBUCHUNG response that is not a HINWEIS.
|
||||
*
|
||||
* The dry run (buchungsart Anfrage) answers <buchung>möglich</buchung> and nothing else, so every
|
||||
* other field is only set by the actual purchase (buchungsart Buchung, <buchung>erfolgt</buchung>).
|
||||
*
|
||||
* The redemption code is deliberately not exposed to the customer before payment: BusPro mails the
|
||||
* voucher itself once the transfer has arrived.
|
||||
*/
|
||||
final readonly class VoucherSaleResponse
|
||||
{
|
||||
public const string SALE_POSSIBLE = 'möglich';
|
||||
public const string SALE_PROCESSED = 'erfolgt';
|
||||
|
||||
public function __construct(
|
||||
public string $status,
|
||||
public ?int $voucherId = null,
|
||||
public ?string $voucherNumber = null,
|
||||
public ?string $redemptionCode = null,
|
||||
public ?float $price = null,
|
||||
public ?string $invoiceNumber = null,
|
||||
public ?string $email = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isInquiryValid(): bool
|
||||
{
|
||||
return self::SALE_POSSIBLE === $this->status;
|
||||
}
|
||||
|
||||
public function isSaleSuccessful(): bool
|
||||
{
|
||||
return self::SALE_PROCESSED === $this->status;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
/**
|
||||
* A voucher design offered for online sale (GUTSCHEINVORLAGEN).
|
||||
*
|
||||
* The id doubles as the key for the local thumbnail, see VoucherThumbnailResolver.
|
||||
*/
|
||||
final readonly class VoucherTemplate
|
||||
{
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public string $type,
|
||||
public string $label,
|
||||
public ?string $note = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
/**
|
||||
* The GUTSCHEINVORLAGEN response: the designs on sale plus BusPro's amount bounds.
|
||||
*
|
||||
* The bounds (gutscheinminbetrag/gutscheinmaxbetrag) are not in the vendor documentation,
|
||||
* they were measured on the test system (10 and 5,000 €, both inclusive).
|
||||
*/
|
||||
final readonly class VoucherTemplates
|
||||
{
|
||||
/**
|
||||
* @param list<VoucherTemplate> $templates
|
||||
*/
|
||||
public function __construct(
|
||||
public int $minAmount,
|
||||
public int $maxAmount,
|
||||
public array $templates,
|
||||
) {
|
||||
}
|
||||
|
||||
public function find(int $id): ?VoucherTemplate
|
||||
{
|
||||
foreach ($this->templates as $template) {
|
||||
if ($id === $template->id) {
|
||||
return $template;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
use League\Flysystem\FilesystemException;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
|
||||
final readonly class XmlExportInfo
|
||||
{
|
||||
private const INFO_FILENAME = 'uebertragung.info';
|
||||
@@ -23,26 +20,6 @@ final readonly class XmlExportInfo
|
||||
return self::INFO_FILENAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the marker from a storage, or null when it is missing or unreadable.
|
||||
*
|
||||
* A marker caught mid-sync or truncated is treated as absent rather than as an error:
|
||||
* callers only ever want to know which export they are looking at, and "unknown" is
|
||||
* an answer they can act on.
|
||||
*/
|
||||
public static function fromStorage(FilesystemOperator $storage): ?self
|
||||
{
|
||||
try {
|
||||
if (false === $storage->fileExists(self::INFO_FILENAME)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::fromString($storage->read(self::INFO_FILENAME));
|
||||
} catch (FilesystemException|\InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromString(string $content): self
|
||||
{
|
||||
$content = mb_convert_encoding($content, 'UTF-8', 'Windows-1252');
|
||||
|
||||
@@ -79,9 +79,6 @@ trait TypeConversionTrait
|
||||
* This method is used primarily to parse pickup-times since data is
|
||||
* either provided with date and time or time only. In this case a
|
||||
* date has to be provided which defaults to a travel's start date.
|
||||
*
|
||||
* Both shapes are read in the default date's zone, so a caller that needs plain clock
|
||||
* readings can pass a UTC date and no daylight saving gap shifts the time.
|
||||
*/
|
||||
protected function stringToDateTimeFuzzy(?string $string, \DateTimeImmutable $defaultDate): ?\DateTimeImmutable
|
||||
{
|
||||
@@ -92,12 +89,12 @@ trait TypeConversionTrait
|
||||
try {
|
||||
if (1 === preg_match('/^\d{2}:\d{2}$/', $string)) {
|
||||
// time only provided
|
||||
$dateTime = Carbon::createFromFormat('H:i', $string, $defaultDate->getTimezone());
|
||||
$dateTime = Carbon::createFromFormat('H:i', $string);
|
||||
// assign default date
|
||||
$dateTime->setDateFrom($defaultDate);
|
||||
} else {
|
||||
// date and time provided
|
||||
$dateTime = Carbon::createFromFormat('d.m.Y H:i', substr($string, 0, 16), $defaultDate->getTimezone());
|
||||
$dateTime = Carbon::createFromFormat('d.m.Y H:i', substr($string, 0, 16));
|
||||
}
|
||||
|
||||
return $dateTime->toDateTimeImmutable();
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Transport;
|
||||
|
||||
use App\Service\RequestIdGenerator;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
/**
|
||||
* Ids for BPN requests: the id of the current HTTP request, or a fresh one on the CLI, with a
|
||||
* running number, so the log entries and dumps of one page load can be found together.
|
||||
*/
|
||||
class RequestIdSequence
|
||||
{
|
||||
private int $counter = 0;
|
||||
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly RequestIdGenerator $requestIdGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
public function next(): string
|
||||
{
|
||||
$baseId = $this->requestStack->getMainRequest()?->attributes->get('request_id')
|
||||
?? $this->requestIdGenerator->generateBaseId();
|
||||
|
||||
return $baseId.'_'.++$this->counter;
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\BusProNet\Transport;
|
||||
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\Exception\ImmediateConnectionCloseException;
|
||||
use App\BusProNet\Exception\TimeoutException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* BusPro's socket framing: one connection per request, each message prefixed with its length
|
||||
* as a 10 byte header. Knows nothing about what the messages contain.
|
||||
*/
|
||||
class SocketTransport
|
||||
{
|
||||
private float $operationStartTime;
|
||||
private int $selectedPort;
|
||||
|
||||
/** @param list<int|string> $ports */
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly string $host,
|
||||
private readonly array $ports,
|
||||
private readonly int $connectionTimeout = 5,
|
||||
private readonly int $streamTimeout = 30,
|
||||
private readonly int $totalTimeout = 45,
|
||||
private readonly int $maxRetries = 25,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends $body over a fresh connection and returns the response payload. $logMessage is
|
||||
* logged with $context and the chosen port once the connection is open.
|
||||
*
|
||||
* @param array<string, mixed> $context
|
||||
*
|
||||
* @throws ApiClientException
|
||||
* @throws ImmediateConnectionCloseException
|
||||
*/
|
||||
public function exchange(string $body, string $logMessage, array $context = []): string
|
||||
{
|
||||
$socket = $this->connect();
|
||||
|
||||
$this->logger->info($logMessage, [...$context, 'port' => $this->selectedPort]);
|
||||
|
||||
$this->send($socket, $body);
|
||||
|
||||
try {
|
||||
// the length header is consumed and verified by receive(), this is the payload
|
||||
return $this->receive($socket);
|
||||
} finally {
|
||||
$this->disconnect($socket);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return resource
|
||||
*
|
||||
* @throws ApiClientException
|
||||
* @throws TimeoutException
|
||||
*/
|
||||
private function connect()
|
||||
{
|
||||
// Randomly select a port from the available ports for load balancing
|
||||
$this->selectedPort = $this->ports[array_rand($this->ports)];
|
||||
$this->operationStartTime = microtime(true);
|
||||
|
||||
$tries = 1;
|
||||
$errNo = $errStr = '';
|
||||
$errorCodesForRetry = [
|
||||
SOCKET_ECONNREFUSED,
|
||||
SOCKET_EBADF,
|
||||
];
|
||||
|
||||
$openSocket = function (&$errNo, &$errStr) {
|
||||
return @fsockopen(
|
||||
$this->host,
|
||||
$this->selectedPort,
|
||||
$errNo,
|
||||
$errStr,
|
||||
$this->connectionTimeout
|
||||
);
|
||||
};
|
||||
|
||||
$socket = $openSocket($errNo, $errStr);
|
||||
|
||||
while (false === $socket && true === in_array($errNo, $errorCodesForRetry) && $this->maxRetries > $tries) {
|
||||
// Check if we've exceeded total timeout during retries
|
||||
if (microtime(true) - $this->operationStartTime > $this->totalTimeout) {
|
||||
$this->logger->error('Connection retry timeout exceeded', [
|
||||
'elapsed_time' => microtime(true) - $this->operationStartTime,
|
||||
'total_timeout' => $this->totalTimeout,
|
||||
'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->streamTimeout);
|
||||
} 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->totalTimeout) {
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a single response message and returns its payload. The protocol prepends the
|
||||
* payload length as a 10 byte header, so the announced length is compared against what
|
||||
* actually arrived: a peer closing mid-stream would otherwise yield a silently
|
||||
* truncated body that only surfaces later as an unexplained parse failure.
|
||||
*
|
||||
* @param resource $socket
|
||||
*
|
||||
* @throws ApiClientException
|
||||
* @throws TimeoutException
|
||||
* @throws ImmediateConnectionCloseException
|
||||
*/
|
||||
private function receive($socket): string
|
||||
{
|
||||
$response = '';
|
||||
$readAttempts = 0;
|
||||
$totalTimeout = $this->totalTimeout;
|
||||
|
||||
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 $this->extractPayload($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
private function extractPayload(string $response): string
|
||||
{
|
||||
if (10 > strlen($response)) {
|
||||
$this->logger->error('Incomplete response header received from API', [
|
||||
'bytes_received' => strlen($response),
|
||||
'port' => $this->selectedPort,
|
||||
]);
|
||||
|
||||
throw new ApiClientException(sprintf('Incomplete response header, got %d of 10 bytes', strlen($response)));
|
||||
}
|
||||
|
||||
$header = substr($response, 0, 10);
|
||||
$announcedLength = (int) trim($header);
|
||||
$payload = substr($response, 10);
|
||||
|
||||
if (1 > $announcedLength) {
|
||||
$this->logger->error('Empty response body announced by API', [
|
||||
'header' => trim($header),
|
||||
'port' => $this->selectedPort,
|
||||
]);
|
||||
|
||||
throw new ApiClientException(sprintf('Response announced an empty body (header "%s")', trim($header)));
|
||||
}
|
||||
|
||||
if (strlen($payload) !== $announcedLength) {
|
||||
$this->logger->error('Truncated response received from API', [
|
||||
'bytes_received' => strlen($payload),
|
||||
'announced_length' => $announcedLength,
|
||||
'port' => $this->selectedPort,
|
||||
]);
|
||||
|
||||
throw new ApiClientException(sprintf('Truncated response, got %d of %d announced bytes', strlen($payload), $announcedLength));
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/** @param resource $socket */
|
||||
private function disconnect($socket): void
|
||||
{
|
||||
@fclose($socket);
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\BusProNet\Transport;
|
||||
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\Exception\ImmediateConnectionCloseException;
|
||||
use App\BusProNet\Exception\ResponseParserException;
|
||||
use App\BusProNet\MessageType;
|
||||
use App\BusProNet\Utility\XmlCharacterFilter;
|
||||
use App\BusProNet\XmlParser\ApiResponseParser;
|
||||
use App\Service\XmlDumpWriter;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Serializer\Encoder\XmlEncoder;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
/**
|
||||
* The XML protocol around one BusPro command: authentication, serialization, dumps, the
|
||||
* busy-retry and parsing. What a command contains is up to the caller.
|
||||
*/
|
||||
class XmlChannel
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
private array $config;
|
||||
|
||||
/** @param array<string, mixed> $options */
|
||||
public function __construct(
|
||||
private readonly SerializerInterface $serializer,
|
||||
private readonly ApiResponseParser $responseParser,
|
||||
private readonly XmlDumpWriter $dumpWriter,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly RequestIdSequence $requestIds,
|
||||
private readonly SocketTransport $transport,
|
||||
array $options,
|
||||
) {
|
||||
$this->config = $this->resolveOptions($options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends command $type with $fields and returns the parsed response.
|
||||
*
|
||||
* @param array<string, mixed> $fields
|
||||
* @param array<string, mixed> $parserArgs
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function request(MessageType $type, array $fields = [], array $parserArgs = [], bool $debug = false): mixed
|
||||
{
|
||||
$data = $this->envelope($type, $fields);
|
||||
|
||||
return $this->executeWithRetry(function () use ($type, $data, $parserArgs, $debug) {
|
||||
$requestId = $this->requestIds->next();
|
||||
$dump = $debug || true === $this->config['debug'];
|
||||
|
||||
$xml = $this->exchange($requestId, $this->serializeXml($data), $type->value, 'Sending request to BPN API', $dump);
|
||||
|
||||
return $this->parseResponse($type, $xml, $parserArgs, $requestId);
|
||||
}, $type->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like request(), but returns the unparsed response.
|
||||
*
|
||||
* @param array<string, mixed> $fields
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function requestRaw(MessageType $type, array $fields = []): string
|
||||
{
|
||||
$data = $this->envelope($type, $fields);
|
||||
|
||||
return $this->executeWithRetry(function () use ($type, $data) {
|
||||
$requestId = $this->requestIds->next();
|
||||
$body = $this->serializeXml($data);
|
||||
|
||||
return $this->exchange($requestId, $body, $type->value, 'Sending raw request to BPN API', true === $this->config['debug']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a recorded request as is, except for the key, which is regenerated for the current
|
||||
* date. Returns the unparsed response.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function replay(string $xml, bool $debug = false): string
|
||||
{
|
||||
return $this->executeWithRetry(fn () => $this->doReplay($xml, $debug));
|
||||
}
|
||||
|
||||
/**
|
||||
* The authentication every request starts with, followed by the command's own fields.
|
||||
*
|
||||
* @param array<string, mixed> $fields
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function envelope(MessageType $type, array $fields): array
|
||||
{
|
||||
return [
|
||||
'user' => $this->config['bpn_username'],
|
||||
'key' => $this->createKey($type->value),
|
||||
'satz' => ['@typ' => $type->value],
|
||||
...$fields,
|
||||
];
|
||||
}
|
||||
|
||||
private function createKey(string $type): string
|
||||
{
|
||||
$date = (new \DateTimeImmutable())->format('Ymd');
|
||||
|
||||
return md5($this->config['bpn_username'].$this->config['bpn_password'].$date.$type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'];
|
||||
$typeContext = null !== $type ? ['type' => $type] : [];
|
||||
$lastException = null;
|
||||
|
||||
for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) {
|
||||
try {
|
||||
return $operation();
|
||||
} catch (ImmediateConnectionCloseException $e) {
|
||||
$lastException = $e;
|
||||
|
||||
if ($attempt < $maxAttempts) {
|
||||
$this->logger->warning('BPN server busy, retrying request', [
|
||||
'attempt' => $attempt,
|
||||
'maxAttempts' => $maxAttempts,
|
||||
'retryDelay' => $retryDelay,
|
||||
...$typeContext,
|
||||
]);
|
||||
sleep($retryDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->error('BPN server busy after all retry attempts', ['attempts' => $maxAttempts, ...$typeContext]);
|
||||
|
||||
throw $lastException;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
* @throws ImmediateConnectionCloseException
|
||||
*/
|
||||
private function doReplay(string $xml, bool $debug): string
|
||||
{
|
||||
$requestId = $this->requestIds->next();
|
||||
|
||||
$doc = new \DOMDocument();
|
||||
if (false === @$doc->loadXML($xml)) {
|
||||
throw new ApiClientException('Invalid XML provided');
|
||||
}
|
||||
|
||||
$satzNode = $doc->getElementsByTagName('satz')->item(0);
|
||||
if (null === $satzNode) {
|
||||
throw new ApiClientException('Missing <satz> element in XML');
|
||||
}
|
||||
|
||||
$type = $satzNode->getAttribute('typ');
|
||||
if ('' === $type) {
|
||||
throw new ApiClientException('Missing typ attribute on <satz> element');
|
||||
}
|
||||
|
||||
$keyNode = $doc->getElementsByTagName('key')->item(0);
|
||||
if (null === $keyNode) {
|
||||
throw new ApiClientException('Missing <key> element in XML');
|
||||
}
|
||||
|
||||
$keyNode->nodeValue = $this->createKey($type);
|
||||
|
||||
return $this->exchange($requestId, $doc->saveXML(), $type, 'Sending raw XML request to BPN API', $debug || true === $this->config['debug']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
* @throws ImmediateConnectionCloseException
|
||||
*/
|
||||
private function exchange(string $requestId, string $body, string $type, string $logMessage, bool $dump): string
|
||||
{
|
||||
if ($dump) {
|
||||
$this->dumpWriter->write($requestId, XmlDumpWriter::REQUEST, $body);
|
||||
}
|
||||
|
||||
$response = $this->transport->exchange($body, $logMessage, ['requestId' => $requestId, 'type' => $type]);
|
||||
|
||||
if ($dump) {
|
||||
$this->dumpWriter->write($requestId, XmlDumpWriter::RESPONSE, $response);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parserArgs
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
private function parseResponse(MessageType $type, string $xml, array $parserArgs, string $requestId, bool $dumpOnFailure = true): mixed
|
||||
{
|
||||
// The dump keeps the raw response; only the parser sees the filtered one.
|
||||
$filtered = XmlCharacterFilter::strip($xml, $removed);
|
||||
if ($removed > 0) {
|
||||
$this->logger->warning('Removed characters not allowed in XML from API response', [
|
||||
'request_id' => $requestId,
|
||||
'type' => $type->value,
|
||||
'removed' => $removed,
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->responseParser->parseXmlString($type, $filtered, $parserArgs);
|
||||
} catch (ResponseParserException $e) {
|
||||
// Keep the evidence even outside debug mode: without the raw response a parse
|
||||
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
|
||||
if ($dumpOnFailure) {
|
||||
$this->dumpWriter->write($requestId, XmlDumpWriter::RESPONSE, $xml);
|
||||
}
|
||||
|
||||
$this->logger->error('Unable to parse response received from API', [
|
||||
'request_id' => $requestId,
|
||||
'type' => $type->value,
|
||||
'response_length' => strlen($xml),
|
||||
'error_message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw new ApiClientException('Unable to parse response received from API: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
private function serializeXml(array $data): string
|
||||
{
|
||||
return $this->serializer->serialize($data, 'xml', [
|
||||
XmlEncoder::ROOT_NODE_NAME => 'anfrage',
|
||||
XmlEncoder::ENCODING => 'UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resolveOptions(array $options): array
|
||||
{
|
||||
$optionsResolver = new OptionsResolver();
|
||||
$optionsResolver->setRequired([
|
||||
'bpn_username',
|
||||
'bpn_password',
|
||||
]);
|
||||
$optionsResolver->setDefaults([
|
||||
'debug' => false,
|
||||
'busy_retry_attempts' => 3,
|
||||
'busy_retry_delay' => 1,
|
||||
]);
|
||||
|
||||
return $optionsResolver->resolve($options);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 654 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user