Compare commits
158
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3221008f90 | ||
|
|
b27639c7fa | ||
|
|
ca1ab17595 | ||
|
|
c5685e6d7c | ||
|
|
0a5602fd41 | ||
|
|
f3745f918e | ||
|
|
65c1a7bd12 | ||
|
|
d0e7ba017c | ||
|
|
be0e15b3d4 | ||
|
|
f5e6d2105d | ||
|
|
0c6126010f | ||
|
|
29f295abf6 | ||
|
|
2aa98b81fe | ||
|
|
56adf72d12 | ||
|
|
773cd4f847 | ||
|
|
876e69e62c | ||
|
|
96b7b3f451 | ||
|
|
e751eed4a0 | ||
|
|
bad916cfb7 | ||
|
|
cc3d32fcbb | ||
|
|
79709e3a3e | ||
|
|
ab7d00b35d | ||
|
|
679a864906 | ||
|
|
f080d05dd6 | ||
|
|
00637042e2 | ||
|
|
fbb3ebb8c1 | ||
|
|
ec83ad598f | ||
|
|
2cb4871268 | ||
|
|
c1415dee41 | ||
|
|
540cd8eeb7 | ||
|
|
725e32daab | ||
|
|
160ebef39e | ||
|
|
672fc30d7e | ||
|
|
ba4f66f117 | ||
|
|
12fe4d8e15 | ||
|
|
5d7ffd7d58 | ||
|
|
6a8328a564 | ||
|
|
019de4e705 | ||
|
|
a1d1fdde14 | ||
|
|
1d14f00bb7 | ||
|
|
042037867b | ||
|
|
5f6ff13ce7 | ||
|
|
f08d9a9f40 | ||
|
|
7b49375efa | ||
|
|
91522ab32d | ||
|
|
a3e8c737de | ||
|
|
67c685f6c1 | ||
|
|
7aad54e6f2 | ||
|
|
b41820c39d | ||
|
|
fab89dead6 | ||
|
|
443a3ed248 | ||
|
|
5b133f724d | ||
|
|
5f8a586385 | ||
|
|
e863025fb1 | ||
|
|
91a38faecd | ||
|
|
1d3263373e | ||
|
|
f8ff4b82a3 | ||
|
|
d6c2cfaf1c | ||
|
|
cfe901b9c2 | ||
|
|
8443dae5ad | ||
|
|
dea7a3dc97 | ||
|
|
9e3be24c51 | ||
|
|
e744ebbe33 | ||
|
|
45ca0d2b46 | ||
|
|
f12561c62b | ||
|
|
976b30ccc0 | ||
|
|
198fbfe6a2 | ||
|
|
acb719730c | ||
|
|
0e3d4cbedb | ||
|
|
715fae8e33 | ||
|
|
f595183c05 | ||
|
|
98e0cfcf6f | ||
|
|
e14d29f7d4 | ||
|
|
24df764efb | ||
|
|
e3c08afe73 | ||
|
|
81b724c11a | ||
|
|
bfad61f1dd | ||
|
|
010cfe56b2 | ||
|
|
c1fa08fed0 | ||
|
|
ec41e2b02d | ||
|
|
bbc1b6c68a | ||
|
|
80d734e7d6 | ||
|
|
61e020c3d0 | ||
|
|
4e6aaba9ff | ||
|
|
578a21d327 | ||
|
|
c9c28792e5 | ||
|
|
35826088cb | ||
|
|
178df9b787 | ||
|
|
96a39d5cb2 | ||
|
|
83b8ba7f8d | ||
|
|
1796ec4964 | ||
|
|
48db290625 | ||
|
|
7c5f6dd5ef | ||
|
|
0a1683667f | ||
|
|
ef439f6faf | ||
|
|
98a303e941 | ||
|
|
3db1850e4f | ||
|
|
c1f0cda479 | ||
|
|
351fbab498 | ||
|
|
d5f9f4ef10 | ||
|
|
e105c611be | ||
|
|
6cdb331aae | ||
|
|
7b0bf9ec94 | ||
|
|
8617bf6c86 | ||
|
|
2601e46ec4 | ||
|
|
0c667d6b69 | ||
|
|
5a3957e143 | ||
|
|
e8accbb3ed | ||
|
|
2d7a02341d | ||
|
|
d3666c4618 | ||
|
|
ef24c9a616 | ||
|
|
8dee65d0dc | ||
|
|
5045846eed | ||
|
|
e8143c055b | ||
|
|
920b09dacc | ||
|
|
b5f1163e2d | ||
|
|
8271a69f45 | ||
|
|
f00351706e | ||
|
|
a5d97f1206 | ||
|
|
582e54ddc4 | ||
|
|
adee913568 | ||
|
|
396aa0bac6 | ||
|
|
77de1643f0 | ||
|
|
bf69fcceff | ||
|
|
d74127ebe2 | ||
|
|
aa486a4931 | ||
|
|
7ebd2dad1a | ||
|
|
7a82127494 | ||
|
|
490acc2e8b | ||
|
|
6af8044f7c | ||
|
|
70a10c4dba | ||
|
|
a1506ef58d | ||
|
|
af04ff690d | ||
|
|
6c1073e41c | ||
|
|
124c0af0f5 | ||
|
|
212bb660a1 | ||
|
|
6ee00f2bee | ||
|
|
b82e017f73 | ||
|
|
577f7445ab | ||
|
|
3a5895e356 | ||
|
|
dd658bf9d6 | ||
|
|
aa8fa5c1b2 | ||
|
|
2cfa87f44b | ||
|
|
0b642dcfe4 | ||
|
|
8a46908856 | ||
|
|
736128476b | ||
|
|
8e4afc0813 | ||
|
|
e5fa63b7e8 | ||
|
|
84df3a94d9 | ||
|
|
2c381ee741 | ||
|
|
dc86e715cb | ||
|
|
5f2252af71 | ||
|
|
455456b6bc | ||
|
|
045fdea923 | ||
|
|
8ff13614d2 | ||
|
|
331c88c38e | ||
|
|
c31d086356 | ||
|
|
dde36ccba2 |
No files matched your search
+47
-34
@@ -1,21 +1,24 @@
|
|||||||
|
name: myep
|
||||||
type: php
|
type: php
|
||||||
docroot: public
|
docroot: public
|
||||||
php_version: "8.3"
|
php_version: "8.4"
|
||||||
webserver_type: apache-fpm
|
webserver_type: apache-fpm
|
||||||
xdebug_enabled: false
|
xdebug_enabled: false
|
||||||
additional_hostnames:
|
additional_hostnames:
|
||||||
- mysnz
|
- mysnz
|
||||||
- mysbw
|
- mysbw
|
||||||
- myser
|
- myser
|
||||||
additional_fqdns: []
|
additional_fqdns: [ ]
|
||||||
database:
|
database:
|
||||||
type: mysql
|
type: mariadb
|
||||||
version: "8.4"
|
version: "11.8"
|
||||||
|
webimage_extra_packages: [ ripgrep, bubblewrap, socat ]
|
||||||
use_dns_when_possible: true
|
use_dns_when_possible: true
|
||||||
|
timezone: Europe/Berlin
|
||||||
composer_version: "2"
|
composer_version: "2"
|
||||||
web_environment: []
|
web_environment: [ ]
|
||||||
corepack_enable: false
|
|
||||||
nodejs_version: "22"
|
nodejs_version: "22"
|
||||||
|
corepack_enable: false
|
||||||
|
|
||||||
# Key features of DDEV's config.yaml:
|
# Key features of DDEV's config.yaml:
|
||||||
|
|
||||||
@@ -24,16 +27,16 @@ nodejs_version: "22"
|
|||||||
# If the name is omitted, the project will take the name of the enclosing directory,
|
# If the name is omitted, the project will take the name of the enclosing directory,
|
||||||
# which is useful if you want to have a copy of the project side by side with this one.
|
# which is useful if you want to have a copy of the project side by side with this one.
|
||||||
|
|
||||||
# type: <projecttype> # backdrop, cakephp, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, generic, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress
|
# type: <projecttype> # asterios, backdrop, cakephp, codeigniter, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, drupal12, generic, joomla, laravel, magento, magento2, maho, modx, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock
|
||||||
# See https://docs.ddev.com/en/stable/users/quickstart/ for more
|
# See https://docs.ddev.com/en/stable/users/quickstart/ for more
|
||||||
# information on the different project types
|
# information on the different project types
|
||||||
|
|
||||||
# docroot: <relative_path> # Relative path to the directory containing index.php.
|
# docroot: <relative_path> # Relative path to the directory containing index.php.
|
||||||
|
|
||||||
# php_version: "8.3" # PHP version to use, "5.6" through "8.5"
|
# php_version: "8.4" # PHP version to use, "5.6" through "8.5"
|
||||||
|
|
||||||
# You can explicitly specify the webimage but this
|
# You can explicitly specify the webimage but this
|
||||||
# is not recommended, as the images are often closely tied to DDEV's' behavior,
|
# is not recommended, as the images are often closely tied to DDEV's behavior,
|
||||||
# so this can break upgrades.
|
# so this can break upgrades.
|
||||||
|
|
||||||
# webimage: <docker_image>
|
# webimage: <docker_image>
|
||||||
@@ -43,10 +46,18 @@ nodejs_version: "22"
|
|||||||
# database:
|
# database:
|
||||||
# type: <dbtype> # mysql, mariadb, postgres
|
# type: <dbtype> # mysql, mariadb, postgres
|
||||||
# version: <version> # database version, like "10.11" or "8.0"
|
# version: <version> # database version, like "10.11" or "8.0"
|
||||||
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8
|
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8, 12.3
|
||||||
# MySQL versions can be 5.5-8.0, 8.4
|
# MySQL versions can be 5.5-8.0, 8.4, 9.7
|
||||||
# PostgreSQL versions can be 9-18
|
# PostgreSQL versions can be 9-18
|
||||||
|
|
||||||
|
# You can explicitly specify the dbimage but this
|
||||||
|
# is not recommended, as the images are often closely tied to DDEV's behavior,
|
||||||
|
# so this can break upgrades.
|
||||||
|
|
||||||
|
# dbimage: <docker_image>
|
||||||
|
# It’s unusual to change this option, and we don’t recommend it without Docker experience and a good reason.
|
||||||
|
# Typically, this means additions to the existing db image using a .ddev/db-build/Dockerfile.*
|
||||||
|
|
||||||
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
|
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
|
||||||
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
|
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
|
||||||
|
|
||||||
@@ -66,8 +77,7 @@ nodejs_version: "22"
|
|||||||
# bind_all_ports is used (normally with router disabled)
|
# bind_all_ports is used (normally with router disabled)
|
||||||
|
|
||||||
# xhprof_mode: [prepend|xhgui|global]
|
# xhprof_mode: [prepend|xhgui|global]
|
||||||
# Set to "xhgui" to enable XHGui features
|
# Default is "xhgui"
|
||||||
# "xhgui" will become default in a future major release
|
|
||||||
|
|
||||||
# webserver_type: nginx-fpm, apache-fpm, generic
|
# webserver_type: nginx-fpm, apache-fpm, generic
|
||||||
|
|
||||||
@@ -85,22 +95,24 @@ nodejs_version: "22"
|
|||||||
# commands are executed.
|
# commands are executed.
|
||||||
|
|
||||||
# composer_version: "2"
|
# composer_version: "2"
|
||||||
# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1
|
# You can set it to "" or "2" (default) for Composer v2
|
||||||
# to use the latest major version available at the time your container is built.
|
# to use the latest major version available at the time your container is built.
|
||||||
# It is also possible to use each other Composer version channel. This includes:
|
# It is also possible to use any other Composer version channel. This includes:
|
||||||
# - 2.2 (latest Composer LTS version)
|
# - 2.2 (latest Composer LTS version)
|
||||||
# - stable
|
# - stable
|
||||||
# - preview
|
# - preview
|
||||||
# - snapshot
|
# - snapshot
|
||||||
# Alternatively, an explicit Composer version may be specified, for example "2.2.18".
|
# Alternatively, an explicit Composer version may be specified, for example "2.2.18".
|
||||||
# To reinstall Composer after the image was built, run "ddev debug rebuild".
|
# To reinstall Composer after the image was built, run "ddev utility rebuild".
|
||||||
|
|
||||||
# nodejs_version: "22"
|
# nodejs_root: <relative_path>
|
||||||
|
# Relative path to the directory containing the Node.js version file from the
|
||||||
|
# project root. Only used with "nodejs_version: auto" or "nodejs_version: engine".
|
||||||
|
|
||||||
|
# nodejs_version: "24"
|
||||||
# change from the default system Node.js version to any other version.
|
# change from the default system Node.js version to any other version.
|
||||||
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
|
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
|
||||||
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation,
|
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation.
|
||||||
# Note that using of 'ddev nvm' is discouraged because "nodejs_version" is much easier to use,
|
|
||||||
# can specify any version, and is more robust than using 'nvm'.
|
|
||||||
|
|
||||||
# corepack_enable: false
|
# corepack_enable: false
|
||||||
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
|
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
|
||||||
@@ -160,9 +172,7 @@ nodejs_version: "22"
|
|||||||
# - "global": uses the value from the global config.
|
# - "global": uses the value from the global config.
|
||||||
# - "none": disables performance optimization for this project.
|
# - "none": disables performance optimization for this project.
|
||||||
# - "mutagen": enables Mutagen for this project.
|
# - "mutagen": enables Mutagen for this project.
|
||||||
# - "nfs": enables NFS for this project.
|
|
||||||
#
|
#
|
||||||
# See https://docs.ddev.com/en/stable/users/install/performance/#nfs
|
|
||||||
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
|
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
|
||||||
|
|
||||||
# fail_on_hook_fail: False
|
# fail_on_hook_fail: False
|
||||||
@@ -192,10 +202,10 @@ nodejs_version: "22"
|
|||||||
# The mailpit port is not normally bound on the host at all, instead being routed
|
# The mailpit port is not normally bound on the host at all, instead being routed
|
||||||
# through ddev-router, but it can be bound directly to localhost if specified here.
|
# through ddev-router, but it can be bound directly to localhost if specified here.
|
||||||
|
|
||||||
# webimage_extra_packages: [php7.4-tidy, php-bcmath]
|
# webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
|
||||||
# Extra Debian packages that are needed in the webimage can be added here
|
# Extra Debian packages that are needed in the webimage can be added here
|
||||||
|
|
||||||
# dbimage_extra_packages: [telnet,netcat]
|
# dbimage_extra_packages: [netcat, telnet, sudo]
|
||||||
# Extra Debian packages that are needed in the dbimage can be added here
|
# Extra Debian packages that are needed in the dbimage can be added here
|
||||||
|
|
||||||
# use_dns_when_possible: true
|
# use_dns_when_possible: true
|
||||||
@@ -207,12 +217,15 @@ nodejs_version: "22"
|
|||||||
# project_tld: ddev.site
|
# project_tld: ddev.site
|
||||||
# The top-level domain used for project URLs
|
# The top-level domain used for project URLs
|
||||||
# The default "ddev.site" allows DNS lookup via a wildcard
|
# The default "ddev.site" allows DNS lookup via a wildcard
|
||||||
# If you prefer you can change this to "ddev.local" to preserve
|
|
||||||
# pre-v1.9 behavior.
|
|
||||||
|
|
||||||
# ngrok_args: --basic-auth username:pass1234
|
# share_default_provider: ngrok
|
||||||
# Provide extra flags to the "ngrok http" command, see
|
# The default share provider to use for "ddev share"
|
||||||
# https://ngrok.com/docs/agent/config/v3/#agent-configuration or run "ngrok http -h"
|
# Defaults to global configuration, usually "ngrok"
|
||||||
|
# Can be "ngrok" or "cloudflared" or the name of a custom provider from .ddev/share-providers/
|
||||||
|
|
||||||
|
# share_provider_args: --basic-auth username:pass1234
|
||||||
|
# Provide extra flags to the share provider script
|
||||||
|
# See https://docs.ddev.com/en/stable/users/configuration/config/#share_provider_args
|
||||||
|
|
||||||
# disable_settings_management: false
|
# disable_settings_management: false
|
||||||
# If true, DDEV will not create CMS-specific settings files like
|
# If true, DDEV will not create CMS-specific settings files like
|
||||||
@@ -274,7 +287,7 @@ nodejs_version: "22"
|
|||||||
# override_config: false
|
# override_config: false
|
||||||
# By default, config.*.yaml files are *merged* into the configuration
|
# By default, config.*.yaml files are *merged* into the configuration
|
||||||
# But this means that some things can't be overridden
|
# But this means that some things can't be overridden
|
||||||
# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge
|
# For example, if you have 'use_dns_when_possible: true' you can't override it with a merge
|
||||||
# and you can't erase existing hooks or all environment variables.
|
# and you can't erase existing hooks or all environment variables.
|
||||||
# However, with "override_config: true" in a particular config.*.yaml file,
|
# However, with "override_config: true" in a particular config.*.yaml file,
|
||||||
# 'use_dns_when_possible: false' can override the existing values, and
|
# 'use_dns_when_possible: false' can override the existing values, and
|
||||||
@@ -284,7 +297,7 @@ nodejs_version: "22"
|
|||||||
# web_environment: []
|
# web_environment: []
|
||||||
# or
|
# or
|
||||||
# additional_hostnames: []
|
# additional_hostnames: []
|
||||||
# can have their intended affect. 'override_config' affects only behavior of the
|
# can have their intended effect. 'override_config' affects only behavior of the
|
||||||
# config.*.yaml file it exists in.
|
# config.*.yaml file it exists in.
|
||||||
|
|
||||||
# Many DDEV commands can be extended to run tasks before or after the
|
# Many DDEV commands can be extended to run tasks before or after the
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ APP_SECRET=642ead390b8a38f0a017ec84d5362129
|
|||||||
#
|
#
|
||||||
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
|
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
|
||||||
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
|
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
|
||||||
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.4.0&charset=utf8mb4"
|
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=mariadb-11.8.9&charset=utf8mb4"
|
||||||
# DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8"
|
# DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8"
|
||||||
###< doctrine/doctrine-bundle ###
|
###< doctrine/doctrine-bundle ###
|
||||||
|
|
||||||
@@ -54,9 +54,20 @@ APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1
|
|||||||
# (override in .env.local/production secrets as usual).
|
# (override in .env.local/production secrets as usual).
|
||||||
ACCOMMODATION_OFFER_LINK_SECRET=abf8ec28cc2162d9121d35d6f8cbf7c29728cdacce0303e22e1bac854501728a
|
ACCOMMODATION_OFFER_LINK_SECRET=abf8ec28cc2162d9121d35d6f8cbf7c29728cdacce0303e22e1bac854501728a
|
||||||
|
|
||||||
|
ACCOMMODATION_TERMS_AT=
|
||||||
|
ACCOMMODATION_TERMS_CH=
|
||||||
|
ACCOMMODATION_TERMS_IT=
|
||||||
|
|
||||||
BPN_CONNECT_BASE_URL=https://bpn-connect.ep-reisen.app
|
BPN_CONNECT_BASE_URL=https://bpn-connect.ep-reisen.app
|
||||||
BPN_CONNECT_API_KEY=
|
BPN_CONNECT_API_KEY=
|
||||||
|
|
||||||
|
# ClickUp task created when an accommodation booking is confirmed. Token/ids are secrets and
|
||||||
|
# stay empty here — set them in .env.local. Without them the handler skips with a warning.
|
||||||
|
CLICKUP_API_TOKEN=
|
||||||
|
CLICKUP_LIST_ID=
|
||||||
|
CLICKUP_TEMPLATE_ID=
|
||||||
|
CLICKUP_API_BASE_URL=https://api.clickup.com/api/v2
|
||||||
|
|
||||||
APP_BPN_USER=
|
APP_BPN_USER=
|
||||||
APP_BPN_PASSWORD=
|
APP_BPN_PASSWORD=
|
||||||
APP_BPN_IP=
|
APP_BPN_IP=
|
||||||
@@ -81,10 +92,21 @@ APP_TRAVEL_PREFER_REMOTE=false
|
|||||||
APP_TRAVEL_ENABLE_FALLBACK=true
|
APP_TRAVEL_ENABLE_FALLBACK=true
|
||||||
APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
|
APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
|
||||||
|
|
||||||
|
# Compensates a BusPro export defect: a <zustieg_rueck> can omit its preis although the booking
|
||||||
|
# engine still bills the return-leg Abfahrtsortzuschlag, so we undercharge by exactly one leg.
|
||||||
|
# A boarding point costs the same in both directions, so the stated price is applied to both.
|
||||||
|
# Off = report the export verbatim. Provisional until the vendor fixes the export; see
|
||||||
|
# docs/buspro-database-reference.md section A7.
|
||||||
|
# Flipping this needs `app:bpn:xml-cache-invalidate` and `app:bpn:refresh-travel-snapshot`,
|
||||||
|
# because the cached travels and TravelSnapshot rows carry the parsed price.
|
||||||
|
APP_BPN_LEVEL_BOARDING_POINT_PRICES=true
|
||||||
|
|
||||||
# Emails
|
# Emails
|
||||||
APP_DEFAULT_EMAIL_FROM=[email protected]
|
APP_DEFAULT_EMAIL_FROM=[email protected]
|
||||||
APP_DEFAULT_EMAIL_TO=[email protected]
|
APP_DEFAULT_EMAIL_TO=[email protected]
|
||||||
ACCOMMODATION_INQUIRY_EMAIL=info@ep-reisen.de
|
ACCOMMODATION_INQUIRY_EMAIL=gruppen@ep-reisen.de
|
||||||
|
# Comma-separated; recipients of the role nomination notification. Empty means disable notification.
|
||||||
|
APP_ROLE_NOMINATION_EMAILS=
|
||||||
|
|
||||||
# Global common defaults
|
# Global common defaults
|
||||||
APP_SEASON_WINTER_FROM=2026-10-01
|
APP_SEASON_WINTER_FROM=2026-10-01
|
||||||
@@ -101,19 +123,11 @@ APP_UNDERSUBSCRIPTION_SURCHARGE_40_CHF=2.5
|
|||||||
# Use 'O' during beta phase, switch to 'F' for production
|
# Use 'O' during beta phase, switch to 'F' for production
|
||||||
DEFAULT_BOOKING_STATUS=F
|
DEFAULT_BOOKING_STATUS=F
|
||||||
|
|
||||||
XML_EXPORT_PATH="%kernel.project_dir%/var/xmlexport"
|
SFTP_XML_SOURCE_HOST=
|
||||||
XML_EXPORT_CONTINGENTS_PATH="%kernel.project_dir%/var/xmlexportzimmer"
|
SFTP_XML_SOURCE_PORT=
|
||||||
JSON_EXPORT_PATH="%kernel.project_dir%/var/jsonexport"
|
SFTP_XML_SOURCE_USER=
|
||||||
|
SFTP_XML_SOURCE_PASSWORD=
|
||||||
SFTP_XML_EXPORT_HOST=
|
SFTP_XML_SOURCE_ROOT=
|
||||||
SFTP_XML_EXPORT_PORT=
|
|
||||||
SFTP_XML_EXPORT_USER=
|
|
||||||
SFTP_XML_EXPORT_PASSWORD=
|
|
||||||
|
|
||||||
SFTP_XML_EXPORT_CONTINGENTS_HOST=
|
|
||||||
SFTP_XML_EXPORT_CONTINGENTS_PORT=
|
|
||||||
SFTP_XML_EXPORT_CONTINGENTS_USER=
|
|
||||||
SFTP_XML_EXPORT_CONTINGENTS_PASSWORD=
|
|
||||||
|
|
||||||
###> league/oauth2-server-bundle ###
|
###> league/oauth2-server-bundle ###
|
||||||
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/secret/private.key
|
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/secret/private.key
|
||||||
@@ -126,3 +140,8 @@ OAUTH_ENCRYPTION_KEY=580084fd179e67399467f59ee96658ac
|
|||||||
# postgresql+advisory://db_user:db_password@localhost/db_name
|
# postgresql+advisory://db_user:db_password@localhost/db_name
|
||||||
LOCK_DSN=flock
|
LOCK_DSN=flock
|
||||||
###< symfony/lock ###
|
###< symfony/lock ###
|
||||||
|
|
||||||
|
MAINTENANCE_MODE_ENABLED=false
|
||||||
|
MAINTENANCE_MODE_IP_WHITELIST=188.245.62.21
|
||||||
|
MAINTENANCE_MODE_ROUTE_WHITELIST=
|
||||||
|
MAINTENANCE_MODE_PATH_WHITELIST=/api,/token
|
||||||
+5
-5
@@ -8,13 +8,9 @@
|
|||||||
/vendor/
|
/vendor/
|
||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
###> phpunit/phpunit ###
|
|
||||||
/phpunit.xml
|
|
||||||
.phpunit.result.cache
|
|
||||||
###< phpunit/phpunit ###
|
|
||||||
|
|
||||||
###> symfony/phpunit-bridge ###
|
###> symfony/phpunit-bridge ###
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
.phpunit.cache
|
||||||
/phpunit.xml
|
/phpunit.xml
|
||||||
###< symfony/phpunit-bridge ###
|
###< symfony/phpunit-bridge ###
|
||||||
|
|
||||||
@@ -37,3 +33,7 @@ yarn-error.log
|
|||||||
###> phpstan/phpstan ###
|
###> phpstan/phpstan ###
|
||||||
phpstan.neon
|
phpstan.neon
|
||||||
###< phpstan/phpstan ###
|
###< phpstan/phpstan ###
|
||||||
|
|
||||||
|
###> config reference ###
|
||||||
|
/config/reference.php
|
||||||
|
###< config reference ###
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unchecks conflicting additional services as soon as one is selected, so the user sees the
|
||||||
|
* result immediately instead of waiting for the debounced htmx refresh. The server resolves
|
||||||
|
* the same conflicts again and stays the source of truth.
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
|
||||||
|
static targets = [ 'input' ]
|
||||||
|
|
||||||
|
select(event) {
|
||||||
|
const input = event.target
|
||||||
|
|
||||||
|
if (!input.checked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = input.dataset.exclusionGroup
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const exclusive = '1' === input.dataset.exclusive
|
||||||
|
|
||||||
|
this.inputTargets
|
||||||
|
.filter(other => other !== input)
|
||||||
|
.filter(other => other.dataset.exclusionGroup === group)
|
||||||
|
.filter(other => exclusive || '1' === other.dataset.exclusive)
|
||||||
|
.forEach(other => { other.checked = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
import Sortable from 'sortablejs'
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
|
||||||
|
static targets = [ 'list', 'item', 'handle', 'positionField', 'positionLabel', 'status' ]
|
||||||
|
|
||||||
|
static values = {
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.abortController = null
|
||||||
|
|
||||||
|
// The sortable container may be nested (e.g. a tbody) when the controller element also
|
||||||
|
// has to wrap elements that must not be dragged, like the status message.
|
||||||
|
new Sortable(this.hasListTarget ? this.listTarget : this.element, {
|
||||||
|
draggable: '[data-sortable-target="item"]',
|
||||||
|
handle: '[data-sortable-target="handle"]',
|
||||||
|
sort: true,
|
||||||
|
fallbackOnBody: true,
|
||||||
|
onEnd: e => {
|
||||||
|
this.updateOrdering()
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.abortController?.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOrdering() {
|
||||||
|
this.positionFieldTargets.forEach((field, index) => {
|
||||||
|
field.value = index + 1
|
||||||
|
})
|
||||||
|
this.positionLabelTargets.forEach((label, index) => {
|
||||||
|
label.innerText = index + 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the new order to the server. Only active when a url value is present, so the
|
||||||
|
* controller stays usable for plain form ordering.
|
||||||
|
*/
|
||||||
|
persist() {
|
||||||
|
if (!this.hasUrlValue || this.urlValue === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A drag while the previous request is still running makes that request obsolete.
|
||||||
|
this.abortController?.abort()
|
||||||
|
this.abortController = new AbortController()
|
||||||
|
|
||||||
|
const ids = this.itemTargets
|
||||||
|
.map(item => Number(item.dataset.sortableId))
|
||||||
|
.filter(id => Number.isInteger(id) && id > 0)
|
||||||
|
|
||||||
|
this.setStatus('speichert …')
|
||||||
|
|
||||||
|
fetch(this.urlValue, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
signal: this.abortController.signal,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Unexpected response ${response.status}`)
|
||||||
|
}
|
||||||
|
this.setStatus('Reihenfolge gespeichert')
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error('Failed to persist ordering', error)
|
||||||
|
this.setStatus('Speichern fehlgeschlagen')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(message) {
|
||||||
|
this.statusTargets.forEach(status => {
|
||||||
|
status.innerText = message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import {Controller} from '@hotwired/stimulus'
|
|||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static classes = ['closed', 'open', 'iconOpen']
|
static classes = ['closed', 'open', 'iconOpen']
|
||||||
static targets = ['content', 'icon', 'container']
|
static targets = ['content', 'icon', 'container', 'trigger']
|
||||||
static values = {
|
static values = {
|
||||||
open: {
|
open: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -43,6 +43,10 @@ export default class extends Controller {
|
|||||||
this.iconTarget.classList.toggle(iconClass, true === open)
|
this.iconTarget.classList.toggle(iconClass, true === open)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.hasTriggerTarget) {
|
||||||
|
this.triggerTarget.setAttribute('aria-expanded', String(open))
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle class on container element (or controller element) if specified
|
// Toggle class on container element (or controller element) if specified
|
||||||
if (this.hasOpenClass) {
|
if (this.hasOpenClass) {
|
||||||
const container = this.hasContainerTarget ? this.containerTarget : this.element
|
const container = this.hasContainerTarget ? this.containerTarget : this.element
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<symbol id="icon-download" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /></symbol>
|
<symbol id="icon-download" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /></symbol>
|
||||||
<symbol id="icon-sort" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" /></symbol>
|
<symbol id="icon-sort" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" /></symbol>
|
||||||
<symbol id="icon-arrow-left" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15m0 0l6.75 6.75M4.5 12l6.75-6.75" /></symbol>
|
<symbol id="icon-arrow-left" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15m0 0l6.75 6.75M4.5 12l6.75-6.75" /></symbol>
|
||||||
|
<symbol id="icon-chevron-down" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /></symbol>
|
||||||
<symbol id="icon-arrow-right" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" /></symbol>
|
<symbol id="icon-arrow-right" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" /></symbol>
|
||||||
<symbol id="icon-document" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" /></symbol>
|
<symbol id="icon-document" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" /></symbol>
|
||||||
<symbol id="icon-filter" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" /></symbol>
|
<symbol id="icon-filter" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" /></symbol>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 700 490" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><mask id="a"><image height="490" transform="matrix(.00288044 .00008847 .00008847 -.00288044 -.5862 .835135)" width="700" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAArwAAAHqCAAAAADD7sLoAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAGsElEQVR4nO3dQW7bRgBA0SHTm/VGvWCPVQRIqy4cSVHkBKRlmvzWewsDjClybPxMZiJZnk4Dmua9BwBvJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXrD/2HsDDTt8+5j7Tp7jFytv8u90o3kE/3r//XHzqfP4wzXdH03Q+msYY8/lofDn/0TT9eDRP46WC6f4xrxxdHvzaCTe3H/PN7W9PuB3MK1/O/Wiv15tuTvjVaOfx/dR5GuOfvxZ/b/dg2UCWeMkSL1niPZiVm7Zp3WOWnflBG8eH9Tds7+kt5Sw78737upy5xYXHZX93bGZessRLlnjJsub92YrlKfsy85IlXrLES5Z4f2/nBbD19+/YsP3ayucg3v3Zq+2eMll34cMy85IlXrLES9bTrXlXLE+3HcPCU8fuoz0uMy9Z4iVLvGSJd6m918n73v6Qnm7D9iZHKGeLTd4Rvq4HmHnJEi9Z4iXrmde8B3jhy3aLziOMYWtm3t35b4y3Ei9Z4iVLvGQ984ZtjOSrtoJD3oiZlyzxkiVesp59zXuxyZuQLj917ZtMbXLh2krazLulVg2t0Q7xEiZessRLlg3b1rbZCTLMvISJlyzxkiXeBx3hZyE2u+bBF+E2bHcO8JPjOz/dV3nhmpmXLPGSJV6ynmXNu/KXTG/3+6i38Kzv92vm/TjbRvNpklxOvGSJl6xnWfOepjHGaXz/cLr8G/vfOP+aivNqcJrHuK4Nbz/38nd9/vHo+rnp/jHXS033J9xe4XqZMcZ8c/TQ+F45GsvGN43xdfk3eA/PEu9Ls2O85MqnYNlAlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVesv4HAfsUn4ytnvgAAAAASUVORK5CYII="/></mask><linearGradient id="b" gradientTransform="matrix(346.842 10.6534 -10.6534 346.842 194.415 296.129)" gradientUnits="userSpaceOnUse" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#bcbbba"/><stop offset="1" stop-color="#565553"/></linearGradient><linearGradient id="c"><stop offset="0" stop-color="#38a548"/><stop offset=".04" stop-color="#38a548"/><stop offset=".91" stop-color="#a3c639"/><stop offset="1" stop-color="#a3c639"/></linearGradient><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="27.6449" x2="329.4659" xlink:href="#c" y1="294.368" y2="294.368"/><linearGradient id="e" gradientUnits="userSpaceOnUse" x1="446.802" x2="605.544" xlink:href="#c" y1="267.649" y2="267.649"/><linearGradient id="f" gradientTransform="matrix(170.152 5.22629 -5.22629 170.152 451.782 264.535)" gradientUnits="userSpaceOnUse" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#118ece"/><stop offset=".04" stop-color="#118ece"/><stop offset=".91" stop-color="#184050"/><stop offset="1" stop-color="#184050"/></linearGradient><g mask="url(#a)"><path d="m194.119 305.842c82.487-5.378 144.251 2.12 145.365 2.304 103.692 17.276 164.073 19.425 201.218 16.582l.165-5.09c-26.441 1.313-62.179-2.767-105.949-8.079-34.114-4.14-128.221-34.151-239.888-35.469z" fill="url(#b)" fill-rule="nonzero"/></g><g fill-rule="nonzero"><path d="m197.896 191.813 65.607-48.765 16.602 4.882 27.895-31.182 35.653 48.047 25.115 9.5 64.998 70.249-69.739-55.007-15.345 5.156-37.617-47.693-17.036 17.035-25.195-4.806-30.27 32.225.755-19.807z" fill="#194050"/><path d="m38.408 306.474c-6.228 20.417-10.765 29.685-10.765 29.685 114.575-92.78 253.335-76.5 301.822-68.124-20.416-6.28-53.109-14.042-93.153-15.321-2.838-.095-5.695-.137-8.575-.137-63.667 0-137.636 22.097-189.329 53.897z" fill="url(#d)"/><path d="m602.405 251.387c-3.773 14.368-15.048 26.054-33.505 34.746-23.254 10.95-65.423 7.092-122.097-11.058 56.626 21.98 99.929 28.75 123.31 19.387 17.943-7.186 29.194-17.413 33.425-30.402 3.003-9.222 2.252-18.934.255-27.32.269 4.797-.095 9.741-1.388 14.647z" fill="url(#e)"/><path d="m605.397 211.094-6.403 4.315c.18.265 18.079 27.414 10.685 52.708-4.169 14.255-15.827 25.648-34.642 33.868-24.444 10.667-68.738 5.138-128.108-16.001-45.857-16.337-84.494-36.357-84.815-36.527-Line truncated
|
||||||
|
After Width: | Height: | Size: 8.4 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 700 490" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><mask id="a"><image height="490" transform="matrix(.00288044 .00008847 .00008847 -.00288044 -.5862 .835135)" width="700" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAArwAAAHqCAAAAADD7sLoAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAGsElEQVR4nO3dQW7bRgBA0SHTm/VGvWCPVQRIqy4cSVHkBKRlmvzWewsDjClybPxMZiJZnk4Dmua9BwBvJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXLPGSJV6yxEuWeMkSL1niJUu8ZImXrD/2HsDDTt8+5j7Tp7jFytv8u90o3kE/3r//XHzqfP4wzXdH03Q+msYY8/lofDn/0TT9eDRP46WC6f4xrxxdHvzaCTe3H/PN7W9PuB3MK1/O/Wiv15tuTvjVaOfx/dR5GuOfvxZ/b/dg2UCWeMkSL1niPZiVm7Zp3WOWnflBG8eH9Tds7+kt5Sw78737upy5xYXHZX93bGZessRLlnjJsub92YrlKfsy85IlXrLES5Z4f2/nBbD19+/YsP3ayucg3v3Zq+2eMll34cMy85IlXrLES9bTrXlXLE+3HcPCU8fuoz0uMy9Z4iVLvGSJd6m918n73v6Qnm7D9iZHKGeLTd4Rvq4HmHnJEi9Z4iXrmde8B3jhy3aLziOMYWtm3t35b4y3Ei9Z4iVLvGQ984ZtjOSrtoJD3oiZlyzxkiVesp59zXuxyZuQLj917ZtMbXLh2krazLulVg2t0Q7xEiZessRLlg3b1rbZCTLMvISJlyzxkiXeBx3hZyE2u+bBF+E2bHcO8JPjOz/dV3nhmpmXLPGSJV6ynmXNu/KXTG/3+6i38Kzv92vm/TjbRvNpklxOvGSJl6xnWfOepjHGaXz/cLr8G/vfOP+aivNqcJrHuK4Nbz/38nd9/vHo+rnp/jHXS033J9xe4XqZMcZ8c/TQ+F45GsvGN43xdfk3eA/PEu9Ls2O85MqnYNlAlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVessRLlnjJEi9Z4iVLvGSJlyzxkiVesv4HAfsUn4ytnvgAAAAASUVORK5CYII="/></mask><linearGradient id="b" gradientTransform="matrix(346.842 10.6534 -10.6534 346.842 194.415 296.129)" gradientUnits="userSpaceOnUse" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#bcbbba"/><stop offset="1" stop-color="#565553"/></linearGradient><linearGradient id="c"><stop offset="0" stop-color="#38a548"/><stop offset=".04" stop-color="#38a548"/><stop offset=".91" stop-color="#a3c639"/><stop offset="1" stop-color="#a3c639"/></linearGradient><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="27.6449" x2="329.4659" xlink:href="#c" y1="294.368" y2="294.368"/><linearGradient id="e" gradientUnits="userSpaceOnUse" x1="446.802" x2="605.544" xlink:href="#c" y1="267.649" y2="267.649"/><linearGradient id="f" gradientTransform="matrix(170.152 5.22629 -5.22629 170.152 451.782 264.535)" gradientUnits="userSpaceOnUse" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#118ece"/><stop offset=".04" stop-color="#118ece"/><stop offset=".91" stop-color="#184050"/><stop offset="1" stop-color="#184050"/></linearGradient><g mask="url(#a)"><path d="m194.119 305.842c82.487-5.378 144.251 2.12 145.365 2.304 103.692 17.276 164.073 19.425 201.218 16.582l.165-5.09c-26.441 1.313-62.179-2.767-105.949-8.079-34.114-4.14-128.221-34.151-239.888-35.469z" fill="url(#b)" fill-rule="nonzero"/></g><g fill-rule="nonzero"><path d="m197.896 191.813 65.607-48.765 16.602 4.882 27.895-31.182 35.653 48.047 25.115 9.5 64.998 70.249-69.739-55.007-15.345 5.156-37.617-47.693-17.036 17.035-25.195-4.806-30.27 32.225.755-19.807z" fill="#194050"/><path d="m38.408 306.474c-6.228 20.417-10.765 29.685-10.765 29.685 114.575-92.78 253.335-76.5 301.822-68.124-20.416-6.28-53.109-14.042-93.153-15.321-2.838-.095-5.695-.137-8.575-.137-63.667 0-137.636 22.097-189.329 53.897z" fill="url(#d)"/><path d="m602.405 251.387c-3.773 14.368-15.048 26.054-33.505 34.746-23.254 10.95-65.423 7.092-122.097-11.058 56.626 21.98 99.929 28.75 123.31 19.387 17.943-7.186 29.194-17.413 33.425-30.402 3.003-9.222 2.252-18.934.255-27.32.269 4.797-.095 9.741-1.388 14.647z" fill="url(#e)"/><path d="m605.397 211.094-6.403 4.315c.18.265 18.079 27.414 10.685 52.708-4.169 14.255-15.827 25.648-34.642 33.868-24.444 10.667-68.738 5.138-128.108-16.001-45.857-16.337-84.494-36.357-84.815-36.527-Line truncated
|
||||||
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,4 +4,5 @@
|
|||||||
@import "tailwindcss/components";
|
@import "tailwindcss/components";
|
||||||
@import "_components.css";
|
@import "_components.css";
|
||||||
|
|
||||||
@import "tailwindcss/utilities";
|
@import "tailwindcss/utilities";
|
||||||
|
@import "_utilities.css";
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
### list custom fields
|
||||||
|
GET {{clickup_baseurl}}/list/{{clickup_list_id}}/field?include_applied_objects=true
|
||||||
|
Authorization: {{clickup_api_token}}
|
||||||
|
|
||||||
+39
-38
@@ -4,7 +4,7 @@
|
|||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.1",
|
"php": ">=8.4",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-dom": "*",
|
"ext-dom": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"doctrine/doctrine-bundle": "^2.13",
|
"doctrine/doctrine-bundle": "^2.13",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.3",
|
"doctrine/doctrine-migrations-bundle": "^3.3",
|
||||||
"doctrine/orm": "^3.3",
|
"doctrine/orm": "^3.3",
|
||||||
|
"dompdf/dompdf": "^3.1",
|
||||||
"fakerphp/faker": "^1.24",
|
"fakerphp/faker": "^1.24",
|
||||||
"flagception/flagception-bundle": "^6.1",
|
"flagception/flagception-bundle": "^6.1",
|
||||||
"knplabs/knp-menu-bundle": "^3.7",
|
"knplabs/knp-menu-bundle": "^3.7",
|
||||||
@@ -28,39 +29,39 @@
|
|||||||
"phpstan/phpdoc-parser": "^2.0",
|
"phpstan/phpdoc-parser": "^2.0",
|
||||||
"spatie/crypto": "^2.1",
|
"spatie/crypto": "^2.1",
|
||||||
"symfony/apache-pack": "^1.0",
|
"symfony/apache-pack": "^1.0",
|
||||||
"symfony/asset": "6.4.*",
|
"symfony/asset": "7.4.*",
|
||||||
"symfony/console": "6.4.*",
|
"symfony/console": "7.4.*",
|
||||||
"symfony/doctrine-messenger": "6.4.*",
|
"symfony/doctrine-messenger": "7.4.*",
|
||||||
"symfony/dom-crawler": "6.4.*",
|
"symfony/dom-crawler": "7.4.*",
|
||||||
"symfony/dotenv": "6.4.*",
|
"symfony/dotenv": "7.4.*",
|
||||||
"symfony/expression-language": "6.4.*",
|
"symfony/expression-language": "7.4.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/form": "6.4.*",
|
"symfony/form": "7.4.*",
|
||||||
"symfony/framework-bundle": "6.4.*",
|
"symfony/framework-bundle": "7.4.*",
|
||||||
"symfony/html-sanitizer": "6.4.*",
|
"symfony/html-sanitizer": "7.4.*",
|
||||||
"symfony/http-client": "6.4.*",
|
"symfony/http-client": "7.4.*",
|
||||||
"symfony/intl": "6.4.*",
|
"symfony/intl": "7.4.*",
|
||||||
"symfony/lock": "6.4.*",
|
"symfony/lock": "7.4.*",
|
||||||
"symfony/mailer": "6.4.*",
|
"symfony/mailer": "7.4.*",
|
||||||
"symfony/mime": "6.4.*",
|
"symfony/mime": "7.4.*",
|
||||||
"symfony/monolog-bundle": "^3.0",
|
"symfony/monolog-bundle": "^3.0",
|
||||||
"symfony/notifier": "6.4.*",
|
"symfony/notifier": "7.4.*",
|
||||||
"symfony/process": "6.4.*",
|
"symfony/process": "7.4.*",
|
||||||
"symfony/property-access": "6.4.*",
|
"symfony/property-access": "7.4.*",
|
||||||
"symfony/property-info": "6.4.*",
|
"symfony/property-info": "7.4.*",
|
||||||
"symfony/runtime": "6.4.*",
|
"symfony/runtime": "7.4.*",
|
||||||
"symfony/security-bundle": "6.4.*",
|
"symfony/security-bundle": "7.4.*",
|
||||||
"symfony/serializer": "6.4.*",
|
"symfony/serializer": "7.4.*",
|
||||||
"symfony/stimulus-bundle": "^2.21",
|
"symfony/stimulus-bundle": "^2.21",
|
||||||
"symfony/string": "6.4.*",
|
"symfony/string": "7.4.*",
|
||||||
"symfony/translation": "6.4.*",
|
"symfony/translation": "7.4.*",
|
||||||
"symfony/twig-bundle": "6.4.*",
|
"symfony/twig-bundle": "7.4.*",
|
||||||
"symfony/uid": "6.4.*",
|
"symfony/uid": "7.4.*",
|
||||||
"symfony/ux-twig-component": "^2.36",
|
"symfony/ux-twig-component": "^2.36",
|
||||||
"symfony/validator": "6.4.*",
|
"symfony/validator": "7.4.*",
|
||||||
"symfony/web-link": "6.4.*",
|
"symfony/web-link": "7.4.*",
|
||||||
"symfony/webpack-encore-bundle": "^2.2",
|
"symfony/webpack-encore-bundle": "^2.2",
|
||||||
"symfony/yaml": "6.4.*",
|
"symfony/yaml": "7.4.*",
|
||||||
"twig/extra-bundle": "^2.12|^3.0",
|
"twig/extra-bundle": "^2.12|^3.0",
|
||||||
"twig/html-extra": "^3.15",
|
"twig/html-extra": "^3.15",
|
||||||
"twig/intl-extra": "*",
|
"twig/intl-extra": "*",
|
||||||
@@ -76,7 +77,7 @@
|
|||||||
},
|
},
|
||||||
"sort-packages": true,
|
"sort-packages": true,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "8.3.30"
|
"php": "8.4.24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -118,7 +119,7 @@
|
|||||||
"extra": {
|
"extra": {
|
||||||
"symfony": {
|
"symfony": {
|
||||||
"allow-contrib": true,
|
"allow-contrib": true,
|
||||||
"require": "6.4.*",
|
"require": "7.4.*",
|
||||||
"docker": true
|
"docker": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -128,14 +129,14 @@
|
|||||||
"phpstan/phpstan": "^2.1",
|
"phpstan/phpstan": "^2.1",
|
||||||
"phpstan/phpstan-doctrine": "^2.0",
|
"phpstan/phpstan-doctrine": "^2.0",
|
||||||
"phpstan/phpstan-symfony": "^2.0",
|
"phpstan/phpstan-symfony": "^2.0",
|
||||||
"phpunit/phpunit": "^9.5",
|
"phpunit/phpunit": "^12",
|
||||||
"rector/rector": "^2.4",
|
"rector/rector": "^2.4",
|
||||||
"symfony/browser-kit": "6.4.*",
|
"symfony/browser-kit": "7.4.*",
|
||||||
"symfony/css-selector": "6.4.*",
|
"symfony/css-selector": "7.4.*",
|
||||||
"symfony/debug-bundle": "6.4.*",
|
"symfony/debug-bundle": "7.4.*",
|
||||||
"symfony/maker-bundle": "^1.0",
|
"symfony/maker-bundle": "^1.0",
|
||||||
"symfony/phpunit-bridge": "^7.1",
|
"symfony/phpunit-bridge": "^7.4",
|
||||||
"symfony/stopwatch": "6.4.*",
|
"symfony/stopwatch": "7.4.*",
|
||||||
"symfony/web-profiler-bundle": "6.4.*"
|
"symfony/web-profiler-bundle": "7.4.*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+2185
-1669
File diff suppressed because it is too large.
Load diff
@@ -9,8 +9,7 @@ doctrine:
|
|||||||
profiling_collect_backtrace: '%kernel.debug%'
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
use_savepoints: true
|
use_savepoints: true
|
||||||
orm:
|
orm:
|
||||||
auto_generate_proxy_classes: true
|
enable_native_lazy_objects: true
|
||||||
enable_lazy_ghost_objects: true
|
|
||||||
validate_xml_mapping: true
|
validate_xml_mapping: true
|
||||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||||
auto_mapping: true
|
auto_mapping: true
|
||||||
@@ -33,8 +32,6 @@ when@test:
|
|||||||
when@prod:
|
when@prod:
|
||||||
doctrine:
|
doctrine:
|
||||||
orm:
|
orm:
|
||||||
auto_generate_proxy_classes: false
|
|
||||||
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
|
|
||||||
query_cache_driver:
|
query_cache_driver:
|
||||||
type: pool
|
type: pool
|
||||||
pool: doctrine.system_cache_pool
|
pool: doctrine.system_cache_pool
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ flysystem:
|
|||||||
storages:
|
storages:
|
||||||
xml_export.storage:
|
xml_export.storage:
|
||||||
local:
|
local:
|
||||||
directory: '%env(resolve:XML_EXPORT_PATH)%'
|
directory: '%kernel.project_dir%/var/xmlexport'
|
||||||
xml_source.storage:
|
xml_source.storage:
|
||||||
sftp:
|
sftp:
|
||||||
host: '%env(SFTP_XML_EXPORT_HOST)%'
|
host: '%env(SFTP_XML_SOURCE_HOST)%'
|
||||||
port: '%env(int:SFTP_XML_EXPORT_PORT)%'
|
port: '%env(int:SFTP_XML_SOURCE_PORT)%'
|
||||||
username: '%env(SFTP_XML_EXPORT_USER)%'
|
username: '%env(SFTP_XML_SOURCE_USER)%'
|
||||||
password: '%env(SFTP_XML_EXPORT_PASSWORD)%'
|
password: '%env(SFTP_XML_SOURCE_PASSWORD)%'
|
||||||
|
root: '%env(SFTP_XML_SOURCE_ROOT)%'
|
||||||
xml_dump.storage:
|
xml_dump.storage:
|
||||||
local:
|
local:
|
||||||
directory: '%kernel.project_dir%/var/bpn'
|
directory: '%kernel.project_dir%/var/bpn'
|
||||||
json_export.storage:
|
json_export.storage:
|
||||||
local:
|
local:
|
||||||
directory: '%env(resolve:JSON_EXPORT_PATH)%'
|
directory: '%kernel.project_dir%/var/jsonexport'
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||||
framework:
|
framework:
|
||||||
secret: '%env(APP_SECRET)%'
|
secret: '%env(APP_SECRET)%'
|
||||||
annotations: false
|
|
||||||
http_method_override: false
|
http_method_override: false
|
||||||
handle_all_throwables: true
|
handle_all_throwables: true
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ league_oauth2_server:
|
|||||||
default: ['email']
|
default: ['email']
|
||||||
persistence:
|
persistence:
|
||||||
doctrine: null
|
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:
|
when@test:
|
||||||
league_oauth2_server:
|
league_oauth2_server:
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ framework:
|
|||||||
Symfony\Component\Notifier\Message\ChatMessage: async
|
Symfony\Component\Notifier\Message\ChatMessage: async
|
||||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||||
App\Message\MailjetNewsletterEventMessage: async
|
App\Message\MailjetNewsletterEventMessage: async
|
||||||
|
App\Message\CreateClickUpBookingTaskMessage: async
|
||||||
|
App\Message\RoleNominationMessage: async
|
||||||
|
|
||||||
# Route your messages to the transports
|
# Route your messages to the transports
|
||||||
# 'App\Message\YourMessage': async
|
# 'App\Message\YourMessage': async
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
framework:
|
||||||
|
property_info:
|
||||||
|
with_constructor_extractor: true
|
||||||
@@ -2,9 +2,12 @@ framework:
|
|||||||
router:
|
router:
|
||||||
utf8: true
|
utf8: true
|
||||||
|
|
||||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
# How URLs are generated where there is no request to take the host from: scheduled
|
||||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
# commands and, notably, the Messenger workers that send mail off the request path.
|
||||||
#default_uri: http://localhost
|
# Without it those links would point at http://localhost. Only ever consulted in a
|
||||||
|
# non-HTTP context, so web requests keep using their own host — which matters here,
|
||||||
|
# since one codebase serves four brands.
|
||||||
|
default_uri: '%env(APP_BASE_URL)%'
|
||||||
|
|
||||||
when@prod:
|
when@prod:
|
||||||
framework:
|
framework:
|
||||||
|
|||||||
@@ -15,9 +15,16 @@ security:
|
|||||||
mailjet:
|
mailjet:
|
||||||
password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%'
|
password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%'
|
||||||
roles: [ ROLE_MAILJET_WEBHOOK ]
|
roles: [ ROLE_MAILJET_WEBHOOK ]
|
||||||
|
|
||||||
role_hierarchy:
|
role_hierarchy:
|
||||||
ROLE_ADMIN: [ ROLE_GROUPS_ADMIN ]
|
# ROLE_CUSTOMER_EXPERT gates the booking-draft and log surfaces, which used to be
|
||||||
ROLE_GROUPS_ADMIN: [ ROLE_GROUPS_MANAGER ]
|
# ROLE_ADMIN only. Inheriting it here is what keeps those surfaces open to administrators
|
||||||
|
# after the #[IsGranted] attributes moved to the narrower role.
|
||||||
|
ROLE_ADMIN:
|
||||||
|
- ROLE_GROUPS_ADMIN
|
||||||
|
- ROLE_CUSTOMER_EXPERT
|
||||||
|
ROLE_GROUPS_ADMIN:
|
||||||
|
- ROLE_GROUPS_MANAGER
|
||||||
firewalls:
|
firewalls:
|
||||||
dev:
|
dev:
|
||||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||||
|
|||||||
@@ -14,4 +14,6 @@ when@test:
|
|||||||
intercept_redirects: false
|
intercept_redirects: false
|
||||||
|
|
||||||
framework:
|
framework:
|
||||||
profiler: { collect: false }
|
profiler:
|
||||||
|
collect: false
|
||||||
|
collect_serializer_data: true
|
||||||
@@ -3,13 +3,14 @@ zenstruck_schedule:
|
|||||||
|
|
||||||
mailer:
|
mailer:
|
||||||
service: mailer
|
service: mailer
|
||||||
default_to: [email protected]
|
default_to: [email protected]
|
||||||
default_from: info@ep-reisen.de
|
default_from: noreply@ep-reisen.de
|
||||||
subject_prefix: "[MyE&P]"
|
subject_prefix: "[MyE&P]"
|
||||||
|
|
||||||
schedule_extensions:
|
schedule_extensions:
|
||||||
email_on_failure:
|
email_on_failure:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
to: [email protected]
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
- task: app:cleanup:xml-dumps
|
- task: app:cleanup:xml-dumps
|
||||||
@@ -58,3 +59,11 @@ when@prod:
|
|||||||
- task: app:bpn:xml-sync
|
- task: app:bpn:xml-sync
|
||||||
frequency: "0 20-23,0-7 * * *"
|
frequency: "0 20-23,0-7 * * *"
|
||||||
description: "Syncs BusPro XML data hourly outside peak hours"
|
description: "Syncs BusPro XML data hourly outside peak hours"
|
||||||
|
|
||||||
|
- task: app:bpn:sync-contingents
|
||||||
|
frequency: "5-59/15 8-19 * * *"
|
||||||
|
description: "Syncs the full contingent horizon every 15 min. during peak hours"
|
||||||
|
|
||||||
|
- task: app:bpn:sync-contingents
|
||||||
|
frequency: "5 20-23,0-7 * * *"
|
||||||
|
description: "Syncs the full contingent horizon hourly outside peak hours"
|
||||||
+100
-2
@@ -1,3 +1,4 @@
|
|||||||
|
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
||||||
# This file is the entry point to configure your own services.
|
# This file is the entry point to configure your own services.
|
||||||
# Files in the packages/ subdirectory configure your dependencies.
|
# Files in the packages/ subdirectory configure your dependencies.
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ parameters:
|
|||||||
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
|
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
|
||||||
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
|
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
|
||||||
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
|
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
|
||||||
|
role_nomination_notification_emails: '%env(csv:APP_ROLE_NOMINATION_EMAILS)%'
|
||||||
|
|
||||||
# MailJet list ids and their labels
|
# MailJet list ids and their labels
|
||||||
mailjet_lists:
|
mailjet_lists:
|
||||||
@@ -30,6 +32,40 @@ parameters:
|
|||||||
10321389: 'Reisen-Alert Stubaital'
|
10321389: 'Reisen-Alert Stubaital'
|
||||||
10554990: 'Reisen-Alert Ski & Boarderweek'
|
10554990: 'Reisen-Alert Ski & Boarderweek'
|
||||||
|
|
||||||
|
# Email domains whose accounts are members of staff. BusPro has no CRM selection expressing
|
||||||
|
# "works here", so ROLE_EMPLOYEE is derived from the address instead. Matched exactly on the
|
||||||
|
# domain part, never as a suffix.
|
||||||
|
employee_email_domains:
|
||||||
|
- 'ep-reisen.de'
|
||||||
|
|
||||||
|
# BusProNet "Hausleitung {CODE}" CRM selections, by selection id.
|
||||||
|
# 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
|
||||||
|
# code from everyone holding it, one login at a time, with manual re-approval per user.
|
||||||
|
# Entries for houses that are not in use yet stay commented out.
|
||||||
|
bpn_crm_house_manager_ids:
|
||||||
|
1299: 'SSL'
|
||||||
|
1300: 'SST'
|
||||||
|
1301: 'MVK'
|
||||||
|
# 1302: 'ASB'
|
||||||
|
1303: 'LPJ'
|
||||||
|
1304: 'DKS'
|
||||||
|
1305: 'DGS'
|
||||||
|
1306: 'DPW'
|
||||||
|
1307: 'DKI'
|
||||||
|
1308: 'DWW'
|
||||||
|
1309: 'PMV'
|
||||||
|
# 1352: 'ASG'
|
||||||
|
1371: 'ASC'
|
||||||
|
1373: 'PCJ'
|
||||||
|
1374: 'SBW'
|
||||||
|
# 1375: 'UCH'
|
||||||
|
# 1376: 'SZO'
|
||||||
|
1377: 'KHH'
|
||||||
|
1459: 'SVS'
|
||||||
|
1461: 'SHM'
|
||||||
|
1462: 'AGR'
|
||||||
|
|
||||||
# MailJet contact metadata names for name synchronization
|
# MailJet contact metadata names for name synchronization
|
||||||
mailjet_contact_metadata_fields:
|
mailjet_contact_metadata_fields:
|
||||||
firstName: 'vorname'
|
firstName: 'vorname'
|
||||||
@@ -149,6 +185,10 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$retentionBufferDays: '%travel_snapshot_retention_buffer_days%'
|
$retentionBufferDays: '%travel_snapshot_retention_buffer_days%'
|
||||||
|
|
||||||
|
App\BusProNet\XmlParser\TravelParser:
|
||||||
|
arguments:
|
||||||
|
$levelBoardingPointPrices: '%env(bool:APP_BPN_LEVEL_BOARDING_POINT_PRICES)%'
|
||||||
|
|
||||||
App\Service\TravelDataProvider:
|
App\Service\TravelDataProvider:
|
||||||
arguments:
|
arguments:
|
||||||
$logger: '@monolog.logger.core'
|
$logger: '@monolog.logger.core'
|
||||||
@@ -167,6 +207,10 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$cache: '@bpn.cache'
|
$cache: '@bpn.cache'
|
||||||
|
|
||||||
|
App\BusProNet\Utility\CountryCodeMapper:
|
||||||
|
arguments:
|
||||||
|
$logger: '@monolog.logger.bpn'
|
||||||
|
|
||||||
App\BusProNet\XmlLoader\HotelLoader:
|
App\BusProNet\XmlLoader\HotelLoader:
|
||||||
arguments:
|
arguments:
|
||||||
$cache: '@bpn.cache'
|
$cache: '@bpn.cache'
|
||||||
@@ -179,6 +223,11 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$cache: '@bpn.cache'
|
$cache: '@bpn.cache'
|
||||||
|
|
||||||
|
App\Service\TravelExportBuilder:
|
||||||
|
arguments:
|
||||||
|
$cache: '@bpn.cache'
|
||||||
|
$logger: '@monolog.logger.bpn'
|
||||||
|
|
||||||
App\Service\VoucherValidator:
|
App\Service\VoucherValidator:
|
||||||
arguments:
|
arguments:
|
||||||
$cache: '@cache.app'
|
$cache: '@cache.app'
|
||||||
@@ -230,6 +279,21 @@ services:
|
|||||||
- '@App\BusProNet\Service\StatusRule\Selection1473StatusRule'
|
- '@App\BusProNet\Service\StatusRule\Selection1473StatusRule'
|
||||||
- '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule'
|
- '@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\Service\CmsDataProvider:
|
App\Service\CmsDataProvider:
|
||||||
arguments:
|
arguments:
|
||||||
$httpClient: '@typo3.client'
|
$httpClient: '@typo3.client'
|
||||||
@@ -253,6 +317,18 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$mailjetLists: '%mailjet_lists%'
|
$mailjetLists: '%mailjet_lists%'
|
||||||
|
|
||||||
|
App\BusProNet\XmlParser\CrmAttributesResponseParser:
|
||||||
|
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:
|
App\Service\DomainConfigProvider:
|
||||||
arguments:
|
arguments:
|
||||||
$domainConfig: '%domain_config%'
|
$domainConfig: '%domain_config%'
|
||||||
@@ -265,7 +341,14 @@ services:
|
|||||||
|
|
||||||
App\Service\AccommodationBookingService:
|
App\Service\AccommodationBookingService:
|
||||||
arguments:
|
arguments:
|
||||||
$officeEmail: '%accommodation_inquiry_email%'
|
$accommodationEmail: '%accommodation_inquiry_email%'
|
||||||
|
|
||||||
|
App\Service\AccommodationTermsUrlProvider:
|
||||||
|
arguments:
|
||||||
|
$config:
|
||||||
|
AT: '%env(ACCOMMODATION_TERMS_AT)%'
|
||||||
|
CH: '%env(ACCOMMODATION_TERMS_CH)%'
|
||||||
|
IT: '%env(ACCOMMODATION_TERMS_IT)%'
|
||||||
|
|
||||||
App\Service\GroupsPriceCalculator:
|
App\Service\GroupsPriceCalculator:
|
||||||
arguments:
|
arguments:
|
||||||
@@ -290,10 +373,25 @@ services:
|
|||||||
App\BpnConnect\ContingentsClient:
|
App\BpnConnect\ContingentsClient:
|
||||||
parent: App\BpnConnect\AbstractApiClient
|
parent: App\BpnConnect\AbstractApiClient
|
||||||
|
|
||||||
App\Service\MailjetApiClient:
|
App\ClickUp\ApiClient:
|
||||||
|
arguments:
|
||||||
|
$apiToken: '%env(default::CLICKUP_API_TOKEN)%'
|
||||||
|
$listId: '%env(default::CLICKUP_LIST_ID)%'
|
||||||
|
$templateId: '%env(default::CLICKUP_TEMPLATE_ID)%'
|
||||||
|
$baseUrl: '%env(default::CLICKUP_API_BASE_URL)%'
|
||||||
|
|
||||||
|
App\Mailjet\ApiClient:
|
||||||
arguments:
|
arguments:
|
||||||
$apiKey: '%env(default::MAILJET_API_KEY)%'
|
$apiKey: '%env(default::MAILJET_API_KEY)%'
|
||||||
$apiSecret: '%env(default::MAILJET_API_SECRET)%'
|
$apiSecret: '%env(default::MAILJET_API_SECRET)%'
|
||||||
$apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
|
$apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
|
||||||
$defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%'
|
$defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%'
|
||||||
$contactMetadataFields: '%mailjet_contact_metadata_fields%'
|
$contactMetadataFields: '%mailjet_contact_metadata_fields%'
|
||||||
|
|
||||||
|
App\EventListener\MaintenanceModeListener:
|
||||||
|
arguments:
|
||||||
|
$options:
|
||||||
|
enabled: '%env(bool:MAINTENANCE_MODE_ENABLED)%'
|
||||||
|
whitelisted_ips: '%env(csv:MAINTENANCE_MODE_IP_WHITELIST)%'
|
||||||
|
whitelisted_routes: '%env(csv:MAINTENANCE_MODE_ROUTE_WHITELIST)%'
|
||||||
|
whitelisted_paths: '%env(csv:MAINTENANCE_MODE_PATH_WHITELIST)%'
|
||||||
+32
-36
@@ -9,7 +9,6 @@ require 'recipe/symfony.php';
|
|||||||
add('shared_dirs', [
|
add('shared_dirs', [
|
||||||
'config/secret',
|
'config/secret',
|
||||||
'var/bpn',
|
'var/bpn',
|
||||||
'var/log',
|
|
||||||
'var/sessions',
|
'var/sessions',
|
||||||
'var/jsonexport',
|
'var/jsonexport',
|
||||||
'var/xmlexport',
|
'var/xmlexport',
|
||||||
@@ -35,6 +34,8 @@ $rsyncOptions = [
|
|||||||
'.jj',
|
'.jj',
|
||||||
'node_modules',
|
'node_modules',
|
||||||
'.editorconfig',
|
'.editorconfig',
|
||||||
|
'.env.local',
|
||||||
|
'.env.local.php',
|
||||||
'.env.dev.local',
|
'.env.dev.local',
|
||||||
'.env.dev.local.php',
|
'.env.dev.local.php',
|
||||||
'.env.test',
|
'.env.test',
|
||||||
@@ -76,75 +77,70 @@ $rsyncOptions = [
|
|||||||
'timeout' => 300,
|
'timeout' => 300,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Shared by all hosts.
|
||||||
|
set('bin/php', '/usr/bin/php');
|
||||||
|
set('http_user', 'myepsf');
|
||||||
|
// Must stay here: contrib/rsync.php sets the same key, but its __DIR__ is the vendor dir.
|
||||||
|
set('rsync_src', __DIR__);
|
||||||
|
set('rsync', $rsyncOptions);
|
||||||
|
|
||||||
host('prod')
|
host('prod')
|
||||||
->setHostname('185.243.135.29')
|
->setHostname('dedi10193.your-server.de')
|
||||||
->setRemoteUser('p704161')
|
->setDeployPath('/usr/www/users/myepsf/prod')
|
||||||
|
->setRemoteUser('myepsf')
|
||||||
->setForwardAgent(true)
|
->setForwardAgent(true)
|
||||||
->setSshMultiplexing(true)
|
->setSshMultiplexing(true)
|
||||||
->setDeployPath('/home/www/p704161/html/myep')
|
|
||||||
->set('writable_mode', 'chmod')
|
|
||||||
->set('http_user', 'p704161')
|
|
||||||
->set('bin/php', '/usr/local/bin/php')
|
|
||||||
->set('rsync_src', __DIR__)
|
|
||||||
->set('rsync', $rsyncOptions)
|
|
||||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
|
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
|
||||||
;
|
;
|
||||||
|
|
||||||
host('staging')
|
host('staging')
|
||||||
->setHostname('185.243.135.29')
|
->setHostname('dedi10193.your-server.de')
|
||||||
->setRemoteUser('p704161')
|
->setDeployPath('/usr/www/users/myepsf/staging')
|
||||||
|
->setRemoteUser('myepsf')
|
||||||
->setForwardAgent(true)
|
->setForwardAgent(true)
|
||||||
->setSshMultiplexing(true)
|
->setSshMultiplexing(true)
|
||||||
->setDeployPath('/home/www/p704161/html/myep-staging')
|
|
||||||
->set('writable_mode', 'chmod')
|
|
||||||
->set('http_user', 'p704161')
|
|
||||||
->set('bin/php', '/usr/local/bin/php')
|
|
||||||
->set('rsync_src', __DIR__)
|
|
||||||
->set('rsync', $rsyncOptions)
|
|
||||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging')
|
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging')
|
||||||
->add('shared_files', [
|
->add('shared_files', [
|
||||||
'public/.htpasswd',
|
'public/.htaccess',
|
||||||
])
|
|
||||||
;
|
|
||||||
|
|
||||||
host('develop')
|
|
||||||
->setHostname('185.243.135.29')
|
|
||||||
->setRemoteUser('p704161')
|
|
||||||
->setForwardAgent(true)
|
|
||||||
->setSshMultiplexing(true)
|
|
||||||
->setDeployPath('/home/www/p704161/html/myep-develop')
|
|
||||||
->set('writable_mode', 'chmod')
|
|
||||||
->set('http_user', 'p704161')
|
|
||||||
->set('bin/php', '/usr/local/bin/php')
|
|
||||||
->set('rsync_src', __DIR__)
|
|
||||||
->set('rsync', $rsyncOptions)
|
|
||||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://mydev.ep-reisen.net --web-basic-auth=myep:develop')
|
|
||||||
->add('shared_files', [
|
|
||||||
'public/.htpasswd',
|
'public/.htpasswd',
|
||||||
])
|
])
|
||||||
;
|
;
|
||||||
|
|
||||||
task('deploy', [
|
task('deploy', [
|
||||||
'deploy:info',
|
'deploy:info',
|
||||||
|
'deploy:assets',
|
||||||
'deploy:setup',
|
'deploy:setup',
|
||||||
'deploy:lock',
|
'deploy:lock',
|
||||||
'deploy:release',
|
'deploy:release',
|
||||||
'deploy:assets',
|
|
||||||
'rsync',
|
'rsync',
|
||||||
'deploy:shared',
|
'deploy:shared',
|
||||||
'deploy:writable',
|
'deploy:writable',
|
||||||
'deploy:cache:clear',
|
'deploy:cache:warmup',
|
||||||
'database:migrate',
|
'database:migrate',
|
||||||
|
// Must run before the symlink flip: the cachetool probe file is only reachable through
|
||||||
|
// the release the docroot currently resolves to. It still pays off, because it drops the
|
||||||
|
// opcache entries keyed under current/public/*.
|
||||||
'cachetool:clear:opcache',
|
'cachetool:clear:opcache',
|
||||||
'deploy:publish',
|
'deploy:publish',
|
||||||
'deploy:stop-workers',
|
'deploy:stop-workers',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Purely local, so it runs before anything is created on the remote. once(), so
|
||||||
|
// deploying multiple hosts at the same time builds the bundle only once.
|
||||||
task('deploy:assets', function () {
|
task('deploy:assets', function () {
|
||||||
runLocally('ddev exec npm ci');
|
runLocally('ddev exec npm ci');
|
||||||
runLocally('ddev exec npm run build');
|
runLocally('ddev exec npm run build');
|
||||||
|
})->once();
|
||||||
|
|
||||||
|
// The recipe's deploy:cache:clear only does something when composer ran with
|
||||||
|
// --no-scripts, which never happens here: vendor/ is rsynced, composer never runs
|
||||||
|
// remotely. A fresh release has no var/cache to clear, so warm it up directly.
|
||||||
|
// Runs after deploy:writable so the default ACLs are already in place.
|
||||||
|
task('deploy:cache:warmup', function () {
|
||||||
|
run('{{bin/console}} cache:warmup {{console_options}}');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Workers keep running the previous release's code until they are told to stop.
|
||||||
task('deploy:stop-workers', function () {
|
task('deploy:stop-workers', function () {
|
||||||
run('{{bin/console}} messenger:stop-workers');
|
run('{{bin/console}} messenger:stop-workers');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,555 @@
|
|||||||
|
# API Consumer Guide
|
||||||
|
|
||||||
|
This document describes the public JSON API exposed under `/api`, written for developers implementing a **client** in another application. It is self-contained: no access to this codebase is required to use it.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A Symfony application exposing a read-mostly JSON API under the `/api` prefix. All `/api/*` requests are stateless and authenticated via OAuth2 Bearer tokens (League OAuth2 Server, JWT access tokens). Responses are `application/json`. HTTPS is enforced on all routes.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
- 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/travels`; re-request one on
|
||||||
|
401 and resume at the page you were on. At the default `perPage=500` the whole export is a single page today.
|
||||||
|
- 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>`.
|
||||||
|
|
||||||
|
Scope required per endpoint:
|
||||||
|
|
||||||
|
| Scope needed | Endpoints |
|
||||||
|
|---|---|
|
||||||
|
| `api` | everything except the two below |
|
||||||
|
| `profile` | `GET /api/crm-attributes` |
|
||||||
|
| `email` | `GET /api/userinfo` (optional extra scopes `id`, `profile`, `roles` widen the response) |
|
||||||
|
|
||||||
|
`/api/userinfo` and `/api/crm-attributes` act on the **authenticated end user**, so they require an authorization_code token issued for a user. All other endpoints are machine-to-machine and work with client_credentials.
|
||||||
|
|
||||||
|
### Machine-to-machine example
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /token
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
|
||||||
|
grant_type=client_credentials&client_id=...&client_secret=...&scope=api
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/hotels
|
||||||
|
Authorization: Bearer eyJ0eXAi...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error conventions
|
||||||
|
|
||||||
|
There is no single error envelope; three shapes occur:
|
||||||
|
|
||||||
|
- `{"message": "..."}` — commonly with 404 / 400.
|
||||||
|
- `{"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/travels` 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Reference data (BusProNet master data)
|
||||||
|
|
||||||
|
#### `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.
|
||||||
|
|
||||||
|
#### `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.
|
||||||
|
|
||||||
|
#### `GET /api/countries` — scope `api`
|
||||||
|
|
||||||
|
Array of `{id, name, token, nationality}`.
|
||||||
|
|
||||||
|
#### `GET /api/products` — scope `api`
|
||||||
|
|
||||||
|
Product master data from the upstream BusProNet system, cached server-side for 1 hour. On upstream failure returns `{"error": "...", "type": "api_error"}` **with status 200**.
|
||||||
|
|
||||||
|
#### `GET /api/last-update` — scope `api`
|
||||||
|
|
||||||
|
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".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pickups (bus boarding points)
|
||||||
|
|
||||||
|
#### `GET /api/pickups` — scope `api`
|
||||||
|
|
||||||
|
Array of `{id, code, city, postalCode, street}`.
|
||||||
|
|
||||||
|
#### `GET /api/pickups/{id}` — scope `api`
|
||||||
|
|
||||||
|
Single pickup by numeric id, same fields as the list.
|
||||||
|
|
||||||
|
#### `GET /api/pickups-planning/{travelCode}` — scope `api`
|
||||||
|
|
||||||
|
Planned outbound (HIN) boarding stops for one travel code. Returns an array, chronologically sorted:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{"city": "...", "location": "...", "datetime": "YYYY-MM-DD HH:MM", "busNumber": "..."}]
|
||||||
|
```
|
||||||
|
|
||||||
|
`404 {"error":"Planning data not available"}` if no planning has been uploaded yet, `404 {"error":"Travel code not found"}` for an unknown code, `500 {"error":"Failed to read planning data"}` on storage errors.
|
||||||
|
|
||||||
|
#### `POST /api/pickups-planning` — scope `api`
|
||||||
|
|
||||||
|
Bulk-replaces the planning dataset. Body: a JSON **array** of raw planning rows using the upstream German keys:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"PlanungsArt": "HIN",
|
||||||
|
"Reisecode": "ABC123",
|
||||||
|
"Zustieg/Ausstieg Ort": "Köln",
|
||||||
|
"Zustieg/Ausstieg Strasse": "Hauptbahnhof",
|
||||||
|
"Zeit": "24.12.2026 07:30",
|
||||||
|
"BusNummer": "1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Rows without `PlanungsArt === "HIN"` or without `Reisecode` are dropped; duplicates (same city + location + datetime + bus) are de-duplicated; results are grouped by `Reisecode` and sorted by time. Response `{"status":"ok"}` (200). `400 {"error":"Invalid JSON payload"}`, `500 {"error":"Failed to write pickups planning data"}`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
This is the only paginated endpoint and the only one 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",
|
||||||
|
"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+01:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"page": 1,
|
||||||
|
"perPage": 500,
|
||||||
|
"total": 415,
|
||||||
|
"totalPages": 1,
|
||||||
|
"skipped": 0,
|
||||||
|
"pickupsWithoutMasterData": 0,
|
||||||
|
"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.
|
||||||
|
|
||||||
|
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`
|
||||||
|
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 moment as a full ISO-8601 string with a
|
||||||
|
`Europe/Berlin` offset (`+01:00` / `+02:00` depending on DST). **Prefer `datetime`.**
|
||||||
|
- Upstream states a boarding time either as a full date and time or as a bare time. A bare time is resolved
|
||||||
|
against the departure date and, where the sequence demands it, rolled onto the following calendar day — so an
|
||||||
|
`02:15` listed after a `23:59` comes back on the next date, not the same one. `datetime` is emitted for
|
||||||
|
bare-time sources too, and carries that resolution.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Travels
|
||||||
|
|
||||||
|
#### `GET /api/travels` — scope `api`
|
||||||
|
|
||||||
|
Index/mapping of all known travel dates. Object keyed by numeric travel id, each value `{id, code, label, dateFrom, dateTo, hotels: [...]}`.
|
||||||
|
|
||||||
|
#### `GET /api/travels/{dateId}/{hotelId}` — scope `api`
|
||||||
|
|
||||||
|
Single travel by **numeric** ids (`hotelId` optional). Route matches only when both segments are digits.
|
||||||
|
|
||||||
|
#### `GET /api/travels/{dateCode}/{hotelCode}` — scope `api`
|
||||||
|
|
||||||
|
Same, addressed by **string** codes (`hotelCode` optional). The date code is sanitized (separators stripped) before lookup. `404 {"message":"Not found"}` if the date code is unknown, `404 {"message":"Travel not found"}` if no data exists.
|
||||||
|
|
||||||
|
Both variants accept optional query parameters:
|
||||||
|
|
||||||
|
- `source=local` — force the local XML/snapshot data.
|
||||||
|
- `source=remote` — force a live upstream call.
|
||||||
|
- `prefer_remote=1` — used only when `source` is absent; prefers upstream with local fallback.
|
||||||
|
|
||||||
|
Response fields: `id`, `code`, `productCode`, `productId`, `label`, `hotelId`, `hotel` (hotel object), `dateFrom`, `dateTo`, `type`, `status`, `allowedBookingStatus[]`, `priceFrom`, `selectionGroups[]`, `additionalServices[]`, `transportationServices[]`, `pickups[]`, `dropOffs[]`, `rooms[]`, `guide`, `insurances[]`.
|
||||||
|
|
||||||
|
#### `GET /api/travels/{dateId}/{hotelId}/{dateTo}/availability` — scope `api`
|
||||||
|
|
||||||
|
Live hotel availability from the upstream system. `dateTo` must be `Y-m-d`. On upstream failure returns `{"error":"...","type":"api_error"}` **with status 200**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Contingents & accommodation prices
|
||||||
|
|
||||||
|
#### `GET /api/contingents/prices?hotelCode=…&year=…` — scope `api`
|
||||||
|
|
||||||
|
Price timeline for one accommodation and calendar year, collapsed into contiguous segments (adjacent days with the same effective price are merged into one row). The timeline never starts before today; if the requested year is entirely in the past the response is `[]`.
|
||||||
|
|
||||||
|
- `hotelCode`: required, max 16 chars, `[A-Za-z0-9_-]+` (matches the accommodation's calendar code).
|
||||||
|
- `year`: required int, 1000–9999.
|
||||||
|
|
||||||
|
Each row:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dateFrom": "2026-06-01",
|
||||||
|
"dateTo": "2026-06-14",
|
||||||
|
"season": "high|low|...|null",
|
||||||
|
"includedPax": 2,
|
||||||
|
"pricePerNight": 89.5,
|
||||||
|
"priceAdditionalPerson": 25.0,
|
||||||
|
"defaultPricePerNight": 99.0,
|
||||||
|
"defaultPriceAdditionalPerson": 30.0,
|
||||||
|
"currency": "EUR",
|
||||||
|
"type": "discount|null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Prices are **decimal major units** (converted from integer minor units server-side). `default*` fields are non-null only when the winning price is a discount, and then carry the undiscounted price. `400 {"error":"Hotel not found for hotelCode."}` for an unknown code; `400` with violations for invalid parameters.
|
||||||
|
|
||||||
|
#### `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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Each entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"date": "2026-06-01",
|
||||||
|
"status": "Free|Blocked|...",
|
||||||
|
"type": "discount|null",
|
||||||
|
"pricePerNight": 89.5,
|
||||||
|
"defaultPricePerNight": 99.0,
|
||||||
|
"priceAdditionalPerson": 25.0,
|
||||||
|
"defaultPriceAdditionalPerson": 30.0,
|
||||||
|
"currency": "EUR",
|
||||||
|
"includedPax": 2,
|
||||||
|
"minNights": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Operating the snapshot (deployment)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Rollout order:
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
That leaves two levers, and consumers should know both:
|
||||||
|
|
||||||
|
- **Their own cache TTL** bounds how stale a rendered calendar can be. A one-hour TTL is the current agreed budget.
|
||||||
|
- **Direct cache invalidation** is the only fast path when something must propagate sooner — a hotel closing, or bad availability that got cached. Consumers should keep this targeted rather than flushing everything; the TYPO3 site, for example, tags its cached calendar and price entries per hotel, so a single hotel can be flushed and re-warmed on its own.
|
||||||
|
|
||||||
|
On this side, staleness is bounded by the sync cadence and hard-capped by the 6-hour guard: past that, the endpoint returns `502` rather than presenting stale availability as current.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Accommodation bookings (group bookings)
|
||||||
|
|
||||||
|
#### `GET /api/accommodation-bookings/{uuid}` — scope `api`
|
||||||
|
|
||||||
|
Single booking by UUID. `404 {"message":"Not found"}` if unknown.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "...",
|
||||||
|
"status": "...",
|
||||||
|
"type": "...",
|
||||||
|
"dateFrom": "2026-06-01",
|
||||||
|
"dateTo": "2026-06-08",
|
||||||
|
"nights": 7,
|
||||||
|
"paxCount": 40,
|
||||||
|
"minorsCount": 12,
|
||||||
|
"childrenCount": 3,
|
||||||
|
"groupName": "...",
|
||||||
|
"acceptedAt": "2026-05-01T10:00:00+00:00",
|
||||||
|
"personalData": {},
|
||||||
|
"accommodation": {},
|
||||||
|
"boardService": {},
|
||||||
|
"additionalServices": [],
|
||||||
|
"accommodationDiscount": null,
|
||||||
|
"boardServiceDiscount": null,
|
||||||
|
"additionalServicesDiscount": null,
|
||||||
|
"totalDiscountAmount": null,
|
||||||
|
"totalDiscountLabel": null,
|
||||||
|
"totalPrice": 123456,
|
||||||
|
"pricingCurrency": "EUR",
|
||||||
|
"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 and running costs sit in `total` but in no section, so they are never discounted.
|
||||||
|
|
||||||
|
`totalDiscountAmount` is a freely named discount on the whole price, labelled by `totalDiscountLabel`. It is a fixed sum, not a percentage, and is subtracted **after** the three section discounts have reduced `total`. It is capped at what is left of that subtotal, so `discountedTotal` is never negative and the amount shown in the breakdown may be smaller than `totalDiscountAmount` itself. The section discounts are each rounded to the nearest cent as they are applied.
|
||||||
|
|
||||||
|
`priceBreakdown` carries the resulting rows, all in minor units:
|
||||||
|
|
||||||
|
| field | meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `discounts` | list of `{label, percent, amount}` for the section discounts; entries rounding to `0` are omitted, and `label` already includes its `Rabatt ` prefix |
|
||||||
|
| `discountSubtotal` | `total` minus the section discounts — present only when `totalDiscountAmount` applies *and* at least one section discount did, otherwise `null` |
|
||||||
|
| `totalDiscountDetails` | `{label, amount}` for the total discount, or `null`; `amount` is the capped figure actually subtracted |
|
||||||
|
| `discountedTotal` | the final price, equal to the booking's `totalPrice` |
|
||||||
|
|
||||||
|
#### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api`
|
||||||
|
|
||||||
|
Marks the booking as accepted (server-side side effects: status change, `acceptedAt`, notifications). No request body. Returns the same booking representation as the GET. `404` if unknown. Not idempotent in terms of side effects — call once.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Contact form
|
||||||
|
|
||||||
|
#### `POST /api/contactform` — scope `api`
|
||||||
|
|
||||||
|
Creates or matches an address record in the upstream CRM.
|
||||||
|
|
||||||
|
Request body (all fields optional strings; the upstream system decides what is required):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lastName": "...", "firstName": "...", "gender": "...",
|
||||||
|
"street": "...", "zipCode": "...", "city": "...",
|
||||||
|
"email": "...", "phone": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `201` — `{"success": true, "addressId": 123, "personId": 456, "isNewRecord": true}`
|
||||||
|
- `409` — `{"success": false, "message": "<upstream notification>"}` (upstream rejected/conflicted)
|
||||||
|
- `400` — `{"success": false, "message": "<error>"}` (upstream call failed)
|
||||||
|
- `422` — malformed payload
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Newsletter
|
||||||
|
|
||||||
|
#### `GET /api/newsletters` — scope `api`
|
||||||
|
|
||||||
|
The configured newsletter lists as an array of `{id, name, ...}` mapping objects. Use the returned `id` values as `listIds` below.
|
||||||
|
|
||||||
|
#### `POST /api/newsletter-subscriptions` — scope `api`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "[email protected]",
|
||||||
|
"firstName": "Ada",
|
||||||
|
"lastName": "Lovelace",
|
||||||
|
"listIds": [1, 2]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `email`: required, strict email validation.
|
||||||
|
- `firstName` / `lastName`: optional, max 255 chars.
|
||||||
|
- `listIds`: required, at least one positive integer; every id must be one of the configured lists.
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `200` — subscription completed, `state: "subscribed"`.
|
||||||
|
- `202` — a double-opt-in confirmation mail was requested, `state: "confirmation_requested"`.
|
||||||
|
- Body in both cases:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"email": "[email protected]",
|
||||||
|
"lists": [{"id": 1, "name": "...", "state": "success|pending|already_registered"}],
|
||||||
|
"state": "subscribed|pending_confirmation|confirmation_requested",
|
||||||
|
"confirmationRequested": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `400` — unknown or disallowed list ids: `{"success": false, "message": "...", "listIds": [...], "unknownListIds": [...]}`, or validation violations.
|
||||||
|
- `503` — `{"success": false, "email": "...", "listIds": [...], "message": "Newsletter subscription request could not be processed."}` when the newsletter provider is unavailable. Safe to retry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### End-user endpoints (require a user-bound token)
|
||||||
|
|
||||||
|
#### `GET /api/userinfo` — scope `email` (+ optional `id`, `profile`, `roles`)
|
||||||
|
|
||||||
|
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`
|
||||||
|
- 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 `profile`: `profile` object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"first_name": "...", "last_name": "...", "title": "...", "salutation": "...",
|
||||||
|
"gender": "...", "date_of_birth": "1990-01-01", "nationality": "...",
|
||||||
|
"address": {"street": "...", "postcode": "...", "city": "...", "district": "...", "country": "..."},
|
||||||
|
"communication": {"email": "...", "mobile": "...", "phone": "...", "newsletter": true},
|
||||||
|
"hotel_codes": ["..."], "height": null, "weight": null, "shoe_size": null, "remarks": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 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.
|
||||||
|
|
||||||
|
`400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials.
|
||||||
|
|
||||||
|
#### `GET /api/crm-attributes` — scope `profile`
|
||||||
|
|
||||||
|
CRM selection attributes/segments for the authenticated user. `400 {"message": "...", "code": "..."}` on upstream failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
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.
|
||||||
|
5. **Server-side caching** (1 h on products) means upstream changes may not be visible immediately. The contingent calendar is snapshot-backed instead: it is at most one sync interval behind upstream. There is no change-notification endpoint — clients are expected to cache on their own TTL and, when something must propagate sooner, invalidate their cache directly.
|
||||||
|
6. **Route ambiguity**: `/api/travels/{a}/{b}` resolves to the id-based route when both segments are numeric, otherwise to the code-based route. Non-numeric codes are safe; purely numeric *codes* will hit the id route.
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
# Body Dimensions Transition Plan (Stakeholder Draft)
|
|
||||||
|
|
||||||
> **STATUS (verified 2026-07-15): NOT IMPLEMENTED.** `src/Form/BodyDimensionsType.php` still declares `height`, `shoeSize`, and `weight` as `IntegerType` fields, and `src/Validator/Constraints/ParticipantValidator.php` has no regex/legacy-format handling — only numeric range checks. This plan has not been acted on.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Prevent edit-form failures for existing bookings while we transition from legacy body-dimension values to a better long-term field model.
|
|
||||||
|
|
||||||
This plan introduces a temporary compatibility phase and keeps sunsetting manual and straightforward.
|
|
||||||
|
|
||||||
## Current Problem
|
|
||||||
|
|
||||||
- Existing bookings may contain legacy values such as `-148`, `149-157`, or `195+`.
|
|
||||||
- Integer-only form fields can fail when these legacy strings are loaded.
|
|
||||||
- Result: participant edit forms can crash before submit.
|
|
||||||
|
|
||||||
## Proposed Transition Strategy
|
|
||||||
|
|
||||||
Use **text input fields** for body dimensions during transition, with explicit server-side format validation.
|
|
||||||
|
|
||||||
### Key Decisions
|
|
||||||
|
|
||||||
- No gating by booking create date.
|
|
||||||
- One unified behavior for all bookings.
|
|
||||||
- Accept both legacy and numeric formats during transition.
|
|
||||||
- Keep manual sunsetting later (no automated migration switch).
|
|
||||||
|
|
||||||
## Functional Scope
|
|
||||||
|
|
||||||
Affected fields:
|
|
||||||
|
|
||||||
- `height`
|
|
||||||
- `weight`
|
|
||||||
- `shoeSize`
|
|
||||||
|
|
||||||
Affected components:
|
|
||||||
|
|
||||||
- `src/Form/BodyDimensionsType.php`
|
|
||||||
- `src/Validator/Constraints/ParticipantValidator.php`
|
|
||||||
- `src/Form/BookingParticipantType.php` (option passthrough already in place)
|
|
||||||
- `config/services.yaml` (ranges remain as validation/config source)
|
|
||||||
|
|
||||||
## Validation Rules During Transition
|
|
||||||
|
|
||||||
### Accepted formats
|
|
||||||
|
|
||||||
- Numeric value: `^\d+$` (e.g. `176`)
|
|
||||||
- Legacy lower/open/range tokens:
|
|
||||||
- `^-\d+$` (e.g. `-148`)
|
|
||||||
- `^\d+\+$` (e.g. `195+`)
|
|
||||||
- `^\d+\s*-\s*\d+$` (e.g. `149-157`)
|
|
||||||
|
|
||||||
### Requiredness
|
|
||||||
|
|
||||||
- Keep existing rule: body dimensions required when rentals are selected.
|
|
||||||
|
|
||||||
### Range checks
|
|
||||||
|
|
||||||
- Keep current numeric range checks based on `body_dimension_ranges`.
|
|
||||||
- Apply range checks only to plain numeric values in transition phase.
|
|
||||||
|
|
||||||
## UX/Behavior Expectations
|
|
||||||
|
|
||||||
- Existing legacy values render without form initialization errors.
|
|
||||||
- New numeric entries are accepted and validated.
|
|
||||||
- Invalid free text (e.g. `abc`, `17x`) shows clear validation errors.
|
|
||||||
- HTMX refresh behavior remains unchanged.
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
|
|
||||||
1. **Form type change**
|
|
||||||
- In `BodyDimensionsType`, replace integer fields with text fields for all three body dimensions.
|
|
||||||
- Keep labels/help text; keep placeholders based on configured ranges.
|
|
||||||
|
|
||||||
2. **Validator extension**
|
|
||||||
- Add format validation for all three fields in `ParticipantValidator`.
|
|
||||||
- Keep existing required-when-rentals logic.
|
|
||||||
- Keep existing range validation for numeric values.
|
|
||||||
|
|
||||||
3. **Message tuning**
|
|
||||||
- Add one clear message for invalid format.
|
|
||||||
- Retain existing range message for numeric out-of-range values.
|
|
||||||
|
|
||||||
4. **Testing and QA**
|
|
||||||
- Validate old bookings with legacy values open/edit successfully.
|
|
||||||
- Validate numeric path (valid and invalid range).
|
|
||||||
- Validate invalid format handling.
|
|
||||||
- Validate rental-required behavior.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- No 500 error when editing participants with legacy body-dimension values.
|
|
||||||
- Legacy values can be loaded and submitted in transition phase.
|
|
||||||
- Numeric values are accepted and range-validated.
|
|
||||||
- Invalid text is rejected with a user-facing validation message.
|
|
||||||
- Existing participant edit/refresh flows continue to work.
|
|
||||||
|
|
||||||
## Risks and Mitigations
|
|
||||||
|
|
||||||
- **Risk:** Free-text field semantics are less strict than integer fields.
|
|
||||||
- **Mitigation:** strict server-side regex + range checks.
|
|
||||||
|
|
||||||
- **Risk:** Inconsistent data representations during transition.
|
|
||||||
- **Mitigation:** explicit acceptance policy and manual sunset plan.
|
|
||||||
|
|
||||||
## Effort Estimate
|
|
||||||
|
|
||||||
- Implementation: 0.5 day
|
|
||||||
- Validation/message tuning: 0.25 day
|
|
||||||
- QA/manual testing: 0.5 day
|
|
||||||
- **Total:** ~1 to 1.5 days
|
|
||||||
|
|
||||||
## Manual Sunset Plan (Later)
|
|
||||||
|
|
||||||
When stakeholders approve end of transition:
|
|
||||||
|
|
||||||
1. Replace text fields with final semantic field type(s).
|
|
||||||
2. Remove legacy format acceptance from validator.
|
|
||||||
3. Keep only final numeric/range validation behavior.
|
|
||||||
4. Remove transition-specific tests and copy.
|
|
||||||
|
|
||||||
Expected cleanup effort: ~0.25 to 0.5 day.
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
# Booking Flow Reference
|
|
||||||
|
|
||||||
Last updated: 2026-07-15
|
|
||||||
|
|
||||||
This document traces the booking create and edit flows from controller entry point through to session/DTO state. It is intended as orientation for developers unfamiliar with the flow, not as a substitute for reading the code.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Shared Concepts
|
|
||||||
|
|
||||||
### `BookingDto`
|
|
||||||
|
|
||||||
The central data object for both flows. Holds room selections, participants, payment method, and travel reference. Lives in the HTTP session between requests. Has two modes: `MODE_CREATE` and `MODE_EDIT`.
|
|
||||||
|
|
||||||
### `BookingSessionManager`
|
|
||||||
|
|
||||||
Owns all reads and writes to session-stored `BookingDto` instances. Also manages the baseline room snapshot used for change detection in the create flow.
|
|
||||||
|
|
||||||
### `BookingCreateContextFactory`
|
|
||||||
|
|
||||||
Assembles the view model (`BookingCreateContext`) passed to create-flow templates. Handles room grouping (by-pax vs. by-room) and wires summary sidebar data.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Create Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Step 1 (room selection)
|
|
||||||
→ Step 2 (participants)
|
|
||||||
→ Step 2 sub-form (per-participant, HTMX)
|
|
||||||
→ Step 3 (payment + API pre-validation)
|
|
||||||
→ Step 4 (confirmation + final submission)
|
|
||||||
→ Success
|
|
||||||
```
|
|
||||||
|
|
||||||
### Entry / Bootstrap
|
|
||||||
|
|
||||||
`AbstractBookingCreateController::loadBookingCreateDto()` is called by every step controller and delegates to `BookingSessionManager::getOrCreateBookingCreateDto()`. If no DTO exists in session, `BookingConfigurator::startFreshBooking()` loads travel data and initialises a fresh `BookingDto`.
|
|
||||||
|
|
||||||
### Step 1 — Room Selection
|
|
||||||
|
|
||||||
**Controller:** `Create/Step1Controller`
|
|
||||||
**Route:** `GET|POST /bookings/create/rooms`
|
|
||||||
|
|
||||||
1. Load or create DTO via `getOrCreateBookingCreateDto()`
|
|
||||||
2. `BookingSessionManager::getOrCreateBaselineSnapshot()` — saves the current room selection as a comparison point for change detection
|
|
||||||
3. Render `BookingCreateStep1Type` form
|
|
||||||
4. On submit:
|
|
||||||
- If room selection changed from baseline: `BookingDto::resetParticipantAssignments()` — clears stale assignments
|
|
||||||
- `BookingConfigurator::updateBookingStatusFromRoomSelection()` — may switch booking to inquiry mode if capacity constraints require it
|
|
||||||
- `BookingSessionManager::saveBookingDto()`, advance `currentStep` to 2
|
|
||||||
- `BookingSessionManager::clearBaselineSnapshot()`
|
|
||||||
- Redirect to Step 2
|
|
||||||
|
|
||||||
HTMX refresh (`/bookings/create/refresh`): processes the form without validation and returns updated room selection + summary sidebar blocks.
|
|
||||||
|
|
||||||
### Step 2 — Participants (Card Overview)
|
|
||||||
|
|
||||||
**Controller:** `Create/Step2Controller`
|
|
||||||
**Route:** `GET|POST /bookings/create/participants`
|
|
||||||
|
|
||||||
1. Load DTO from session (or redirect to Step 1 if missing)
|
|
||||||
2. `TravelDataProvider::enrichWithFreshAvailabilities()` — refreshes room availability
|
|
||||||
3. `ensureCorrectNumberOfParticipants()` — syncs the `BookingDto::participants` array length with the room selections (e.g., 2× double room → 4 participants). Preserves existing participant data for slots that still exist.
|
|
||||||
4. `ParticipantDataPrefiller::shouldPrefillApplicant()` + `prefillApplicantFromUser()` — prefills participant[0] from user profile on first visit
|
|
||||||
5. `RoomAssigner::validateAndResetInvalidAssignments()` — clears any room assignments that no longer match the current room selection (handles back-navigation from Step 2 → Step 1 → Step 2)
|
|
||||||
6. `RoomAssigner::assignRoomsIfNeeded()` — auto-assigns participants to rooms
|
|
||||||
7. `BookingConfigurator::preselectDefaultServices()` — selects mandatory and auto-book services for all participants
|
|
||||||
8. `BookingConfigurator::applyCreateBookingStatusRules()` — re-evaluates booking status (book / inquiry / not-bookable)
|
|
||||||
9. `BookingSessionManager::saveBookingDto()`
|
|
||||||
10. On form submit (all participants valid): advance to Step 3, redirect
|
|
||||||
|
|
||||||
### Step 2 — Individual Participant Form (HTMX)
|
|
||||||
|
|
||||||
**Controller:** `Create/Step2ParticipantController`
|
|
||||||
**Routes:** `GET|POST /bookings/create/participants/{index}` and `/participants/{index}/refresh`
|
|
||||||
|
|
||||||
Each participant card lazy-loads its form via HTMX. The controller:
|
|
||||||
|
|
||||||
1. Verifies the participant slot exists
|
|
||||||
2. Refreshes availability
|
|
||||||
3. Handles dummy data fill (`ParticipantDataPrefiller::isDummyDataFillRequested()` / `fillDummyParticipant()`)
|
|
||||||
4. On submit: re-applies status rules, saves to session, redirects to card overview
|
|
||||||
5. `ParticipantFormSupport` is used for form creation options and notification draining
|
|
||||||
|
|
||||||
### Step 3 — Payment + API Pre-Validation
|
|
||||||
|
|
||||||
**Controller:** `Create/Step3Controller`
|
|
||||||
**Route:** `GET|POST /bookings/create/payment`
|
|
||||||
|
|
||||||
1. Load DTO from session
|
|
||||||
2. `BookingConfigurator::applyCreateBookingStatusRules()`
|
|
||||||
3. On submit:
|
|
||||||
- `ApiClient::createBookingInquiry()` — submits booking to API for validation without committing it
|
|
||||||
- If inquiry fails with "nicht möglich" + "Anfrage" in message: auto-switch to inquiry mode, advance to Step 4
|
|
||||||
- If inquiry fails otherwise: show error, stay on step
|
|
||||||
- Price verification: `BookingPriceCalculator::calculateGrandTotal()` is compared against the API response total (accounting for promotional/goodwill voucher discounts and API-applied group discounts). Mismatch triggers `BookingPriceMismatchAnalyzer` for detailed logging.
|
|
||||||
- On success: advance `currentStep` to 4, redirect
|
|
||||||
|
|
||||||
### Step 4 — Confirmation + Final Submission
|
|
||||||
|
|
||||||
**Controller:** `Create/Step4Controller`
|
|
||||||
**Route:** `GET|POST /bookings/create/confirmation`
|
|
||||||
|
|
||||||
1. Load DTO from session
|
|
||||||
2. Newsletter subscription check for opt-in display
|
|
||||||
3. On submit:
|
|
||||||
- `BookingConfigurator::applyCreateBookingStatusRules()`
|
|
||||||
- `ApiClient::createBooking()` — final booking submission (already validated in Step 3)
|
|
||||||
- Newsletter double-opt-in if selected
|
|
||||||
- On success: store booking number + total in flash (for analytics), clear travel data cache, `BookingSessionManager::clearBookingDto()`, redirect to success page
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Edit Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Entry (start)
|
|
||||||
→ Load (session or API + draft restore)
|
|
||||||
→ Participant sub-forms (HTMX, per-participant)
|
|
||||||
→ Submission
|
|
||||||
```
|
|
||||||
|
|
||||||
### Entry
|
|
||||||
|
|
||||||
**Controller:** `Edit/IndexController::start()`
|
|
||||||
**Route:** `GET /bookings/{id}/edit/start`
|
|
||||||
|
|
||||||
Clears any existing session DTO for edit mode and invalidates the API booking cache, then redirects to the edit page. This ensures a clean load from API on every intentional re-entry.
|
|
||||||
|
|
||||||
### Load
|
|
||||||
|
|
||||||
**Controller:** `Edit/IndexController::index()`
|
|
||||||
**Route:** `GET|POST /bookings/{id}/edit`
|
|
||||||
|
|
||||||
Loading is delegated entirely to `BookingEditDataLoader::loadFormData()`:
|
|
||||||
|
|
||||||
**If session DTO exists and matches the requested booking ID:**
|
|
||||||
- `TravelDataProvider::enrichWithFreshAvailabilities()` — refreshes room availability
|
|
||||||
- `TravelDataProvider::getMutabilityData()` + `patchMutability()` — applies cached mutability state to DTO
|
|
||||||
|
|
||||||
**If no session DTO (first load or after `start`):**
|
|
||||||
|
|
||||||
`initializeFromApi()`:
|
|
||||||
1. `fetchBookingData()` — `ApiClient::getBooking()` with 5-minute cache (tagged by user ID for bulk invalidation)
|
|
||||||
2. `TravelDataProvider::getTravelData()` — loads travel data (XML or snapshot)
|
|
||||||
3. `TravelDataProvider::getAvailabilityData(forceRefresh: true)` + `patchAvailabilities()` — force-refreshes availability at edit start, populates cache for subsequent loads within the session
|
|
||||||
4. `TravelDataProvider::getMutabilityData(cached: true)` + `patchMutability()` — applies which service categories are currently editable
|
|
||||||
5. `BookingDataProcessor::createBookingDtoFromBooking()` — hydrates `BookingDto` from API booking entity
|
|
||||||
6. Agency code resolved via `AgencyLoader` (used by field state conditions)
|
|
||||||
7. `BookingChangeTracker::generateFingerprint()` — sets `originalFingerprint` on DTO **before** draft is applied, so dirty detection compares against original API data
|
|
||||||
8. `BookingEditDraftManager::findDraft()` + `applyDraftToDto()` — if draft exists, merges user edits on top of fresh API data. Sets `draftWasRestored = true` if successful.
|
|
||||||
9. `BookingSessionManager::saveBookingDto()`
|
|
||||||
|
|
||||||
Back in the controller: shows "draft restored" flash if applicable, fetches booking data for surcharge/status display, renders the card overview with `BookingEditContextFactory::createOverviewContext()`.
|
|
||||||
|
|
||||||
### Individual Participant Form (HTMX)
|
|
||||||
|
|
||||||
**Controller:** `Edit/ParticipantController`
|
|
||||||
**Routes:** `GET|POST /bookings/{id}/edit/participants/{index}` and `/{index}/refresh`
|
|
||||||
|
|
||||||
Per-participant forms are lazy-loaded, matching the create flow pattern.
|
|
||||||
|
|
||||||
1. `BookingEditContextFactory::prepareBookingDto()` — refreshes availabilities on the DTO travel object
|
|
||||||
2. `ParticipantFormSupport` — form creation, notification draining
|
|
||||||
3. On submit: `BookingSessionManager::saveBookingDto()` + `BookingEditDraftManager::saveDraft()` — persists participant edits to both session and draft
|
|
||||||
|
|
||||||
### Submission
|
|
||||||
|
|
||||||
**Service:** `BookingEditSubmitter::handleSubmission()`
|
|
||||||
Called from `Edit/IndexController` on valid form submit.
|
|
||||||
|
|
||||||
1. Invalidate API cache, fetch fresh booking data
|
|
||||||
2. Force-refresh mutability data (`TravelDataProvider::getMutabilityData(forceRefresh: true)`) and patch DTO travel
|
|
||||||
3. `BookingEditSubmitGuard::reconcileImmutableCategories()` — reverts any DTO fields that have become immutable since the session was loaded. Shows flash if anything was reverted.
|
|
||||||
4. `ApiClient::updateBooking()` — submit
|
|
||||||
5. On success: invalidate cache again, `BookingSessionManager::clearBookingDto()`, `BookingEditDraftManager::deleteDraft()`
|
|
||||||
|
|
||||||
### Other Edit Routes
|
|
||||||
|
|
||||||
| Route | Action |
|
|
||||||
|-------|--------|
|
|
||||||
| `GET /bookings/{id}/edit/reload` | Shows confirmation modal |
|
|
||||||
| `POST /bookings/{id}/edit/reload` | Clears session + **deletes draft**, reloads from API |
|
|
||||||
| `GET /bookings/{id}/edit/cancel` | Clears session, **preserves draft**, redirects to booking list |
|
|
||||||
|
|
||||||
The distinction between reload and cancel is intentional: cancel keeps the draft so the user can return to their changes later; reload discards everything.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Invariants
|
|
||||||
|
|
||||||
- `currentStep` on the DTO gates forward navigation in create mode; controllers redirect to the current step if a higher step is requested directly.
|
|
||||||
- The original fingerprint is set before draft application in edit mode. Dirty detection (`BookingChangeTracker::isDirty()`) always compares the live session DTO against the original API state, not against the draft.
|
|
||||||
- Mutability is always patched from fresh or cached data before rendering. The DTO travel object is the authoritative source of which fields are editable at render time.
|
|
||||||
- Drafts survive cancel but not reload, and are deleted on successful submission.
|
|
||||||
@@ -0,0 +1,902 @@
|
|||||||
|
# bpn-connect Query Reference
|
||||||
|
|
||||||
|
> **Scope: endpoint design and the XML→SQL migration plan for the `bpn-connect` Go proxy.**
|
||||||
|
> For schema semantics, conventions and confidence marks see
|
||||||
|
> [`docs/buspro-database-reference.md`](buspro-database-reference.md), which is authoritative
|
||||||
|
> and wins on any conflict. The table descriptions below are kept for endpoint context.
|
||||||
|
|
||||||
|
This document summarizes the BusProNet schema exports in
|
||||||
|
`docs/buspronet-schema` for future `bpn-connect` endpoint/query development.
|
||||||
|
|
||||||
|
Generated from:
|
||||||
|
|
||||||
|
- `docs/buspronet-schema/BusPronet_20260601-1517.csv`: table columns
|
||||||
|
- `docs/buspronet-schema/BusPronet_20260601-1518.csv`: foreign keys
|
||||||
|
|
||||||
|
Current limitation: the visible exports do not include primary-key or index
|
||||||
|
catalog data, even though those were expected. Identity flags are not a reliable
|
||||||
|
primary-key substitute in this database because many `ID...` columns are not
|
||||||
|
marked as identity. Add PK/index exports before finalizing performance-sensitive
|
||||||
|
queries.
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
- Primary identifiers usually follow `ID<TableName>`, for example
|
||||||
|
`Buchung.IDBuchung`, `Adresse.IDAdresse`, `AdressePerson.IDAdressePerson`.
|
||||||
|
- Foreign key columns usually include `_FS`, for example
|
||||||
|
`BuchungTN.IDBuchung_FS`.
|
||||||
|
- Address data is split into:
|
||||||
|
- `Adresse`: address/customer account root.
|
||||||
|
- `AdressePerson`: person attached to an address.
|
||||||
|
- `AdresseAnschrift`: postal address.
|
||||||
|
- `AdresseKommunikation`: communication values such as email/phone.
|
||||||
|
- Booking data is split into:
|
||||||
|
- `Buchung`: booking header.
|
||||||
|
- `BuchungTN`: booking participants.
|
||||||
|
- `BuchungTNFerienziel`, `BuchungTNLeistung`: participant travel/service rows.
|
||||||
|
- Public booking number appears to be `Buchung.Vorgang`.
|
||||||
|
- Internal booking id is `Buchung.IDBuchung`.
|
||||||
|
- Many tables have `Status` fields. Based on observed data, `Status = 'L'`
|
||||||
|
appears to be a soft-delete marker and should be excluded from portal lookup
|
||||||
|
queries. Other status values still need table-specific business validation.
|
||||||
|
|
||||||
|
## Core Tables
|
||||||
|
|
||||||
|
### `Buchung`
|
||||||
|
|
||||||
|
Booking header. Important columns:
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `IDBuchung` | `bigint` | no | Internal booking id. |
|
||||||
|
| `Vorgang` | `nvarchar(100)` | no | Public booking number/reference. |
|
||||||
|
| `IDReise_FS` | `bigint` | yes | Links to `Reise.IDReise`; no FK was present in the export. |
|
||||||
|
| `IDAdresse_FS_Anmelder` | `bigint` | yes | Applicant address id; no FK was present in the export. |
|
||||||
|
| `IDAdresseAnschrift_FS_Anmelder` | `bigint` | yes | Applicant postal address id; no FK was present in the export. |
|
||||||
|
| `IdAdressePerson_FS_Anmelder` | `bigint` | yes | Applicant person id; FK exists. |
|
||||||
|
| `IDAdresse_FS_Agentur` | `bigint` | no | Agency address; FK exists. |
|
||||||
|
| `IDAdresse_FS_Mandant` | `bigint` | no | Tenant/client address; FK exists. |
|
||||||
|
| `LoginWert1` | `nvarchar(100)` | no | Possible login-related value; semantics unknown. |
|
||||||
|
| `LoginWert2` | `nvarchar(100)` | no | Possible login-related value; semantics unknown. |
|
||||||
|
| `LoginWert3` | `nvarchar(100)` | no | Possible login-related value; semantics unknown. |
|
||||||
|
|
||||||
|
Known exported foreign keys:
|
||||||
|
|
||||||
|
- `Buchung.IdAdressePerson_FS_Anmelder -> AdressePerson.IDAdressePerson`
|
||||||
|
- `Buchung.IDAdresse_FS_Agentur -> Adresse.IDAdresse`
|
||||||
|
- `Buchung.IDAdresse_FS_ErsterSachb -> Adresse.IDAdresse`
|
||||||
|
- `Buchung.IDAdresse_FS_LetzterSachb -> Adresse.IDAdresse`
|
||||||
|
- `Buchung.IDAdresse_FS_Mandant -> Adresse.IDAdresse`
|
||||||
|
|
||||||
|
Notable missing FKs in the export:
|
||||||
|
|
||||||
|
- `Buchung.IDAdresse_FS_Anmelder`
|
||||||
|
- `Buchung.IDAdresseAnschrift_FS_Anmelder`
|
||||||
|
- `Buchung.IDReise_FS`
|
||||||
|
|
||||||
|
Treat those as likely relationships but verify against real data.
|
||||||
|
|
||||||
|
### `BuchungTN`
|
||||||
|
|
||||||
|
Booking participant.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `IDBuchungTN` | `bigint` | no | Participant row id. |
|
||||||
|
| `IDBuchung_FS` | `bigint` | no | Booking header id. |
|
||||||
|
| `IDAdressePerson_FS` | `bigint` | no | Participant person id. |
|
||||||
|
| `IDAdresseAnschrift_FS` | `bigint` | yes | Participant postal address id. |
|
||||||
|
| `Status` | `nvarchar(2)` | no | Participant status; values need business validation. |
|
||||||
|
| `BuchungsDatum` | `datetime` | no | Booking date. |
|
||||||
|
| `StornoStatusKV` | `nvarchar(2)` | yes | Cancellation-related status. |
|
||||||
|
| `StornoDatum` | `datetime` | yes | Cancellation date. |
|
||||||
|
| `Position` | `smallint` | no | Participant position within booking. |
|
||||||
|
|
||||||
|
Known exported foreign keys:
|
||||||
|
|
||||||
|
- `BuchungTN.IDBuchung_FS -> Buchung.IDBuchung`
|
||||||
|
- `BuchungTN.IDAdressePerson_FS -> AdressePerson.IDAdressePerson`
|
||||||
|
|
||||||
|
### `Adresse`
|
||||||
|
|
||||||
|
Customer/address root.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `IDAdresse` | `bigint` | no | Address/customer id needed by the local user record. |
|
||||||
|
| `SuchName` | `nvarchar(402)` | no | Search/display name. |
|
||||||
|
| `Status` | `nvarchar(2)` | no | Address status; active values need validation. |
|
||||||
|
| `Art` | `tinyint` | no | Address type; values need validation. |
|
||||||
|
| `IDAdresseAnschrift_FS_Versandanschrift` | `bigint` | no | Default shipping/postal address. |
|
||||||
|
| `IDAdressePerson_FS_ReferenzM` | `bigint` | no | Reference person. |
|
||||||
|
| `DatumLöschungDSGVO` | `datetime` | yes | GDPR deletion marker. Exclude rows where this is not null for portal access. |
|
||||||
|
|
||||||
|
### `AdressePerson`
|
||||||
|
|
||||||
|
Person attached to an address.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `IDAdressePerson` | `bigint` | no | Person id needed by the local user record. |
|
||||||
|
| `IDAdresse_FS` | `bigint` | no | Parent address id. |
|
||||||
|
| `Name` | `nvarchar(200)` | no | Last name. |
|
||||||
|
| `Vorname` | `nvarchar(200)` | yes | First name. |
|
||||||
|
| `Geschlecht` | `nvarchar(2)` | no | Gender/salutation code. |
|
||||||
|
| `Status` | `nvarchar(2)` | no | Person status; active values need validation. |
|
||||||
|
| `Passwort` | `nvarchar(200)` | no | Legacy BusProNet password field. Do not use from `bpn-connect`; password migration remains on the BusProNet XML API until the algorithm is known. |
|
||||||
|
| `Registrierung` | `bit` | no | Registration flag; semantics need validation. |
|
||||||
|
| `IDFremd` | `nvarchar(100)` | no | External id field. |
|
||||||
|
|
||||||
|
Known exported foreign key:
|
||||||
|
|
||||||
|
- `AdressePerson.IDAdresse_FS -> Adresse.IDAdresse`
|
||||||
|
|
||||||
|
### `AdresseKommunikation`
|
||||||
|
|
||||||
|
Communication values for an address/person.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `IDAdresseKommunikation` | `bigint` | no | Communication row id. |
|
||||||
|
| `IDAdresse_FS` | `bigint` | no | Address id. |
|
||||||
|
| `IDAdressePerson_FS` | `bigint` | no | Person id. No FK was present in the export, but the name strongly implies `AdressePerson.IDAdressePerson`. |
|
||||||
|
| `Wert` | `nvarchar(512)` | no | Communication value, likely email/phone depending on type. |
|
||||||
|
| `WertTelefonie` | `nvarchar(100)` | yes | Phone-specific normalized value. |
|
||||||
|
| `IDArtKommunikation_FS` | `bigint` | no | Communication type. |
|
||||||
|
| `Status` | `nvarchar(2)` | no | Communication status; active values need validation. |
|
||||||
|
| `Newsletter` | `bit` | no | Newsletter flag. |
|
||||||
|
| `DoubleOptIn` | `bit` | no | Double opt-in flag. |
|
||||||
|
|
||||||
|
Known exported foreign keys:
|
||||||
|
|
||||||
|
- `AdresseKommunikation.IDAdresse_FS -> Adresse.IDAdresse`
|
||||||
|
- `AdresseKommunikation.IDArtKommunikation_FS -> ArtKommunikation.IDArtKommunikation`
|
||||||
|
|
||||||
|
Important missing FK:
|
||||||
|
|
||||||
|
- `AdresseKommunikation.IDAdressePerson_FS` is not exported as a FK. Join it to
|
||||||
|
`AdressePerson.IDAdressePerson` only after validating with real data.
|
||||||
|
|
||||||
|
### `ArtKommunikation`
|
||||||
|
|
||||||
|
Communication type lookup.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `IDArtKommunikation` | `bigint` | no | Type id. |
|
||||||
|
| `Langtext` | `nvarchar(200)` | yes | Long label. |
|
||||||
|
| `Kurztext` | `nvarchar(40)` | yes | Short label. |
|
||||||
|
| `Code` | `nvarchar(20)` | yes | Type code. Find the email code/id here. |
|
||||||
|
| `Status` | `nvarchar(2)` | yes | Type status. |
|
||||||
|
|
||||||
|
Before relying on email lookup queries, run:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT IDArtKommunikation, Langtext, Kurztext, Code, Status
|
||||||
|
FROM dbo.ArtKommunikation
|
||||||
|
ORDER BY IDArtKommunikation;
|
||||||
|
```
|
||||||
|
|
||||||
|
Then hard-code or configure the email type ids/codes in `bpn-connect`.
|
||||||
|
|
||||||
|
### `AdresseAnschrift`
|
||||||
|
|
||||||
|
Postal address.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `IDAdresseAnschrift` | `bigint` | no | Postal address id. |
|
||||||
|
| `IDAdresse_FS` | `bigint` | no | Address id. |
|
||||||
|
| `IDAdressePerson_FS` | `bigint` | yes | Person id. |
|
||||||
|
| `Anrede` | `nvarchar(100)` | yes | Salutation. |
|
||||||
|
| `Name` | `nvarchar(400)` | yes | Postal name. |
|
||||||
|
| `Strasse` | `nvarchar(200)` | no | Street. |
|
||||||
|
| `PLZ` | `nvarchar(20)` | no | Postal code. |
|
||||||
|
| `Ort` | `nvarchar(200)` | no | City. |
|
||||||
|
| `Status` | `nvarchar(2)` | no | Postal address status. |
|
||||||
|
|
||||||
|
No direct FK from `AdresseAnschrift` to `Adresse` or `AdressePerson` was present
|
||||||
|
in the export subset shown by the current FK file. Treat these as likely
|
||||||
|
relationships and validate them with data.
|
||||||
|
|
||||||
|
### `Reise` and `Produkt`
|
||||||
|
|
||||||
|
Travel/product metadata.
|
||||||
|
|
||||||
|
`Reise` important columns:
|
||||||
|
|
||||||
|
- `IDReise`
|
||||||
|
- `IDProdukt_FS`
|
||||||
|
- `Bezeichnung`
|
||||||
|
- `Code`
|
||||||
|
- `TerminVon`
|
||||||
|
- `TerminBis`
|
||||||
|
- `Status`
|
||||||
|
- `StornoDatum`
|
||||||
|
|
||||||
|
`Produkt` important columns:
|
||||||
|
|
||||||
|
- `IDProdukt`
|
||||||
|
- `Bezeichnung`
|
||||||
|
- `Code`
|
||||||
|
- `Suchfeld`
|
||||||
|
- `Status`
|
||||||
|
|
||||||
|
Known exported foreign key:
|
||||||
|
|
||||||
|
- `Reise.IDProdukt_FS -> Produkt.IDProdukt`
|
||||||
|
|
||||||
|
## Relationship Map
|
||||||
|
|
||||||
|
Most relevant joins for portal access:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Buchung.IDBuchung
|
||||||
|
-> BuchungTN.IDBuchung_FS
|
||||||
|
-> BuchungTN.IDAdressePerson_FS
|
||||||
|
-> AdressePerson.IDAdressePerson
|
||||||
|
-> AdressePerson.IDAdresse_FS
|
||||||
|
-> Adresse.IDAdresse
|
||||||
|
```
|
||||||
|
|
||||||
|
Applicant joins:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Buchung.IdAdressePerson_FS_Anmelder
|
||||||
|
-> AdressePerson.IDAdressePerson
|
||||||
|
-> AdressePerson.IDAdresse_FS
|
||||||
|
-> Adresse.IDAdresse
|
||||||
|
```
|
||||||
|
|
||||||
|
Email joins to validate:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AdresseKommunikation.IDAdressePerson_FS
|
||||||
|
-> AdressePerson.IDAdressePerson
|
||||||
|
|
||||||
|
AdresseKommunikation.IDAdresse_FS
|
||||||
|
-> Adresse.IDAdresse
|
||||||
|
|
||||||
|
AdresseKommunikation.IDArtKommunikation_FS
|
||||||
|
-> ArtKommunikation.IDArtKommunikation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required bpn-connect Endpoints
|
||||||
|
|
||||||
|
All endpoints are private and authenticated by API key:
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-API-Key: {apiKey}
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep responses narrow. `bpn-connect` should return only fields needed by the
|
||||||
|
Symfony portal and should never return BusProNet passwords, bank data, credit
|
||||||
|
card data, or broad customer records by default.
|
||||||
|
|
||||||
|
Authentication rule: Symfony owns portal passwords after a user has been
|
||||||
|
bootstrapped locally. The BusProNet legacy password algorithm is currently
|
||||||
|
unknown, so `bpn-connect` must not verify passwords from the database. Keep the
|
||||||
|
existing BusProNet XML password check only for first-time legacy migration. Once
|
||||||
|
the local Symfony password hash exists, all subsequent login/profile refresh
|
||||||
|
logic should use local authentication plus `bpn-connect` read endpoints.
|
||||||
|
|
||||||
|
### `GET /api/bookings/applicant-lookup`
|
||||||
|
|
||||||
|
Purpose: resolve an applicant by email and public booking number. This is the
|
||||||
|
core endpoint for local user creation after a booking and for the portal access
|
||||||
|
request flow.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/bookings/applicant-lookup?email={email}&bookingNumber={vorgang}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bookingNumber": "111386",
|
||||||
|
"bookingId": 123456,
|
||||||
|
"addressId": 98765,
|
||||||
|
"personId": 54321,
|
||||||
|
"email": "[email protected]",
|
||||||
|
"firstName": "Example",
|
||||||
|
"lastName": "Customer"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response rules:
|
||||||
|
|
||||||
|
- Return `200` when `email` belongs to `Buchung.IdAdressePerson_FS_Anmelder`
|
||||||
|
for the given `Buchung.Vorgang`.
|
||||||
|
- Return `404` when there is no matching non-deleted applicant.
|
||||||
|
- Return `409` if the query returns more than one applicant row after
|
||||||
|
deduplication. This should be rare and indicates inconsistent BusProNet data.
|
||||||
|
- Normalize input email by trimming and lowercasing before querying.
|
||||||
|
|
||||||
|
SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DECLARE @Email nvarchar(512) = LOWER(LTRIM(RTRIM(@email)));
|
||||||
|
DECLARE @Vorgang nvarchar(100) = LTRIM(RTRIM(@bookingNumber));
|
||||||
|
|
||||||
|
SELECT TOP (2)
|
||||||
|
b.Vorgang AS bookingNumber,
|
||||||
|
b.IDBuchung AS bookingId,
|
||||||
|
a.IDAdresse AS addressId,
|
||||||
|
ap.IDAdressePerson AS personId,
|
||||||
|
@Email AS email,
|
||||||
|
ap.Vorname AS firstName,
|
||||||
|
ap.Name AS lastName
|
||||||
|
FROM dbo.Buchung b
|
||||||
|
INNER JOIN dbo.AdressePerson ap
|
||||||
|
ON ap.IDAdressePerson = b.IdAdressePerson_FS_Anmelder
|
||||||
|
INNER JOIN dbo.Adresse a
|
||||||
|
ON a.IDAdresse = ap.IDAdresse_FS
|
||||||
|
WHERE b.Vorgang = @Vorgang
|
||||||
|
AND a.DatumLöschungDSGVO IS NULL
|
||||||
|
AND a.Status <> 'L'
|
||||||
|
AND ap.Status <> 'L'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dbo.AdresseKommunikation ak
|
||||||
|
WHERE ak.IDAdresse_FS = ap.IDAdresse_FS
|
||||||
|
AND ak.IDAdressePerson_FS = ap.IDAdressePerson
|
||||||
|
AND LOWER(LTRIM(RTRIM(ak.Wert))) = @Email
|
||||||
|
AND ak.Status <> 'L'
|
||||||
|
-- TODO: restrict to validated email communication type.
|
||||||
|
-- AND ak.IDArtKommunikation_FS IN (...)
|
||||||
|
)
|
||||||
|
ORDER BY ap.IDAdressePerson;
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/customers/{addressId}/persons/{personId}`
|
||||||
|
|
||||||
|
Purpose: fetch the personal data for a known BusProNet person. Use this after a
|
||||||
|
local user exists and already stores `addressId` and `personId`. Do not use email
|
||||||
|
as the identifier because multiple `Adresse` rows can share an email address.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/customers/{addressId}/persons/{personId}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"addressId": 98765,
|
||||||
|
"personId": 54321,
|
||||||
|
"firstName": "Example",
|
||||||
|
"lastName": "Customer",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"genderCode": "F",
|
||||||
|
"title": null,
|
||||||
|
"birthDate": "1980-01-01T00:00:00Z",
|
||||||
|
"nationality": "deutsch",
|
||||||
|
"nationalityCountryName": "Deutschland",
|
||||||
|
"street": "Example Street 1",
|
||||||
|
"postalCode": "12345",
|
||||||
|
"city": "Example City",
|
||||||
|
"addressCountryName": "Deutschland"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response rules:
|
||||||
|
|
||||||
|
- Return `200` for a non-deleted address/person pair.
|
||||||
|
- Return `404` if the address/person pair does not exist or has `Status = 'L'`.
|
||||||
|
- Return only portal-editable or portal-visible fields.
|
||||||
|
|
||||||
|
SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DECLARE @AddressId bigint = @addressId;
|
||||||
|
DECLARE @PersonId bigint = @personId;
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
a.IDAdresse AS addressId,
|
||||||
|
ap.IDAdressePerson AS personId,
|
||||||
|
ap.Name AS lastName,
|
||||||
|
ap.Vorname AS firstName,
|
||||||
|
ap.Geschlecht AS genderCode,
|
||||||
|
ap.Titel AS title,
|
||||||
|
ap.Geburtsdatum AS birthDate,
|
||||||
|
ap.Briefanrede AS letterSalutation,
|
||||||
|
ap.Anrede AS salutation,
|
||||||
|
ap.Newsletter AS newsletter,
|
||||||
|
email.Wert AS email,
|
||||||
|
|
||||||
|
manifest.IDArtNationalitaet_FS AS nationalityId,
|
||||||
|
person_nat.Langtext AS nationalityCountryName,
|
||||||
|
person_nat.Kurztext AS nationality,
|
||||||
|
person_nat.Code AS nationalityCode,
|
||||||
|
|
||||||
|
aa.IDAdresseAnschrift AS postalAddressId,
|
||||||
|
aa.Anrede AS postalSalutation,
|
||||||
|
aa.Name AS postalName,
|
||||||
|
aa.Strasse AS street,
|
||||||
|
aa.PLZ AS postalCode,
|
||||||
|
aa.Ort AS city,
|
||||||
|
aa.Ortsteil AS district,
|
||||||
|
aa.IDArtNationalitaet_FS_Land AS addressCountryId,
|
||||||
|
address_country.Langtext AS addressCountryName,
|
||||||
|
address_country.Kurztext AS addressCountryNationality,
|
||||||
|
address_country.Code AS addressCountryCode,
|
||||||
|
|
||||||
|
ad.TelefonOK AS phoneAllowed,
|
||||||
|
ad.MailOK AS mailAllowed,
|
||||||
|
ad.PostOK AS postAllowed
|
||||||
|
FROM dbo.AdressePerson ap
|
||||||
|
INNER JOIN dbo.Adresse a
|
||||||
|
ON a.IDAdresse = ap.IDAdresse_FS
|
||||||
|
OUTER APPLY (
|
||||||
|
SELECT TOP (1)
|
||||||
|
ak.Wert
|
||||||
|
FROM dbo.AdresseKommunikation ak
|
||||||
|
WHERE ak.IDAdresse_FS = ap.IDAdresse_FS
|
||||||
|
AND ak.IDAdressePerson_FS = ap.IDAdressePerson
|
||||||
|
AND ak.Status <> 'L'
|
||||||
|
-- TODO: restrict to validated email communication type.
|
||||||
|
-- AND ak.IDArtKommunikation_FS IN (...)
|
||||||
|
ORDER BY ak.IDAdresseKommunikation DESC
|
||||||
|
) email
|
||||||
|
LEFT JOIN dbo.AdresseAnschrift aa
|
||||||
|
ON aa.IDAdresseAnschrift = ap.IDAdresseAnschrift_FS_Versandanschrift
|
||||||
|
AND aa.Status <> 'L'
|
||||||
|
LEFT JOIN dbo.ArtNationalitaet address_country
|
||||||
|
ON address_country.IDArtNationalitaet = aa.IDArtNationalitaet_FS_Land
|
||||||
|
AND COALESCE(address_country.Status, '') <> 'L'
|
||||||
|
OUTER APPLY (
|
||||||
|
SELECT TOP (1)
|
||||||
|
m.IDArtNationalitaet_FS
|
||||||
|
FROM dbo.AdressePersonManifest m
|
||||||
|
WHERE m.IDAdressePerson_FS = ap.IDAdressePerson
|
||||||
|
AND m.IDAdresse_FS = ap.IDAdresse_FS
|
||||||
|
AND COALESCE(m.Status, '') <> 'L'
|
||||||
|
ORDER BY m.IDAdressePersonManifest DESC
|
||||||
|
) manifest
|
||||||
|
LEFT JOIN dbo.ArtNationalitaet person_nat
|
||||||
|
ON person_nat.IDArtNationalitaet = manifest.IDArtNationalitaet_FS
|
||||||
|
AND COALESCE(person_nat.Status, '') <> 'L'
|
||||||
|
LEFT JOIN dbo.AdresseDatenschutz ad
|
||||||
|
ON ad.IdAdresse_FS = a.IDAdresse
|
||||||
|
WHERE a.IDAdresse = @AddressId
|
||||||
|
AND ap.IDAdressePerson = @PersonId
|
||||||
|
AND a.DatumLöschungDSGVO IS NULL
|
||||||
|
AND a.Status <> 'L'
|
||||||
|
AND ap.Status <> 'L';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Debug Endpoint: `GET /api/customers/by-email`
|
||||||
|
|
||||||
|
Purpose: manual investigation only. Email is not unique across `Adresse`, so this
|
||||||
|
endpoint should return a list and should not be used as a local user identifier.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/customers/by-email?email={email}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the same joins as `GET /api/customers/{addressId}/persons/{personId}`, but
|
||||||
|
start from `AdresseKommunikation` and return all non-deleted matching persons.
|
||||||
|
Do not use this for account creation unless the user also provides a booking
|
||||||
|
number or another disambiguating key.
|
||||||
|
|
||||||
|
## Symfony Login And Bootstrap Replacement Plan
|
||||||
|
|
||||||
|
Current Symfony login behavior:
|
||||||
|
|
||||||
|
- Existing local users authenticate against the local Symfony password hash.
|
||||||
|
- Existing legacy local users may still have an encrypted legacy password in the
|
||||||
|
local `User.password` column. Symfony validates it locally once, then replaces
|
||||||
|
it with a Symfony password hash.
|
||||||
|
- If no local user exists, `BpnAuthenticator::bootstrapUser()` currently calls
|
||||||
|
the BusProNet XML API with email and password:
|
||||||
|
- `getPersonalDataWithPassword(email, password)`
|
||||||
|
- `getCrmAttributesWithPassword(email, password)`
|
||||||
|
- After any successful local login, `BpnAuthenticator::refreshBpnSnapshot()`
|
||||||
|
calls the BusProNet XML API by local `addressId` and `personId`:
|
||||||
|
- `getPersonalData(email, addressId, personId)`
|
||||||
|
- `getCrmAttributes(email, addressId, personId)`
|
||||||
|
|
||||||
|
Replacement rule:
|
||||||
|
|
||||||
|
- Keep login password validation local to Symfony.
|
||||||
|
- Keep the current XML password-based bootstrap for users that exist in
|
||||||
|
BusProNet but do not yet have a local Symfony user.
|
||||||
|
- On successful XML legacy verification, Symfony creates a local user with a
|
||||||
|
Symfony password hash from the submitted password.
|
||||||
|
- Future logins use only the local hash and must not call XML for password
|
||||||
|
verification.
|
||||||
|
- Replace post-login snapshot refresh with `bpn-connect` id-based read endpoints.
|
||||||
|
|
||||||
|
### Login Endpoint Usage
|
||||||
|
|
||||||
|
No `bpn-connect` endpoint is needed to authenticate a password.
|
||||||
|
|
||||||
|
Existing local user login should do:
|
||||||
|
|
||||||
|
1. Find local `User` by normalized email.
|
||||||
|
2. Validate local Symfony password hash.
|
||||||
|
3. If the local row still has a legacy encrypted local password, migrate it to a
|
||||||
|
Symfony hash without contacting BusProNet.
|
||||||
|
4. Call `GET /api/customers/{addressId}/persons/{personId}` to refresh profile
|
||||||
|
completeness.
|
||||||
|
5. Call `GET /api/customers/{addressId}/crm-attributes` to refresh roles and
|
||||||
|
hotel codes.
|
||||||
|
|
||||||
|
Missing local user login should do:
|
||||||
|
|
||||||
|
1. Call the existing BusProNet XML password-based bootstrap.
|
||||||
|
2. If valid, create a local `User` with a Symfony hash of the submitted password.
|
||||||
|
3. Store returned `addressId`, `personId`, `roles`, `hotelCodes`, and
|
||||||
|
`profileComplete`.
|
||||||
|
4. Persist and continue login.
|
||||||
|
5. If invalid, fail with the generic invalid-login message.
|
||||||
|
|
||||||
|
Do not add a `bpn-connect` password verification endpoint until the BusProNet
|
||||||
|
password algorithm is known and validated against real examples. The observed
|
||||||
|
database values do not currently match simple MD5/SHA/Base64 derivations.
|
||||||
|
|
||||||
|
### `GET /api/customers/{addressId}/crm-attributes`
|
||||||
|
|
||||||
|
Purpose: replace XML `SelektionCRM` reads used during login refresh and by the
|
||||||
|
OAuth CRM attributes endpoint.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/customers/{addressId}/crm-attributes
|
||||||
|
```
|
||||||
|
|
||||||
|
Minimal response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"roles": ["ROLE_CUSTOMER"],
|
||||||
|
"hotelCodes": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Detailed response may also include selection groups and CRM actions if the
|
||||||
|
current `/api/crm-attributes` response must remain compatible.
|
||||||
|
|
||||||
|
Known role mapping from the XML parser:
|
||||||
|
|
||||||
|
- `Selektionsstamm.IDSelektionsstamm = 1292` selected: `ROLE_ADMIN` and
|
||||||
|
`ROLE_HOUSE_MANAGER`
|
||||||
|
- `Selektionsstamm.IDSelektionsstamm = 1293` selected: `ROLE_MANAGER`
|
||||||
|
- `Selektionsstamm.IDSelektionsstamm = 1070` selected: `ROLE_TEAMER`
|
||||||
|
- selected `Selektionsstamm.Selektion` matching `Hausleitung {CODE}`:
|
||||||
|
`ROLE_HOUSE_MANAGER` and hotel code `{CODE}`
|
||||||
|
- 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
|
||||||
|
DECLARE @AddressId bigint = @addressId;
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
sg.IDSelektionsgruppenstamm AS groupId,
|
||||||
|
sg.Selektionsgruppe AS groupLabel,
|
||||||
|
s.IDSelektionsstamm AS selectionId,
|
||||||
|
s.Selektion AS selectionLabel,
|
||||||
|
CAST(1 AS bit) AS selected
|
||||||
|
FROM dbo.Selektionen sel
|
||||||
|
INNER JOIN dbo.Selektionsstamm s
|
||||||
|
ON s.IDSelektionsstamm = sel.IDSelektionsstamm_FS
|
||||||
|
LEFT JOIN dbo.Selektionsgruppe sg_map
|
||||||
|
ON sg_map.IDSelektionsstamm_FS = s.IDSelektionsstamm
|
||||||
|
LEFT JOIN dbo.Selektionsgruppenstamm sg
|
||||||
|
ON sg.IDSelektionsgruppenstamm = sg_map.IDSelektionsgruppenstamm_FS
|
||||||
|
WHERE sel.IDAdresse_FS = @AddressId
|
||||||
|
AND COALESCE(s.Status, '') <> 'L'
|
||||||
|
AND COALESCE(sg.Status, '') <> 'L'
|
||||||
|
ORDER BY sg.Position, sg.Selektionsgruppe, sg_map.Position, s.Selektion;
|
||||||
|
```
|
||||||
|
|
||||||
|
The Go service can derive `roles` and `hotelCodes` from this result with the
|
||||||
|
mapping above. Before shipping, verify whether XML `SelektionCRM` includes
|
||||||
|
unselected mutable options; if the portal only needs roles/hotel codes, selected
|
||||||
|
rows are enough.
|
||||||
|
|
||||||
|
Candidate SQL for CRM actions if compatibility is required:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DECLARE @AddressId bigint = @addressId;
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
ca.IDCRMAktion AS id,
|
||||||
|
ca.Code AS code,
|
||||||
|
ca.Bezeichnung AS label,
|
||||||
|
ca.Aktiv AS selected
|
||||||
|
FROM dbo.CRMZuweisung cz
|
||||||
|
INNER JOIN dbo.CRMAktion ca
|
||||||
|
ON ca.IDCRMAktion = cz.IDCRMAktion_FS
|
||||||
|
WHERE cz.IDAdresse_FS = @AddressId
|
||||||
|
AND ca.Status <> 'L'
|
||||||
|
ORDER BY ca.Bezeichnung;
|
||||||
|
```
|
||||||
|
|
||||||
|
This needs data validation against the XML response before compatibility is
|
||||||
|
guaranteed.
|
||||||
|
|
||||||
|
## XML Calls To Replace For Local User Processes
|
||||||
|
|
||||||
|
| Current Symfony call | Current XML purpose | Replacement |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `getPersonalData(email, addressId, personId)` | Load profile by known user ids. | `GET /api/customers/{addressId}/persons/{personId}` |
|
||||||
|
| `updatePersonalData(email, personalData)` | Persist profile edits. | Future `PATCH /api/customers/{addressId}/persons/{personId}` with transaction-safe updates. |
|
||||||
|
| `getCrmAttributes(email, addressId, personId)` | Refresh roles/hotel codes and expose CRM attributes. | `GET /api/customers/{addressId}/crm-attributes` |
|
||||||
|
| `getPersonalDataWithPassword(email, password)` | Missing-local-user bootstrap by BPN password. | Keep XML until the local user has been created/migrated. |
|
||||||
|
| `getCrmAttributesWithPassword(email, password)` | Roles for missing-local-user bootstrap by BPN password. | Keep XML until the local user has been created/migrated. |
|
||||||
|
| `register(registrationData)` | Create BusProNet address/person and local user. | Future `POST /api/customers` if direct DB writes are allowed; otherwise keep XML until write rules are known. |
|
||||||
|
| `createAddress(personalData)` | Create BusProNet address/person without mail. | Future `POST /api/customers` if direct DB writes are allowed; otherwise keep XML until write rules are known. |
|
||||||
|
|
||||||
|
Recommended migration sequence:
|
||||||
|
|
||||||
|
1. Keep XML for legacy password bootstrap only.
|
||||||
|
2. Replace booking applicant lookup for guest provisioning and portal access.
|
||||||
|
3. Replace post-login profile/CRM refresh reads.
|
||||||
|
4. Replace personal-data page reads.
|
||||||
|
5. Replace OAuth/userinfo and CRM API reads.
|
||||||
|
6. Replace booking form prefill reads.
|
||||||
|
7. Defer personal-data writes until update rules are validated.
|
||||||
|
8. Defer registration/create-address until the insert graph is known.
|
||||||
|
|
||||||
|
### Phase 1: Keep XML For Legacy Password Bootstrap
|
||||||
|
|
||||||
|
Keep these XML calls temporarily:
|
||||||
|
|
||||||
|
- `getPersonalDataWithPassword(email, password)`
|
||||||
|
- `getCrmAttributesWithPassword(email, password)`
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
|
||||||
|
- Only used when no local Symfony user exists yet.
|
||||||
|
- On success, create the local user with a Symfony password hash from the
|
||||||
|
submitted password.
|
||||||
|
- Store `addressId`, `personId`, `roles`, `hotelCodes`, and `profileComplete`.
|
||||||
|
- Future logins for that user use only the local Symfony password hash.
|
||||||
|
|
||||||
|
Do not implement a `bpn-connect` password verification endpoint until the
|
||||||
|
BusProNet password algorithm is known and verified.
|
||||||
|
|
||||||
|
### Phase 2: Booking Applicant Lookup
|
||||||
|
|
||||||
|
Replace XML/fragile booking lookup plumbing with:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/bookings/applicant-lookup?email={email}&bookingNumber={vorgang}
|
||||||
|
```
|
||||||
|
|
||||||
|
Symfony integration points:
|
||||||
|
|
||||||
|
- `BookingGuestAccountManager::provisionGuestAccountForBooking()`
|
||||||
|
- `BookingGuestAccountManager::requestPortalAccess()`
|
||||||
|
- booking-created welcome account provisioning
|
||||||
|
|
||||||
|
This endpoint returns only the fields needed for local user creation:
|
||||||
|
|
||||||
|
- `bookingNumber`
|
||||||
|
- `bookingId`
|
||||||
|
- `addressId`
|
||||||
|
- `personId`
|
||||||
|
- `email`
|
||||||
|
- `firstName`
|
||||||
|
- `lastName`
|
||||||
|
|
||||||
|
### Phase 3: Post-login Snapshot Refresh
|
||||||
|
|
||||||
|
Replace XML reads in `BpnAuthenticator::refreshBpnSnapshot()` with:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/customers/{addressId}/persons/{personId}
|
||||||
|
GET /api/customers/{addressId}/crm-attributes
|
||||||
|
```
|
||||||
|
|
||||||
|
Symfony keeps local password validation. `bpn-connect` only refreshes ids,
|
||||||
|
profile completeness, roles, and hotel codes after the password has already been
|
||||||
|
validated locally.
|
||||||
|
|
||||||
|
### Phase 4: Personal-data Page Reads
|
||||||
|
|
||||||
|
Replace XML read in `PersonalDataController::loadPersonalData()` with:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/customers/{addressId}/persons/{personId}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `updatePersonalData()` on XML for now. Reads are low-risk; writes require
|
||||||
|
validated BusProNet update semantics.
|
||||||
|
|
||||||
|
### Phase 5: OAuth/Userinfo And CRM Reads
|
||||||
|
|
||||||
|
Replace XML reads in:
|
||||||
|
|
||||||
|
- `UserinfoController`
|
||||||
|
- `CrmAttributeController`
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/customers/{addressId}/persons/{personId}
|
||||||
|
GET /api/customers/{addressId}/crm-attributes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 6: Booking Form Prefill Reads
|
||||||
|
|
||||||
|
Replace XML read in `ParticipantDataPrefiller::prefillApplicantFromUser()` with:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/customers/{addressId}/persons/{personId}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 7: Defer Personal-data Writes
|
||||||
|
|
||||||
|
Keep XML call:
|
||||||
|
|
||||||
|
- `updatePersonalData(email, personalData)`
|
||||||
|
|
||||||
|
Future replacement:
|
||||||
|
|
||||||
|
```http
|
||||||
|
PATCH /api/customers/{addressId}/persons/{personId}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do this only after write rules are validated for:
|
||||||
|
|
||||||
|
- `AdressePerson`
|
||||||
|
- `AdresseAnschrift`
|
||||||
|
- `AdresseKommunikation`
|
||||||
|
- `AdressePersonManifest`
|
||||||
|
- `AdresseZusatzfeld` or other tables holding height, weight, shoe size, and
|
||||||
|
remarks if those are not in the core person/address tables.
|
||||||
|
|
||||||
|
### Phase 8: Defer Registration And Create-address
|
||||||
|
|
||||||
|
Keep XML calls:
|
||||||
|
|
||||||
|
- `register(registrationData)`
|
||||||
|
- `createAddress(personalData)`
|
||||||
|
|
||||||
|
Future replacement:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/customers
|
||||||
|
```
|
||||||
|
|
||||||
|
This is higher risk than read replacement because it requires the full BusProNet
|
||||||
|
insert graph, required defaults, and side effects.
|
||||||
|
|
||||||
|
## Candidate SQL: Find Email Communication Type
|
||||||
|
|
||||||
|
Run once during `bpn-connect` development:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
IDArtKommunikation,
|
||||||
|
Langtext,
|
||||||
|
Kurztext,
|
||||||
|
Code,
|
||||||
|
Status
|
||||||
|
FROM dbo.ArtKommunikation
|
||||||
|
WHERE
|
||||||
|
LOWER(COALESCE(Langtext, '')) LIKE '%mail%'
|
||||||
|
OR LOWER(COALESCE(Kurztext, '')) LIKE '%mail%'
|
||||||
|
OR LOWER(COALESCE(Code, '')) LIKE '%mail%'
|
||||||
|
ORDER BY IDArtKommunikation;
|
||||||
|
```
|
||||||
|
|
||||||
|
If the data uses German labels, also inspect all rows manually. The schema alone
|
||||||
|
cannot prove the canonical email type.
|
||||||
|
|
||||||
|
## Candidate SQL: Validate Missing Relationships
|
||||||
|
|
||||||
|
Use these checks before shipping lookup logic:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Check whether AdresseKommunikation.IDAdressePerson_FS is a real person link.
|
||||||
|
SELECT TOP (20)
|
||||||
|
ak.IDAdresseKommunikation,
|
||||||
|
ak.IDAdresse_FS,
|
||||||
|
ak.IDAdressePerson_FS,
|
||||||
|
ap.IDAdressePerson,
|
||||||
|
ap.IDAdresse_FS AS PersonAddressId,
|
||||||
|
ak.Wert
|
||||||
|
FROM dbo.AdresseKommunikation ak
|
||||||
|
LEFT JOIN dbo.AdressePerson ap
|
||||||
|
ON ap.IDAdressePerson = ak.IDAdressePerson_FS
|
||||||
|
WHERE ak.IDAdressePerson_FS <> 0
|
||||||
|
AND ap.IDAdressePerson IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Check whether Buchung.IDAdresse_FS_Anmelder matches the applicant person's address.
|
||||||
|
SELECT TOP (100)
|
||||||
|
b.IDBuchung,
|
||||||
|
b.Vorgang,
|
||||||
|
b.IDAdresse_FS_Anmelder,
|
||||||
|
b.IdAdressePerson_FS_Anmelder,
|
||||||
|
ap.IDAdresse_FS AS ApplicantPersonAddressId
|
||||||
|
FROM dbo.Buchung b
|
||||||
|
LEFT JOIN dbo.AdressePerson ap
|
||||||
|
ON ap.IDAdressePerson = b.IdAdressePerson_FS_Anmelder
|
||||||
|
WHERE b.IDAdresse_FS_Anmelder IS NOT NULL
|
||||||
|
AND ap.IDAdresse_FS <> b.IDAdresse_FS_Anmelder;
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Inspect status values before adding active-only predicates.
|
||||||
|
SELECT 'Adresse' AS table_name, Status, COUNT(*) AS rows
|
||||||
|
FROM dbo.Adresse
|
||||||
|
GROUP BY Status
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'AdressePerson', Status, COUNT(*)
|
||||||
|
FROM dbo.AdressePerson
|
||||||
|
GROUP BY Status
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'AdresseKommunikation', Status, COUNT(*)
|
||||||
|
FROM dbo.AdresseKommunikation
|
||||||
|
GROUP BY Status
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'BuchungTN', Status, COUNT(*)
|
||||||
|
FROM dbo.BuchungTN
|
||||||
|
GROUP BY Status
|
||||||
|
ORDER BY table_name, Status;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Indexes To Confirm Or Add
|
||||||
|
|
||||||
|
The current CSV files do not include index definitions. For the lookup endpoint,
|
||||||
|
confirm whether these access paths already exist:
|
||||||
|
|
||||||
|
- `Buchung(Vorgang)` including `IDBuchung`, `IdAdressePerson_FS_Anmelder`
|
||||||
|
- `BuchungTN(IDBuchung_FS)` including `IDAdressePerson_FS`, `Status`
|
||||||
|
- `AdressePerson(IDAdressePerson)` including `IDAdresse_FS`, `Vorname`, `Name`,
|
||||||
|
`Status`
|
||||||
|
- `AdresseKommunikation(IDAdressePerson_FS, Wert)` including `IDAdresse_FS`,
|
||||||
|
`IDArtKommunikation_FS`, `Status`
|
||||||
|
- `AdresseKommunikation(IDAdresse_FS, Wert)` including `IDAdressePerson_FS`,
|
||||||
|
`IDArtKommunikation_FS`, `Status`
|
||||||
|
|
||||||
|
If the database collation is case-sensitive and email lookup must be
|
||||||
|
case-insensitive, avoid `LOWER(Wert)` on large tables unless a computed
|
||||||
|
normalized-email column or suitable index exists.
|
||||||
|
|
||||||
|
## Future Endpoint Candidates
|
||||||
|
|
||||||
|
The XML plumbing can be replaced incrementally by small read endpoints backed by
|
||||||
|
targeted SQL:
|
||||||
|
|
||||||
|
| Endpoint | Purpose | Core tables |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET /api/bookings/lookup` | Resolve email + public booking number to booking/person/address ids. | `Buchung`, `BuchungTN`, `AdressePerson`, `AdresseKommunikation`, `Adresse` |
|
||||||
|
| `GET /api/bookings/{bookingId}` | Fetch booking header/detail by internal id. | `Buchung`, `Reise`, `Produkt`, `BuchungTN` |
|
||||||
|
| `GET /api/bookings/{bookingId}/participants` | Fetch participants for a booking. | `BuchungTN`, `AdressePerson`, `AdresseAnschrift` |
|
||||||
|
| `GET /api/customers/{addressId}/persons/{personId}` | Fetch a minimal customer/person profile. | `Adresse`, `AdressePerson`, `AdresseKommunikation`, `AdresseAnschrift` |
|
||||||
|
|
||||||
|
Keep endpoint responses narrow. Return only ids and fields needed by the Symfony
|
||||||
|
portal so the Go service does not become a broad BusProNet data mirror.
|
||||||
|
|
||||||
|
## Regeneration Notes
|
||||||
|
|
||||||
|
When new catalog exports are added, regenerate this document with:
|
||||||
|
|
||||||
|
1. Columns export from `sys.tables`, `sys.schemas`, `sys.columns`, `sys.types`.
|
||||||
|
2. Foreign key export from `sys.foreign_keys` and related FK columns.
|
||||||
|
3. Primary-key export from `sys.key_constraints`.
|
||||||
|
4. Index export from `sys.indexes`, `sys.index_columns`, and `sys.columns`.
|
||||||
|
|
||||||
|
Primary keys and indexes should be added to the table sections and the lookup
|
||||||
|
query should be revisited once the real access paths are known.
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
# 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. `ApiClient::createKey()` implements it, and `sendRawXml()` 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` (`ApiClient::TYPE_AVAILABILITY_EXTENDED`) — 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`). Once a room is present, every `teilnehmer` needs a `Mail-Adresse`, children
|
||||||
|
included: without `kommunikation` the answer is `650 … 1.Teilnehmer-Mail-Adresse, 2.Teilnehmer-Mail-Adresse`.
|
||||||
|
`telefonmobil` and an empty `anschrift` pass for participants. An email identical to the Anmelder's is
|
||||||
|
accepted. The field list above (Strasse, Plz, Ort, Telefon-Mobil for every participant) was measured on
|
||||||
|
the placeholder records and is stricter than what BusPro applies to ordinary participants.
|
||||||
|
|
||||||
|
**The Anmelder does not have to travel.** An `<anmelder>` that matches no `teilnehmer` (a parent booking
|
||||||
|
for their children) returns `möglich` with the same price as the control where the Anmelder is
|
||||||
|
`teilnehmer 1`. Whether the committed booking binds to the Anmelder's existing customer record has not
|
||||||
|
been measured yet.
|
||||||
|
|
||||||
|
Note `650` again: both of those are hard rejections delivered under the code
|
||||||
|
`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; `ApiClient::createBooking()`/`updateBooking()` wrap them.
|
||||||
|
|
||||||
|
| Documented element | Sent on create | Sent on edit | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`ApiClient.php:233`) |
|
||||||
|
| `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]; 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 (`ApiClient.php:233`), 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.
|
||||||
|
|
||||||
|
## 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`, 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 · 610/611 Buchungsart · 612-621 Gutscheinart / Stamm /
|
||||||
|
Kapazität · 622 Einlösecode fehlt · 630-632 Aktionsgutschein
|
||||||
|
|
||||||
|
## 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`.
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,971 @@
|
|||||||
|
"foreign_key_name";"child_schema";"child_table";"child_column";"parent_schema";"parent_table";"parent_column"
|
||||||
|
"FK_Tarif_Schulamt";"dbo";"ABOSummeFahrscheine";"IDAdresse_FS_Schulamt";"dbo";"Schulamt";"IDSchulamt"
|
||||||
|
"FK_Tarif_Schule";"dbo";"ABOSummeFahrscheine";"IDAdresse_FS_Schule";"dbo";"Schule";"IDSchule"
|
||||||
|
"FK_Tarif_Schuljahr";"dbo";"ABOSummeFahrscheine";"IDABOSchuljahr_FS";"dbo";"ABOSchuljahr";"IDABOSchuljahr"
|
||||||
|
"FK_AdressAuswertungProtokoll_Adresse";"dbo";"AdressAuswertungProtokoll";"IDAdresse_FS_Sachbearbeiter";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseAgenturKatalog_Adresse";"dbo";"AdresseAgenturKatalog";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseAgenturKatalog_ArtKatalog";"dbo";"AdresseAgenturKatalog";"IDArtKatalog_FS";"dbo";"ArtKatalog";"IDArtKatalog"
|
||||||
|
"FK_AdresseAgenturOrganisation_Adresse";"dbo";"AdresseAgenturOrganisation";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseAgenturOrganisation_ArtAgenturOrganisation";"dbo";"AdresseAgenturOrganisation";"IDArtAgenturOrganisation_FS";"dbo";"ArtAgenturOrganisation";"IDArtAgenturOrganisation"
|
||||||
|
"FK_AdresseAgenturProvision_Adresse";"dbo";"AdresseAgenturProvision";"IDArtProvisionsstaffel_FS";"dbo";"ArtProvisionsstaffel";"IDArtProvisionsstaffel"
|
||||||
|
"FK_AdresseAgenturProvision_Adresse1";"dbo";"AdresseAgenturProvision";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseAgenturZusatz_Adresse";"dbo";"AdresseAgenturZusatz";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseAgenturZusatz_ArtAgenturPreisgruppe";"dbo";"AdresseAgenturZusatz";"IDArtAgenturPreisgruppe_FS";"dbo";"ArtAgenturPreisgruppe";"IDArtAgenturPreisgruppe"
|
||||||
|
"FK_AdresseAgenturZusatz_ArtPreisschema";"dbo";"AdresseAgenturZusatz";"IDArtPreisschema_FS";"dbo";"ArtPreisschema";"IDArtPreisschema"
|
||||||
|
"FK_AdresseAnruf_Adresse";"dbo";"AdresseAnruf";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseAnschriftKoordinate_AdresseAnschrift";"dbo";"AdresseAnschriftKoordinate";"IDAdresseAnschrift_FS";"dbo";"AdresseAnschrift";"IDAdresseAnschrift"
|
||||||
|
"FK_AdresseAnschriftKoordinate_ArtZustiegKoordinaten";"dbo";"AdresseAnschriftKoordinate";"IDArtZustiegKoordinaten_FS";"dbo";"ArtZustiegKoordinaten";"IDArtZustiegKoordinaten"
|
||||||
|
"FK_Adresse_Bankverbindung";"dbo";"AdresseBankverbindung";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Adresse_Bearbeitung";"dbo";"AdresseBearbeitung";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Adresse_Bearbeitung_ErsterSachbearbeiter";"dbo";"AdresseBearbeitung";"IDAdresse_FS_ErsterSachbearbeiter";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Adresse_Bearbeitung_LetzterSachbearbeiter";"dbo";"AdresseBearbeitung";"IDAdresse_FS_LetzterSachbearbeiter";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseCRS_Adresse";"dbo";"AdresseCRS";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseCRS_ArtCRS";"dbo";"AdresseCRS";"IDArtCRS_FS";"dbo";"ArtCRS";"IDArtCRS"
|
||||||
|
"FK_AdresseDatenschutz_Adresse";"dbo";"AdresseDatenschutz";"IdAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseEmpfehlerKonto_Adresse";"dbo";"AdresseEmpfehlerKonto";"IDAdresse_FS_Empfehler";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseEmpfehlerKonto_Adresse1";"dbo";"AdresseEmpfehlerKonto";"IDAdresse_FS_Empfohlener";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseHinweis_Adresse";"dbo";"AdresseHinweis";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseHistorie_Adresse";"dbo";"AdresseHistorie";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseHistorieBuchungTN_Adresse";"dbo";"AdresseHistorieBuchungTN";"IDAdresseHistorie_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseHistorieMietbus_Adresse";"dbo";"AdresseHistorieMietbus";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Adresse_Kommunikation";"dbo";"AdresseKommunikation";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseKommunikation_ArtKommunikation";"dbo";"AdresseKommunikation";"IDArtKommunikation_FS";"dbo";"ArtKommunikation";"IDArtKommunikation"
|
||||||
|
"FK_Adresse_Kreditkarte";"dbo";"AdresseKreditkarte";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseLandSteuer_Adresse";"dbo";"AdresseLandSteuer";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseLandSteuer_Land";"dbo";"AdresseLandSteuer";"IDLand_FS";"dbo";"Land";"IDLand"
|
||||||
|
"FK_AdresseLinieAuftraggeber_Adresse";"dbo";"AdresseLinieAuftraggeber";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseLinieAuftraggeber_ArtStatistikGruppe";"dbo";"AdresseLinieAuftraggeber";"IDArtStatistikGruppe_FS";"dbo";"ArtStatistikGruppe";"IDArtStatistikGruppe"
|
||||||
|
"FK_AdresseLinieAuftraggeberBlock_Adresse";"dbo";"AdresseLinieAuftraggeberBlock";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseLinieAuftraggeberBlock_AdresseLinieAuftraggeber";"dbo";"AdresseLinieAuftraggeberBlock";"IDAdresseLinieAuftraggeber_FS";"dbo";"AdresseLinieAuftraggeber";"IDAdresseLinieAuftraggeber"
|
||||||
|
"FK_AdresseLinieAuftraggeberLinie_Adresse";"dbo";"AdresseLinieAuftraggeberLinie";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseLinieAuftraggeberLinie_AdresseLinieAuftraggeber";"dbo";"AdresseLinieAuftraggeberLinie";"IDAdresseLinieAuftraggeber_FS";"dbo";"AdresseLinieAuftraggeber";"IDAdresseLinieAuftraggeber"
|
||||||
|
"FK_AdresseLinieAuftraggeberTour_Adresse";"dbo";"AdresseLinieAuftraggeberTour";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseLinieAuftraggeberTour_AdresseLinieAuftraggeber";"dbo";"AdresseLinieAuftraggeberTour";"IDAdresseLinieAuftraggeber_FS";"dbo";"AdresseLinieAuftraggeber";"IDAdresseLinieAuftraggeber"
|
||||||
|
"FK_AdressMandant_Adresse";"dbo";"AdresseMandant";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseMandantDokumentAusgabe_Adresse";"dbo";"AdresseMandantDokumentAusgabe";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseMandantDokumentAusgabe_ArtDokumentAusgabe";"dbo";"AdresseMandantDokumentAusgabe";"IDArtDokumentAusgabe_FS";"dbo";"ArtDokumentAusgabe";"IDArtDokumentAusgabe"
|
||||||
|
"FK_AdresseMandantERechnung_Adresse";"dbo";"AdresseMandantERechnung";"IDAdresse_FS_Mandant";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseMandantERechnungBV_AdresseBankverbindung";"dbo";"AdresseMandantERechnungBV";"IDAdresseBankverbindung_FS";"dbo";"AdresseBankverbindung";"IDAdresseBankverbindung"
|
||||||
|
"FK_AdresseMandantERechnungBV_AdresseMandantERechnung";"dbo";"AdresseMandantERechnungBV";"IDAdresseMandantERechnung_FS";"dbo";"AdresseMandantERechnung";"IDAdresseMandantERechnung"
|
||||||
|
"FK_AdresseMandantNummernkreis_Adresse";"dbo";"AdresseMandantNummernkreis";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseMandantNummernkreis_Nummernkreis";"dbo";"AdresseMandantNummernkreis";"IDNummernkreis_FS";"dbo";"Nummernkreis";"IDNummernkreis"
|
||||||
|
"FK_Adresse_Person";"dbo";"AdressePerson";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdressePersonal_Adresse";"dbo";"AdressePersonal";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdressePersonal_ArtBetriebshof";"dbo";"AdressePersonal";"IDArtBetriebshof_FS";"dbo";"ArtBetriebshof";"IDArtBetriebshof"
|
||||||
|
"FK_AdressePersonal_ArtLohnMandant";"dbo";"AdressePersonal";"IDArtLohnMandant_FS";"dbo";"ArtLohnMandant";"IDArtLohnMandant"
|
||||||
|
"FK_AdressePersonal_ArtPersonalEinsatz";"dbo";"AdressePersonal";"IDArtPersonalEinsatz_FS";"dbo";"ArtPersonalEinsatz";"IDArtPersonalEinsatz"
|
||||||
|
"FK_AdressePersonal_ArtPersonalGruppeKompetenz";"dbo";"AdressePersonal";"IDArtPersonalGruppeKompetenz_FS";"dbo";"ArtPersonalGruppeKompetenz";"IDArtPersonalGruppeKompetenz"
|
||||||
|
"FK_AdressePersonalArt_Adresse";"dbo";"AdressePersonalArt";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdressePersonalGruppeProdukt_Adresse";"dbo";"AdressePersonalGruppeProdukt";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdressePersonalGruppeProdukt_ArtPersonalGruppeProdukt";"dbo";"AdressePersonalGruppeProdukt";"IDArtPersonalGruppeProdukt_FS";"dbo";"ArtPersonalGruppeProdukt";"IDArtPersonalGruppeProdukt"
|
||||||
|
"FK_AdressePersonalKleidung_Adresse";"dbo";"AdressePersonalKleidung";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdressePersonalKleidung_ArtPersonalKleidung";"dbo";"AdressePersonalKleidung";"IDArtPersonalKleidung_FS";"dbo";"ArtPersonalKleidung";"IDArtPersonalKleidung"
|
||||||
|
"FK_AdressePersonalNummer_Adresse";"dbo";"AdressePersonalNummer";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdressePersonalNummer_ArtPersonalNummer";"dbo";"AdressePersonalNummer";"IDArtPersonalNummer_FS";"dbo";"ArtPersonalNummer";"IDArtPersonalNummer"
|
||||||
|
"FK_AdressePersonalPunkte_Adresse";"dbo";"AdressePersonalPunkte";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdressePersonalSonstGegenstaende_Adresse";"dbo";"AdressePersonalSonstGegenstaende";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdressePersonalSonstGegenstaende_ArtPersonalSonstGegenstaende";"dbo";"AdressePersonalSonstGegenstaende";"IDArtPersonalSonstGegenstaende_FS";"dbo";"ArtPersonalSonstGegenstaende";"IDArtPersonalSonstGegenstaende"
|
||||||
|
"FK_AdressePersonalSperrKasse_Adresse";"dbo";"AdressePersonalSperrKasse";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdressePersonalSperrKasse_ZahlungStamm";"dbo";"AdressePersonalSperrKasse";"IDZahlungStamm_FS";"dbo";"ZahlungStamm";"IDZahlungStamm"
|
||||||
|
"FK_AdressePersonalUnfall_Adresse";"dbo";"AdressePersonalUnfall";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdressePersonalVerwendung_Adresse";"dbo";"AdressePersonalVerwendung";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdressePersonalZusatz_Adresse";"dbo";"AdressePersonalZusatz";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdressePersonalZusatz_SpracheMaske";"dbo";"AdressePersonalZusatz";"IDSpracheMaske_FS";"dbo";"SpracheMaske";"IDSpracheMaske"
|
||||||
|
"FK_AdressePunkte_Adresse";"dbo";"AdressePunkte";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdressePunkte_Adresse1";"dbo";"AdressePunkte";"IDAdresse_FS_Sachbearbeiter";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseSteueraufteilungDetail_Adresse";"dbo";"AdresseSteueraufteilungDetail";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseSteueraufteilungDetail_Steueraufteilung";"dbo";"AdresseSteueraufteilungDetail";"IDSteueraufteilungDetail_FS";"dbo";"SteueraufteilungDetail";"IDSteueraufteilungDetail"
|
||||||
|
"FK_AdresseZusatz_Adresse";"dbo";"AdresseZusatz";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseZusatzfeld_Adresse";"dbo";"AdresseZusatzfeld";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AdresseZusatzfeld_AdressePerson";"dbo";"AdresseZusatzfeld";"IDAdressePerson_FS";"dbo";"AdressePerson";"IDAdressePerson"
|
||||||
|
"FK_AdresseZusatzfeld_ArtAdresseZusatzfeld";"dbo";"AdresseZusatzfeld";"IDArtAdresseZusatzfeld_FS";"dbo";"ArtAdresseZusatzfeld";"IDArtAdresseZusatzfeld"
|
||||||
|
"FK_AdresseMietbusZusatz_Adresse";"dbo";"AdresseZusatzMietbus";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Adresse_AgenturSelektion";"dbo";"AgenturSelektionenKunde";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Selektionsstamm_AgenturSelektion";"dbo";"AgenturSelektionenKunde";"IDSelektionsstamm_FS";"dbo";"Selektionsstamm";"IDSelektionsstamm"
|
||||||
|
"FK_ArbeitswertMaterial_Arbeitswert";"dbo";"ArbeitswertMaterial";"IDArbeitswert_FS";"dbo";"Arbeitswert";"IDArbeitswert"
|
||||||
|
"FK_ArbeitswertMaterial_Artikel";"dbo";"ArbeitswertMaterial";"IDArtikel_FS";"dbo";"Artikel";"IDArtikel"
|
||||||
|
"FK_ArbeitswertPersonal_Arbeitswert";"dbo";"ArbeitswertPersonal";"IDArbeitswert_FS";"dbo";"Arbeitswert";"IDArbeitswert"
|
||||||
|
"FK_ArbeitswertPersonal_Arbeitszeitwert";"dbo";"ArbeitswertPersonal";"IDArbeitszeitwert_FS";"dbo";"Arbeitszeitwert";"IDArbeitszeitwert"
|
||||||
|
"FK_ArtArbeitszeitZusatzEinst_ArtArbeitszeitZusatz";"dbo";"ArtArbeitszeitZusatzEinst";"IDArtArbeitszeitZusatz_FS";"dbo";"ArtArbeitszeitzusatz";"IDArtArbeitszeitzusatz"
|
||||||
|
"FK_ArtikelBewegung_Artikel";"dbo";"ArtikelBewegung";"IDArtikel_FS";"dbo";"Artikel";"IDArtikel"
|
||||||
|
"FK_ArtikelImport_ArtArtikelImport";"dbo";"ArtikelImport";"IDArtArtikelImport_FS";"dbo";"ArtArtikelImport";"IDArtArtikelImport"
|
||||||
|
"FK_Artikelzusatz_ArtEinheit";"dbo";"Artikelzusatz";"IDArtEinheit_FS";"dbo";"ArtEinheit";"IDArtEinheit"
|
||||||
|
"FK_ArtikelZuweisungWarengruppe_Artikel";"dbo";"ArtikelZuweisungWarengruppe";"IDArtikel_FS";"dbo";"Artikel";"IDArtikel"
|
||||||
|
"FK_ArtikelZuweisungWarengruppe_Warengruppe";"dbo";"ArtikelZuweisungWarengruppe";"IDArtWarengruppe_FS";"dbo";"ArtWarengruppe";"IDArtWarengruppe"
|
||||||
|
"FK_Auftrag_Adresse3";"dbo";"Auftrag";"IDAdresse_FS_Kunde";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Auftrag_Auftragsart";"dbo";"Auftrag";"IDAuftragsart_FS";"dbo";"Auftragsart";"IDAuftragsart"
|
||||||
|
"FK_AuftragBlock_Auftrag";"dbo";"AuftragBlock";"IDAuftrag_FS";"dbo";"Auftrag";"IDAuftrag"
|
||||||
|
"FK_AuftragFahrer_Auftrag";"dbo";"AuftragFahrer";"IDAuftrag_FS";"dbo";"Auftrag";"IDAuftrag"
|
||||||
|
"FK_AuftragFahrer_AuftragFahrzeug";"dbo";"AuftragFahrer";"IDAuftragFahrzeug_FS";"dbo";"AuftragFahrzeug";"IDAuftragFahrzeug"
|
||||||
|
"FK_AuftragFahrer_AuftragTeilauftrag";"dbo";"AuftragFahrer";"IDAuftragTeilauftrag_FS";"dbo";"AuftragTeilauftrag";"IDAuftragTeilauftrag"
|
||||||
|
"FK_AuftragFahrerArbeitszeit_ArtArbeitszeitzusatz";"dbo";"AuftragFahrerArbeitszeit";"IDArtArbeitszeitzusatz_FS";"dbo";"ArtArbeitszeitzusatz";"IDArtArbeitszeitzusatz"
|
||||||
|
"FK_AuftragFahrerArbeitszeit_AuftragFahrer";"dbo";"AuftragFahrerArbeitszeit";"IDAuftragFahrer_FS";"dbo";"AuftragFahrer";"IDAuftragFahrer"
|
||||||
|
"FK_AuftragFahrerArbeitszeitSpesen_AuftragFahrer";"dbo";"AuftragFahrerArbeitszeitSpesen";"IDAuftragFahrer_FS";"dbo";"AuftragFahrer";"IDAuftragFahrer"
|
||||||
|
"FK_AuftragFahrerTagesStunden_AuftragFahrer";"dbo";"AuftragFahrerTagesStunden";"IDAuftragFahrer_FS";"dbo";"AuftragFahrer";"IDAuftragFahrer"
|
||||||
|
"FK_AuftragFahrzeug_Auftrag";"dbo";"AuftragFahrzeug";"IDAuftrag_FS";"dbo";"Auftrag";"IDAuftrag"
|
||||||
|
"FK_AuftragFahrzeug_AuftragTeilauftrag";"dbo";"AuftragFahrzeug";"IDAuftragTeilauftrag_FS";"dbo";"AuftragTeilauftrag";"IDAuftragTeilauftrag"
|
||||||
|
"FK_AuftragFahrzeugNavi_AuftragFahrzeug";"dbo";"AuftragFahrzeugNavi";"IDAuftragFahrzeug_FS";"dbo";"AuftragFahrzeug";"IDAuftragFahrzeug"
|
||||||
|
"FK_AuftragFahrzeugNavi_Fahrzeug";"dbo";"AuftragFahrzeugNavi";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_AuftragFahrzeugNaviGrenzuebertritt_AuftragFahrzeug";"dbo";"AuftragFahrzeugNaviGrenzuebertritt";"IDAuftragFahrzeug_FS";"dbo";"AuftragFahrzeug";"IDAuftragFahrzeug"
|
||||||
|
"FK_AuftragFahrzeugTagesKM_AuftragFahrzeug";"dbo";"AuftragFahrzeugTagesKM";"IDAuftragFahrzeug_FS";"dbo";"AuftragFahrzeug";"IDAuftragFahrzeug"
|
||||||
|
"FK_AuftragInkludierteLeistung_Auftrag";"dbo";"AuftragInkludierteLeistung";"IDAuftrag_FS";"dbo";"Auftrag";"IDAuftrag"
|
||||||
|
"FK_AuftragInkludierteLeistung_AuftragTeilauftrag";"dbo";"AuftragInkludierteLeistung";"IDAuftragTeilauftrag_FS";"dbo";"AuftragTeilauftrag";"IDAuftragTeilauftrag"
|
||||||
|
"FK_AuftragNebenleistung_Auftrag";"dbo";"AuftragInkludierteLeistung";"IDAuftrag_FS";"dbo";"Auftrag";"IDAuftrag"
|
||||||
|
"FK_AuftragNebenleistung_AuftragTeilauftrag";"dbo";"AuftragInkludierteLeistung";"IDAuftragTeilauftrag_FS";"dbo";"AuftragTeilauftrag";"IDAuftragTeilauftrag"
|
||||||
|
"FK_AuftragKalkulationswert_Auftrag";"dbo";"AuftragKalkulationswert";"IDAuftrag_FS";"dbo";"Auftrag";"IDAuftrag"
|
||||||
|
"FK_AuftragKalkulationswert_AuftragTeilauftrag";"dbo";"AuftragKalkulationswert";"IDAuftragTeilauftrag_FS";"dbo";"AuftragTeilauftrag";"IDAuftragTeilauftrag"
|
||||||
|
"FK_AuftragKilometer_Auftrag";"dbo";"AuftragKilometer";"IDAuftrag_FS";"dbo";"Auftrag";"IDAuftrag"
|
||||||
|
"FK_AuftragKilometer_AuftragFahrzeug";"dbo";"AuftragKilometer";"IDAuftragFahrzeug_FS";"dbo";"AuftragFahrzeug";"IDAuftragFahrzeug"
|
||||||
|
"FK_AuftragKilometer_AuftragTeilauftrag";"dbo";"AuftragKilometer";"IDAuftragTeilauftrag_FS";"dbo";"AuftragTeilauftrag";"IDAuftragTeilauftrag"
|
||||||
|
"FK_AuftragKilometerLand_Auftrag";"dbo";"AuftragKilometerLand";"IDAuftrag_FS";"dbo";"Auftrag";"IDAuftrag"
|
||||||
|
"FK_AuftragKilometerLand_AuftragFahrzeug";"dbo";"AuftragKilometerLand";"IDAuftragFahrzeug_FS";"dbo";"AuftragFahrzeug";"IDAuftragFahrzeug"
|
||||||
|
"FK_AuftragKilometerLand_AuftragKilometer";"dbo";"AuftragKilometerLand";"IDAuftragKilometer_FS";"dbo";"AuftragKilometer";"IDAuftragKilometer"
|
||||||
|
"FK_AuftragKilometerLand_AuftragTeilauftrag";"dbo";"AuftragKilometerLand";"IDAuftragTeilauftrag_FS";"dbo";"AuftragTeilauftrag";"IDAuftragTeilauftrag"
|
||||||
|
"FK_AuftragKilometerLand_Land";"dbo";"AuftragKilometerLand";"IDLand_FS";"dbo";"Land";"IDLand"
|
||||||
|
"FK_AuftragsartMwst_Auftragsart";"dbo";"AuftragsartMwst";"IDAuftragsart_FS";"dbo";"Auftragsart";"IDAuftragsart"
|
||||||
|
"FK_AuftragSuche_Adresse1";"dbo";"AuftragSuche";"IDAdresse_FS_Kunde";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AuftragSuche_Auftrag";"dbo";"AuftragSuche";"IDAuftrag_FS";"dbo";"Auftrag";"IDAuftrag"
|
||||||
|
"FK_AuftragSuche_Auftragsart";"dbo";"AuftragSuche";"IDAuftragsArt_FS";"dbo";"Auftragsart";"IDAuftragsart"
|
||||||
|
"FK_AuftragTAAbholstelle_AuftragTeilauftrag";"dbo";"AuftragTAAbholstelle";"IDAuftragTeilauftrag_FS";"dbo";"AuftragTeilauftrag";"IDAuftragTeilauftrag"
|
||||||
|
"FK_AuftragTARoute_Auftrag";"dbo";"AuftragTARoute";"IDAuftrag_FS";"dbo";"Auftrag";"IDAuftrag"
|
||||||
|
"FK_AuftragTARoute_AuftragTeilauftrag";"dbo";"AuftragTARoute";"IDAuftragTeilauftrag_FS";"dbo";"AuftragTeilauftrag";"IDAuftragTeilauftrag"
|
||||||
|
"FK_AuftragTARoute_Land";"dbo";"AuftragTARoute";"IDLand_FS";"dbo";"Land";"IDLand"
|
||||||
|
"FK_AuftragTeilauftrag_Auftrag";"dbo";"AuftragTeilauftrag";"IDAuftrag_FS";"dbo";"Auftrag";"IDAuftrag"
|
||||||
|
"FK_AuftragTeilauftragPreis_Auftrag";"dbo";"AuftragTeilauftragPreis";"IDAuftrag_FS";"dbo";"Auftrag";"IDAuftrag"
|
||||||
|
"FK_AuftragTeilauftragPreis_AuftragTeilauftrag";"dbo";"AuftragTeilauftragPreis";"IDAuftragTeilauftrag_FS";"dbo";"AuftragTeilauftrag";"IDAuftragTeilauftrag"
|
||||||
|
"FK_AuftragTeilauftragPreisErweitert_AuftragTeilauftrag";"dbo";"AuftragTeilauftragPreisErweitert";"IDAuftragTeilauftrag_FS";"dbo";"AuftragTeilauftrag";"IDAuftragTeilauftrag"
|
||||||
|
"FK_AuftragTeilauftragPreisErweitert_Ausstattung";"dbo";"AuftragTeilauftragPreisErweitert";"IDAusstattung_FS";"dbo";"Ausstattung";"IDAusstattung"
|
||||||
|
"FK_AuftragText_ArtAuftragstext";"dbo";"AuftragText";"IDArtAuftragstext_FS";"dbo";"ArtAuftragstext";"IDArtAuftragstext"
|
||||||
|
"FK_AuftragText_Auftrag";"dbo";"AuftragText";"IDAuftrag_FS";"dbo";"Auftrag";"IDAuftrag"
|
||||||
|
"FK_AuftragZusatz_ArtStornogrund";"dbo";"AuftragZusatz";"IDArtStornogrund_FS";"dbo";"ArtStornogrund";"IDArtStornogrund"
|
||||||
|
"FK_AuftragZusatz_Auftrag";"dbo";"AuftragZusatz";"IDAuftrag_FS";"dbo";"Auftrag";"IDAuftrag"
|
||||||
|
"FK_AuftragZusatzleistung_Auftrag";"dbo";"AuftragZusatzleistung";"IDAuftrag_FS";"dbo";"Auftrag";"IDAuftrag"
|
||||||
|
"FK_AuftragZusatzleistung_AuftragTeilauftrag";"dbo";"AuftragZusatzleistung";"IDAuftragTeilauftrag_FS";"dbo";"AuftragTeilauftrag";"IDAuftragTeilauftrag"
|
||||||
|
"FK_AusstattungFahrzeugAusstattung_ArtFahrzeugAusstattungMerkmal";"dbo";"AusstattungFahrzeugAusstattung";"IDArtFahrzeugAusstattungMerkmal_FS";"dbo";"ArtFahrzeugAusstattungMerkmal";"IDArtFahrzeugAusstattungMerkmal"
|
||||||
|
"FK_AusstattungFahrzeugAusstattung_Ausstattung";"dbo";"AusstattungFahrzeugAusstattung";"IDAusstattung_FS";"dbo";"Ausstattung";"IDAusstattung"
|
||||||
|
"FK_AusstattungKalkulationsWert_Ausstattung";"dbo";"AusstattungKalkulationsWert";"IDAusstattung_FS";"dbo";"Ausstattung";"IDAusstattung"
|
||||||
|
"FK_AusstattungKalkulationsWert_KalkulationswertBezeichnung";"dbo";"AusstattungKalkulationsWert";"IDKalkulationsWertBezeichnung_FS";"dbo";"KalkulationswertBezeichnung";"IDKalkulationswertBezeichnung"
|
||||||
|
"FK_AusstattungKalkulationswertPersonen_AusstattungKalkulationsWert";"dbo";"AusstattungKalkulationswertPersonen";"IDAusstattungKalkulationswert_FS";"dbo";"AusstattungKalkulationsWert";"IDAusstattungKalkulationsWert"
|
||||||
|
"FK_AZKImport_Adresse";"dbo";"AZKImport";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_AZKImport_AZKImportEinstellung";"dbo";"AZKImport";"IDAZKImportEinstellung_FS";"dbo";"AZKImportEinstellung";"IDAZKImportEinstellung"
|
||||||
|
"FK_Befoerderungsvertrag_Adresse";"dbo";"Befoerderungsvertrag";"IDAdresse_FS_Partner";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Befoerderungsvertrag_AdresseAnschrift";"dbo";"Befoerderungsvertrag";"IDAdresseAnschrift_FS_Partner";"dbo";"AdresseAnschrift";"IDAdresseAnschrift"
|
||||||
|
"FK_BefoerderungsvertragTeilauftrag_AuftragTeilauftrag";"dbo";"BefoerderungsvertragTeilauftrag";"IDAuftragTeilauftrag_FS";"dbo";"AuftragTeilauftrag";"IDAuftragTeilauftrag"
|
||||||
|
"FK_BefoerderungsvertragTeilauftrag_Befoerderungsvertrag";"dbo";"BefoerderungsvertragTeilauftrag";"IDBefoerderungsvertrag_FS";"dbo";"Befoerderungsvertrag";"IDBefoerderungsvertrag"
|
||||||
|
"FK_Block_Adresse";"dbo";"Block";"IDAdresse_FS_Fahrer";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Block_ArtBetriebshof";"dbo";"Block";"IDArtBetriebshof_FS_Anfang";"dbo";"ArtBetriebshof";"IDArtBetriebshof"
|
||||||
|
"FK_Block_ArtBetriebshof1";"dbo";"Block";"IDArtBetriebshof_FS_Ende";"dbo";"ArtBetriebshof";"IDArtBetriebshof"
|
||||||
|
"FK_Block_ArtBlockArt";"dbo";"Block";"IDArtBlockart_FS";"dbo";"ArtBlockArt";"IDArtBlockArt"
|
||||||
|
"FK_Block_ArtFahrplanVersion";"dbo";"Block";"IDArtFahrplanVersion_FS";"dbo";"ArtFahrplanVersion";"IDArtFahrplanVersion"
|
||||||
|
"FK_Block_ArtFremdunternehmen";"dbo";"Block";"IDArtFremdunternehmen_FS";"dbo";"ArtFremdunternehmen";"IDArtFremdunternehmen"
|
||||||
|
"FK_Block_ArtReichweitentyp";"dbo";"Block";"IDArtReichweitentyp_FS";"dbo";"ArtReichweitentyp";"IDArtReichweitentyp"
|
||||||
|
"FK_Block_Fahrzeug";"dbo";"Block";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_Block_Ort_AbstellAnfang";"dbo";"Block";"IDOrt_FS_AbstellAnfang";"dbo";"Ort";"IDOrt"
|
||||||
|
"FK_Block_Ort_AbstellEnde";"dbo";"Block";"IDOrt_FS_AbstellEnde";"dbo";"Ort";"IDOrt"
|
||||||
|
"FK_BlockFahrzeugtyp_ArtFahrzeugTypen";"dbo";"BlockFahrzeugtyp";"IDArtFahrzeugTypen_FS";"dbo";"ArtFahrzeugTypen";"IDArtFahrzeugTypen"
|
||||||
|
"FK_BlockFahrzeugtyp_Block";"dbo";"BlockFahrzeugtyp";"IDBlock_FS";"dbo";"Block";"IDBlock"
|
||||||
|
"FK_BlockGruppeVerkehrstag_ArtGruppeVerkehrstag";"dbo";"BlockGruppeVerkehrstag";"IDArtGruppeVerkehrstag_FS";"dbo";"ArtGruppeVerkehrstag";"IDArtGruppeVerkehrstag"
|
||||||
|
"FK_BlockGruppeVerkehrstag_Block";"dbo";"BlockGruppeVerkehrstag";"IDBlock_FS";"dbo";"Block";"IDBlock"
|
||||||
|
"FK_Blockkombination_ArtFahrplanVersion";"dbo";"Blockkombination";"IDArtFahrplanVersion_FS";"dbo";"ArtFahrplanVersion";"IDArtFahrplanVersion"
|
||||||
|
"FK_BlockkombinationBlock_Block";"dbo";"BlockkombinationBlock";"IDBlock_FS";"dbo";"Block";"IDBlock"
|
||||||
|
"FK_BlockkombinationBlock_Blockkombination";"dbo";"BlockkombinationBlock";"IDBlockkombination_FS";"dbo";"Blockkombination";"IDBlockkombination"
|
||||||
|
"FK_BlockkombinationGruppeVerkehrstag_ArtGruppeVerkehrstag";"dbo";"BlockkombinationGruppeVerkehrstag";"IDArtGruppeVerkehrstag_FS";"dbo";"ArtGruppeVerkehrstag";"IDArtGruppeVerkehrstag"
|
||||||
|
"FK_BlockkombinationGruppeVerkehrstag_Blockkombination";"dbo";"BlockkombinationGruppeVerkehrstag";"IDBlockkombination_FS";"dbo";"Blockkombination";"IDBlockkombination"
|
||||||
|
"FK_BlockPause_Block";"dbo";"BlockPause";"IDBlock_FS";"dbo";"Block";"IDBlock"
|
||||||
|
"FK_BlockPause_Ort";"dbo";"BlockPause";"IDOrt_FS";"dbo";"Ort";"IDOrt"
|
||||||
|
"FK_BlockPlanung_Adresse";"dbo";"BlockPlanung";"IDAdresse_FS_Fahrer";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_BlockPlanung_Adresse2";"dbo";"BlockPlanung";"LoeschenManuellIDAdresse_FS_SB";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_BlockPlanung_ArtBetriebshof";"dbo";"BlockPlanung";"IDArtBetriebshof_FS_Anfang";"dbo";"ArtBetriebshof";"IDArtBetriebshof"
|
||||||
|
"FK_BlockPlanung_ArtBetriebshof1";"dbo";"BlockPlanung";"IDArtBetriebshof_FS_Ende";"dbo";"ArtBetriebshof";"IDArtBetriebshof"
|
||||||
|
"FK_BlockPlanung_ArtBlockArt";"dbo";"BlockPlanung";"IDArtBlockart_FS";"dbo";"ArtBlockArt";"IDArtBlockArt"
|
||||||
|
"FK_BlockPlanung_ArtFahrplanVersion";"dbo";"BlockPlanung";"IDArtFahrplanVersion_FS";"dbo";"ArtFahrplanVersion";"IDArtFahrplanVersion"
|
||||||
|
"FK_BlockPlanung_ArtFremdunternehmen";"dbo";"BlockPlanung";"IDArtFremdunternehmen_FS";"dbo";"ArtFremdunternehmen";"IDArtFremdunternehmen"
|
||||||
|
"FK_BlockPlanung_ArtReichweitentyp";"dbo";"BlockPlanung";"IDArtReichweitentyp_FS";"dbo";"ArtReichweitentyp";"IDArtReichweitentyp"
|
||||||
|
"FK_BlockPlanung_Block";"dbo";"BlockPlanung";"IDBlock_FS_Basis";"dbo";"Block";"IDBlock"
|
||||||
|
"FK_BlockPlanung_Fahrzeug";"dbo";"BlockPlanung";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_BlockPlanung_Ort_AbstellAnfang";"dbo";"BlockPlanung";"IDOrt_FS_AbstellAnfang";"dbo";"Ort";"IDOrt"
|
||||||
|
"FK_BlockPlanung_Ort_AbstellEnde";"dbo";"BlockPlanung";"IDOrt_FS_AbstellEnde";"dbo";"Ort";"IDOrt"
|
||||||
|
"FK_BlockPlanungFahrzeugtyp_ArtFahrzeugTypen";"dbo";"BlockPlanungFahrzeugtyp";"IDArtFahrzeugTypen_FS";"dbo";"ArtFahrzeugTypen";"IDArtFahrzeugTypen"
|
||||||
|
"FK_BlockPlanungFahrzeugtyp_BlockPlanung";"dbo";"BlockPlanungFahrzeugtyp";"IDBlockPlanung_FS";"dbo";"BlockPlanung";"IDBlockPlanung"
|
||||||
|
"FK_BlockPlanungPause_BlockPlanung";"dbo";"BlockPlanungPause";"IDBlockPlanung_FS";"dbo";"BlockPlanung";"IDBlockPlanung"
|
||||||
|
"FK_BlockPlanungPause_Ort";"dbo";"BlockPlanungPause";"IDOrt_FS";"dbo";"Ort";"IDOrt"
|
||||||
|
"FK_BlockPlanungPreisMehrMinder_TourPlanung";"dbo";"BlockPlanungPreisMehrMinder";"IDTourPlanung_FS";"dbo";"TourPlanung";"IDTourPlanung"
|
||||||
|
"FK_BlockPlanungZusatz_BlockPlanung";"dbo";"BlockPlanungZusatz";"IDBlockPlanung_FS";"dbo";"BlockPlanung";"IDBlockPlanung"
|
||||||
|
"FK_BlockSitzenbleiber_Block";"dbo";"BlockSitzenbleiber";"IDBlock_FS";"dbo";"Block";"IDBlock"
|
||||||
|
"FK_BlockSitzenbleiber_TourNach";"dbo";"BlockSitzenbleiber";"IdTour_FS_Nach";"dbo";"Tour";"IDTour"
|
||||||
|
"FK_BlockSitzenbleiber_TourVon";"dbo";"BlockSitzenbleiber";"IdTour_FS_Von";"dbo";"Tour";"IDTour"
|
||||||
|
"FK_BlockTour_Block";"dbo";"BlockTour";"IDBlock_FS";"dbo";"Block";"IDBlock"
|
||||||
|
"FK_BlockTour_Tour";"dbo";"BlockTour";"IDTour_FS";"dbo";"Tour";"IDTour"
|
||||||
|
"FK_BlockZusatz_Block";"dbo";"BlockZusatz";"IDBlock_FS";"dbo";"Block";"IDBlock"
|
||||||
|
"FK_Buchung_Adresse";"dbo";"Buchung";"IDAdresse_FS_Agentur";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Buchung_Adresse1";"dbo";"Buchung";"IDAdresse_FS_ErsterSachb";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Buchung_Adresse2";"dbo";"Buchung";"IDAdresse_FS_LetzterSachb";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Buchung_Adresse3";"dbo";"Buchung";"IDAdresse_FS_Mandant";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Buchung_IdAdressePerson";"dbo";"Buchung";"IdAdressePerson_FS_Anmelder";"dbo";"AdressePerson";"IDAdressePerson"
|
||||||
|
"FK_BuchungBemerkung_Buchung";"dbo";"BuchungBemerkung";"IDBuchung_FS";"dbo";"Buchung";"IDBuchung"
|
||||||
|
"FK_BuchungDokument_Adresse";"dbo";"BuchungDokument";"IDAdresse_FS_Sachbearbeiter";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_BuchungDokument_ArtDokument";"dbo";"BuchungDokument";"IDArtDokument_FS";"dbo";"ArtDokument";"IDArtDokument"
|
||||||
|
"FK_BuchungDokument_Buchung";"dbo";"BuchungDokument";"IDBuchung_FS";"dbo";"Buchung";"IDBuchung"
|
||||||
|
"FK_BuchungDruck_Buchung";"dbo";"BuchungDruck";"IDBuchung_FS";"dbo";"Buchung";"IDBuchung"
|
||||||
|
"FK_BuchungMeldung_Adresse";"dbo";"BuchungMeldung";"IDAdresse_FS_Mitarbeiter_Eingabe";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_BuchungMeldung_Adresse1";"dbo";"BuchungMeldung";"IDAdresse_FS_Mitarbeiter_Meldung";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_BuchungMeldung_ArtMeldungArt";"dbo";"BuchungMeldung";"IDArtMeldungart_FS";"dbo";"ArtMeldungArt";"IDArtMeldungArt"
|
||||||
|
"FK_BuchungMeldung_Buchung";"dbo";"BuchungMeldung";"IDBuchung_FS";"dbo";"Buchung";"IDBuchung"
|
||||||
|
"FK_BuchungMeldung_BuchungTNFerienziel";"dbo";"BuchungMeldung";"IDBuchungTNFerienziel_FS";"dbo";"BuchungTNFerienziel";"IDBuchungTNFerienziel"
|
||||||
|
"FK_BuchungMeldung_BuchungTNLeistung";"dbo";"BuchungMeldung";"IDBuchungTNLeistung_FS";"dbo";"BuchungTNLeistung";"IDBuchungTNLeistung"
|
||||||
|
"FK_BuchungReisebüro_Buchung";"dbo";"BuchungReisebüro";"IDBuchung_FS";"dbo";"Buchung";"IDBuchung"
|
||||||
|
"FK_BuchungReisebüroVersicherungsGruppe_ArtVersicherungsGruppe";"dbo";"BuchungReisebüroVersicherungsGruppe";"IDArtVersicherungsGruppe_FS";"dbo";"ArtVersicherungsGruppe";"IDArtVersicherungsGruppe"
|
||||||
|
"FK_BuchungReisebüroVersicherungsGruppe_BuchungReisebüro";"dbo";"BuchungReisebüroVersicherungsGruppe";"IDBuchungReisebüro_FS";"dbo";"BuchungReisebüro";"IDBuchungReisebüro"
|
||||||
|
"FK_BuchungReisebüroVisum_BuchungReisebüro";"dbo";"BuchungReisebüroVisum";"IDBuchungReisebüro_FS";"dbo";"BuchungReisebüro";"IDBuchungReisebüro"
|
||||||
|
"FK_BuchungTextbaustein_Buchung";"dbo";"BuchungTextbaustein";"IDBuchung_FS";"dbo";"Buchung";"IDBuchung"
|
||||||
|
"FK_BuchungTextbaustein_Textbaustein";"dbo";"BuchungTextbaustein";"IDTextbaustein_FS";"dbo";"Textbaustein";"IDTextbaustein"
|
||||||
|
"FK_BuchungTN_AdressePerson";"dbo";"BuchungTN";"IDAdressePerson_FS";"dbo";"AdressePerson";"IDAdressePerson"
|
||||||
|
"FK_BuchungTN_ArtTeilnahmeStornoGrund";"dbo";"BuchungTN";"IDArtTeilnahmeStornoGrund_FS";"dbo";"ArtTeilnahmeStornoGrund";"IDArtTeilnahmeStornoGrund"
|
||||||
|
"FK_BuchungTN_Buchung";"dbo";"BuchungTN";"IDBuchung_FS";"dbo";"Buchung";"IDBuchung"
|
||||||
|
"FK_BuchungTNFerienziel_Adresse";"dbo";"BuchungTNFerienziel";"IDAdresse_FS_Partner";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_BuchungTNFerienziel_ArtVerpflegung";"dbo";"BuchungTNFerienziel";"IDArtVerpflegung_FS";"dbo";"ArtVerpflegung";"IDArtVerpflegung"
|
||||||
|
"FK_BuchungTNFerienziel_BuchungTN";"dbo";"BuchungTNFerienziel";"IDBuchungTN_FS";"dbo";"BuchungTN";"IDBuchungTN"
|
||||||
|
"FK_BuchungTNFerienziel_Zimmerart";"dbo";"BuchungTNFerienziel";"IDZimmerart_FS";"dbo";"Zimmerart";"IDZimmerart"
|
||||||
|
"FK_BuchungTNFerienzielPreis_BuchungTNFerienziel";"dbo";"BuchungTNFerienzielPreis";"IDBuchungTNFerienziel_FS";"dbo";"BuchungTNFerienziel";"IDBuchungTNFerienziel"
|
||||||
|
"FK_BuchungTNFerienzielPreis_BuchungTNPreis";"dbo";"BuchungTNFerienzielPreis";"IDBuchungTNPreis_FS";"dbo";"BuchungTNPreis";"IDBuchungTNPreis"
|
||||||
|
"FK_BuchungTNLeistung_BuchungTN";"dbo";"BuchungTNLeistung";"IDBuchungTN_FS";"dbo";"BuchungTN";"IDBuchungTN"
|
||||||
|
"FK_BuchungTNLeistung_Leistung";"dbo";"BuchungTNLeistung";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_BuchungTNLeistungPreis_BuchungTNLeistung";"dbo";"BuchungTNLeistungPreis";"IDBuchungTNLeistung_FS";"dbo";"BuchungTNLeistung";"IDBuchungTNLeistung"
|
||||||
|
"FK_BuchungTNLeistungPreis_BuchungTNPreis";"dbo";"BuchungTNLeistungPreis";"IDBuchungTNPreis_FS";"dbo";"BuchungTNPreis";"IDBuchungTNPreis"
|
||||||
|
"FK_BuchungTNLeistungZusatz_BuchungTNLeistung";"dbo";"BuchungTNLeistungZusatz";"IDBuchungTNLeistung_FS";"dbo";"BuchungTNLeistung";"IDBuchungTNLeistung"
|
||||||
|
"FK_BuchungTNPreis_Adresse";"dbo";"BuchungTNPreis";"IDAdresse_FS_Sachb";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_BuchungTNPreis_ArtPreisschema";"dbo";"BuchungTNPreis";"IDArtPreisschema_FS";"dbo";"ArtPreisschema";"IDArtPreisschema"
|
||||||
|
"FK_BuchungTNPreis_BuchungTN";"dbo";"BuchungTNPreis";"IDBuchungTN_FS";"dbo";"BuchungTN";"IDBuchungTN"
|
||||||
|
"FK_BuchungTNPreisErm_BuchungTNPreis";"dbo";"BuchungTNPreisErm";"IDBuchungTNPreis_FS";"dbo";"BuchungTNPreis";"IDBuchungTNPreis"
|
||||||
|
"FK_BuchungTNVersicherung_BuchungTN";"dbo";"BuchungTNVersicherung";"IDBuchungTN_FS";"dbo";"BuchungTN";"IDBuchungTN"
|
||||||
|
"FK_BuchungTNVersicherung_Leistung";"dbo";"BuchungTNVersicherung";"IDLeistung_FS_Versicherung";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_BuchungTNVersicherungPreis_BuchungTNPreis";"dbo";"BuchungTNVersicherungPreis";"IDBuchungTNPreis_FS";"dbo";"BuchungTNPreis";"IDBuchungTNPreis"
|
||||||
|
"FK_BuchungTNVersicherungPreis_BuchungTNVersicherung";"dbo";"BuchungTNVersicherungPreis";"IDBuchungTNVersicherung_FS";"dbo";"BuchungTNVersicherung";"IDBuchungTNVersicherung"
|
||||||
|
"FK_BuchungTNWunsch_BuchungTN";"dbo";"BuchungTNWunsch";"IDBuchungTN_FS";"dbo";"BuchungTN";"IDBuchungTN"
|
||||||
|
"FK_BuchungTNZahlung_BuchungTN";"dbo";"BuchungTNZahlung";"IDBuchungTN_FS";"dbo";"BuchungTN";"IDBuchungTN"
|
||||||
|
"FK_BuchungTNZahlung_Zahlung";"dbo";"BuchungTNZahlung";"IDZahlung_FS";"dbo";"Zahlung";"IDZahlung"
|
||||||
|
"FK_BuchungTNZuschlag_BuchungTN";"dbo";"BuchungTNZuschlag";"IDBuchungTN_FS";"dbo";"BuchungTN";"IDBuchungTN"
|
||||||
|
"FK_BuchungTNZuschlagPreis_BuchungTNPreis";"dbo";"BuchungTNZuschlagPreis";"IDBuchungTNPreis_FS";"dbo";"BuchungTNPreis";"IDBuchungTNPreis"
|
||||||
|
"FK_BuchungTNZuschlagPreis_BuchungTNZuschlag";"dbo";"BuchungTNZuschlagPreis";"IDBuchungTNZuschlag_FS";"dbo";"BuchungTNZuschlag";"IDBuchungTNZuschlag"
|
||||||
|
"FK_BuchungTNZuschlagZusatz_BuchungTNZuschlag";"dbo";"BuchungTNZuschlagZusatz";"IDBuchungTNZuschlag_FS";"dbo";"BuchungTNZuschlag";"IDBuchungTNZuschlag"
|
||||||
|
"FK_BuchungUnterlagenStatus_Adresse";"dbo";"BuchungUnterlagenStatus";"IDAdresse_FS_Sachbearbeiter";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_BuchungUnterlagenStatus_ArtUnterlagenStatus";"dbo";"BuchungUnterlagenStatus";"IDArtUnterlagenStatus_FS";"dbo";"ArtUnterlagenStatus";"IDArtUnterlagenStatus"
|
||||||
|
"FK_BuchungUnterlagenStatus_Buchung";"dbo";"BuchungUnterlagenStatus";"IDBuchung_FS";"dbo";"Buchung";"IDBuchung"
|
||||||
|
"FK_BuchungVersicherungAngebot_Buchung";"dbo";"BuchungVersicherungAngebot";"IDBuchung_FS";"dbo";"Buchung";"IDBuchung"
|
||||||
|
"FK_BuchungVersicherungAngebot_Leistung";"dbo";"BuchungVersicherungAngebot";"IDLeistung_FS_Versicherung";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_BuchungZahlung_ArtZahlungsart";"dbo";"BuchungZahlung";"IDArtZahlungsart_FS";"dbo";"ArtZahlungsart";"IDArtZahlungsart"
|
||||||
|
"FK_BuchungZahlung_Buchung";"dbo";"BuchungZahlung";"IDBuchung_FS";"dbo";"Buchung";"IDBuchung"
|
||||||
|
"FK_BundeslandPLZOrt_ArtBundesland";"dbo";"BundeslandPLZOrt";"IDArtBundesland_FS";"dbo";"ArtBundesland";"IDArtBundesland"
|
||||||
|
"FK_ConnectChat_ArtConnectChatKategorie";"dbo";"ConnectChat";"IDArtConnectChatKategorie_FS";"dbo";"ArtConnectChatKategorie";"IDArtConnectChatKategorie"
|
||||||
|
"FK_ConnectChatKategorieKompetenz_ArtPersonalGruppeKompetenz";"dbo";"ConnectChatKategorieKompetenz";"IDArtPersonalGruppeKompetenz_FS";"dbo";"ArtPersonalGruppeKompetenz";"IDArtPersonalGruppeKompetenz"
|
||||||
|
"FK_ChatNachricht_ChatRaum";"dbo";"ConnectChatNachricht";"IDChat_FS";"dbo";"ConnectChat";"IDChat"
|
||||||
|
"FK_ChatTeilnehmer_ChatRaum";"dbo";"ConnectChatTeilnehmer";"IDChat_FS";"dbo";"ConnectChat";"IDChat"
|
||||||
|
"FK_ConnectDokumentReferenzen_ConnectDokument";"dbo";"ConnectDokumentReferenzen";"IDConnectDokument_FS";"dbo";"ConnectDokument";"IDConnectDokument"
|
||||||
|
"FK_ConnectKompetenz_ArtPersonalGruppeKompetenz";"dbo";"ConnectKompetenz";"IDArtPersonalGruppeKompetenz_FS";"dbo";"ArtPersonalGruppeKompetenz";"IDArtPersonalGruppeKompetenz"
|
||||||
|
"FK_ConnectNotificationsReceiver_ConnectNotifications";"dbo";"ConnectNotificationsReceiver";"IDConnectNotification";"dbo";"ConnectNotifications";"Id"
|
||||||
|
"FK_ConnectOrdner_ConnectOrdner";"dbo";"ConnectOrdner";"IDConnectOrdner_FS_Parent";"dbo";"ConnectOrdner";"IDConnectOrdner"
|
||||||
|
"FK_Connect_Planungsabruf_Adresse_Aenderung";"dbo";"ConnectPlanungsabruf";"IDAdresse_FS_Aenderung";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Connect_Planungsabruf_Adresse_Fahrer";"dbo";"ConnectPlanungsabruf";"IDAdresse_FS_Fahrer";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_ConnectPlanungsabrufHistorie_Adresse";"dbo";"ConnectPlanungsabrufHistorie";"IDAdresse_FS_Fahrer";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_ConnectRueckmeldungMB_AuftragFahrer";"dbo";"ConnectRueckmeldungMB";"IDAuftragFahrer_FS";"dbo";"AuftragFahrer";"IDAuftragFahrer"
|
||||||
|
"FK_ConnectRueckmeldungMBFoto_ConnectRueckmeldungMB";"dbo";"ConnectRueckmeldungMBFoto";"IDConnectRueckmeldungMB_FS";"dbo";"ConnectRueckmeldungMB";"IDConnectRueckmeldungMB"
|
||||||
|
"FK_ConnectRueckmeldungMBKilometer_ConnectRueckmeldungMB";"dbo";"ConnectRueckmeldungMBKilometer";"IDConnectRueckmeldungMB_FS";"dbo";"ConnectRueckmeldungMB";"IDConnectRueckmeldungMB"
|
||||||
|
"FK_ArtConnectRueckmeldungMBText_ConnectRueckmeldungMBText";"dbo";"ConnectRueckmeldungMBText";"IDArtConnectRueckmeldungMBText_FS";"dbo";"ArtConnectRueckmeldungMBText";"IDArtConnectRueckmeldungMBText"
|
||||||
|
"FK_ConnectRueckmeldungMBText_ConnectRueckmeldungMB";"dbo";"ConnectRueckmeldungMBText";"IDConnectRueckmeldungMB_FS";"dbo";"ConnectRueckmeldungMB";"IDConnectRueckmeldungMB"
|
||||||
|
"FK_CRMAktionDesignSeite_CRMAktion";"dbo";"CRMAktionDesignSeite";"IDCRMAktion_FS";"dbo";"CRMAktion";"IDCRMAktion"
|
||||||
|
"FK_CRMAktionDesignSeiteZuweisung_CRMAktionDesignSeite";"dbo";"CRMAktionDesignSeiteZuweisung";"IDCRMAktionDesignSeite_FS";"dbo";"CRMAktionDesignSeite";"IDCRMAktionDesignSeite"
|
||||||
|
"FK_CRMAktionPLZ_CRMAktion";"dbo";"CRMAktionPLZ";"IDCRMAktion_FS";"dbo";"CRMAktion";"IDCRMAktion"
|
||||||
|
"FK_CRMAktionZuGruppe_CRMAktion";"dbo";"CRMAktionZuGruppe";"IDCRMAktion_FS";"dbo";"CRMAktion";"IDCRMAktion"
|
||||||
|
"FK_CRMAktionZuGruppe_CRMAktionZuGruppe";"dbo";"CRMAktionZuGruppe";"IDArtCRMGruppe_FS";"dbo";"ArtCRMGruppe";"IDArtCRMGruppe"
|
||||||
|
"FK_CRMAktionZuProdukt_CRMAktion";"dbo";"CRMAktionZuProdukt";"IDCRMAktion_FS";"dbo";"CRMAktion";"IDCRMAktion"
|
||||||
|
"FK_CRMAktionZuProdukt_Produkt";"dbo";"CRMAktionZuProdukt";"IDProdukt_FS";"dbo";"Produkt";"IDProdukt"
|
||||||
|
"FK_CRMAktionZuReise_CRMAktion";"dbo";"CRMAktionZuReise";"IDCRMAktion_FS";"dbo";"CRMAktion";"IDCRMAktion"
|
||||||
|
"FK_CRMAktionZuReise_Reise";"dbo";"CRMAktionZuReise";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_CRMDesignSegment_CRMDesign";"dbo";"CRMDesignSegment";"IDCRMDesign_FS";"dbo";"CRMDesign";"IDCRMDesign"
|
||||||
|
"FK_CRMInternetZuweisung_ArtCRMInternet";"dbo";"CRMInternetZuweisung";"IDArtCRMInternet_FS";"dbo";"ArtCRMInternet";"IDArtCRMInternet"
|
||||||
|
"FK_CRMInternetZuweisung_Buchung";"dbo";"CRMInternetZuweisung";"IDBuchung_FS";"dbo";"Buchung";"IDBuchung"
|
||||||
|
"FK_CRMWerbepartner_Adresse";"dbo";"CRMWerbepartner";"IDAdresse_FS_Partner";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_CRMWerbepartnerDesign_CRMDesign";"dbo";"CRMWerbepartnerDesign";"IDCRMDesign_FS";"dbo";"CRMDesign";"IDCRMDesign"
|
||||||
|
"FK_CRMWerbepartnerDesign_CRMWerbepartner";"dbo";"CRMWerbepartnerDesign";"IDCRMWerbepartner_FS";"dbo";"CRMWerbepartner";"IDCRMWerbepartner"
|
||||||
|
"FK_CRMWerbepartnerPLZ_CRMWerbepartner";"dbo";"CRMWerbepartnerPLZ";"IDCRMWerbepartner_FS";"dbo";"CRMWerbepartner";"IDCRMWerbepartner"
|
||||||
|
"FK_CRMZuweisung_Adresse";"dbo";"CRMZuweisung";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_CRMZuweisung_Adresse1";"dbo";"CRMZuweisung";"IDAdresse_FS_VonPersonal";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_DienstplandruckExport_ArtDienstplandruckExport";"dbo";"DienstplandruckExport";"IDArtDienstplandruckExport_FS";"dbo";"ArtDienstplandruckExport";"IDArtDienstplandruckExport"
|
||||||
|
"FK_DispoAenderung_Adresse";"dbo";"DispoAenderung";"IDAdresse_FS_Disponent";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_DispoAenderungDisponent_DispoAenderung";"dbo";"DispoAenderungDisponent";"IDDispoAenderung_FS";"dbo";"DispoAenderung";"IDDispoAenderung"
|
||||||
|
"FK_DispoAenderungDisponent_DispoAnmeldungDisponent";"dbo";"DispoAenderungDisponent";"IDDispoAnmeldungDisponent_FS";"dbo";"DispoAnmeldungDisponent";"IDDispoAnmeldungDisponent"
|
||||||
|
"FK_DispoAnmeldungDisponent_Adresse";"dbo";"DispoAnmeldungDisponent";"IDAdresse_FS_Disponent";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_DispoEinstellungKompetenzen_ArtPersonalGruppeKompetenz";"dbo";"DispoEinstellungKompetenz";"IDArtPersonalGruppeKompetenz_FS";"dbo";"ArtPersonalGruppeKompetenz";"IDArtPersonalGruppeKompetenz"
|
||||||
|
"FK_DispoEinstellungKompetenzBetriebshofAnzeige_ArtBetriebshof";"dbo";"DispoEinstellungKompetenzBetriebshofAnzeige";"IDArtBetriebshof_FS";"dbo";"ArtBetriebshof";"IDArtBetriebshof"
|
||||||
|
"FK_DispoEinstellungKompetenzBetriebshofAnzeige_DispoEinstellungKompetenz";"dbo";"DispoEinstellungKompetenzBetriebshofAnzeige";"IDDispoEinstellungKompetenz_FS";"dbo";"DispoEinstellungKompetenz";"IDDispoEinstellungKompetenz"
|
||||||
|
"FK_DispoEinstellungKompetenzMandantAnzeige_Adresse";"dbo";"DispoEinstellungKompetenzMandantAnzeige";"IDAdresse_FS_Mandant";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_DispoEinstellungKompetenzMandantAnzeige_DispoEinstellungKompetenzen";"dbo";"DispoEinstellungKompetenzMandantAnzeige";"IDDispoEinstellungKompetenz_FS";"dbo";"DispoEinstellungKompetenz";"IDDispoEinstellungKompetenz"
|
||||||
|
"FK_Dokument_Adresse";"dbo";"Dokument";"IDAdresse_FS_Sachbearbeiter";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Dokument_ArtDokument";"dbo";"Dokument";"IDArtDokument_FS";"dbo";"ArtDokument";"IDArtDokument"
|
||||||
|
"FK_DruckArchiv_Adresse";"dbo";"DruckArchiv";"IDAdresse_FS_SB";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_DruckArchivZIP_Adresse";"dbo";"DruckArchivZIP";"IDAdresse_FS_Sachbearbeiter";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_DTAMeldung_RechnungHaupt";"dbo";"DTAMeldung";"IDRechnungHaupt_FS";"dbo";"RechnungHaupt";"IDRechnungHaupt"
|
||||||
|
"FK_EinreisebestimmungText_Einreisebestimmung";"dbo";"EinreisebestimmungText";"IDEinreisebestimmung_FS";"dbo";"Einreisebestimmung";"IDEinreisebestimmung"
|
||||||
|
"FK_ExportInternetSpalte_ExportInternet";"dbo";"ExportInternetSpalte";"IDExportInternetZeile_FS";"dbo";"ExportInternetZeile";"IDExportInternetZeile"
|
||||||
|
"FK_ExportInternetZeile_ExportInternetZeile";"dbo";"ExportInternetZeile";"IDExportInternet_FS";"dbo";"ExportInternet";"IDExportInternet"
|
||||||
|
"FK_FahrtArt_ArtFahrtArt";"dbo";"FahrtArtKonten";"IdArtFahrtArt_FS";"dbo";"ArtFahrtArt";"IDArtFahrtArt"
|
||||||
|
"FK_Fahrzeug_ArtFahrzeugBriefOrt";"dbo";"Fahrzeug";"IDArtFahrzeugBriefOrt_FS";"dbo";"ArtFahrzeugBriefOrt";"IDArtFahrzeugBriefOrt"
|
||||||
|
"FK_Fahrzeug_ArtReichweitentyp";"dbo";"Fahrzeug";"IDArtReichweitentyp_FS";"dbo";"ArtReichweitentyp";"IDArtReichweitentyp"
|
||||||
|
"FK_Fahrzeug_FahrzeugBetriebshof";"dbo";"Fahrzeug";"IDArtBetriebshof_FS";"dbo";"ArtBetriebshof";"IDArtBetriebshof"
|
||||||
|
"FK_Fahrzeug_FahrzeugEinsatz";"dbo";"Fahrzeug";"IDArtFahrzeugEinsatz_FS";"dbo";"ArtFahrzeugEinsatz";"IDArtFahrzeugEinsatz"
|
||||||
|
"FK_Fahrzeug_FahrzeugTypen";"dbo";"Fahrzeug";"IDArtFahrzeugTypen_FS";"dbo";"ArtFahrzeugTypen";"IDArtFahrzeugTypen"
|
||||||
|
"FK_FahrzeugAusstattung_AusstattungsMerkmale";"dbo";"FahrzeugAusstattung";"IDArtFahrzeugAusstattungMerkmal_FS";"dbo";"ArtFahrzeugAusstattungMerkmal";"IDArtFahrzeugAusstattungMerkmal"
|
||||||
|
"FK_FahrzeugAusstattung_Fahrzeug";"dbo";"FahrzeugAusstattung";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_FahrzeugEinbauten_Fahrzeug";"dbo";"FahrzeugEinbauten";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_FahrzeugGerät_ArtFahrzeugGerätTyp";"dbo";"FahrzeugGerät";"IDArtFahrzeugGerätTyp_FS";"dbo";"ArtFahrzeugGerätTyp";"IDArtFahrzeugGerätTyp"
|
||||||
|
"FK_FahrzeugGerätHistorie_ArtFahrzeugGerätOrt";"dbo";"FahrzeugGerätHistorie";"IDArtFahrzeugGerätOrt_FS";"dbo";"ArtFahrzeugGerätOrt";"IDArtFahrzeugGerätOrt"
|
||||||
|
"FK_FahrzeugGerätHistorie_Fahrzeug";"dbo";"FahrzeugGerätHistorie";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_FahrzeugGerätHistorie_FahrzeugGerät";"dbo";"FahrzeugGerätHistorie";"IDFahrzeugGerät_FS";"dbo";"FahrzeugGerät";"IDFahrzeugGerät"
|
||||||
|
"FK_FahrzeugLeasing_Fahrzeug";"dbo";"FahrzeugLeasing";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_FahrzeugNummer_ArtFahrzeugNummer";"dbo";"FahrzeugNummer";"IDArtFahrzeugNummer_FS";"dbo";"ArtFahrzeugNummer";"IDArtFahrzeugNummer"
|
||||||
|
"FK_FahrzeugNummer_Fahrzeug";"dbo";"FahrzeugNummer";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_FahrzeugStatistikAnmelder_Fahrzeug";"dbo";"FahrzeugStatistikAnmelder";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_FahrzeugTerminKettenAufbau_FahrzeugTermin";"dbo";"FahrzeugTerminKettenAufbau";"IDFahrzeugTermin_FS";"dbo";"FahrzeugTermin";"IDFahrzeugTermin"
|
||||||
|
"FK_FahrzeugTerminKettenAufbau_FahrzeugTerminHaeufigkeit";"dbo";"FahrzeugTerminKettenAufbau";"IDFahrzeugTerminHaeufigkeit_FS";"dbo";"FahrzeugTerminHaeufigkeit";"IDFahrzeugTerminHaeufigkeit"
|
||||||
|
"FK_FahrzeugTerminKettenAufbau_FahrzeugTerminkette";"dbo";"FahrzeugTerminKettenAufbau";"IDFahrzeugTerminkette_FS";"dbo";"FahrzeugTerminkette";"IDFahrzeugTerminkette"
|
||||||
|
"FK_FahrzeugTerminZuweisung_Fahrzeug";"dbo";"FahrzeugTerminZuweisung";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_FahrzeugTerminZuweisung_FahrzeugTerminKettenAufbau";"dbo";"FahrzeugTerminZuweisung";"IDFahrzeugTerminKettenAufbau_FS";"dbo";"FahrzeugTerminKettenAufbau";"IDFahrzeugTerminKettenAufbau"
|
||||||
|
"FK_FahrzeugVermietung_Fahrzeug";"dbo";"FahrzeugVermietung";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_FahrzeugVersicherung_Fahrzeug";"dbo";"FahrzeugVersicherung";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_FahrzeugVerwendung_Fahrzeug";"dbo";"FahrzeugVerwendung";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_FahrzeugWerbung_Fahrzeug";"dbo";"FahrzeugWerbung";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_FehlerProtokoll_Adresse";"dbo";"FehlerProtokoll";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_FehlerProtokoll_FehlerProtokollTyp";"dbo";"FehlerProtokoll";"IDFehlerProtokollTyp_FS";"dbo";"FehlerProtokollTyp";"IDFehlerProtokollTyp"
|
||||||
|
"FK_FehlerProtokollTypEinstellung_FehlerProtokollTyp_FS";"dbo";"FehlerProtokollTypEinstellung";"IDFehlerProtokollTyp_FS";"dbo";"FehlerProtokollTyp";"IDFehlerProtokollTyp"
|
||||||
|
"FK_FehlerProtokollTypEinstellung_Kommunikation_FS";"dbo";"FehlerProtokollTypEinstellung";"IDKommunikation_FS";"dbo";"Kommunikation";"IDKommunikation"
|
||||||
|
"FK_FerienzielZwischenPartner_Adresse";"dbo";"FerienzielZwischenPartner";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_FerienzielZwischenPartner_FerienzielZwischenZeitraum";"dbo";"FerienzielZwischenPartner";"IDFerienzielZwischenZeitraum_FS";"dbo";"FerienzielZwischenZeitraum";"IDFerienzielZwischenZeitraum"
|
||||||
|
"FK_FerienzielZwischenZeitraum_Produkt";"dbo";"FerienzielZwischenZeitraum";"IDProdukt_FS";"dbo";"Produkt";"IDProdukt"
|
||||||
|
"FK_Fibuaufteilung_ArtFibuaufteilung";"dbo";"Fibuaufteilung";"IdArtFibuaufteilung_FS";"dbo";"ArtFibuaufteilung";"IDArtFibuaufteilung"
|
||||||
|
"FK_Fibuaufteilung_Kontenplan";"dbo";"Fibuaufteilung";"IdKontenplan_FS";"dbo";"Kontenplan";"IDKontenplan"
|
||||||
|
"FK_FibuAufteilungWerte_Buchung";"dbo";"FibuAufteilungWerte";"IDBuchung_FS";"dbo";"Buchung";"IDBuchung"
|
||||||
|
"FK_FibuAufteilungWerte_Fibuaufteilung";"dbo";"FibuAufteilungWerte";"IDFibuAufteilung_FS";"dbo";"Fibuaufteilung";"IdFibuaufteilung"
|
||||||
|
"FK_FPAllgemein_ArtFahrplanVersion";"dbo";"FPAllgemein";"IDArtFahrplanVersion_FS";"dbo";"ArtFahrplanVersion";"IDArtFahrplanVersion"
|
||||||
|
"FK_FPLinie_ArtFahrplanVersion";"dbo";"FPLinie";"IDArtFahrplanVersion_FS";"dbo";"ArtFahrplanVersion";"IDArtFahrplanVersion"
|
||||||
|
"FK_FPLinie_ArtFPLinienTyp";"dbo";"FPLinie";"IDArtFPLinienTyp_FS";"dbo";"ArtFPLinienTyp";"IDArtFPLinienTyp"
|
||||||
|
"FK_FPLinie_ArtLinieVerbund";"dbo";"FPLinie";"IDArtLinieVerbund_FS";"dbo";"ArtLinieVerbund";"IDArtLinieVerbund"
|
||||||
|
"FK_FPLinie_ArtLinieVUBereich";"dbo";"FPLinie";"IDArtLinieVUBereich_FS";"dbo";"ArtLinieVUBereich";"IDArtLinieVUBereich"
|
||||||
|
"FK_FPLinie_ArtStatistikGruppe";"dbo";"FPLinie";"IDArtStatistikgruppe_FS";"dbo";"ArtStatistikGruppe";"IDArtStatistikGruppe"
|
||||||
|
"FK_FPLinieAnschluss_FPLinie";"dbo";"FPLinieAnschluss";"IDFPLinie_FS";"dbo";"FPLinie";"IDFPLinie"
|
||||||
|
"FK_FPLinieAnschluss_FPLinie_Bringer";"dbo";"FPLinieAnschluss";"IDFPLinie_FS_Bringer";"dbo";"FPLinie";"IDFPLinie"
|
||||||
|
"FK_FPLinieAnschluss_FPLinieHaltestelle";"dbo";"FPLinieAnschluss";"IDFPLinieHaltestelle_FS_Position";"dbo";"FPLinieHaltestelle";"IDFPLinieHaltestelle"
|
||||||
|
"FK_FPLinieAnschluss_FPLinieHaltestelleZubringer";"dbo";"FPLinieAnschluss";"IDFPLinieHaltestelle_FS_Position_Zubringer";"dbo";"FPLinieHaltestelle";"IDFPLinieHaltestelle"
|
||||||
|
"FK_FPLinieAnschluss_FPLinieSymbol";"dbo";"FPLinieAnschluss";"IDFPLinieSymbol_FS";"dbo";"FPLinieSymbol";"IDFPLinieSymbol"
|
||||||
|
"FK_FPLinieAnschlussDefinition_ArtFahrplanVersion";"dbo";"FPLinieAnschlussDefinition";"IDArtFahrplanVersion_FS";"dbo";"ArtFahrplanVersion";"IDArtFahrplanVersion"
|
||||||
|
"FK_FPLinieAnschlussDefinition_FPLinie_Abbringer";"dbo";"FPLinieAnschlussDefinition";"IDFPLinie_FS_Abbringer";"dbo";"FPLinie";"IDFPLinie"
|
||||||
|
"FK_FPLinieAnschlussDefinition_FPLinie_Zubringer";"dbo";"FPLinieAnschlussDefinition";"IDFPLinie_FS_Zubringer";"dbo";"FPLinie";"IDFPLinie"
|
||||||
|
"FK_FPLinieAnschlussDefinition_Haltepunkt_Abbringer";"dbo";"FPLinieAnschlussDefinition";"IDHaltepunkt_FS_Abbringer";"dbo";"Haltepunkt";"IDHaltepunkt"
|
||||||
|
"FK_FPLinieAnschlussDefinition_Haltepunkt_Zubringer";"dbo";"FPLinieAnschlussDefinition";"IDHaltepunkt_FS_Zubringer";"dbo";"Haltepunkt";"IDHaltepunkt"
|
||||||
|
"FK_FPLinieAnschlussDefinition_Haltestelle_Abbringer";"dbo";"FPLinieAnschlussDefinition";"IDHaltestelle_FS_Abbringer";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_FPLinieAnschlussDefinition_Haltestelle_Zubringer";"dbo";"FPLinieAnschlussDefinition";"IDHaltestelle_FS_Zubringer";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_FPLinieAnschlussDefinitionUmstieg_FPLinieAnschlussDefinition";"dbo";"FPLinieAnschlussDefinitionUmstieg";"IDFPLinieAnschlussDefinition_FS";"dbo";"FPLinieAnschlussDefinition";"IDFPLinieAnschlussDefinition"
|
||||||
|
"FK_FPLinieAnschlussDefinitionUmstiegGruppeVT_FPLinieAnschlussDefinitionUmstieg";"dbo";"FPLinieAnschlussDefinitionUmstiegGruppeVT";"IDFPLinieAnschlussDefinitionUmstieg_FS";"dbo";"FPLinieAnschlussDefinitionUmstieg";"IDFPLinieAnschlussDefinitionUmstieg"
|
||||||
|
"FK_FPLinieBeschraenkung_ArtLaufzeitschema";"dbo";"FPLinieBeschraenkung";"IDArtLaufzeitschema_FS";"dbo";"ArtLaufzeitschema";"IDArtLaufzeitschema"
|
||||||
|
"FK_FPLinieBeschraenkung_FPSondererklaerung";"dbo";"FPLinieBeschraenkung";"IDFPSondererklaerung_FS";"dbo";"FPSondererklaerung";"IDFPSondererklaerung"
|
||||||
|
"FK_FPLinieBeschraenkungFerienFeier_FPLinieBeschraenkung";"dbo";"FPLinieBeschraenkungFerienFeier";"IDFPLinieBeschraenkung_FS";"dbo";"FPLinieBeschraenkung";"IDFPLinieBeschraenkung"
|
||||||
|
"FK_FPLinieBeschraenkungLaufzeit_FPLinieBeschraenkung";"dbo";"FPLinieBeschraenkungLaufzeit";"IDFPLinieBeschraenkung_FS";"dbo";"FPLinieBeschraenkung";"IDFPLinieBeschraenkung"
|
||||||
|
"FK_FPLinieBeschraenkungWochentyp_ArtWochentyp";"dbo";"FPLinieBeschraenkungWochentyp";"IDArtWochentyp_FS";"dbo";"ArtWochentyp";"IDArtWochentyp"
|
||||||
|
"FK_FPLinieBeschraenkungWochentyp_FPLinieBeschraenkung";"dbo";"FPLinieBeschraenkungWochentyp";"IDFPLinieBeschraenkung_FS";"dbo";"FPLinieBeschraenkung";"IDFPLinieBeschraenkung"
|
||||||
|
"FK_FPLinieDruck_FPLinie";"dbo";"FPLinieDruck";"IDFPLinie_FS";"dbo";"FPLinie";"IDFPLinie"
|
||||||
|
"FK_FPLinieFahrplanAdresse_FahrplanAdresse";"dbo";"FPLinieFahrplanAdresse";"IDFahrplanAdresse_FS";"dbo";"FahrplanAdresse";"IDFahrplanAdresse"
|
||||||
|
"FK_FPLinieFahrplanAdresse_FPLinie";"dbo";"FPLinieFahrplanAdresse";"IDFPLinie_FS";"dbo";"FPLinie";"IDFPLinie"
|
||||||
|
"FK_FPLinieFahrt_ArtFremdunternehmen";"dbo";"FPLinieFahrt";"IDArtFremdunternehmen_FS";"dbo";"ArtFremdunternehmen";"IDArtFremdunternehmen"
|
||||||
|
"FK_FPLinieFahrt_FPLinie";"dbo";"FPLinieFahrt";"IDFPLinie_FS";"dbo";"FPLinie";"IDFPLinie"
|
||||||
|
"FK_FPLinieFahrt_FPLinieFahrt";"dbo";"FPLinieFahrt";"IDFPLinieFahrt_FS_VerstaerkerVon";"dbo";"FPLinieFahrt";"IDFPLinieFahrt"
|
||||||
|
"FK_FPLinieFahrt_FPLinieFahrt_TeilenOriginal";"dbo";"FPLinieFahrt";"IDFPLinieFahrt_FS_TeilenOriginal";"dbo";"FPLinieFahrt";"IDFPLinieFahrt"
|
||||||
|
"FK_FPLinieFahrt_FPLinieZeitart";"dbo";"FPLinieFahrt";"IDFPLinieZeitart_FS";"dbo";"FPLinieZeitart";"IDFPLinieZeitart"
|
||||||
|
"FK_FPLinieFahrtAnschluss_FPLinieAnschluss";"dbo";"FPLinieFahrtAnschluss";"IDFPLinieAnschluss_FS";"dbo";"FPLinieAnschluss";"IDFPLinieAnschluss"
|
||||||
|
"FK_FPLinieFahrtAnschluss_FPLinieFahrt";"dbo";"FPLinieFahrtAnschluss";"IDFPLinieFahrt_FS";"dbo";"FPLinieFahrt";"IDFPLinieFahrt"
|
||||||
|
"FK_FPLinieFahrtAnschluss_FPLinieFahrt_Bringer";"dbo";"FPLinieFahrtAnschluss";"IDFPLinieFahrt_FS_Bringer";"dbo";"FPLinieFahrt";"IDFPLinieFahrt"
|
||||||
|
"FK_FPLinieFahrtAnschluss_FPLinieHaltestelle_Bringer";"dbo";"FPLinieFahrtAnschluss";"IDFPLinieHaltestelle_FS_Bringer";"dbo";"FPLinieHaltestelle";"IDFPLinieHaltestelle"
|
||||||
|
"FK_FPLinieFahrtAnschlussBeschraenkung_FPLinieBeschraenkung";"dbo";"FPLinieFahrtAnschlussBeschraenkung";"IDFPLinieBeschraenkung_FS";"dbo";"FPLinieBeschraenkung";"IDFPLinieBeschraenkung"
|
||||||
|
"FK_FPLinieFahrtAnschlussBeschraenkung_FPLinieFahrtAnschluss";"dbo";"FPLinieFahrtAnschlussBeschraenkung";"IDFPLinieFahrtAnschluss_FS";"dbo";"FPLinieFahrtAnschluss";"IDFPLinieFahrtAnschluss"
|
||||||
|
"FK_FPLinieFahrtBeschraenkung_FPLinieBeschraenkung";"dbo";"FPLinieFahrtBeschraenkung";"IDFPLinieBeschraenkung_FS";"dbo";"FPLinieBeschraenkung";"IDFPLinieBeschraenkung"
|
||||||
|
"FK_FPLinieFahrtBeschraenkung_FPLinieFahrt";"dbo";"FPLinieFahrtBeschraenkung";"IDFPLinieFahrt_FS";"dbo";"FPLinieFahrt";"IDFPLinieFahrt"
|
||||||
|
"FK_FPLinieHaltestelle_ArtKreisGebiet";"dbo";"FPLinieHaltestelle";"IDArtKreisGebiet_FS";"dbo";"ArtKreisGebiet";"IDArtKreisGebiet"
|
||||||
|
"FK_FPLinieHaltestelle_FPLinie";"dbo";"FPLinieHaltestelle";"IDFPLinie_FS";"dbo";"FPLinie";"IDFPLinie"
|
||||||
|
"FK_FPLinieHaltestelle_FPLinieHaltestelle";"dbo";"FPLinieHaltestelle";"IDFPLinieHaltestelle_FS_VerschiebenAuf";"dbo";"FPLinieHaltestelle";"IDFPLinieHaltestelle"
|
||||||
|
"FK_FPLinieHaltestelle_Haltepunkt";"dbo";"FPLinieHaltestelle";"IDHaltepunkt_FS";"dbo";"Haltepunkt";"IDHaltepunkt"
|
||||||
|
"FK_FPLinieHaltestelle_Haltestelle";"dbo";"FPLinieHaltestelle";"IDHaltestelle_FS";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_FPLinieHaltestelleBemerkung_FPLinieHaltestelle";"dbo";"FPLinieHaltestelleBemerkung";"IDFPLinieHaltestelle_FS";"dbo";"FPLinieHaltestelle";"IDFPLinieHaltestelle"
|
||||||
|
"FK_FPLinieHaltestelleBemerkung_HaltestelleBemerkung";"dbo";"FPLinieHaltestelleBemerkung";"IDHaltestelleBemerkung_FS";"dbo";"HaltestelleBemerkung";"IDHaltestelleBemerkung"
|
||||||
|
"FK_FPLinieHaltestelleZeitart_ArtKreisGebiet";"dbo";"FPLinieHaltestelleZeitart";"IDArtKreisGebiet_FS";"dbo";"ArtKreisGebiet";"IDArtKreisGebiet"
|
||||||
|
"FK_FPLinieHaltestelleZeitart_FPLinieHaltestelle";"dbo";"FPLinieHaltestelleZeitart";"IDFPLinieHaltestelle_FS";"dbo";"FPLinieHaltestelle";"IDFPLinieHaltestelle"
|
||||||
|
"FK_FPLinieHaltestelleZeitart_FPLinieZeitart";"dbo";"FPLinieHaltestelleZeitart";"IDFPLinieZeitart_FS";"dbo";"FPLinieZeitart";"IDFPLinieZeitart"
|
||||||
|
"FK_FPLinieHaltestelleZeitart_HaltestelleBemerkung";"dbo";"FPLinieHaltestelleZeitart";"IDHaltestelleBemerkung_FS";"dbo";"HaltestelleBemerkung";"IDHaltestelleBemerkung"
|
||||||
|
"FK_FPLinieHaltestelleZeitart_LinieZiel";"dbo";"FPLinieHaltestelleZeitart";"IDLinieZiel_FS";"dbo";"LinieZiel";"IDLinieZiel"
|
||||||
|
"FK_FPLinieZeitart_ArtStatistikGruppe";"dbo";"FPLinieZeitart";"IDArtStatistikgruppe_FS";"dbo";"ArtStatistikGruppe";"IDArtStatistikGruppe"
|
||||||
|
"FK_FPLinieZeitart_FPLinie";"dbo";"FPLinieZeitart";"IDFPLinie_FS";"dbo";"FPLinie";"IDFPLinie"
|
||||||
|
"FK_FPLinieZeitart_FPLinieHaltestelle_TeilenAbschnittBis";"dbo";"FPLinieZeitart";"IDFPLinieHaltestelle_FS_TeilenAbschnittBis";"dbo";"FPLinieHaltestelle";"IDFPLinieHaltestelle"
|
||||||
|
"FK_FPLinieZeitart_FPLinieHaltestelle_TeilenAbschnittVon";"dbo";"FPLinieZeitart";"IDFPLinieHaltestelle_FS_TeilenAbschnittVon";"dbo";"FPLinieHaltestelle";"IDFPLinieHaltestelle"
|
||||||
|
"FK_FPLinieZeitart_FPLinieZeitart";"dbo";"FPLinieZeitart";"IDFPLinieZeitart_FS_TeilenOriginal";"dbo";"FPLinieZeitart";"IDFPLinieZeitart"
|
||||||
|
"FK_FPSchuelerAbsage_Adresse";"dbo";"FPSchuelerAbsage";"IdAdresse_FS_Bearbeitet";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_FPSchuelerAbsage_FPSchueler";"dbo";"FPSchuelerAbsage";"IDFPSchueler_FS";"dbo";"FPSchueler";"IDFPSchueler"
|
||||||
|
"FK_FPSchuelerBefoerderung_FPSchueler";"dbo";"FPSchuelerBefoerderung";"IDFPSchueler_FS";"dbo";"FPSchueler";"IDFPSchueler"
|
||||||
|
"FK_FPSchuelerBefoerderung_FPSchule";"dbo";"FPSchuelerBefoerderung";"IDFPSchule_FS";"dbo";"FPSchule";"IDFPSchule"
|
||||||
|
"FK_FPSchuelerBefoerderung_FPSchuljahr";"dbo";"FPSchuelerBefoerderung";"IDFPSchuljahr_FS";"dbo";"FPSchuljahr";"IDFPSchuljahr"
|
||||||
|
"FK_FPSchuelerBefoerderungBewegung_Haltepunkt_Ausstieg";"dbo";"FPSchuelerBefoerderungBewegung";"IDHaltepunkt_FS_Ausstieg";"dbo";"Haltepunkt";"IDHaltepunkt"
|
||||||
|
"FK_FPSchuelerBefoerderungBewegung_Haltepunkt_Einstieg";"dbo";"FPSchuelerBefoerderungBewegung";"IDHaltepunkt_FS_Einstieg";"dbo";"Haltepunkt";"IDHaltepunkt"
|
||||||
|
"FK_FPSchuelerBefoerderungBewegung_Haltestelle_Ausstieg";"dbo";"FPSchuelerBefoerderungBewegung";"IDHaltestelle_FS_Ausstieg";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_FPSchuelerBefoerderungBewegung_Haltestelle_Einstieg";"dbo";"FPSchuelerBefoerderungBewegung";"IDHaltestelle_FS_Einstieg";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_FPSchuelerBefoerderungBewegung_IDFPSchuelerBefoerderung";"dbo";"FPSchuelerBefoerderungBewegung";"IDFPSchuelerBefoerderung_FS";"dbo";"FPSchuelerBefoerderung";"IDFPSchuelerBefoerderung"
|
||||||
|
"FK_FPSchuelerBefoerderungBewegungAbsage_FPSchuelerAbsage";"dbo";"FPSchuelerBefoerderungBewegungAbsage";"IDFPSchuelerAbsage_FS";"dbo";"FPSchuelerAbsage";"IDFPSchuelerAbsage"
|
||||||
|
"FK_FPSchuelerBefoerderungBewegungAbsage_FPSchuelerBefoerderungBewegung";"dbo";"FPSchuelerBefoerderungBewegungAbsage";"IDFPSchuelerBefoerderungBewegung_FS";"dbo";"FPSchuelerBefoerderungBewegung";"IDFPSchuelerBefoerderungBewegung"
|
||||||
|
"FK_FPSchuelerBefoerderungBewegungZuordnung_FPLinieFahrt";"dbo";"FPSchuelerBefoerderungBewegungZuordnung";"IDFPLinieFahrt_FS";"dbo";"FPLinieFahrt";"IDFPLinieFahrt"
|
||||||
|
"FK_FPSchuelerBefoerderungBewegungZuordnung_FPLinieHaltestelle";"dbo";"FPSchuelerBefoerderungBewegungZuordnung";"IDFPLinieHaltestelle_FS";"dbo";"FPLinieHaltestelle";"IDFPLinieHaltestelle"
|
||||||
|
"FK_FPSchuelerBefoerderungBewegungZuordnung_FPSchuelerBefoerderungBewegung";"dbo";"FPSchuelerBefoerderungBewegungZuordnung";"IDFPSchuelerBefoerderungBewegung_FS";"dbo";"FPSchuelerBefoerderungBewegung";"IDFPSchuelerBefoerderungBewegung"
|
||||||
|
"FK_FPSchuelerBefoerderungBewegungZuordnung_FPZeitart";"dbo";"FPSchuelerBefoerderungBewegungZuordnung";"IDFPLinieZeitart_FS";"dbo";"FPLinieZeitart";"IDFPLinieZeitart"
|
||||||
|
"FK_FPSchuleHaltestelleHaltepunkt_FPSchule";"dbo";"FPSchuleHaltestelleHaltepunkt";"IDFPSchule_FS";"dbo";"FPSchule";"IDFPSchule"
|
||||||
|
"FK_FPSchuleHaltestelleHaltepunkt_Haltepunkt";"dbo";"FPSchuleHaltestelleHaltepunkt";"IDHaltepunkt_FS";"dbo";"Haltepunkt";"IDHaltepunkt"
|
||||||
|
"FK_FPSchuleHaltestelleHaltepunkt_Haltestelle";"dbo";"FPSchuleHaltestelleHaltepunkt";"IDHaltestelle_FS";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_FreibescheinigungManuelleEingabe_Adresse";"dbo";"FreibescheinigungManuelleEingabe";"IDAdresse_FS_Fahrer";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_FreibescheinigungRuhetag_Adresse";"dbo";"FreibescheinigungRuhetag";"IDAdresse_FS_Fahrer";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_GruppeVerkehrstag_ArtGruppeVerkehrstag";"dbo";"GruppeVerkehrstag";"IDArtGruppeVerkehrstag_FS";"dbo";"ArtGruppeVerkehrstag";"IDArtGruppeVerkehrstag"
|
||||||
|
"FK_GruppeVerkehrstagFerien_ArtGruppeVerkehrstag";"dbo";"GruppeVerkehrstagFerien";"IDArtGruppeVerkehrstag_FS";"dbo";"ArtGruppeVerkehrstag";"IDArtGruppeVerkehrstag"
|
||||||
|
"FK_Gutschein_ArtGutscheinversand";"dbo";"Gutschein";"IDArtGutscheinversand_FS";"dbo";"ArtGutscheinversand";"IDArtGutscheinversand"
|
||||||
|
"FK_GutscheinlayoutInternet_ArtGutscheinlayout";"dbo";"GutscheinlayoutInternet";"IDArtGutscheinlayout_FS";"dbo";"ArtGutscheinlayout";"IDArtGutscheinlayout"
|
||||||
|
"FK_Gutscheinzuschlag_Gutschein";"dbo";"GutscheinZuschlag";"IDGutschein_FS";"dbo";"Gutschein";"IDGutschein"
|
||||||
|
"FK_Gutscheinzuschlag_Kontenplan";"dbo";"GutscheinZuschlag";"IDKontenplan_FS";"dbo";"Kontenplan";"IDKontenplan"
|
||||||
|
"FK_Haltepunkt_ArtHaltepunktArt";"dbo";"Haltepunkt";"IDArtHaltepunktArt_FS";"dbo";"ArtHaltepunktArt";"IDArtHaltepunktArt"
|
||||||
|
"FK_Haltepunkt_Haltestelle";"dbo";"Haltepunkt";"IDHaltestelle_FS";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_HaltepunktFahrplankasten_ArtHaltepunktFahrplankasten";"dbo";"HaltepunktFahrplankasten";"IDArtHaltepunktFahrplankasten_FS";"dbo";"ArtHaltepunktFahrplankasten";"IDArtHaltepunktFahrplankasten"
|
||||||
|
"FK_HaltepunktFahrplankasten_HaltestelleHaltepunkt";"dbo";"HaltepunktFahrplankasten";"IDHaltestelleHaltepunkt_FS";"dbo";"HaltestelleHaltepunkt";"IDHaltestelleHaltepunkt"
|
||||||
|
"FK_Haltestelle_ArtHaltestelleZwischenpunkttyp";"dbo";"Haltestelle";"IDArtHaltestelleZwischenpunkttyp_FS";"dbo";"ArtHaltestelleZwischenpunkttyp";"IDArtHaltestelleZwischenpunkttyp"
|
||||||
|
"FK_Haltestelle_HaltestelleNach";"dbo";"Haltestelle";"IDHaltestelle_FS_ZwNach";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_Haltestelle_HaltestelleVon";"dbo";"Haltestelle";"IDHaltestelle_FS_ZwVon";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_HaltestelleDruck_Haltestelle";"dbo";"HaltestelleDruck";"IDHaltestelle_FS";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_HaltestelleFahrplanAdresse_FahrplanAdresse";"dbo";"HaltestelleFahrplanAdresse";"IDFahrplanAdresse_FS";"dbo";"FahrplanAdresse";"IDFahrplanAdresse"
|
||||||
|
"FK_HaltestelleFahrplanAdresse_Haltestelle";"dbo";"HaltestelleFahrplanAdresse";"IDHaltestelle_FS";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_HaltestelleHaltepunkt_ArtBetriebshof";"dbo";"HaltestelleHaltepunkt";"IDArtBetriebshof_FS";"dbo";"ArtBetriebshof";"IDArtBetriebshof"
|
||||||
|
"FK_HaltestelleHaltepunkt_ArtHaltepunktAbfallbehaelter";"dbo";"HaltestelleHaltepunkt";"IDArtHaltepunktAbfallbehaelter_FS";"dbo";"ArtHaltepunktAbfallbehaelter";"IDArtHaltepunktAbfallbehaelter"
|
||||||
|
"FK_HaltestelleHaltepunkt_ArtHaltepunktBlindenleitsystem";"dbo";"HaltestelleHaltepunkt";"IDArtHaltepunktBlindenleitsystem_FS";"dbo";"ArtHaltepunktBlindenleitsystem";"IDArtHaltepunktBlindenleitsystem"
|
||||||
|
"FK_HaltestelleHaltepunkt_ArtHaltepunktBlinkaufforderung";"dbo";"HaltestelleHaltepunkt";"IDArtHaltepunktBlinkaufforderung_FS";"dbo";"ArtHaltepunktBlinkaufforderung";"IDArtHaltepunktBlinkaufforderung"
|
||||||
|
"FK_HaltestelleHaltepunkt_ArtHaltepunktBusbordstein";"dbo";"HaltestelleHaltepunkt";"IDArtHaltepunktBusbordstein_FS";"dbo";"ArtHaltepunktBusbordstein";"IDArtHaltepunktBusbordstein"
|
||||||
|
"FK_HaltestelleHaltepunkt_Haltepunkt";"dbo";"HaltestelleHaltepunkt";"IDHaltepunkt_FS";"dbo";"Haltepunkt";"IDHaltepunkt"
|
||||||
|
"FK_HaltestelleHaltepunkt_Haltestelle";"dbo";"HaltestelleHaltepunkt";"IDHaltestelle_FS";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_HaltestelleHaltepunkt_Ort";"dbo";"HaltestelleHaltepunkt";"IDOrt_FS";"dbo";"Ort";"IDOrt"
|
||||||
|
"FK_ImportAdressen_ArtImportAdressen";"dbo";"ImportAdressen";"IDArtImportAdressen_FS";"dbo";"ArtImportAdressen";"IDArtImportAdressen"
|
||||||
|
"FK_ImportMatrix_ArtBuchungsImport";"dbo";"ImportMatrix";"IDArtBuchungsImport_FS";"dbo";"ArtBuchungsImport";"IDArtBuchungsImport"
|
||||||
|
"FK_InternetCRMZuweisung_ArtCRMGruppe";"dbo";"InternetCRMZuweisung";"IDArtCRMGruppe_FS";"dbo";"ArtCRMGruppe";"IDArtCRMGruppe"
|
||||||
|
"FK_InternetCRMZuweisung_ArtCRMInternet";"dbo";"InternetCRMZuweisung";"IDArtCRMInternet_FS";"dbo";"ArtCRMInternet";"IDArtCRMInternet"
|
||||||
|
"FK_KalenderFahrplanversion_ArtFahrplanVersion";"dbo";"KalenderFahrplanversion";"IDArtFahrplanversion_FS";"dbo";"ArtFahrplanVersion";"IDArtFahrplanVersion"
|
||||||
|
"FK_KalenderLaufzeitschema_ArtLaufzeitschema";"dbo";"KalenderLaufzeitschema";"IDArtLaufzeitschema_FS";"dbo";"ArtLaufzeitschema";"IDArtLaufzeitschema"
|
||||||
|
"FK_KalenderSondergruppen_ArtGruppeVerkehrstag";"dbo";"KalenderSondergruppen";"IDArtSondergruppen_FS";"dbo";"ArtGruppeVerkehrstag";"IDArtGruppeVerkehrstag"
|
||||||
|
"FK_KalenderWochentypen_ArtWochentyp";"dbo";"KalenderWochentypen";"IDArtWochentyp_FS";"dbo";"ArtWochentyp";"IDArtWochentyp"
|
||||||
|
"FK_KalkulationBeförderung_Kalkulation";"dbo";"KalkulationBeförderung";"IDKalkulation_FS";"dbo";"Kalkulation";"IDKalkulation"
|
||||||
|
"FK_KalkulationBeförderungLand_KalkulationBeförderung";"dbo";"KalkulationBeförderungLand";"IDKalkulationBeförderung_FS";"dbo";"KalkulationBeförderung";"IDKalkulationBeförderung"
|
||||||
|
"FK_KalkulationBusleistung_Kalkulation";"dbo";"KalkulationBusleistung";"IDKalkulation_FS";"dbo";"Kalkulation";"IDKalkulation"
|
||||||
|
"FK_KalkulationBusleistungPreis_KalkulationBusleistung";"dbo";"KalkulationBusleistungPreis";"IDKalkulationBusleistung_FS";"dbo";"KalkulationBusleistung";"IDKalkulationBusleistung"
|
||||||
|
"FK_KalkulationBusleistungPreis_Kalkulationswertbezeichnung";"dbo";"KalkulationBusleistungPreis";"IDKalkulationswertbezeichnung_FS";"dbo";"KalkulationswertBezeichnung";"IDKalkulationswertBezeichnung"
|
||||||
|
"FK_KalkulationErgebnis_Kalkulation";"dbo";"KalkulationErgebnis";"IDKalkulation_FS";"dbo";"Kalkulation";"IDKalkulation"
|
||||||
|
"FK_KalkulationErgebnisM_Kalkulation";"dbo";"KalkulationErgebnisM";"IDKalkulation_FS";"dbo";"Kalkulation";"IDKalkulation"
|
||||||
|
"FK_KalkulationErgebnisZimmerPreis_KalkulationErgebnis";"dbo";"KalkulationErgebnisZimmerPreis";"IDKalkulationErgebnis_FS";"dbo";"KalkulationErgebnis";"IDKalkulationErgebnis"
|
||||||
|
"FK_KalkulationErgebnisZimmerPreis_Zimmerart";"dbo";"KalkulationErgebnisZimmerPreis";"IDZimmerart_FS";"dbo";"Zimmerart";"IDZimmerart"
|
||||||
|
"FK_KalkulationFZAusgrenzung_KalkulationFZ";"dbo";"KalkulationFZAusgrenzung";"IDKalkulationFZ_FS";"dbo";"KalkulationFZ";"IDKalkulationFZ"
|
||||||
|
"FK_KalkulationFZBeförderung_KalkulationFZ";"dbo";"KalkulationFZBeförderung";"IDKalkulationFZ_FS";"dbo";"KalkulationFZ";"IDKalkulationFZ"
|
||||||
|
"FK_KalkulationFZErgebnis_ArtPartnerPreisCode";"dbo";"KalkulationFZErgebnis";"IdArtPartnerPreisCode_FS";"dbo";"ArtPartnerPreisCode";"IDArtPartnerPreisCode"
|
||||||
|
"FK_KalkulationFZErgebnis_ArtVerpflegung";"dbo";"KalkulationFZErgebnis";"IDArtVerpflegung_FS";"dbo";"ArtVerpflegung";"IDArtVerpflegung"
|
||||||
|
"FK_KalkulationFZErgebnis_KalkulationFZ";"dbo";"KalkulationFZErgebnis";"IDKalkulationFZ_FS";"dbo";"KalkulationFZ";"IDKalkulationFZ"
|
||||||
|
"FK_KalkulationFZTag_KalkulationFZ";"dbo";"KalkulationFZTag";"IDKalkulationFZ_FS";"dbo";"KalkulationFZ";"IDKalkulationFZ"
|
||||||
|
"FK_KalkulationPax_Kalkulation";"dbo";"KalkulationPax";"IDKalkulation_FS";"dbo";"Kalkulation";"IDKalkulation"
|
||||||
|
"FK_KalkulationReise_Kalkulation";"dbo";"KalkulationReise";"IDKalkulation_FS";"dbo";"Kalkulation";"IDKalkulation"
|
||||||
|
"FK_KalkulationReise_Reise";"dbo";"KalkulationReise";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_KalkulationSonstiges_Kalkulation";"dbo";"KalkulationSonstiges";"IDKalkulation_FS";"dbo";"Kalkulation";"IDKalkulation"
|
||||||
|
"FK_KalkulationSonstigesPreis_KalkulationSonstiges";"dbo";"KalkulationSonstigesPreis";"IDKalkulationSonstiges_FS";"dbo";"KalkulationSonstiges";"IDKalkulationSonstiges"
|
||||||
|
"FK_KalkulationUnterbringung_Kalkulation";"dbo";"KalkulationUnterbringung";"IDKalkulation_FS";"dbo";"Kalkulation";"IDKalkulation"
|
||||||
|
"FK_KalkulationUnterbringungM_Kalkulation";"dbo";"KalkulationUnterbringungM";"IDKalkulation_FS";"dbo";"Kalkulation";"IDKalkulation"
|
||||||
|
"FK_KalkulationUnterbringungMPreis_KalkulationUnterbringungM";"dbo";"KalkulationUnterbringungMPreis";"IDKalkulationUnterbringungM_FS";"dbo";"KalkulationUnterbringungM";"IDKalkulationUnterbringungM"
|
||||||
|
"FK_KalkulationUnterbringungPartner_Adresse";"dbo";"KalkulationUnterbringungPartner";"IDAdresse_FS_Partner";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_KalkulationUnterbringungPartner_KalkulationUnterbringung";"dbo";"KalkulationUnterbringungPartner";"IDKalkulationUnterbringung_FS";"dbo";"KalkulationUnterbringung";"IDKalkulationUnterbringung"
|
||||||
|
"FK_KalkulationUnterbringungPreis_KalkulationUnterbringung";"dbo";"KalkulationUnterbringungPreis";"IDKalkulationUnterbringung_FS";"dbo";"KalkulationUnterbringung";"IDKalkulationUnterbringung"
|
||||||
|
"FK_KalkulationUnterbringungPreis_KalkulationUnterbringungPartner";"dbo";"KalkulationUnterbringungPreis";"IDKalkulationUnterbringungPartner_FS";"dbo";"KalkulationUnterbringungPartner";"IDKalkulationUnterbringungPartner"
|
||||||
|
"FK_KalkulationUnterbringungPreis_KalkulationUnterbringungZimmer";"dbo";"KalkulationUnterbringungPreis";"IDKalkulationUnterbringungZimmer_FS";"dbo";"KalkulationUnterbringungZimmer";"IDKalkulationUnterbringungZimmer"
|
||||||
|
"FK_KalkulationUnterbringungZimmer_KalkulationUnterbringung";"dbo";"KalkulationUnterbringungZimmer";"IDKalkulationUnterbringung_FS";"dbo";"KalkulationUnterbringung";"IDKalkulationUnterbringung"
|
||||||
|
"FK_KalkulationUnterbringungZimmer_Zimmerart";"dbo";"KalkulationUnterbringungZimmer";"IDZimmerart_FS";"dbo";"Zimmerart";"IDZimmerart"
|
||||||
|
"FK_KalkulationUnterbringungZimmerPreis_KalkulationUnterbringung";"dbo";"KalkulationUnterbringungZimmerPreis";"IDKalkulationUnterbringung_FS";"dbo";"KalkulationUnterbringung";"IDKalkulationUnterbringung"
|
||||||
|
"FK_KalkulationUnterbringungZimmerPreis_KalkulationUnterbringungZimmer";"dbo";"KalkulationUnterbringungZimmerPreis";"IDKalkulationUnterbringungZimmerPreis";"dbo";"KalkulationUnterbringungZimmer";"IDKalkulationUnterbringungZimmer"
|
||||||
|
"FK_KalkulationZuschlag_Kalkulation";"dbo";"KalkulationZuschlag";"IDKalkulation_FS";"dbo";"Kalkulation";"IDKalkulation"
|
||||||
|
"FK_KalkulationZuschlagPosten_KalkulationZuschlag";"dbo";"KalkulationZuschlagPosten";"IDKalkulationZuschlag_FS";"dbo";"KalkulationZuschlag";"IDKalkulationZuschlag"
|
||||||
|
"FK_KommunikationEinstellung_Kommunikation";"dbo";"KommunikationEinstellung";"IDKommunikation_FS";"dbo";"Kommunikation";"IDKommunikation"
|
||||||
|
"FK_KonstantenErmäßigungProdukt_KonstantenErmäßigung";"dbo";"KonstantenErmäßigungProdukt";"IDKonstantenErmäßigungStaffel_FS";"dbo";"KonstantenErmäßigungStaffel";"IDKonstantenErmäßigungStaffel"
|
||||||
|
"FK_KonstantenErmäßigungProdukt_Produkt";"dbo";"KonstantenErmäßigungProdukt";"IDProdukt_FS";"dbo";"Produkt";"IDProdukt"
|
||||||
|
"FK_KonstantenErmäßigungStaffel_KonstantenErmäßigung";"dbo";"KonstantenErmäßigungStaffel";"IDKonstantenErmäßigung_FS";"dbo";"KonstantenErmäßigung";"IDKonstantenErmäßigung"
|
||||||
|
"FK_KonstantenErmäßigungZimmer_KonstantenErmäßigung";"dbo";"KonstantenErmäßigungZimmer";"IDKonstantenErmäßigungStaffel_FS";"dbo";"KonstantenErmäßigungStaffel";"IDKonstantenErmäßigungStaffel"
|
||||||
|
"FK_KonstantenErmäßigungZimmer_Zimmerart";"dbo";"KonstantenErmäßigungZimmer";"IDZimmerart_FS";"dbo";"Zimmerart";"IDZimmerart"
|
||||||
|
"FK_KonstantenErmäßigungZustieg_KonstantenErmäßigung";"dbo";"KonstantenErmäßigungZustieg";"IDKonstantenErmäßigungStaffel_FS";"dbo";"KonstantenErmäßigungStaffel";"IDKonstantenErmäßigungStaffel"
|
||||||
|
"FK_KonstantenErmäßigungZustieg_Zustieg";"dbo";"KonstantenErmäßigungZustieg";"IDZustieg_FS";"dbo";"Zustieg";"IDZustieg"
|
||||||
|
"FK_KonstantenFlexTarifStaffel_KonstantenFlexTarif";"dbo";"KonstantenFlexTarifStaffel";"IDKonstantenFlexTarif_FS";"dbo";"KonstantenFlexTarif";"IDKonstantenFlexTarif"
|
||||||
|
"FK_KonstantenFlexTarifStaffel_Leistung";"dbo";"KonstantenFlexTarifStaffel";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_KonstantenGruppenermStaffel_KonstantenGruppenerm";"dbo";"KonstantenGruppenermStaffel";"IDKonstantenGruppenerm_FS";"dbo";"KonstantenGruppenerm";"IDKonstantenGruppenerm"
|
||||||
|
"FK_KonstantenRabattStaffelEingabe_KonstantenRabattStaffel";"dbo";"KonstantenRabattStaffelEingabe";"IDKonstantenRabattStaffel_FS";"dbo";"KonstantenRabattStaffel";"IDKonstantenRabattStaffel"
|
||||||
|
"FK_KonstantenStAuftZuschlag_Land";"dbo";"KonstantenStAuftZuschlag";"IDLand_FS";"dbo";"Land";"IDLand"
|
||||||
|
"FK_KonstantenSteueraufteilung_SteueraufteilungArt";"dbo";"KonstantenSteueraufteilung";"IDSteueraufteilungArt_FS";"dbo";"SteueraufteilungArt";"IDSteueraufteilungArt"
|
||||||
|
"FK_KonstantenSteueraufteilungDetail_KonstantenSteueraufteilung";"dbo";"KonstantenSteueraufteilungDetail";"IDKonstantenSteueraufteilung_FS";"dbo";"KonstantenSteueraufteilung";"IDKonstantenSteueraufteilung"
|
||||||
|
"FK_KonstantenSteueraufteilungDetail_Steueraufteilung";"dbo";"KonstantenSteueraufteilungDetail";"IDSteueraufteilungDetail_FS";"dbo";"SteueraufteilungDetail";"IDSteueraufteilungDetail"
|
||||||
|
"FK_KonstantenStornoStaffelTage_KonstantenStornoStaffel";"dbo";"KonstantenStornoStaffelTage";"IDKonstantenStornoStaffel_FS";"dbo";"KonstantenStornoStaffel";"IDKonstantenStornoStaffel"
|
||||||
|
"FK_ArtikelLagerort_Artikel";"dbo";"LagerRegal";"IDLager_FS";"dbo";"Lager";"IDLager"
|
||||||
|
"FK_LandFiskal_Land";"dbo";"LandFiskal";"IDLand_Fs";"dbo";"Land";"IDLand"
|
||||||
|
"FK_LandHinweis_Land";"dbo";"LandHinweis";"IDLand_FS";"dbo";"Land";"IDLand"
|
||||||
|
"FK_LandKonto_Land";"dbo";"LandKonto";"IDLand_FS";"dbo";"Land";"IDLand"
|
||||||
|
"FK_LandMwst_Land";"dbo";"LandMwst";"IDLand_FS";"dbo";"Land";"IDLand"
|
||||||
|
"FK_LandVerpflegungSpesen_ArtSpesenLand";"dbo";"LandVerpflegungSpesen";"IDArtSpesenLand_FS";"dbo";"ArtSpesenLand";"IDArtSpesenLand"
|
||||||
|
"FK_LandVerpflegungSpesen_ArtSpesenVerpflegung";"dbo";"LandVerpflegungSpesen";"IDArtSpesenVerpflegung_FS";"dbo";"ArtSpesenVerpflegung";"IDArtSpesenVerpflegung"
|
||||||
|
"FK_Leistung_ArtLeistungSonstigesUnterart";"dbo";"Leistung";"IDArtLeistungSonstigesUnterart_FS";"dbo";"ArtLeistungSonstigesUnterart";"IDArtLeistungSonstigesUnterart"
|
||||||
|
"FK_Leistung_LeistungBefoerderung";"dbo";"LeistungBefoerderung";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_Leistung_LeistungExternSystem";"dbo";"LeistungExternSystem";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_LeistungHotelMoeglich_Adresse";"dbo";"LeistungHotelGesperrt";"IDAdresse_FS_Partner";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_LeistungHotelMoeglich_LeistungBefoerderung";"dbo";"LeistungHotelGesperrt";"IDLeistungBefoerderung_FS";"dbo";"LeistungBefoerderung";"IDLeistungBefoerderung"
|
||||||
|
"FK_Adresse_LeistungMandantMoeglich";"dbo";"LeistungMandantMoeglich";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Leistung_LeistungMandantMoeglich";"dbo";"LeistungMandantMoeglich";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_Adresse_LeistungPartnerMoeglich";"dbo";"LeistungPartnerMoeglich";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Leistung_LeistungPartnerMoeglich";"dbo";"LeistungPartnerMoeglich";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_LeistungPreis_ArtPreisschema";"dbo";"LeistungPreis";"IDArtPreisSchema_FS";"dbo";"ArtPreisschema";"IDArtPreisschema"
|
||||||
|
"FK_LeistungPreis_Leistung";"dbo";"LeistungPreis";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_LeistungProdukt_Leistung";"dbo";"LeistungProdukt";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_LeistungProdukt_Produkt";"dbo";"LeistungProdukt";"IDProdukt_FS";"dbo";"Produkt";"IDProdukt"
|
||||||
|
"FK_Leistung_LeistungSelektion";"dbo";"LeistungSelektionenKunde";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_Selektionsstamm_LeistungSelektion";"dbo";"LeistungSelektionenKunde";"IDSelektionsstamm_FS";"dbo";"Selektionsstamm";"IDSelektionsstamm"
|
||||||
|
"FK_LeistungSitzplan_Leistung";"dbo";"LeistungSitzplan";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_LeistungSitzplan_Sitzplan";"dbo";"LeistungSitzplan";"IDSitzplan_FS";"dbo";"Sitzplan";"IDSitzplan"
|
||||||
|
"FK_Leistung_LeistungText";"dbo";"LeistungText";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_Leistung_LeistungTicket";"dbo";"LeistungTicket";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_LeistungTicketZusatz_LeistungTicket";"dbo";"LeistungTicketZusatz";"IDLeistungTicket_FS";"dbo";"LeistungTicket";"IDLeistungTicket"
|
||||||
|
"FK_LeistungVersicherung_Leistung";"dbo";"LeistungVersicherung";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_LeistungVersicherung_Versicherungsbedingung_Leistung";"dbo";"LeistungVersicherung_Versicherungsbedingung";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_LeistungVersicherung_Versicherungsbedingung_Versicherungsbedingung";"dbo";"LeistungVersicherung_Versicherungsbedingung";"IDVersicherungsbedingung_FS";"dbo";"Versicherungsbedingung";"IDVersicherungsbedingung"
|
||||||
|
"FK_LeistungVersicherungsGruppe_ArtVersicherungsGruppe";"dbo";"LeistungVersicherungsGruppe";"IDArtVersicherungsGruppe_FS";"dbo";"ArtVersicherungsGruppe";"IDArtVersicherungsGruppe"
|
||||||
|
"FK_LeistungVersicherungsGruppe_LeistungVersicherung";"dbo";"LeistungVersicherungsGruppe";"IDLeistungVersicherung_FS";"dbo";"LeistungVersicherung";"IDLeistungVersicherung"
|
||||||
|
"FK_Leistung_LeistungVoucher";"dbo";"LeistungVoucher";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_LeistungZusatz_ArtAgenturPreisgruppe";"dbo";"LeistungZusatz";"IDArtAgenturPreisgruppe_FS";"dbo";"ArtAgenturPreisgruppe";"IDArtAgenturPreisgruppe"
|
||||||
|
"FK_LeistungZusatz_KonstantenSteueraufteilung";"dbo";"LeistungZusatz";"IDKonstantenSteueraufteilung_FS";"dbo";"KonstantenSteueraufteilung";"IDKonstantenSteueraufteilung"
|
||||||
|
"FK_LeistungZusatz_Leistung";"dbo";"LeistungZusatz";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_LeistungZustiegMoeglich_LeistungBefoerderung";"dbo";"LeistungZustiegMoeglichGesperrt";"IDLeistungBefoerderung_FS";"dbo";"LeistungBefoerderung";"IDLeistungBefoerderung"
|
||||||
|
"FK_Zustieg_LeistungZustiegMoeglich";"dbo";"LeistungZustiegMoeglichGesperrt";"IDZustieg_FS";"dbo";"Zustieg";"IDZustieg"
|
||||||
|
"FK_LiniePlanungEinstellung_Adresse";"dbo";"LiniePlanungEinstellung";"IDAdresse_FS_Personal";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_LiniePlanungEinstellung_ArtPersonalGruppeKompetenz";"dbo";"LiniePlanungEinstellung";"IDArtPersonalGruppeKompetenz_FS";"dbo";"ArtPersonalGruppeKompetenz";"IDArtPersonalGruppeKompetenz"
|
||||||
|
"FK_LinieStatistikAufteilungSpalteZuordnung_ArtStatistikGruppe";"dbo";"LinieStatistikAufteilungSpalteZuordnung";"IDArtStatistikGruppe_FS";"dbo";"ArtStatistikGruppe";"IDArtStatistikGruppe"
|
||||||
|
"FK_LinieStatistikAufteilungSpalteZuordnung_Auftragsart";"dbo";"LinieStatistikAufteilungSpalteZuordnung";"IDAuftragsart_FS";"dbo";"Auftragsart";"IDAuftragsart"
|
||||||
|
"FK_LinieStatistikAufteilungSpalteZuordnung_LinieStatistikAufteilungSpalte";"dbo";"LinieStatistikAufteilungSpalteZuordnung";"IDLinieStatistikAufteilungSpalte_FS";"dbo";"LinieStatistikAufteilungSpalte";"IDLinieStatistikAufteilungSpalte"
|
||||||
|
"FK_LinieStatistikSummeSpalte_ArtLinieStatistikSumme";"dbo";"LinieStatistikSummeSpalte";"IDArtLinieStatistikSumme_FS";"dbo";"ArtLinieStatistikSumme";"IDArtLinieStatistikSumme"
|
||||||
|
"FK_LinieStatistikSummeSpalteZuordnung_ArtStatistikGruppe";"dbo";"LinieStatistikSummeSpalteZuordnung";"IDArtStatistikGruppe_FS";"dbo";"ArtStatistikGruppe";"IDArtStatistikGruppe"
|
||||||
|
"FK_LinieStatistikSummeSpalteZuordnung_Auftragsart";"dbo";"LinieStatistikSummeSpalteZuordnung";"IDAuftragsart_FS";"dbo";"Auftragsart";"IDAuftragsart"
|
||||||
|
"FK_LinieStatistikSummeSpalteZuordnung_LinieStatistikSummeSpalte";"dbo";"LinieStatistikSummeSpalteZuordnung";"IDLinieStatistikSummeSpalte_FS";"dbo";"LinieStatistikSummeSpalte";"IDLinieStatistikSummeSpalte"
|
||||||
|
"FK_LinieWegPunkteGM_Haltestelle";"dbo";"LinieWegPunkteGM";"IDHaltestelle_FS_von";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_LinieWegPunkteGM_Haltestelle1";"dbo";"LinieWegPunkteGM";"IDHaltestelle_FS_nach";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_LinieZiel_ArtLinieZielSonderzeichen";"dbo";"LinieZiel";"IDArtLinieZielSonderzeichen_FS";"dbo";"ArtLinieZielSonderzeichen";"IDArtLinieZielSonderzeichen"
|
||||||
|
"FK_LinieZusatzKilometer_ArtStatistikGruppe";"dbo";"LinieZusatzKilometer";"IDArtStatistikgruppe_FS";"dbo";"ArtStatistikGruppe";"IDArtStatistikGruppe"
|
||||||
|
"FK_LinieZusatzKilometer_Auftragsart";"dbo";"LinieZusatzKilometer";"IDAuftragsart_FS";"dbo";"Auftragsart";"IDAuftragsart"
|
||||||
|
"FK_LinieZusatzKilometer_Fahrzeug";"dbo";"LinieZusatzKilometer";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_ListenterminalEinstellung_Adresse";"dbo";"ListenterminalEinstellung";"IDAdresse_FS_Personal";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_ListenterminalEinstellung_ArtPersonalGruppeKompetenz";"dbo";"ListenterminalEinstellung";"IDArtPersonalGruppeKompetenz_FS";"dbo";"ArtPersonalGruppeKompetenz";"IDArtPersonalGruppeKompetenz"
|
||||||
|
"FK_Lohnart_ArtLohnsystem";"dbo";"Lohnart";"IDArtLohnsystem_FS";"dbo";"ArtLohnsystem";"IDArtLohnsystem"
|
||||||
|
"FK_LohnartPersonalSperrung_Adresse";"dbo";"LohnartPersonalSperrung";"IDAdresse_FS_Fahrer";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_LohnartPersonalSperrung_Lohnart";"dbo";"LohnartPersonalSperrung";"IDLohnart_FS";"dbo";"Lohnart";"IDLohnart"
|
||||||
|
"FK_LohnartTag_Lohnart";"dbo";"LohnartTag";"IDLohnart_FS";"dbo";"Lohnart";"IDLohnart"
|
||||||
|
"FK_LohnDruckLobuPersonalSperrung_Adresse";"dbo";"LohnDruckLobuPersonalSperrung";"IDAdresse_FS_Fahrer";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_LohnDruckLobuPersonalSperrung_ArtLohnDruckLobu";"dbo";"LohnDruckLobuPersonalSperrung";"IDArtLohnDruckLobu_FS";"dbo";"ArtLohnDruckLobu";"IDArtLohnDruckLobu"
|
||||||
|
"FK_Lohnextra_Adresse";"dbo";"Lohnextra";"IDAdresse_FS_Personal";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Lohnextra_ArtLohnextra";"dbo";"Lohnextra";"IDArtLohnextra_FS";"dbo";"ArtLohnextra";"IDArtLohnextra"
|
||||||
|
"FK_LohnParameter_Adresse";"dbo";"LohnParameter";"IDAdresse_FS_Fahrer";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_LohnParameter_ArtLohnsystem";"dbo";"LohnParameter";"IDArtLohnsystem_FS";"dbo";"ArtLohnsystem";"IDArtLohnsystem"
|
||||||
|
"FK_LohnStundenkonto_Adresse";"dbo";"LohnStundenkonto";"IDAdresse_FS_Fahrer";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_LohnStundenkonto_ArtLohnStundenkonto";"dbo";"LohnStundenkonto";"IDArtLohnStundenkonto_FS";"dbo";"ArtLohnStundenkonto";"IDArtLohnStundenkonto"
|
||||||
|
"FK_LohnsystemPersonalSperrung_Adresse";"dbo";"LohnsystemPersonalSperrung";"IDAdresse_FS_Fahrer";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_LohnsystemPersonalSperrung_ArtLohnsystem";"dbo";"LohnsystemPersonalSperrung";"IDArtLohnsystem_FS";"dbo";"ArtLohnsystem";"IDArtLohnsystem"
|
||||||
|
"FK_LohnTagesdaten_Adresse";"dbo";"LohnTagesdaten";"IDAdresse_FS_Personal";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_LohnUebergabe_Adresse";"dbo";"LohnUebergabe";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_LohnUebergabe_ArtLohnsystem";"dbo";"LohnUebergabe";"IDArtLohnsystem_FS";"dbo";"ArtLohnsystem";"IDArtLohnsystem"
|
||||||
|
"FK_MandantZuordnungZahlung_Adresse";"dbo";"MandantZuordnungKontenplan";"IDAdresse_FS_Mandant";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_MandantZuordnungZahlung_ZahlungCode";"dbo";"MandantZuordnungKontenplan";"IDMandantZuordnung_FS";"dbo";"Kontenplan";"IDKontenplan"
|
||||||
|
"FK_MandantZuordnungReise_Adresse";"dbo";"MandantZuordnungReise";"IDAdresse_FS_Mandant";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_MandantZuordnungReise_Reise";"dbo";"MandantZuordnungReise";"IDMandantZuordnung_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_MandantZuordnungZustieg_Adresse";"dbo";"MandantZuordnungZustieg";"IDAdresse_FS_Mandant";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_MandantZuordnungZustieg_Zustieg";"dbo";"MandantZuordnungZustieg";"IDMandantZuordnung_FS";"dbo";"Zustieg";"IDZustieg"
|
||||||
|
"FK_MitgliedAdresse_ArtMitgliedsstufen";"dbo";"MitgliedsstufeAdresse";"IDArtMitgliedsstufe_FS";"dbo";"ArtMitgliedsstufe";"IDArtMitgliedsstufe"
|
||||||
|
"FK_MitgliedAdresse_ArtZahlungsart";"dbo";"MitgliedsstufeAdresse";"IDArtZahlungsart_FS";"dbo";"ArtZahlungsart";"IDArtZahlungsart"
|
||||||
|
"FK_MitgliedsstufeAdresse_Adresse";"dbo";"MitgliedsstufeAdresse";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_MitgliedsEinstellungBetrag_ArtMitgliedsstufen";"dbo";"MitgliedsstufeBetrag";"IDArtMitgliedsstufe_FS";"dbo";"ArtMitgliedsstufe";"IDArtMitgliedsstufe"
|
||||||
|
"FK_MitgliedVorteilBezeichnungArtMitgliedsstufe_ArtMitgliedsstufe";"dbo";"MitgliedsVorteilBezeichnungArtMitgliedsstufe";"IDArtMitgliedsstufe_FS";"dbo";"ArtMitgliedsstufe";"IDArtMitgliedsstufe"
|
||||||
|
"FK_Newsletter_Adresse";"dbo";"Newsletter";"IDAdresse_FS_ErsterSB";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Newsletter_Adresse1";"dbo";"Newsletter";"IDAdresse_FS_LetzterSB";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_NewsletterAdressat_Adresse";"dbo";"NewsletterAdressat";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_NewsletterAdressat_Newsletter";"dbo";"NewsletterAdressat";"IDNewsletter_FS";"dbo";"Newsletter";"IDNewsletter"
|
||||||
|
"FK_NewsletterAdressat_NewsletterVersand";"dbo";"NewsletterAdressat";"IDNewsletterVersand_FS";"dbo";"NewsletterVersand";"IDNewsletterVersand"
|
||||||
|
"FK_NewsletterBlock_Newsletter";"dbo";"NewsletterBlock";"IDNewsletter_FS";"dbo";"Newsletter";"IDNewsletter"
|
||||||
|
"FK_NewsletterVersand_Adresse";"dbo";"NewsletterVersand";"IDAdresse_FS_Sachbearbeiter";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_NewsletterVersand_Newsletter";"dbo";"NewsletterVersand";"IDNewsletter_FS";"dbo";"Newsletter";"IDNewsletter"
|
||||||
|
"FK_NotizFahrerFahrzeug_ArtNotizart";"dbo";"NotizFahrerFahrzeug";"IDArtNotizart_FS";"dbo";"ArtNotizart";"IDArtNotizart"
|
||||||
|
"FK_NummernVerwaltung_Nummernkreis";"dbo";"NummernVerwaltung";"IDNummernkreis_FS";"dbo";"Nummernkreis";"IDNummernkreis"
|
||||||
|
"FK_OrtBeziehung_Ort";"dbo";"OrtBeziehung";"IDOrt_FS_Ort1";"dbo";"Ort";"IDOrt"
|
||||||
|
"FK_OrtBeziehung_Ort1";"dbo";"OrtBeziehung";"IDOrt_FS_Ort2";"dbo";"Ort";"IDOrt"
|
||||||
|
"FK_PartnerBefoederungsvertragspreis_Adresse";"dbo";"PartnerBefoederungsvertragspreis";"IDAdresse_FS_Partner";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_PartnerDokument_Adresse";"dbo";"PartnerDokument";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_PartnerGebiet_Land";"dbo";"PartnerGebiet";"IDLand_FS";"dbo";"Land";"IDLand"
|
||||||
|
"FK_PartnerGebietPreis_PartnerGebiet";"dbo";"PartnerGebietPreis";"IDPartnergebiet_FS";"dbo";"PartnerGebiet";"IDPartnergebiet"
|
||||||
|
"FK_PartnerKontingent_Adresse";"dbo";"PartnerKontingent";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_PartnerLeistung_Adresse";"dbo";"PartnerLeistung";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_PartnerLeistungPreis_PartnerLeistung";"dbo";"PartnerLeistungPreis";"IDPartnerLeistung_FS";"dbo";"PartnerLeistung";"IDPartnerLeistung"
|
||||||
|
"FK_PartnerLeistungPreis_PartnerSaisontafel";"dbo";"PartnerLeistungPreis";"IDPartnerSaisontafel_FS";"dbo";"PartnerSaisontafel";"IDPartnerSaisontafel"
|
||||||
|
"FK_PartnerPreis_ArtPreisschema";"dbo";"PartnerPreis";"IDPartnerSaisonTafel_FS";"dbo";"PartnerSaisontafel";"IDPartnerSaisontafel"
|
||||||
|
"FK_PartnerPreis_PartnerPreisKategorie";"dbo";"PartnerPreis";"IDPartnerPreisKategorie_FS";"dbo";"PartnerPreisKategorie";"IDPartnerPreisKategorie"
|
||||||
|
"FK_PartnerPreisKategorie_Adresse";"dbo";"PartnerPreisKategorie";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_PartnerPreisKategorie_ArtPartnerPreisCode";"dbo";"PartnerPreisKategorie";"IDArtPartnerPreisCode_FS";"dbo";"ArtPartnerPreisCode";"IDArtPartnerPreisCode"
|
||||||
|
"FK_PartnerPreisKategorie_ArtVerpflegung";"dbo";"PartnerPreisKategorie";"IDArtVerpflegung_FS";"dbo";"ArtVerpflegung";"IDArtVerpflegung"
|
||||||
|
"FK_PartnerProdukt_Adresse";"dbo";"PartnerProdukt";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_PartnerProdukt_Produkt";"dbo";"PartnerProdukt";"IDProdukt_FS";"dbo";"Produkt";"IDProdukt"
|
||||||
|
"FK_PartnerSaisontafel_Adresse";"dbo";"PartnerSaisontafel";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_PartnerSaisonzeit_Adresse";"dbo";"PartnerSaisonzeit";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_PartnerSaisonzeit_PartnerSaisontafel";"dbo";"PartnerSaisonzeit";"IDPartnerSaisonTafel_FS";"dbo";"PartnerSaisontafel";"IDPartnerSaisontafel"
|
||||||
|
"FK_Adresse_PartnerSelektion";"dbo";"PartnerSelektionenKunde";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Selektionsstamm_PartnerSelektion";"dbo";"PartnerSelektionenKunde";"IDSelektionsstamm_FS";"dbo";"Selektionsstamm";"IDSelektionsstamm"
|
||||||
|
"FK_PartnerZimmer_Adresse";"dbo";"PartnerZimmer";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_PartnerZimmer_ArtPartnerPreisCode";"dbo";"PartnerZimmer";"IDArtPartnerPreisCode_FS";"dbo";"ArtPartnerPreisCode";"IDArtPartnerPreisCode"
|
||||||
|
"FK_PartnerZimmer_Zimmerart";"dbo";"PartnerZimmer";"IDZimmerart_FS";"dbo";"Zimmerart";"IDZimmerart"
|
||||||
|
"FK_PartnerZusatz_Adresse";"dbo";"PartnerZusatz";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_PartnerZusatz_ArtAgenturPreisgruppe";"dbo";"PartnerZusatz";"IDArtAgenturPreisgruppe_FS";"dbo";"ArtAgenturPreisgruppe";"IDArtAgenturPreisgruppe"
|
||||||
|
"FK_PartnerZusatz_KonstantenSteueraufteilung";"dbo";"PartnerZusatz";"IDKonstantenSteueraufteilung_FS";"dbo";"KonstantenSteueraufteilung";"IDKonstantenSteueraufteilung"
|
||||||
|
"FK_PersonalGruppeDatenschutz_ArtPersonalGruppeKompetenz";"dbo";"PersonalGruppeDatenschutz";"IDArtPersonalGruppeKompetenz_FS";"dbo";"ArtPersonalGruppeKompetenz";"IDArtPersonalGruppeKompetenz"
|
||||||
|
"FK_PersonalGruppeDatenschutzDarstellung_ArtPersonalGruppeKompetenz";"dbo";"PersonalGruppeDatenschutzDarstellung";"IDArtPersonalGruppeKompetenz_FS";"dbo";"ArtPersonalGruppeKompetenz";"IDArtPersonalGruppeKompetenz"
|
||||||
|
"FK_PersonalGruppeDatenschutzLöschung_ArtPersonalGruppeKompetenz";"dbo";"PersonalGruppeDatenschutzLöschung";"IDArtPersonalGruppeKompetenz_FS";"dbo";"ArtPersonalGruppeKompetenz";"IDArtPersonalGruppeKompetenz"
|
||||||
|
"FK_PersonalGruppeKompetenz_ArtPersonalGruppeKompetenz";"dbo";"PersonalGruppeKompetenz";"IDArtPersonalGruppeKompetenz_FS";"dbo";"ArtPersonalGruppeKompetenz";"IDArtPersonalGruppeKompetenz"
|
||||||
|
"FK_Personalkapazitaet_Adresse";"dbo";"Personalkapazitaet";"IDAdresse_FS_Personal";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Personalkapazitaet_ArtPersonalkapazitaet";"dbo";"Personalkapazitaet";"IDArtPersonalkapazitaet_FS";"dbo";"ArtPersonalkapazitaet";"IDArtPersonalkapazitaet"
|
||||||
|
"FK_PersonalkapazitaetArbeitszeit_ArtPersonalkapazitaet";"dbo";"PersonalkapazitaetArbeitszeit";"IDArtPersonalkapazitaet_FS";"dbo";"ArtPersonalkapazitaet";"IDArtPersonalkapazitaet"
|
||||||
|
"FK_PersonalkapazitaetEingabe_Adresse";"dbo";"PersonalkapazitaetEingabe";"IDAdresse_FS_Personal";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_PersonalkapazitaetMindestbesetzung_ArtBetriebshof";"dbo";"PersonalkapazitaetMindestbesetzung";"IDArtBetriebshof_FS";"dbo";"ArtBetriebshof";"IDArtBetriebshof"
|
||||||
|
"FK_PersonalkapazitaetMindestbesetzung_ArtMindestbesetzungZeitraum";"dbo";"PersonalkapazitaetMindestbesetzung";"IDArtMindestbesetzungZeitraum_FS";"dbo";"ArtMindestbesetzungZeitraum";"IDArtMindestbesetzungZeitraum"
|
||||||
|
"FK_PersonalkapazitaetMindestbesetzungZeitraum_ArtMindestbesetzungZeitraum";"dbo";"PersonalkapazitaetMindestbesetzungZeitraum";"IDArtMindestbesetzungZeitraum_FS";"dbo";"ArtMindestbesetzungZeitraum";"IDArtMindestbesetzungZeitraum"
|
||||||
|
"FK_PlanungSoll_Adresse";"dbo";"PlanungSoll";"IDAdresse_FS_Fahrer";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_PlanungSoll_ArtPersonalkapazitaet";"dbo";"PlanungSoll";"IDArtPersonalkapazitaet_FS_Schema";"dbo";"ArtPersonalkapazitaet";"IDArtPersonalkapazitaet"
|
||||||
|
"FK_PlanungSollBlock_BlockPlanung";"dbo";"PlanungSollBlock";"IDBlockPlanung_FS";"dbo";"BlockPlanung";"IDBlockPlanung"
|
||||||
|
"FK_PlanungSollBlock_PlanungSoll";"dbo";"PlanungSollBlock";"IDPlanungSoll_FS";"dbo";"PlanungSoll";"IDPlanungSoll"
|
||||||
|
"FK_PlanungSollLohnarten_Lohnart";"dbo";"PlanungSollLohnart";"IDLohnart_FS";"dbo";"Lohnart";"IDLohnart"
|
||||||
|
"FK_PlanungSollLohnarten_PlanungSoll";"dbo";"PlanungSollLohnart";"IDPlanungSoll_FS";"dbo";"PlanungSoll";"IDPlanungSoll"
|
||||||
|
"FK_PortalBemerkung_PortalBuchung";"dbo";"PortalBemerkung";"IDPortalBuchung_FS";"dbo";"PortalBuchung";"IDPortalBuchung"
|
||||||
|
"FK_PortalLeistung_PortalBuchung";"dbo";"PortalLeistung";"IDPortalBuchung_FS";"dbo";"PortalBuchung";"IDPortalBuchung"
|
||||||
|
"FK_PortalLeistungTN_PortalLeistung";"dbo";"PortalLeistungTN";"IDPortalLeistung_FS";"dbo";"PortalLeistung";"IDPortalLeistung"
|
||||||
|
"FK_PortalLeistungTN_PortalTN";"dbo";"PortalLeistungTN";"IDPortalTN_FS";"dbo";"PortalTN";"IDPortalTN"
|
||||||
|
"FK_PortalLeistungZusatz_PortalBuchung";"dbo";"PortalLeistungZusatz";"IDPortalBuchung_FS";"dbo";"PortalBuchung";"IDPortalBuchung"
|
||||||
|
"FK_PortalTN_PortalBuchung";"dbo";"PortalTN";"IDPortalBuchung_FS";"dbo";"PortalBuchung";"IDPortalBuchung"
|
||||||
|
"FK_Produkt_KonstantenSteueraufteilung";"dbo";"Produkt";"IDKonstantenSteueraufteilung_FS";"dbo";"KonstantenSteueraufteilung";"IDKonstantenSteueraufteilung"
|
||||||
|
"FK_ProduktArtPersonalGruppe_ArtPersonalGruppeProdukt";"dbo";"ProduktArtPersonalGruppe";"IDArtPersonalGruppeProdukt_FS";"dbo";"ArtPersonalGruppeProdukt";"IDArtPersonalGruppeProdukt"
|
||||||
|
"FK_ProduktArtPersonalGruppe_Produkt";"dbo";"ProduktArtPersonalGruppe";"IDProdukt_FS";"dbo";"Produkt";"IDProdukt"
|
||||||
|
"FK_ProduktBeschreibungInhalt_ProduktBeschreibung";"dbo";"ProduktBeschreibungInhalt";"IDProduktBeschreibung_FS";"dbo";"ProduktBeschreibung";"IDProduktBeschreibung"
|
||||||
|
"FK_ProduktbeschreibungKategorie_ProduktBeschreibung";"dbo";"ProduktbeschreibungKategorie";"IDProduktbeschreibung_FS";"dbo";"ProduktBeschreibung";"IDProduktBeschreibung"
|
||||||
|
"FK_ProduktbeschreibungKategorie_WWSuchKategorie";"dbo";"ProduktbeschreibungKategorie";"IDWWSuchKategorie_FS";"dbo";"WWSuchKategorie";"IDWWSuchKategorie"
|
||||||
|
"FK_ProduktbeschreibungKategorie_WWSuchKategorieEintrag";"dbo";"ProduktbeschreibungKategorie";"IDWWSuchKategorieEintrag_FS";"dbo";"WWSuchKategorieEintrag";"IDWWSuchKategorieEintrag"
|
||||||
|
"FK_ProduktBeschreibungZusatz_ProduktBeschreibung";"dbo";"ProduktBeschreibungZusatz";"IDProduktBeschreibung_FS";"dbo";"ProduktBeschreibung";"IDProduktBeschreibung"
|
||||||
|
"FK_ProduktDokument_Produkt";"dbo";"ProduktDokument";"IDProdukt_FS";"dbo";"Produkt";"IDProdukt"
|
||||||
|
"FK_ProduktSachbearbeiter_Adresse";"dbo";"ProduktSachbearbeiter";"IDAdresse_FS_Personal";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_ProduktSachbearbeiter_Produkt";"dbo";"ProduktSachbearbeiter";"IDProdukt_FS";"dbo";"Produkt";"IDProdukt"
|
||||||
|
"FK_Produkt_ProduktText";"dbo";"ProduktText";"IDProdukt_FS";"dbo";"Produkt";"IDProdukt"
|
||||||
|
"FK_Qualitaet_ArtFahrplanVersion";"dbo";"Qualitaet";"IDArtFahrplanversion_FS";"dbo";"ArtFahrplanVersion";"IDArtFahrplanVersion"
|
||||||
|
"FK_QualitaetDefinition_ArtBlockArt";"dbo";"QualitaetDefinition";"IDArtBlockArt_FS";"dbo";"ArtBlockArt";"IDArtBlockArt"
|
||||||
|
"FK_QualitaetDefinition_ArtGruppeVerkehrstag";"dbo";"QualitaetDefinition";"IDArtGruppeVerkehrstag_FS";"dbo";"ArtGruppeVerkehrstag";"IDArtGruppeVerkehrstag"
|
||||||
|
"FK_QualitaetDefinition_Qualitaet";"dbo";"QualitaetDefinition";"IDQualitaet_FS";"dbo";"Qualitaet";"IDQualitaet"
|
||||||
|
"FK_QualitaetDefinitionAuftragsart_Auftragsart";"dbo";"QualitaetDefinitionAuftragsart";"IDAuftragsart_FS";"dbo";"Auftragsart";"IDAuftragsart"
|
||||||
|
"FK_QualitaetDefinitionAuftragsart_Qualitaet";"dbo";"QualitaetDefinitionAuftragsart";"IDQualitaet_FS";"dbo";"Qualitaet";"IDQualitaet"
|
||||||
|
"FK_QualitaetDefinitionFahrzeug_Fahrzeug";"dbo";"QualitaetDefinitionFahrzeug";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_QualitaetDefinitionFahrzeug_Qualitaet";"dbo";"QualitaetDefinitionFahrzeug";"IDQualitaet_FS";"dbo";"Qualitaet";"IDQualitaet"
|
||||||
|
"FK_QualitaetDefinitionLinie_Qualitaet";"dbo";"QualitaetDefinitionLinie";"IDQualitaet_FS";"dbo";"Qualitaet";"IDQualitaet"
|
||||||
|
"FK_QualitaetZuordnung_Qualitaet";"dbo";"QualitaetZuordnung";"IDQualitaet_FS";"dbo";"Qualitaet";"IDQualitaet"
|
||||||
|
"FK_QuittierungAusfahrt_AuftragFahrer";"dbo";"QuittierungAusfahrt";"IDAuftragFahrer_FS";"dbo";"AuftragFahrer";"IDAuftragFahrer"
|
||||||
|
"FK_QuittierungAusfahrt_BlockPlanung";"dbo";"QuittierungAusfahrt";"IDBlockPlanung_FS";"dbo";"BlockPlanung";"IDBlockPlanung"
|
||||||
|
"FK_Rechnung_AdresseAnschrift";"dbo";"Rechnung";"IDAdresseAnschrift_FS";"dbo";"AdresseAnschrift";"IDAdresseAnschrift"
|
||||||
|
"FK_Rechnung_ArtZahlungsart";"dbo";"Rechnung";"IDArtZahlungsart_FS";"dbo";"ArtZahlungsart";"IDArtZahlungsart"
|
||||||
|
"FK_Rechnung_RechnungHaupt";"dbo";"Rechnung";"IDRechnungHaupt_FS";"dbo";"RechnungHaupt";"IDRechnungHaupt"
|
||||||
|
"FK_RechnungBetrag_Rechnung";"dbo";"RechnungBetrag";"IDRechnung_FS";"dbo";"Rechnung";"IDRechnung"
|
||||||
|
"FK_RechnungBetragMahnung_RechnungBetrag";"dbo";"RechnungBetragMahnung";"IdRechnungBetrag_FS";"dbo";"RechnungBetrag";"IDRechnungBetrag"
|
||||||
|
"FK_RechnungBuchung_Buchung";"dbo";"RechnungBuchung";"IDBuchung_FS";"dbo";"Buchung";"IDBuchung"
|
||||||
|
"FK_RechnungBuchung_RechnungHaupt";"dbo";"RechnungBuchung";"IDRechnungHaupt_FS";"dbo";"RechnungHaupt";"IDRechnungHaupt"
|
||||||
|
"FK_RechnungHaupt_Adresse";"dbo";"RechnungHaupt";"IDAdresse_FS_Mandant";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_RechnungHaupt_Adresse1";"dbo";"RechnungHaupt";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_RechnungPosten_Rechnung";"dbo";"RechnungPosten";"IDRechnung_FS";"dbo";"Rechnung";"IDRechnung"
|
||||||
|
"FK_RechnungPosten_Teilnehmer_BuchungTNPreis";"dbo";"RechnungPosten_Teilnehmer";"IDBuchungTN_FS";"dbo";"BuchungTNPreis";"IDBuchungTNPreis"
|
||||||
|
"FK_RechnungPosten_Teilnehmer_RechnungPostenBetragTOU";"dbo";"RechnungPosten_Teilnehmer";"IDRechnungPostenBetragTOU_FS";"dbo";"RechnungPostenBetragTOU";"IDRechnungPostenBetragTOU"
|
||||||
|
"FK_RechnungPostenBetragALL_RechnungPosten";"dbo";"RechnungPostenBetragALL";"IDRechnungPosten_FS";"dbo";"RechnungPosten";"IDRechnungPosten"
|
||||||
|
"FK_RechnungPostenBetragTOU_RechnungPosten";"dbo";"RechnungPostenBetragTOU";"IDRechnungPosten_FS";"dbo";"RechnungPosten";"IDRechnungPosten"
|
||||||
|
"FK_RechnungPostenLinieAufteilungTour_RechnungPostenLinieAufteilung";"dbo";"RechnungPostenLinieAufteilungTour";"IDRechnungPostenLinieAufteilung_FS";"dbo";"RechnungPostenLinieAufteilung";"IDRechnungPostenLinieAufteilung"
|
||||||
|
"FK_RechnungPostenMietbusAufteilung_RechnungPostenBetragALL";"dbo";"RechnungPostenMietbusAufteilung";"IDRechnungPostenBetragAll_FS";"dbo";"RechnungPostenBetragALL";"IDRechnungPostenBetragALL"
|
||||||
|
"FK_RechnungSteueraufteilung_Rechnung";"dbo";"RechnungSteueraufteilung";"IDRechnung_FS";"dbo";"Rechnung";"IDRechnung"
|
||||||
|
"FK_ReisabBefoerderung_Reisab";"dbo";"ReisabBefoerderung";"IdReisab_FS";"dbo";"Reisab";"IdReisab"
|
||||||
|
"FK_ReisabLand_Land";"dbo";"ReisabLand";"IdLand_FS";"dbo";"Land";"IDLand"
|
||||||
|
"FK_ReisabLand_Reisab";"dbo";"ReisabLand";"IDReisab_FS";"dbo";"Reisab";"IdReisab"
|
||||||
|
"FK_ReisabLeistung_ArtReiseKosten";"dbo";"ReisabLeistung";"IdArtReiseKosten_FS";"dbo";"ArtReiseKosten";"IDArtReiseKosten"
|
||||||
|
"FK_ReisabLeistung_Reisab";"dbo";"ReisabLeistung";"IdReisab_FS";"dbo";"Reisab";"IdReisab"
|
||||||
|
"FK_ReisabReise_Reisab";"dbo";"ReisabReise";"IDReisab_FS";"dbo";"Reisab";"IdReisab"
|
||||||
|
"FK_ReisabReise_Reise";"dbo";"ReisabReise";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReisabUmsatz_Reisab";"dbo";"ReisabUmsatz";"IdReisab_FS";"dbo";"Reisab";"IdReisab"
|
||||||
|
"FK_Reise_Produkt";"dbo";"Reise";"IDProdukt_FS";"dbo";"Produkt";"IDProdukt"
|
||||||
|
"FK_ReiseBeleg_Reise";"dbo";"ReiseBeleg";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseBelegDokument_ReiseBeleg";"dbo";"ReiseBelegDokument";"IDReiseBeleg_FS";"dbo";"ReiseBeleg";"IDReiseBeleg"
|
||||||
|
"FK_ReiseBelegPosten_ReiseBeleg";"dbo";"ReiseBelegPosten";"IDReiseBeleg_FS";"dbo";"ReiseBeleg";"IDReiseBeleg"
|
||||||
|
"FK_ReiseDevise_ArtWaehrung";"dbo";"ReiseDevise";"IDArtWaehrung_FS";"dbo";"ArtWaehrung";"IDArtWaehrung"
|
||||||
|
"FK_ReiseDevise_Reise";"dbo";"ReiseDevise";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseDokument_Reise";"dbo";"ReiseDokument";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseFlexTarif_KonstantenFlexTarif";"dbo";"ReiseFlexTarif";"IDKonstantenFlexTarif_FS";"dbo";"KonstantenFlexTarif";"IDKonstantenFlexTarif"
|
||||||
|
"FK_ReiseFlexTarif_Reise";"dbo";"ReiseFlexTarif";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseGuides_ArtReiseGuides";"dbo";"ReiseGuides";"IDArtReiseGuides_FS";"dbo";"ArtReiseGuides";"IDArtReiseGuides"
|
||||||
|
"FK_ReiseKosten_Reise";"dbo";"ReiseKosten";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseLand_Land";"dbo";"ReiseLand";"IDLand_FS";"dbo";"Land";"IDLand"
|
||||||
|
"FK_ReiseLand_Reise";"dbo";"ReiseLand";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseLeistung_Leistung";"dbo";"ReiseLeistung";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_ReiseLeistung_Reise";"dbo";"ReiseLeistung";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseLeistungGruppe_Reise";"dbo";"ReiseLeistungGruppe";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseLeistungGruppeZuweisung_Leistung";"dbo";"ReiseLeistungGruppeZuweisung";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_ReiseLeistungGruppeZuweisung_ReiseLeistungGruppe";"dbo";"ReiseLeistungGruppeZuweisung";"IDReiseLeistungGruppe_FS";"dbo";"ReiseLeistungGruppe";"IDReiseLeistungGruppe"
|
||||||
|
"FK_ReiseleiterAbrechnung_Adresse";"dbo";"ReiseleiterAbrechnung";"IDAdresse_FS_Personal";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_ReiseleiterAbrechnung_Reise";"dbo";"ReiseleiterAbrechnung";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseleiterAbrechnung_ReiseleiterZuordnungStamm";"dbo";"ReiseleiterAbrechnung";"IDReiseleiterZuordnungStamm_FS";"dbo";"ReiseleiterZuordnungStamm";"IDReiseleiterZuordnungStamm"
|
||||||
|
"FK_ReiseleiterAbrechnungPosten_ReiseleiterAbrechnung";"dbo";"ReiseleiterAbrechnungPosten";"IDReiseleiterAbrechnung_FS";"dbo";"ReiseleiterAbrechnung";"IDReiseleiterAbrechnung"
|
||||||
|
"FK_ReiseleiterAbrechnungPosten_ReiseleiterPosten";"dbo";"ReiseleiterAbrechnungPosten";"IDReiseleiterPosten_FS";"dbo";"ReiseleiterPosten";"IDReiseleiterPosten"
|
||||||
|
"FK_ReiseleiterAbrechnungSpesen_ArtSpesenVerpflegung";"dbo";"ReiseleiterAbrechnungSpesen";"IDArtSpesenVerpflegung_FS";"dbo";"ArtSpesenVerpflegung";"IDArtSpesenVerpflegung"
|
||||||
|
"FK_ReiseleiterAbrechnungSpesen_ReiseleiterAbrechnung";"dbo";"ReiseleiterAbrechnungSpesen";"IDReiseleiterAbrechnung_FS";"dbo";"ReiseleiterAbrechnung";"IDReiseleiterAbrechnung"
|
||||||
|
"FK_ReiseleiterAbrechnungZuAbschlag_ReiseleiterAbrechnung";"dbo";"ReiseleiterAbrechnungZuAbschlag";"IDReiseleiterAbrechnung_FS";"dbo";"ReiseleiterAbrechnung";"IDReiseleiterAbrechnung"
|
||||||
|
"FK_ReiseleiterAbrVergütungStaffel_ReiseleiterAbrVergütungStamm1";"dbo";"ReiseleiterAbrVergütungStaffel";"IDReiseleiterAbrVergütungStamm_FS";"dbo";"ReiseleiterAbrVergütungStamm";"IDReiseleiterAbrVergütungStamm"
|
||||||
|
"FK_ReiseleiterAbrVergütungStaffelAnAbreise_ReiseleiterAbrVergütungStamm";"dbo";"ReiseleiterAbrVergütungStaffelAnAbreise";"IDReiseleiterAbrVergütungStamm_FS";"dbo";"ReiseleiterAbrVergütungStamm";"IDReiseleiterAbrVergütungStamm"
|
||||||
|
"FK_ReiseleiterArbeitszeit_ReiseleiterZuordnungReiseleiter";"dbo";"ReiseleiterArbeitszeit";"IDReiseleiterZuordnungReiseleiter_FS";"dbo";"ReiseleiterZuordnungReiseleiter";"IDReiseleiterZuordnungReiseleiter"
|
||||||
|
"FK_ReiseleiterHonorarStaffel_Adresse";"dbo";"ReiseleiterHonorarStaffel";"IDAdresse_FS_Personal";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_ReiseleiterHonorarStaffel_ArtReiseleiterHonorar";"dbo";"ReiseleiterHonorarStaffel";"IDArtReiseleiterHonorar_FS";"dbo";"ArtReiseleiterHonorar";"IDArtReiseleiterHonorar"
|
||||||
|
"FK_ReiseleiterSelektionen_Adresse";"dbo";"ReiseleiterSelektionen";"IDAdresse_FS_Personal";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_ReiseleiterSelektionen_Selektionsstamm";"dbo";"ReiseleiterSelektionen";"IDSelektionsstamm_FS";"dbo";"Selektionsstamm";"IDSelektionsstamm"
|
||||||
|
"FK_ReiseleiterSpesenBetrag_ArtSpesenLand";"dbo";"ReiseleiterSpesenBetrag";"IDArtSpesenLand_FS";"dbo";"ArtSpesenLand";"IDArtSpesenLand"
|
||||||
|
"FK_ReiseleiterSpesenBetrag_ReiseleiterSpesenStaffel";"dbo";"ReiseleiterSpesenBetrag";"IDReiseleiterSpesenStaffel_FS";"dbo";"ReiseleiterSpesenStaffel";"IDReiseleiterSpesenStaffel"
|
||||||
|
"FK_ReiseleiterStamm_Adresse";"dbo";"ReiseleiterStamm";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_ReiseleiterWunschReise_Adresse";"dbo";"ReiseleiterWunschReise";"IDAdresse_FS_Personal";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_ReiseleiterWunschReise_Reise";"dbo";"ReiseleiterWunschReise";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseleiterZuordnungReiseleiter_Adresse1";"dbo";"ReiseleiterZuordnungReiseleiter";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_ReiseleiterZuordnungReiseleiter_ReiseleiterZuordnungStamm";"dbo";"ReiseleiterZuordnungReiseleiter";"IDReiseleiterZuordnungStamm_FS";"dbo";"ReiseleiterZuordnungStamm";"IDReiseleiterZuordnungStamm"
|
||||||
|
"FK_ReiseleiterZuordnungZusatzleistung_Leistung";"dbo";"ReiseleiterZuordnungZusatzleistung";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_ReiseleiterZuordnungZusatzleistung_ReiseleiterZuordnungReiseleiter";"dbo";"ReiseleiterZuordnungZusatzleistung";"IDReiseleiterZuordnungReiseleiter_FS";"dbo";"ReiseleiterZuordnungReiseleiter";"IDReiseleiterZuordnungReiseleiter"
|
||||||
|
"FK_ReiseListenVersand_ArtListenart";"dbo";"ReiseListenVersand";"IdArtListenart_FS";"dbo";"ArtListenart";"IDArtListenArt"
|
||||||
|
"FK_ReiseListenVersand_ArtListenart1";"dbo";"ReiseListenVersand";"IdArtListenart_FS";"dbo";"ArtListenart";"IDArtListenArt"
|
||||||
|
"FK_ReiseListenVersand_DruckEinstellungEMail";"dbo";"ReiseListenVersand";"IdDruckeinstellungEmail_FS";"dbo";"DruckEinstellungEMail";"IDDruckEinstellungEMail"
|
||||||
|
"FK_ReiseListenVersand_Reise";"dbo";"ReiseListenVersand";"IdReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReisePartner_Adresse";"dbo";"ReisePartner";"IDAdresse_FS_Partner";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_ReisePartner_ArtVerpflegung";"dbo";"ReisePartner";"IDArtVerpflegung_FS";"dbo";"ArtVerpflegung";"IDArtVerpflegung"
|
||||||
|
"FK_ReisePartner_Reise";"dbo";"ReisePartner";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReisePersonalZimmer_Reise";"dbo";"ReisePersonalZimmer";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReisePersonalZimmer_Zimmerart";"dbo";"ReisePersonalZimmer";"IDZimmerart_FS";"dbo";"Zimmerart";"IDZimmerart"
|
||||||
|
"FK_ReiseSelektionen_Reise";"dbo";"ReiseSelektionen";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseSelektionen_Selektionsstamm";"dbo";"ReiseSelektionen";"IDSelektionsstamm_FS";"dbo";"Selektionsstamm";"IDSelektionsstamm"
|
||||||
|
"FK_ReiseSitzplan_Reise";"dbo";"ReiseSitzplan";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseSitzplan_Sitzplan";"dbo";"ReiseSitzplan";"IDSitzplan_FS";"dbo";"Sitzplan";"IDSitzplan"
|
||||||
|
"FK_ReiseSitzplatzPreis_ArtSitzplatzKategorie";"dbo";"ReiseSitzplatzPreis";"IDArtSitzplatzKategorie_FS";"dbo";"ArtSitzplatzKategorie";"IDArtSitzplatzKategorie"
|
||||||
|
"FK_ReiseSitzplatzPreis_Reise";"dbo";"ReiseSitzplatzPreis";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseStatistik_Reise";"dbo";"ReiseStatistik";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseStatistikVertrieb_ReiseStatistik";"dbo";"ReiseStatistikVertrieb";"IDReiseStatistik_FS";"dbo";"ReiseStatistik";"IDReiseStatistik"
|
||||||
|
"FK_ReiseStatus_Reise";"dbo";"ReiseStatus";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseStatusBuchhaltung_ArtReiseStatusBuchhaltung";"dbo";"ReiseStatusBuchhaltung";"IDArtReiseStatusBuchhaltung_FS";"dbo";"ArtReiseStatusBuchhaltung";"IDArtReiseStatusBuchhaltung"
|
||||||
|
"FK_ReiseStatusBuchhaltung_Reise";"dbo";"ReiseStatusBuchhaltung";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_Reise_ReiseText";"dbo";"ReiseText";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseTextbaustein_Reise";"dbo";"ReiseTextbaustein";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseTextbaustein_Textbaustein";"dbo";"ReiseTextbaustein";"IDTextbaustein_FS";"dbo";"Textbaustein";"IDTextbaustein"
|
||||||
|
"FK_ReiseTextbausteinAbwJeMandant_Mandant";"dbo";"ReiseTextbausteinAbwJeMandant";"IDAdresse_FS_Mandant";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_ReiseTextbausteinAbwJeMandant_Reise";"dbo";"ReiseTextbausteinAbwJeMandant";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseTextbausteinAbwJeMandant_Textbaustein";"dbo";"ReiseTextbausteinAbwJeMandant";"IDTextbaustein_FS";"dbo";"Textbaustein";"IDTextbaustein"
|
||||||
|
"FK_Reiseunterlage_Reise";"dbo";"Reiseunterlage";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_Reiseunterlage_ReiseunterlagenBaustein";"dbo";"Reiseunterlage";"IDReiseunterlagenBaustein_FS";"dbo";"ReiseunterlagenBaustein";"IDReiseunterlagenBaustein"
|
||||||
|
"FK_ReiseunterlageInhalt_Reiseunterlage";"dbo";"ReiseunterlageInhalt";"IDReiseunterlage_FS";"dbo";"Reiseunterlage";"IDReiseunterlage"
|
||||||
|
"FK_ReiseunterlagenBaustein_ReiseunterlagenBausteinTyp";"dbo";"ReiseunterlagenBaustein";"IDReiseunterlagenBausteinTyp_FS";"dbo";"ReiseunterlagenBausteinTyp";"IDReiseunterlagenBausteinTyp"
|
||||||
|
"FK_ReiseunterlagenBausteinBedingung_ReiseunterlagenBaustein";"dbo";"ReiseunterlagenBausteinBedingung";"IDReiseunterlagenBaustein_FS";"dbo";"ReiseunterlagenBaustein";"IDReiseunterlagenBaustein"
|
||||||
|
"FK_ReiseunterlagenBausteinBedingung_ReiseunterlagenBedingung";"dbo";"ReiseunterlagenBausteinBedingung";"IDReiseunterlagenBedingung_FS";"dbo";"ReiseunterlagenBedingung";"IDReiseunterlagenBedingung"
|
||||||
|
"FK_ReiseunterlagenBausteinInhalt_ReiseunterlagenBaustein";"dbo";"ReiseunterlagenBausteinInhalt";"IDReiseunterlagenBaustein_FS";"dbo";"ReiseunterlagenBaustein";"IDReiseunterlagenBaustein"
|
||||||
|
"FK_ReiseunterlagenVorlageBaustein_ReiseunterlagenBaustein";"dbo";"ReiseunterlagenVorlageBaustein";"IDReiseUnterlagenBaustein_FS";"dbo";"ReiseunterlagenBaustein";"IDReiseunterlagenBaustein"
|
||||||
|
"FK_ReiseunterlagenVorlageBaustein_ReiseunterlagenVorlage";"dbo";"ReiseunterlagenVorlageBaustein";"IDReiseUnterlagenVorlage_FS";"dbo";"ReiseunterlagenVorlage";"IDReiseUnterlagenVorlage"
|
||||||
|
"FK_ReiseunterlagenVorlageInhalt_ReiseunterlagenVorlage";"dbo";"ReiseunterlagenVorlageInhalt";"IDReiseunterlagenVorlage_FS";"dbo";"ReiseunterlagenVorlage";"IDReiseUnterlagenVorlage"
|
||||||
|
"FK_ReiseVersicherungsGruppe_ArtVersicherungsGruppe";"dbo";"ReiseVersicherungsGruppe";"IDArtVersicherungsGruppe_FS";"dbo";"ArtVersicherungsGruppe";"IDArtVersicherungsGruppe"
|
||||||
|
"FK_ReiseVersicherungsGruppe_Reise";"dbo";"ReiseVersicherungsGruppe";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseVerwendung_Reise";"dbo";"ReiseVerwendung";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseVplDokument_Dokument";"dbo";"ReiseVplDokument";"IdDokument_FS";"dbo";"Dokument";"IDDokument"
|
||||||
|
"FK_ReiseVplDokument_ReiseVpl";"dbo";"ReiseVplDokument";"IdReiseVpl_FS";"dbo";"ReiseVpl";"IdReiseVpl"
|
||||||
|
"FK_ReiseVplKdVormerkung_ReiseVpl";"dbo";"ReiseVplKdVormerkung";"IdReiseVpl_FS";"dbo";"ReiseVpl";"IdReiseVpl"
|
||||||
|
"FK_ReiseVplPartner_ReiseVpl";"dbo";"ReiseVplPartner";"IdReiseVpl_FS";"dbo";"ReiseVpl";"IdReiseVpl"
|
||||||
|
"FK_ReiseVplPartnerDokument_Dokument";"dbo";"ReiseVplPartnerDokument";"IdDokument_FS";"dbo";"Dokument";"IDDokument"
|
||||||
|
"FK_ReiseVplPartnerDokument_ReiseVplPartner";"dbo";"ReiseVplPartnerDokument";"IdReiseVplPartner_FS";"dbo";"ReiseVplPartner";"IdReiseVplPartner"
|
||||||
|
"FK_ReiseVplPartnerStatus_ArtReiseVplPartnerStatus";"dbo";"ReiseVplPartnerStatus";"IDArtReiseVplPartnerStatus_FS";"dbo";"ArtReiseVplPartnerStatus";"IDArtReiseVplPartnerStatus"
|
||||||
|
"FK_ReiseVplPartnerStatus_ReiseVplPartner";"dbo";"ReiseVplPartnerStatus";"IDReiseVplPartner_FS";"dbo";"ReiseVplPartner";"IdReiseVplPartner"
|
||||||
|
"FK_ReiseVplStatus_ArtReiseVplStatus";"dbo";"ReiseVplStatus";"IDArtReiseVplStatus_FS";"dbo";"ArtReiseVplStatus";"IDArtReiseVplStatus"
|
||||||
|
"FK_ReiseVplStatus_ReiseVpl";"dbo";"ReiseVplStatus";"IDReiseVpl_FS";"dbo";"ReiseVpl";"IdReiseVpl"
|
||||||
|
"FK_ReiseVplVerwendung_ReiseVpl";"dbo";"ReiseVplVerwendung";"IDReiseVpl_FS";"dbo";"ReiseVpl";"IdReiseVpl"
|
||||||
|
"FK_ReiseWerbeplanung_Reise";"dbo";"ReiseWerbeplanung";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReisezielSelektionsstamm_Reiseziel";"dbo";"ReisezielSelektionsstamm";"IDReiseziel_FS";"dbo";"Reiseziel";"IDReiseziel"
|
||||||
|
"FK_ReisezielSelektionsstamm_Selektionsstamm";"dbo";"ReisezielSelektionsstamm";"IDSelektionsstamm_FS";"dbo";"Selektionsstamm";"IDSelektionsstamm"
|
||||||
|
"FK_ReiseZusatz_ArtAgenturPreisgruppe";"dbo";"ReiseZusatz";"IDArtAgenturPreisgruppe_FS";"dbo";"ArtAgenturPreisgruppe";"IDArtAgenturPreisgruppe"
|
||||||
|
"FK_ReiseZusatz_Reise";"dbo";"ReiseZusatz";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReiseZusatzAbwJeMandant_Adresse";"dbo";"ReiseZusatzAbwJeMandant";"IDAdresse_FS_Mandant";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_ReiseZusatzAbwJeMandant_Reise";"dbo";"ReiseZusatzAbwJeMandant";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ReparaturAuftragZusatz_ReparaturAuftrag";"dbo";"ReparaturAuftragZusatz";"IDReparaturAuftrag_FS";"dbo";"Reparaturauftrag";"IDReparaturauftrag"
|
||||||
|
"FK_Rollsystem_ArtFahrplanVersion";"dbo";"Rollsystem";"IDArtFahrplanversion_FS";"dbo";"ArtFahrplanVersion";"IDArtFahrplanVersion"
|
||||||
|
"FK_RollsystemBlock_Block";"dbo";"RollsystemBlock";"IDBlock_FS";"dbo";"Block";"IDBlock"
|
||||||
|
"FK_RollsystemBlock_RollsystemFahrerFahrzeug";"dbo";"RollsystemBlock";"IDRollsystemFahrerFahrzeug_FS";"dbo";"RollsystemFahrerFahrzeug";"IDRollsystemFahrerFahrzeug"
|
||||||
|
"FK_RollsystemFahrerFahrzeug_Rollsystem";"dbo";"RollsystemFahrerFahrzeug";"IDRollsystem_FS";"dbo";"Rollsystem";"IDRollsystem"
|
||||||
|
"FK_RollsystemFerien_Rollsystem";"dbo";"RollsystemFerien";"IDRollsystem_FS";"dbo";"Rollsystem";"IDRollsystem"
|
||||||
|
"FK_RollsystemPerkap_ArtPersonalkapazitaet";"dbo";"RollsystemPerkap";"IDArtPersonalkapazitaet_FS";"dbo";"ArtPersonalkapazitaet";"IDArtPersonalkapazitaet"
|
||||||
|
"FK_RollsystemPerkap_RollsystemFahrerFahrzeug";"dbo";"RollsystemPerkap";"IDRollsystemFahrerFahrzeug_FS";"dbo";"RollsystemFahrerFahrzeug";"IDRollsystemFahrerFahrzeug"
|
||||||
|
"FK_RsbVeransAnzahlung_RsbVerans";"dbo";"RsbVeransAnzahlung";"IDRsbVerans_FS";"dbo";"RsbVerans";"IDRsbVeranstalter"
|
||||||
|
"FK_RsbVeransZusatz_RsbVerans";"dbo";"RsbVeransZusatz";"IDRsbVerans_FS";"dbo";"RsbVerans";"IDRsbVeranstalter"
|
||||||
|
"FK_Rufbus_FPLinieFahrt";"dbo";"Rufbus";"IDFPLinieFahrt_FS";"dbo";"FPLinieFahrt";"IDFPLinieFahrt"
|
||||||
|
"FK_Rufbus_FPLinieHaltestelleZeitartNach";"dbo";"Rufbus";"FPLinieHaltestelleZeitart_FS_Nach";"dbo";"FPLinieHaltestelleZeitart";"IDFPLinieHaltestelleZeitart"
|
||||||
|
"FK_Rufbus_FPLinieHaltestelleZeitartVon";"dbo";"Rufbus";"FPLinieHaltestelleZeitart_FS_Von";"dbo";"FPLinieHaltestelleZeitart";"IDFPLinieHaltestelleZeitart"
|
||||||
|
"FK_Rufbus_Haltepunkt_Nach";"dbo";"Rufbus";"IDHaltepunkt_FS_Nach";"dbo";"Haltepunkt";"IDHaltepunkt"
|
||||||
|
"FK_Rufbus_Haltepunkt_Von";"dbo";"Rufbus";"IDHaltepunkt_FS_Von";"dbo";"Haltepunkt";"IDHaltepunkt"
|
||||||
|
"FK_Rufbus_Haltestelle_Nach";"dbo";"Rufbus";"IDHaltestelle_FS_Nach";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_Rufbus_Haltestelle_Von";"dbo";"Rufbus";"IDHaltestelle_FS_Von";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_Rufbus_Linie";"dbo";"Rufbus";"IDFPLinie_FS";"dbo";"FPLinie";"IDFPLinie"
|
||||||
|
"FK_SaisonZeit_SaisonBezeichnung";"dbo";"SaisonZeit";"IDSaisonBezeichnung_FS";"dbo";"SaisonBezeichnung";"IDSaisonBezeichnung"
|
||||||
|
"FK_SchiffsDeck_Schiff";"dbo";"SchiffsDeck";"IDSchiff_FS";"dbo";"Schiff";"IDSchiff"
|
||||||
|
"FK_SchiffsKabine_SchiffsDeck";"dbo";"SchiffsKabine";"IDSchiffsDeck_FS";"dbo";"SchiffsDeck";"IDSchiffsDeck"
|
||||||
|
"FK_Schule_Schulamt";"dbo";"Schule";"IDSchulamt_FS";"dbo";"Schulamt";"IDSchulamt"
|
||||||
|
"FK_Adresse_Selektion";"dbo";"Selektionen";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Selektionsstamm_Selektion";"dbo";"Selektionen";"IDSelektionsstamm_FS";"dbo";"Selektionsstamm";"IDSelektionsstamm"
|
||||||
|
"FK_Selektionsgruppenstamm_Selektionsgruppe";"dbo";"Selektionsgruppe";"IDSelektionsgruppenstamm_FS";"dbo";"Selektionsgruppenstamm";"IDSelektionsgruppenstamm"
|
||||||
|
"FK_Selektionsstamm_Selektionsgruppe";"dbo";"Selektionsgruppe";"IDSelektionsstamm_FS";"dbo";"Selektionsstamm";"IDSelektionsstamm"
|
||||||
|
"FK_Sitzplan_Sitzplanobjekt";"dbo";"Sitzplanobjekt";"IDSitzplan_FS";"dbo";"Sitzplan";"IDSitzplan"
|
||||||
|
"FK_Sperrung_Adresse";"dbo";"Sperrung";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Sperrung_ArtSperrartFahrer";"dbo";"Sperrung";"IDArtSperrartFahrer_FS";"dbo";"ArtSperrartFahrer";"IDArtSperrartFahrer"
|
||||||
|
"FK_Sperrung_ArtSperrartFahrzeug";"dbo";"Sperrung";"IDArtSperrartFahrzeug_FS";"dbo";"ArtSperrartFahrzeug";"IDArtSperrartFahrzeug"
|
||||||
|
"FK_Sperrung_ArtSperrKategorie";"dbo";"Sperrung";"IDArtSperrKategorie_FS";"dbo";"ArtSperrKategorie";"IDArtSperrKategorie"
|
||||||
|
"FK_Sperrung_Fahrzeug";"dbo";"Sperrung";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_SperrungSperrStatus_ArtSperrStatus";"dbo";"SperrungSperrStatus";"IDArtSperrStatus_FS";"dbo";"ArtSperrStatus";"IDArtSperrStatus"
|
||||||
|
"FK_SperrungSperrStatus_Sperrung";"dbo";"SperrungSperrStatus";"IDSperrung_FS";"dbo";"Sperrung";"IDSperrung"
|
||||||
|
"FK_Spesen_Adresse";"dbo";"Spesen";"IDAdresse_FS_Personal";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Spesen_ArtSpesen";"dbo";"Spesen";"IDArtSpesen_FS";"dbo";"ArtSpesen";"IDArtSpesen"
|
||||||
|
"FK_SpracheMaskeModul_Programmmodul";"dbo";"SpracheMaskeModul";"IDProgrammmodul_FS";"dbo";"Programmmodul";"IDProgrammmodul"
|
||||||
|
"FK_SpracheMaskeText_SpracheMaske";"dbo";"SpracheMaskeText";"IDSpracheMaske_FS";"dbo";"SpracheMaske";"IDSpracheMaske"
|
||||||
|
"FK_SteueraufteilungDetail_Land";"dbo";"SteueraufteilungDetail";"IDLand_FS";"dbo";"Land";"IDLand"
|
||||||
|
"FK_SteueraufteilungDetail_SteueraufteilungArt";"dbo";"SteueraufteilungDetail";"IDSteueraufteilungArt_FS";"dbo";"SteueraufteilungArt";"IDSteueraufteilungArt"
|
||||||
|
"FK_SteueraufteilungDetail_SteueraufteilungUnterart";"dbo";"SteueraufteilungDetail";"IDSteueraufteilungUnterart_FS";"dbo";"SteueraufteilungUnterart";"IDSteueraufteilungUnterart"
|
||||||
|
"FK_SteueraufteilungDetailArtVerpflegung_ArtVerpflegung";"dbo";"SteueraufteilungDetailArtVerpflegung";"IDArtVerpflegung_FS";"dbo";"ArtVerpflegung";"IDArtVerpflegung"
|
||||||
|
"FK_SteueraufteilungDetailArtVerpflegung_SteueraufteilungDetail";"dbo";"SteueraufteilungDetailArtVerpflegung";"IDSteueraufteilungDetail_FS";"dbo";"SteueraufteilungDetail";"IDSteueraufteilungDetail"
|
||||||
|
"FK_SteueraufteilungUnterart_SteueraufteilungArt";"dbo";"SteueraufteilungUnterart";"IDSteueraufteilungArt_FS";"dbo";"SteueraufteilungArt";"IDSteueraufteilungArt"
|
||||||
|
"FK_Tachostand_Fahrzeug";"dbo";"Tachostand";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_Tachostand_TachostandImportEinstellung";"dbo";"Tachostand";"IDTachostandImportEinstellung_FS";"dbo";"TachostandImportEinstellung";"IDTachostandImportEinstellung"
|
||||||
|
"FK_Tankerfassung_ArtBetankung";"dbo";"Tankerfassung";"IDArtBetankung_FS";"dbo";"ArtBetankung";"IDArtBetankung"
|
||||||
|
"FK_Tankerfassung_ArtKarftstoffart";"dbo";"Tankerfassung";"IDArtKraftstoffart_FS";"dbo";"ArtKraftstoffart";"IDArtKraftstoffart"
|
||||||
|
"FK_Tankerfassung_Fahrzeug";"dbo";"Tankerfassung";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_Tankerfassung_TankimportEinstellung";"dbo";"Tankerfassung";"IDTankimportEinstellung_FS";"dbo";"TankimportEinstellung";"IDTankimportEinstellung"
|
||||||
|
"FK_TankimportEinstellung_ArtBetankung";"dbo";"TankimportEinstellung";"IDArtBetankung_FS";"dbo";"ArtBetankung";"IDArtBetankung"
|
||||||
|
"FK_TankimportEinstellungArtBetankung_ArtBetankung";"dbo";"TankimportEinstellungArtBetankung";"IDArtBetankung_FS";"dbo";"ArtBetankung";"IDArtBetankung"
|
||||||
|
"FK_TankimportEinstellungArtBetankung_TankimportEinstellung";"dbo";"TankimportEinstellungArtBetankung";"IDTankimportEinstellung_FS";"dbo";"TankimportEinstellung";"IDTankimportEinstellung"
|
||||||
|
"FK_TankimportEinstellungKraftstoff_ArtKraftstoffart";"dbo";"TankimportEinstellungKraftstoff";"IDArtKraftstoffart_FS";"dbo";"ArtKraftstoffart";"IDArtKraftstoffart"
|
||||||
|
"FK_TankimportEinstellungKraftstoff_TankimportEinstellung";"dbo";"TankimportEinstellungKraftstoff";"IDTankimportEinstellung_FS";"dbo";"TankimportEinstellung";"IDTankimportEinstellung"
|
||||||
|
"FK_TaxiPartner_Adresse";"dbo";"TaxiPartner";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_TaxiPartnerKosten_TaxiPartner";"dbo";"TaxiPartnerKosten";"IDTaxiPartner_FS";"dbo";"TaxiPartner";"IDTaxiPartner"
|
||||||
|
"FK_TaxiPartnerMapGuide_TaxiPartner";"dbo";"TaxiPartnerMapGuide";"IDTaxiPartner_FS";"dbo";"TaxiPartner";"IDTaxiPartner"
|
||||||
|
"FK_TaxiPartnerZustieg_Adresse";"dbo";"TaxiPartnerZustieg";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_TaxiPartnerZustieg_Zustieg";"dbo";"TaxiPartnerZustieg";"IDZustieg_FS";"dbo";"Zustieg";"IDZustieg"
|
||||||
|
"FK_TerminalFreigabe_ArtTerminalFreigabe";"dbo";"TerminalFreigabe";"IDArtTerminalFreigabe_FS";"dbo";"ArtTerminalFreigabe";"IDArtTerminalFreigabe"
|
||||||
|
"FK_TerminalFreigabeArtBetriebshof_ArtBetriebshof";"dbo";"TerminalFreigabeArtBetriebshof";"IDArtBetriebshof_FS";"dbo";"ArtBetriebshof";"IDArtBetriebshof"
|
||||||
|
"FK_TerminalProtokoll_Adresse";"dbo";"TerminalProtokoll";"IDAdresse_FS_Sachbearbeiter";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_TerminArtSachbearbeiter_Adresse";"dbo";"TerminArtSachbearbeiter";"IDAdresse_FS_Personal";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_TerminArtSachbearbeiter_TerminArt";"dbo";"TerminArtSachbearbeiter";"IDTerminArt_FS";"dbo";"TerminArt";"IDTerminArt"
|
||||||
|
"FK_TerminKetteAbfolge_TerminKette";"dbo";"TerminKetteAbfolge";"IDTerminArt_FS";"dbo";"TerminArt";"IDTerminArt"
|
||||||
|
"FK_TerminKetteAbfolge_TerminKette1";"dbo";"TerminKetteAbfolge";"IDTerminKette_FS";"dbo";"TerminKette";"IDTerminKette"
|
||||||
|
"FK_TerminSachbearbeiter_Termin";"dbo";"TerminSachbearbeiter";"IDTermin_FS";"dbo";"Termin";"IDTermin"
|
||||||
|
"FK_Tour_ArtFahrplanVersion";"dbo";"Tour";"IDArtFahrplanVersion_FS";"dbo";"ArtFahrplanVersion";"IDArtFahrplanVersion"
|
||||||
|
"FK_Tour_ArtStatistikGruppe";"dbo";"Tour";"IDArtStatistikgruppe_FS_Haupt";"dbo";"ArtStatistikGruppe";"IDArtStatistikGruppe"
|
||||||
|
"FK_Tour_FPLinieFahrt";"dbo";"Tour";"IDFPLinieFahrt_FS";"dbo";"FPLinieFahrt";"IDFPLinieFahrt"
|
||||||
|
"FK_Tour_Haltepunkt_Anfang";"dbo";"Tour";"IDHaltepunkt_FS_Anfang";"dbo";"Haltepunkt";"IDHaltepunkt"
|
||||||
|
"FK_Tour_Haltepunkt_Ende";"dbo";"Tour";"IDHaltepunkt_FS_Ende";"dbo";"Haltepunkt";"IDHaltepunkt"
|
||||||
|
"FK_Tour_Haltestelle_Anfang";"dbo";"Tour";"IDHaltestelle_FS_Anfang";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_Tour_Haltestelle_Ende";"dbo";"Tour";"IDHaltestelle_FS_Ende";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_Tour_Ort_Anfang";"dbo";"Tour";"IDOrt_FS_Anfang";"dbo";"Ort";"IDOrt"
|
||||||
|
"FK_Tour_Ort_Ende";"dbo";"Tour";"IDOrt_FS_Ende";"dbo";"Ort";"IDOrt"
|
||||||
|
"FK_TourFahrzeugtyp_ArtFahrzeugTypen";"dbo";"TourFahrzeugtyp";"IDArtFahrzeugTypen_FS";"dbo";"ArtFahrzeugTypen";"IDArtFahrzeugTypen"
|
||||||
|
"FK_TourFahrzeugtyp_Tour";"dbo";"TourFahrzeugtyp";"IDTour_FS";"dbo";"Tour";"IDTour"
|
||||||
|
"FK_TourFerienFeier_Tour";"dbo";"TourFerienFeier";"IDTour_FS";"dbo";"Tour";"IDTour"
|
||||||
|
"FK_TourHaltestelleHaltepunkt_Haltepunkt";"dbo";"TourHaltestelleHaltepunkt";"IDHaltepunkt_FS";"dbo";"Haltepunkt";"IDHaltepunkt"
|
||||||
|
"FK_TourHaltestelleHaltepunkt_Haltestelle";"dbo";"TourHaltestelleHaltepunkt";"IDHaltestelle_FS";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_TourHaltestelleHaltepunkt_Tour";"dbo";"TourHaltestelleHaltepunkt";"IDTour_FS";"dbo";"Tour";"IDTour"
|
||||||
|
"FK_TourKreisGebiet_ArtKreisGebiet";"dbo";"TourKreisGebiet";"IDArtKreisGebiet_FS";"dbo";"ArtKreisGebiet";"IDArtKreisGebiet"
|
||||||
|
"FK_TourKreisGebiet_Tour";"dbo";"TourKreisGebiet";"IDTour_FS";"dbo";"Tour";"IDTour"
|
||||||
|
"FK_TourLaufzeit_TourLaufzeit";"dbo";"TourLaufzeit";"IDTour_FS";"dbo";"Tour";"IDTour"
|
||||||
|
"FK_TourPlanung_Adresse";"dbo";"TourPlanung";"IDAdresse_FS_Fahrer";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_TourPlanung_Adresse2";"dbo";"TourPlanung";"LoeschenManuellIDAdresse_FS_SB";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_TourPlanung_ArtFahrplanVersion";"dbo";"TourPlanung";"IDArtFahrplanVersion_FS";"dbo";"ArtFahrplanVersion";"IDArtFahrplanVersion"
|
||||||
|
"FK_TourPlanung_ArtStatistikGruppe";"dbo";"TourPlanung";"IDArtStatistikgruppe_FS_Haupt";"dbo";"ArtStatistikGruppe";"IDArtStatistikGruppe"
|
||||||
|
"FK_TourPlanung_BlockPlanung";"dbo";"TourPlanung";"IDBlockPlanung_FS";"dbo";"BlockPlanung";"IDBlockPlanung"
|
||||||
|
"FK_TourPlanung_Fahrzeug";"dbo";"TourPlanung";"IDFahrzeug_FS";"dbo";"Fahrzeug";"IDFahrzeug"
|
||||||
|
"FK_Tourplanung_Haltepunkt_Anfang";"dbo";"TourPlanung";"IDHaltepunkt_FS_Anfang";"dbo";"Haltepunkt";"IDHaltepunkt"
|
||||||
|
"FK_Tourplanung_Haltepunkt_Ende";"dbo";"TourPlanung";"IDHaltepunkt_FS_Ende";"dbo";"Haltepunkt";"IDHaltepunkt"
|
||||||
|
"FK_Tourplanung_Haltestelle_Anfang";"dbo";"TourPlanung";"IDHaltestelle_FS_Anfang";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_Tourplanung_Haltestelle_Ende";"dbo";"TourPlanung";"IDHaltestelle_FS_Ende";"dbo";"Haltestelle";"IDHaltestelle"
|
||||||
|
"FK_TourPlanung_Ort_Anfang";"dbo";"TourPlanung";"IDOrt_FS_Anfang";"dbo";"Ort";"IDOrt"
|
||||||
|
"FK_TourPlanung_Ort_Ende";"dbo";"TourPlanung";"IDOrt_FS_Ende";"dbo";"Ort";"IDOrt"
|
||||||
|
"FK_TourPlanung_Tour";"dbo";"TourPlanung";"IDTour_FS_Basis";"dbo";"Tour";"IDTour"
|
||||||
|
"FK_TourPlanungFahrzeugtyp_ArtFahrzeugTypen";"dbo";"TourPlanungFahrzeugtyp";"IDArtFahrzeugTypen_FS";"dbo";"ArtFahrzeugTypen";"IDArtFahrzeugTypen"
|
||||||
|
"FK_TourPlanungFahrzeugtyp_TourPlanung";"dbo";"TourPlanungFahrzeugtyp";"IDTourPlanung_FS";"dbo";"TourPlanung";"IDTourPlanung"
|
||||||
|
"FK_TourPlanungKreisGebiet_ArtKreisGebiet";"dbo";"TourPlanungKreisGebiet";"IDArtKreisGebiet_FS";"dbo";"ArtKreisGebiet";"IDArtKreisGebiet"
|
||||||
|
"FK_TourPlanungKreisGebiet_TourPlanung";"dbo";"TourPlanungKreisGebiet";"IDTourPlanung_FS";"dbo";"TourPlanung";"IDTourPlanung"
|
||||||
|
"FK_TourPlanungStatistik_ArtStatistikGruppe";"dbo";"TourPlanungStatistik";"IDArtStatistikGruppe_FS";"dbo";"ArtStatistikGruppe";"IDArtStatistikGruppe"
|
||||||
|
"FK_TourPlanungStatistik_TourPlanung";"dbo";"TourPlanungStatistik";"IDTourPlanung_FS";"dbo";"TourPlanung";"IDTourPlanung"
|
||||||
|
"FK_TourPlanungZusatz_TourPlanung";"dbo";"TourPlanungZusatz";"IDTourPlanung_FS";"dbo";"TourPlanung";"IDTourPlanung"
|
||||||
|
"FK_TourStatistik_ArtStatistikGruppe";"dbo";"TourStatistik";"IDArtStatistikGruppe_FS";"dbo";"ArtStatistikGruppe";"IDArtStatistikGruppe"
|
||||||
|
"FK_TourStatistik_Tour";"dbo";"TourStatistik";"IDTour_FS";"dbo";"Tour";"IDTour"
|
||||||
|
"FK_TourWochentyp_ArtWochentyp";"dbo";"TourWochentyp";"IDArtWochentyp_FS";"dbo";"ArtWochentyp";"IDArtWochentyp"
|
||||||
|
"FK_TourWochentyp_Tour";"dbo";"TourWochentyp";"IDTour_FS";"dbo";"Tour";"IDTour"
|
||||||
|
"FK_TourZeitraum_ArtLaufzeitschema";"dbo";"TourZeitraum";"IDArtLaufzeitschema_FS";"dbo";"ArtLaufzeitschema";"IDArtLaufzeitschema"
|
||||||
|
"FK_TourZeitraum_Tour";"dbo";"TourZeitraum";"IDTour_FS";"dbo";"Tour";"IDTour"
|
||||||
|
"FK_TourZusatz_Tour";"dbo";"TourZusatz";"IDTour_FS";"dbo";"Tour";"IDTour"
|
||||||
|
"FK_UserStartmenue_Adresse";"dbo";"UserStartmenue";"IDAdresse_FS";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_UserStartmenue_Programmmodul";"dbo";"UserStartmenue";"IDProgrammmodul_FS";"dbo";"Programmmodul";"IDProgrammmodul"
|
||||||
|
"FK_VorplanungRasterAblauf_VorplanungRaster";"dbo";"VorplanungRasterAblauf";"IDVorplanungRaster_FS";"dbo";"VorplanungRaster";"IDVorplanungRaster"
|
||||||
|
"FK_VorplanungRasterAblaufAbwPartner_Adresse";"dbo";"VorplanungRasterAblaufAbwPartner";"IDAdresse_FS_Partner";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_VorplanungRasterAblaufAbwPartner_VorplanungRasterAblauf";"dbo";"VorplanungRasterAblaufAbwPartner";"IDVorplanungRasterAblauf_FS";"dbo";"VorplanungRasterAblauf";"IDVorplanungRasterAblauf"
|
||||||
|
"FK_VorplanungRasterAblaufAbwPartner_VorplanungRasterTermin";"dbo";"VorplanungRasterAblaufAbwPartner";"IDVorplanungRasterTermin_FS";"dbo";"VorplanungRasterTermin";"IDVorplanungRasterTermin"
|
||||||
|
"FK_VorplanungRasterAblaufStatus_Adresse";"dbo";"VorplanungRasterAblaufStatus";"IDAdresse_FS_Sachbearbeiter";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_VorplanungRasterAblaufStatus_ArtReiseVplPartnerStatus";"dbo";"VorplanungRasterAblaufStatus";"IDArtReiseVplPartnerStatus_FS";"dbo";"ArtReiseVplPartnerStatus";"IDArtReiseVplPartnerStatus"
|
||||||
|
"FK_VorplanungRasterAblaufStatus_VorplanungRasterAblauf";"dbo";"VorplanungRasterAblaufStatus";"IDVorplanungRasterAblauf_FS";"dbo";"VorplanungRasterAblauf";"IDVorplanungRasterAblauf"
|
||||||
|
"FK_VorplanungRasterAblaufStatus_VorplanungRasterTermin";"dbo";"VorplanungRasterAblaufStatus";"IDVorplanungRasterTermin_FS";"dbo";"VorplanungRasterTermin";"IDVorplanungRasterTermin"
|
||||||
|
"FK_VorplanungRasterAblaufZusatz_VorplanungRasterAblauf";"dbo";"VorplanungRasterAblaufZusatz";"IDVorplanungRasterAblauf_FS";"dbo";"VorplanungRasterAblauf";"IDVorplanungRasterAblauf"
|
||||||
|
"FK_VorplanungRasterBemerkung_VorplanungRaster";"dbo";"VorplanungRasterBemerkung";"IDVorplanungRaster_FS";"dbo";"VorplanungRaster";"IDVorplanungRaster"
|
||||||
|
"FK_VorplanungRasterDokument_Adresse";"dbo";"VorplanungRasterDokument";"IDAdresse_FS_Sachbearbeiter";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_VorplanungRasterDokument_ArtDokument";"dbo";"VorplanungRasterDokument";"IDArtDokument_FS";"dbo";"ArtDokument";"IDArtDokument"
|
||||||
|
"FK_VorplanungRasterDokument_VorplanungRaster";"dbo";"VorplanungRasterDokument";"IDVorplanungRaster_FS";"dbo";"VorplanungRaster";"IDVorplanungRaster"
|
||||||
|
"FK_VorplanungRasterKalkulationÜbernahme_VorplanungRasterKalkulation";"dbo";"VorplanungRasterKalkulationÜbernahme";"IDVorplanungRasterKalkulation_FS";"dbo";"VorplanungRasterKalkulation";"IDVorplanungRasterKalkulation"
|
||||||
|
"FK_VorplanungRasterTermin_VorplanungRaster";"dbo";"VorplanungRasterTermin";"IDVorplanungRaster_FS";"dbo";"VorplanungRaster";"IDVorplanungRaster"
|
||||||
|
"FK_WaehrungsKurse_ArtWaehrung";"dbo";"WaehrungsKurse";"IDArtWaehrung_FS";"dbo";"ArtWaehrung";"IDArtWaehrung"
|
||||||
|
"FK_WerbeplanungPunkte_ArtWerbeplanungReiseart";"dbo";"WerbeplanungPunkte";"IDArtWerbeplanungReiseart_FS";"dbo";"ArtWerbeplanungReiseart";"IDArtWerbeplanungReiseart"
|
||||||
|
"FK_WWKategorieEintrag_WWSuchKategorie";"dbo";"WWSuchKategorieEintrag";"IDWWSuchKategorie_FS";"dbo";"WWSuchKategorie";"IDWWSuchKategorie"
|
||||||
|
"FK_Zahlung_Adresse";"dbo";"Zahlung";"IDAdresse_FS_Sachbearbeiter";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Zahlung_ArtWaehrung";"dbo";"Zahlung";"IDWaehrung_FS";"dbo";"ArtWaehrung";"IDArtWaehrung"
|
||||||
|
"FK_Zahlung_ArtZahlungsart";"dbo";"Zahlung";"IDArtZahlungart_FS";"dbo";"ArtZahlungsart";"IDArtZahlungsart"
|
||||||
|
"FK_Zahlung_ZahlungCode";"dbo";"Zahlung";"IDKontenplan_FS";"dbo";"Kontenplan";"IDKontenplan"
|
||||||
|
"FK_Zahlung_ZahlungStamm";"dbo";"Zahlung";"IDZahlungStamm_FS";"dbo";"ZahlungStamm";"IDZahlungStamm"
|
||||||
|
"FK_ZahlungDokument_Adresse";"dbo";"ZahlungDokument";"IDAdresse_FS_Sachbearbeiter";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_ZahlungDokument_ArtDokument";"dbo";"ZahlungDokument";"IDArtDokument_FS";"dbo";"ArtDokument";"IDArtDokument"
|
||||||
|
"FK_ZahlungDokument_Zahlung";"dbo";"ZahlungDokument";"IDZahlung_FS";"dbo";"Zahlung";"IDZahlung"
|
||||||
|
"FK_ZahlungStamm_Adresse";"dbo";"ZahlungStamm";"IDAdresse_FS_Mandant";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_ZahlungStamm_ArtWaehrung";"dbo";"ZahlungStamm";"IDWaehrung_FS";"dbo";"ArtWaehrung";"IDArtWaehrung"
|
||||||
|
"FK_ZahlungStamm_Nummernkreis";"dbo";"ZahlungStamm";"IDNummernkreis_FS";"dbo";"Nummernkreis";"IDNummernkreis"
|
||||||
|
"FK_ZiellandRoute_Land";"dbo";"ZiellandRoute";"IDLand_FS_Route";"dbo";"Land";"IDLand"
|
||||||
|
"FK_ZiellandRoute_Land1";"dbo";"ZiellandRoute";"IDLand_FS_Zielland";"dbo";"Land";"IDLand"
|
||||||
|
"FK_Zusatzinformation_Adresse";"dbo";"Zusatzinformation";"IDAdresse_FS_Partner";"dbo";"Adresse";"IDAdresse"
|
||||||
|
"FK_Zusatzinformation_ArtZusatzinfoStamm";"dbo";"Zusatzinformation";"IDArtZusatzinfoStamm_FS";"dbo";"ArtZusatzinfoStamm";"IDArtZusatzinfoStamm"
|
||||||
|
"FK_Zusatzinformation_Reise";"dbo";"Zusatzinformation";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ZusatzZimmer_ArtBuchungsStatus";"dbo";"ZusatzZimmer";"IDArtBuchungsstatus_FS";"dbo";"ArtBuchungsStatus";"IDArtBuchungsStatus"
|
||||||
|
"FK_ZusatzZimmer_Reise";"dbo";"ZusatzZimmer";"IDReise_FS";"dbo";"Reise";"IDReise"
|
||||||
|
"FK_ZustiegHinweis_Zustieg";"dbo";"ZustiegHinweis";"IDZustieg_FS";"dbo";"Zustieg";"IDZustieg"
|
||||||
|
"FK_Zustiegkoordinate_Zustieg";"dbo";"Zustiegkoordinate";"IDZustieg_FS";"dbo";"Zustieg";"IDZustieg"
|
||||||
|
"FK_ZustiegKoordinate_ZustiegKoordinatenArt";"dbo";"Zustiegkoordinate";"IDArtKoordinaten_FS";"dbo";"ArtZustiegKoordinaten";"IDArtZustiegKoordinaten"
|
||||||
|
"FK_ZustiegPLZ_Zustieg1";"dbo";"ZustiegPLZ";"IDZustieg_FS";"dbo";"Zustieg";"IDZustieg"
|
||||||
|
"FK_ZustiegPreis_ArtZustiegPreis1";"dbo";"ZustiegPreis";"IDArtZustiegPreis_FS";"dbo";"ArtZustiegPreis";"IDArtZustiegPreis"
|
||||||
|
"FK_ZustiegPreis_Zustieg1";"dbo";"ZustiegPreis";"IDZustieg_FS";"dbo";"Zustieg";"IDZustieg"
|
||||||
|
"FK_ZustiegRoute_ArtZustiegRoutenbeschreibung";"dbo";"ZustiegRoute";"IDArtZustiegRoutenbeschreibung_FS";"dbo";"ArtZustiegRoutenbeschreibung";"IDArtZustiegRoutenbeschreibung"
|
||||||
|
"FK_ZustiegRoute_Zustieg";"dbo";"ZustiegRoute";"IDZustieg_FS";"dbo";"Zustieg";"IDZustieg"
|
||||||
|
"FK_ZustiegsPlanungBesonders_ZustiegsPlanungPerson";"dbo";"ZustiegsPlanungBesonders";"IDZustiegsPlanungPerson_FS";"dbo";"ZustiegsPlanungPerson";"IDZustiegsPlanungPerson"
|
||||||
|
"FK_ZustiegsPlanungBus_ZustiegsPlanung";"dbo";"ZustiegsPlanungBus";"IDZustiegsPlanung_FS";"dbo";"ZustiegsPlanung";"IDZustiegsPlanung"
|
||||||
|
"FK_ZustiegsPlanungBusStelle_ZustiegsPlanungBus";"dbo";"ZustiegsPlanungBusStelle";"IDZustiegsPlanungBus_FS";"dbo";"ZustiegsPlanungBus";"IDZustiegsPlanungBus"
|
||||||
|
"FK_ZustiegsPlanungLeistung_ZustiegsPlanung";"dbo";"ZustiegsPlanungLeistung";"IDZustiegsPlanung_FS";"dbo";"ZustiegsPlanung";"IDZustiegsPlanung"
|
||||||
|
"FK_ZustiegsPlanungPerson_BuchungTN";"dbo";"ZustiegsPlanungPerson";"IDBuchungTN_FS";"dbo";"BuchungTN";"IDBuchungTN"
|
||||||
|
"FK_ZustiegsPlanungPerson_Leistung";"dbo";"ZustiegsPlanungPerson";"IDLeistung_FS";"dbo";"Leistung";"IDLeistung"
|
||||||
|
"FK_ZustiegsPlanungPerson_ZustiegsPlanung";"dbo";"ZustiegsPlanungPerson";"IDZustiegsPlanung_FS";"dbo";"ZustiegsPlanung";"IDZustiegsPlanung"
|
||||||
|
"FK_ZustiegsPlanungPersonStelle_ZustiegsPlanungBusStelle";"dbo";"ZustiegsPlanungPersonStelle";"IDZustiegsPlanungBusStelle_FS";"dbo";"ZustiegsPlanungBusStelle";"IDZustiegsPlanungBusStelle"
|
||||||
|
"FK_ZustiegsPlanungPersonStelle_ZustiegsPlanungPerson";"dbo";"ZustiegsPlanungPersonStelle";"IDZustiegsPlanungPerson_FS";"dbo";"ZustiegsPlanungPerson";"IDZustiegsPlanungPerson"
|
||||||
|
"FK_ZustiegZeitplan_ArtZustiegZeitplan";"dbo";"ZustiegZeitplan";"IDArtZustiegZeitplan_FS";"dbo";"ArtZustiegZeitplan";"IDArtZustiegZeitplan"
|
||||||
|
@@ -0,0 +1,94 @@
|
|||||||
|
# Boarding Plan — Table Relations (BusPro schema)
|
||||||
|
|
||||||
|
> **Authority: [`docs/buspro-database-reference.md`](../buspro-database-reference.md) §8.4.**
|
||||||
|
> Retained for the detailed ER diagram of the `ZustiegsPlanung*` subsystem.
|
||||||
|
|
||||||
|
Read-only reference for building the boarding-plan API endpoint (look up a plan
|
||||||
|
by `IDZustiegsPlanung`, returning names, addresses, and times).
|
||||||
|
|
||||||
|
The query walks:
|
||||||
|
**`ZustiegsPlanung` → persons → (name via `BuchungTN`/`AdressePerson`) →
|
||||||
|
`PersonStelle` junction → `BusStelle` (time) → `Zustieg` (address) → `Bus`.**
|
||||||
|
`ZustiegsPlanungPersonStelle` is the pivot that ties a passenger to a timed stop.
|
||||||
|
|
||||||
|
## ER diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
ZustiegsPlanung ||--o{ ZustiegsPlanungBus : "IDZustiegsPlanung_FS"
|
||||||
|
ZustiegsPlanung ||--o{ ZustiegsPlanungPerson : "IDZustiegsPlanung_FS"
|
||||||
|
ZustiegsPlanungBus ||--o{ ZustiegsPlanungBusStelle : "IDZustiegsPlanungBus_FS"
|
||||||
|
ZustiegsPlanungPerson ||--o{ ZustiegsPlanungPersonStelle : "IDZustiegsPlanungPerson_FS"
|
||||||
|
ZustiegsPlanungBusStelle ||--o{ ZustiegsPlanungPersonStelle : "IDZustiegsPlanungBusStelle_FS"
|
||||||
|
|
||||||
|
ZustiegsPlanungPerson }o..|| BuchungTN : "IDBuchungTN_FS"
|
||||||
|
BuchungTN }o..|| AdressePerson : "IDAdressePerson_FS (name)"
|
||||||
|
ZustiegsPlanungBusStelle }o..o| Zustieg : "IDZustieg_FS (soft, address)"
|
||||||
|
|
||||||
|
ZustiegsPlanung {
|
||||||
|
bigint IDZustiegsPlanung PK
|
||||||
|
datetime Datum
|
||||||
|
}
|
||||||
|
ZustiegsPlanungBus {
|
||||||
|
bigint IDZustiegsPlanungBus PK
|
||||||
|
string ReiseleiterName
|
||||||
|
}
|
||||||
|
ZustiegsPlanungBusStelle {
|
||||||
|
bigint IDZustiegsPlanungBusStelle PK
|
||||||
|
datetime Zeit "TIME"
|
||||||
|
smallint Sortierung
|
||||||
|
}
|
||||||
|
ZustiegsPlanungPerson {
|
||||||
|
bigint IDZustiegsPlanungPerson PK
|
||||||
|
}
|
||||||
|
ZustiegsPlanungPersonStelle {
|
||||||
|
bigint IDZustiegsPlanungPersonStelle PK
|
||||||
|
string Art "Einstieg/Ausstieg"
|
||||||
|
}
|
||||||
|
AdressePerson {
|
||||||
|
string Name
|
||||||
|
string Vorname
|
||||||
|
}
|
||||||
|
Zustieg {
|
||||||
|
string Strasse
|
||||||
|
string Plz
|
||||||
|
string Ort
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ASCII fallback
|
||||||
|
|
||||||
|
```
|
||||||
|
ZustiegsPlanung (the planning id)
|
||||||
|
/ \
|
||||||
|
IDZustiegsPlanung_FS IDZustiegsPlanung_FS
|
||||||
|
/ \
|
||||||
|
ZustiegsPlanungBus ZustiegsPlanungPerson ──IDBuchungTN_FS──▶ BuchungTN
|
||||||
|
│ (Reiseleiter) │ │
|
||||||
|
IDZustiegsPlanungBus_FS IDZustiegsPlanungPerson_FS IDAdressePerson_FS
|
||||||
|
│ │ ▼
|
||||||
|
▼ ▼ AdressePerson
|
||||||
|
ZustiegsPlanungBusStelle ◀──┐ ZustiegsPlanungPersonStelle (NAME)
|
||||||
|
• Zeit (TIME) │ • Art (boarding/alighting)
|
||||||
|
• IDZustieg_FS ··soft··▶ Zustieg (ADDRESS)
|
||||||
|
└──IDZustiegsPlanungBusStelle_FS───┘ (junction: passenger ↔ stop)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Legend:** `||--o{` / solid = enforced FK constraint; `}o..` / `··soft··` =
|
||||||
|
convention-only `_FS` reference (no DB constraint — join defensively, expect
|
||||||
|
`NULL`/`0`/orphaned values).
|
||||||
|
|
||||||
|
## Notes for the query
|
||||||
|
|
||||||
|
- **Name** is not stored in the plan; join out live via
|
||||||
|
`ZustiegsPlanungPerson.IDBuchungTN_FS → BuchungTN.IDAdressePerson_FS → AdressePerson`.
|
||||||
|
- **Time** lives on `ZustiegsPlanungBusStelle.Zeit` (per stop), not on the plan
|
||||||
|
header (`ZustiegsPlanung.Datum` is the plan date).
|
||||||
|
- **Address** comes from `Zustieg` (the boarding point). Prefer reaching it via
|
||||||
|
`ZustiegsPlanungBusStelle.IDZustieg_FS` so it never depends on
|
||||||
|
`ZustiegsPlanungPerson.IDZustieg_FS` being populated.
|
||||||
|
- A passenger can have multiple `ZustiegsPlanungPersonStelle` rows (boarding +
|
||||||
|
alighting), distinguished by `Art`. Filter on `Art` for boarding-only.
|
||||||
|
- To verify against real data: the actual `Art` codes (`nvarchar(6)`), and
|
||||||
|
whether `BusStelle.IDZustieg_FS` always matches the passenger's
|
||||||
|
`Person.IDZustieg_FS` on the boarding leg.
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
# `SelektionCRM` — how the XML is assembled, and how to debug a missing selection
|
||||||
|
|
||||||
|
> **Authority: [`docs/buspro-database-reference.md`](../buspro-database-reference.md).**
|
||||||
|
> Schema semantics and the falsified-theory record live there and win on any conflict.
|
||||||
|
> This file is retained as the **full investigation record** for the missing-selection /
|
||||||
|
> `aenderbar` problem, including the queries that closed each theory.
|
||||||
|
|
||||||
|
Companion to `travel-queries.md` (static catalog) and `live-api-queries.md` (live
|
||||||
|
socket endpoints). This one covers the `KUNDENKONTO` / `art=SelektionCRM` response —
|
||||||
|
the document that drives the portal's entire authorization model.
|
||||||
|
|
||||||
|
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
|
||||||
|
Symfony roles:
|
||||||
|
|
||||||
|
| `IDSelektionsstamm` | Role |
|
||||||
|
| --- | --- |
|
||||||
|
| `1070` | `ROLE_TEAMER` |
|
||||||
|
| `1292` | `ROLE_ADMIN` |
|
||||||
|
| `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) |
|
||||||
|
| 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::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
|
||||||
|
|
||||||
|
From `BusPronet_20260601-1517.csv` (columns) and `BusPronet_20260601-1518.csv` (FKs):
|
||||||
|
|
||||||
|
| Table | Columns | Role |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `dbo.Selektionsstamm` | `IDSelektionsstamm`, `Selektion`, `Status` nvarchar(2) null, `System` tinyint | the selection master — that's *all four* columns |
|
||||||
|
| `dbo.Selektionsgruppenstamm` | `IDSelektionsgruppenstamm`, `Selektionsgruppe`, `Status`, `System`, `Ausgrenzung`, `Position` | the group master |
|
||||||
|
| `dbo.Selektionsgruppe` | `IDSelektionsgruppenstamm_FS`, `IDSelektionsstamm_FS`, `Position` | **membership** — which selection sits in which group, and in what order |
|
||||||
|
| `dbo.Selektionen` | `IDAdresse_FS`, `IDSelektionsstamm_FS` | **assignment** — which address holds which selection |
|
||||||
|
|
||||||
|
`<crmaktionen>` comes from a parallel pair: `dbo.CRMAktion` (master: `Code`,
|
||||||
|
`Bezeichnung`, `Aktiv`, `Status`, `AktivDatumVon/Bis`, …) and `dbo.CRMZuweisung`
|
||||||
|
(assignment via `IDAdresse_FS` + `IDCRMAktion_FS`).
|
||||||
|
|
||||||
|
### XML → column mapping
|
||||||
|
|
||||||
|
| XML | Source |
|
||||||
|
| --- | --- |
|
||||||
|
| `<idadresse>` | `Adresse.IDAdresse` (the authenticated account) |
|
||||||
|
| `<idperson>` | `AdressePerson.IDAdressePerson` |
|
||||||
|
| `<selektionsgruppe id>` / `bezeichnung` | `Selektionsgruppenstamm.IDSelektionsgruppenstamm` / `.Selektionsgruppe` |
|
||||||
|
| `<selektion id>` / `bezeichnung` | `Selektionsstamm.IDSelektionsstamm` / `.Selektion` |
|
||||||
|
| `selektion@auswahl` | `EXISTS (Selektionen WHERE IDAdresse_FS = <account> AND IDSelektionsstamm_FS = <id>)` — confirmed |
|
||||||
|
| `selektion@aenderbar` | **computed, not stored** — no such column exists anywhere (see below) |
|
||||||
|
| group order | `Selektionsgruppenstamm.Position`, `<ohne Gruppe>` first |
|
||||||
|
| selection order within a group | `Selektionsgruppe.Position` |
|
||||||
|
| `<crmaktion …>` | `CRMAktion` + `CRMZuweisung` (`auswahl` = assignment exists) |
|
||||||
|
|
||||||
|
### `aenderbar` is not a stored flag
|
||||||
|
|
||||||
|
A case-insensitive search for `aenderbar` / `auswahl` across **every column name in the
|
||||||
|
database** (1023 tables, `ABOBewegungsdaten` … `ZustiegsPlanungPersonStelle`, so the
|
||||||
|
export is complete and untruncated) returns no hit on any `Selektion*` table. The only
|
||||||
|
`*Aenderbar*` column in the entire schema is `AdresseAgenturZusatz.ZahlungsartAenderbar`,
|
||||||
|
which is unrelated. And the three core tables have no spare bit to hide it in:
|
||||||
|
`Selektionsstamm` has exactly four columns, `Selektionsgruppe` three, `Selektionen` two.
|
||||||
|
|
||||||
|
So `aenderbar` is **derived at response time**. Don't go looking for the column again —
|
||||||
|
this is what Query D and Query E are for.
|
||||||
|
|
||||||
|
The `<crmaktionen>` half of the document hints at the intended semantics: `CRMAktion`
|
||||||
|
*does* carry real flags (`Aktiv`, `Anzeige`, `AktivDatumVon`/`Bis`), and in the reference
|
||||||
|
sample the long-expired "Deal Der Woche KW40-2018" is `aenderbar="False"` while the
|
||||||
|
standing "NOMAIL" opt-out is `"True"`. `aenderbar` reads as *"the customer may still
|
||||||
|
toggle this"* — a validity/whitelist question, not a static per-row property.
|
||||||
|
|
||||||
|
Two orderings worth noting, because they rule out the obvious alternatives:
|
||||||
|
|
||||||
|
- Inside `TEAM` the ids run `… 1374, 1375, 1376, 1373, 1377 …` — neither id order nor
|
||||||
|
label order. That is `Selektionsgruppe.Position`.
|
||||||
|
- Within `Interessen` the labels *are* alphabetical, which is consistent with
|
||||||
|
`Position` having been maintained alphabetically there. Don't read it as a
|
||||||
|
different rule.
|
||||||
|
|
||||||
|
### The decisive observation: the catalog is group-driven
|
||||||
|
|
||||||
|
Every `Hausleitung *` row is emitted with `auswahl="False"` for an account that holds
|
||||||
|
none of them. So the endpoint does **not** list "the selections this address has" — it
|
||||||
|
lists **the whole published catalog** and then flags which entries the address holds.
|
||||||
|
|
||||||
|
`<selektionsgruppe id="0" bezeichnung="<ohne Gruppe>">` is the bucket for
|
||||||
|
selections with no (or a `0`) `Selektionsgruppe` membership row.
|
||||||
|
|
||||||
|
The practical consequence: **inserting a row into `dbo.Selektionen` can never make a
|
||||||
|
selection appear.** It can only flip `auswahl` from `False` to `True` on an entry that
|
||||||
|
the catalog already emits. A selection that is absent from the XML is absent from the
|
||||||
|
*catalog*, and the assignment is irrelevant until that is fixed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Query A — reconstruct the response
|
||||||
|
|
||||||
|
Run this for the account in question and diff its rows against the actual XML. A clean
|
||||||
|
match proves the reconstruction above; any mismatch belongs in the caveats section.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DECLARE @AddressId bigint = 141747;
|
||||||
|
|
||||||
|
SELECT COALESCE(sg.IDSelektionsgruppenstamm, 0) AS groupId,
|
||||||
|
COALESCE(sg.Selektionsgruppe, '<ohne Gruppe>') AS groupLabel,
|
||||||
|
s.IDSelektionsstamm AS selectionId,
|
||||||
|
s.Selektion AS selectionLabel,
|
||||||
|
CASE WHEN s.System = 0 THEN 'True' ELSE 'False' END AS aenderbar,
|
||||||
|
CASE WHEN sel.IDAdresse_FS IS NULL THEN 'False' ELSE 'True' END AS auswahl
|
||||||
|
FROM dbo.Selektionsstamm s
|
||||||
|
LEFT JOIN dbo.Selektionsgruppe sgm
|
||||||
|
ON sgm.IDSelektionsstamm_FS = s.IDSelektionsstamm
|
||||||
|
LEFT JOIN dbo.Selektionsgruppenstamm sg
|
||||||
|
ON sg.IDSelektionsgruppenstamm = sgm.IDSelektionsgruppenstamm_FS
|
||||||
|
LEFT JOIN dbo.Selektionen sel
|
||||||
|
ON sel.IDSelektionsstamm_FS = s.IDSelektionsstamm
|
||||||
|
AND sel.IDAdresse_FS = @AddressId
|
||||||
|
WHERE COALESCE(s.Status, '') <> 'L'
|
||||||
|
AND COALESCE(sg.Status, '') <> 'L'
|
||||||
|
ORDER BY COALESCE(sg.Position, -1), sgm.Position, s.Selektion;
|
||||||
|
```
|
||||||
|
|
||||||
|
(`Status = 'L'` is this database's soft-delete marker — see
|
||||||
|
`../bpn-connect-query-reference.md`.)
|
||||||
|
|
||||||
|
Note the join fans out: a selection can belong to **several** groups (confirmed —
|
||||||
|
`1292` is in both `10` and `91`), so this query emits one row per membership. The XML
|
||||||
|
shows a selection once per group it is published in.
|
||||||
|
|
||||||
|
`Selektionsgruppenstamm.Ausgrenzung` is *exclusion* semantics (mutually exclusive
|
||||||
|
group), not visibility — don't mistake it for a publish flag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Query B — diagnose a selection that never appears
|
||||||
|
|
||||||
|
Compares the suspect row against known-good neighbours: `1292`/`1293` (long-standing,
|
||||||
|
emitted, `aenderbar=False`) and `1462` (most recent one that *is* emitted,
|
||||||
|
`aenderbar=True`). Whatever column differs is the answer.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT s.IDSelektionsstamm,
|
||||||
|
s.Selektion,
|
||||||
|
s.Status,
|
||||||
|
s.System,
|
||||||
|
sgm.IDSelektionsgruppenstamm_FS AS groupId,
|
||||||
|
sgm.Position AS positionInGroup,
|
||||||
|
(SELECT COUNT(*) FROM dbo.Selektionen x
|
||||||
|
WHERE x.IDSelektionsstamm_FS = s.IDSelektionsstamm
|
||||||
|
AND x.IDAdresse_FS = 141747) AS assignedToAddress
|
||||||
|
FROM dbo.Selektionsstamm s
|
||||||
|
LEFT JOIN dbo.Selektionsgruppe sgm
|
||||||
|
ON sgm.IDSelektionsstamm_FS = s.IDSelektionsstamm
|
||||||
|
WHERE s.IDSelektionsstamm IN (1477, 1478, 1292, 1293, 1462)
|
||||||
|
ORDER BY s.IDSelektionsstamm;
|
||||||
|
```
|
||||||
|
|
||||||
|
### What Query B ruled out (address `141747`, 2026-08-04)
|
||||||
|
|
||||||
|
| id | Selektion | Status | System | groupId | posInGroup | assigned |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1292 | Admin | *(blank)* | 1 | 10 | 33 | 1 |
|
||||||
|
| 1292 | Admin | *(blank)* | 1 | **91** | 1 | 1 |
|
||||||
|
| 1293 | Manager | *(blank)* | 1 | **92** | 1 | 1 |
|
||||||
|
| 1293 | Manager | *(blank)* | 1 | 10 | 34 | 1 |
|
||||||
|
| 1462 | Hausleitung AGR | *(blank)* | 1 | 10 | 74 | 0 |
|
||||||
|
| 1477 | Preisrechner | *(blank)* | 1 | 10 | 75 | 0 |
|
||||||
|
| 1478 | Preisrechner Admin | *(blank)* | 1 | 10 | 76 | 1 |
|
||||||
|
|
||||||
|
Three conclusions:
|
||||||
|
|
||||||
|
1. **The missing-membership theory is dead.** `1477`/`1478` *are* in group `10` (TEAM),
|
||||||
|
at positions `75`/`76`, and `1478` *is* assigned to the address. On every column
|
||||||
|
available in these three tables, `1478` is indistinguishable from `1462` — which the
|
||||||
|
XML does emit. Whatever excludes them lives **outside**
|
||||||
|
`Selektionsstamm` / `Selektionsgruppe` / `Selektionen`.
|
||||||
|
2. **`aenderbar` is not derived from `System`.** `1292` (`aenderbar="False"`) and `1462`
|
||||||
|
(`aenderbar="True"`) both have `System = 1`. `Selektionsstamm` has no other column,
|
||||||
|
so `aenderbar` comes from somewhere else entirely.
|
||||||
|
3. **A selection can belong to several groups, and some groups are not published.**
|
||||||
|
`1292` is in `10` + `91`, `1293` is in `10` + `92`; groups `91`/`92` appear nowhere in
|
||||||
|
the XML, which only emits `0`, `10`, `83`. So there is a **group-level publish
|
||||||
|
filter** — and, suggestively, the two rows with a second (unpublished) group
|
||||||
|
membership are exactly the two that come back `aenderbar="False"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Query D — the agency whitelist (run this first)
|
||||||
|
|
||||||
|
`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
|
||||||
|
`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.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT ask.IDAdresse_FS AS agencyAddressId,
|
||||||
|
a.SuchName AS agencyName,
|
||||||
|
ask.IDSelektionsstamm_FS AS selectionId,
|
||||||
|
s.Selektion AS selectionLabel
|
||||||
|
FROM dbo.AgenturSelektionenKunde ask
|
||||||
|
JOIN dbo.Selektionsstamm s ON s.IDSelektionsstamm = ask.IDSelektionsstamm_FS
|
||||||
|
LEFT JOIN dbo.Adresse a ON a.IDAdresse = ask.IDAdresse_FS
|
||||||
|
ORDER BY ask.IDAdresse_FS, s.Selektion;
|
||||||
|
|
||||||
|
-- same shape, same question:
|
||||||
|
SELECT * FROM dbo.PartnerSelektionenKunde;
|
||||||
|
```
|
||||||
|
|
||||||
|
Compare the row set for the portal's agency address against a freshly captured response:
|
||||||
|
|
||||||
|
| Result | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| set == the 32 emitted entries | **this is the publish gate** — `1477`/`1478` just need rows here |
|
||||||
|
| set == the `aenderbar="True"` subset only | this is the `aenderbar` source |
|
||||||
|
| set is some third thing | note it in this doc and continue to C1 |
|
||||||
|
| table empty | both hypotheses dead — fall through to C1/C2/C3 |
|
||||||
|
|
||||||
|
**Result (2026-08-04): `dbo.AgenturSelektionenKunde` is empty.** The agency-whitelist
|
||||||
|
theory is dead. Do not re-run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Query E — group-count cross-check (the secondary `aenderbar` theory)
|
||||||
|
|
||||||
|
Query B hinted that the two `aenderbar="False"` rows were the two with a *second*,
|
||||||
|
unpublished group membership (`1292`→{10, 91}, `1293`→{10, 92}) while `aenderbar="True"`
|
||||||
|
`1462` sat in group `10` alone. Two data points is not a rule — this settles it in one run:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT s.IDSelektionsstamm,
|
||||||
|
s.Selektion,
|
||||||
|
COUNT(*) AS groupCount,
|
||||||
|
STRING_AGG(CAST(sgm.IDSelektionsgruppenstamm_FS AS varchar(10)), ',') AS groupIds
|
||||||
|
FROM dbo.Selektionsstamm s
|
||||||
|
JOIN dbo.Selektionsgruppe sgm ON sgm.IDSelektionsstamm_FS = s.IDSelektionsstamm
|
||||||
|
WHERE s.IDSelektionsstamm IN (1064,1065,1066,1067,1068,1069,1070,1156,1292,1293,
|
||||||
|
1299,1304,1377,1459,1461,1462,1477,1478)
|
||||||
|
GROUP BY s.IDSelektionsstamm, s.Selektion
|
||||||
|
ORDER BY s.IDSelektionsstamm;
|
||||||
|
```
|
||||||
|
|
||||||
|
Read against the known `aenderbar` values from the reference response:
|
||||||
|
|
||||||
|
- `aenderbar="False"`: `1070`, `1156`, `1292`, `1293`, `1299`, `1304`, `1377`
|
||||||
|
- `aenderbar="True"`: `1064`–`1069`, `1459`, `1461`, `1462`
|
||||||
|
|
||||||
|
If every `False` row has `groupCount > 1` and every `True` row has `groupCount = 1`, the
|
||||||
|
theory holds. Note it still would not explain `1478` (group `10` only, yet omitted), so
|
||||||
|
Query D remains the priority.
|
||||||
|
|
||||||
|
**Result (2026-08-04): the theory is dead.** Group membership does not correlate with
|
||||||
|
`aenderbar` at all:
|
||||||
|
|
||||||
|
| id | Selektion | groupCount | groupIds | `aenderbar` |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1064–1069 | *Interessen* | 1 | 83 | **True** |
|
||||||
|
| 1070 | E&P Teamer - allg. Merkmal | 1 | 10 | **False** |
|
||||||
|
| 1292 | Admin | 2 | 91, 10 | **False** |
|
||||||
|
| 1293 | Manager | 2 | 92, 10 | **False** |
|
||||||
|
| 1299 / 1304 / 1377 | Hausleitung SSL / DKS / KHH | 1 | 10 | **False** |
|
||||||
|
| 1459 / 1461 / 1462 | Hausleitung SVS / SHM / AGR | 1 | 10 | **True** |
|
||||||
|
| 1477 / 1478 | Preisrechner, Preisrechner Admin | 1 | 10 | *(not emitted)* |
|
||||||
|
|
||||||
|
`1299`/`1304`/`1377` and `1459`/`1461`/`1462` have **identical** group membership,
|
||||||
|
identical `Status`, identical `System` — and opposite `aenderbar`. The `1292`/`1293`
|
||||||
|
double membership was a coincidence.
|
||||||
|
|
||||||
|
Confirmed as a side effect: `1156` is absent from this result because the inner join
|
||||||
|
found no `Selektionsgruppe` row for it. `<ohne Gruppe>` really does mean "no membership
|
||||||
|
row", as modelled at the top of this document.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Query F — exhaust every remaining link to `Selektionsstamm`
|
||||||
|
|
||||||
|
After D and E, the schema export lists exactly these tables with an FK into
|
||||||
|
`Selektionsstamm`. This counts all of them at once for the rows whose behaviour differs,
|
||||||
|
which is the last thing the database can tell us:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT s.IDSelektionsstamm, s.Selektion,
|
||||||
|
(SELECT COUNT(*) FROM dbo.Selektionen x WHERE x.IDSelektionsstamm_FS = s.IDSelektionsstamm) AS addressAssignments,
|
||||||
|
(SELECT COUNT(*) FROM dbo.PartnerSelektionenKunde x WHERE x.IDSelektionsstamm_FS = s.IDSelektionsstamm) AS partnerLinks,
|
||||||
|
(SELECT COUNT(*) FROM dbo.LeistungSelektionenKunde x WHERE x.IDSelektionsstamm_FS = s.IDSelektionsstamm) AS serviceLinks,
|
||||||
|
(SELECT COUNT(*) FROM dbo.ReiseleiterSelektionen x WHERE x.IDSelektionsstamm_FS = s.IDSelektionsstamm) AS guideLinks,
|
||||||
|
(SELECT COUNT(*) FROM dbo.ReiseSelektionen x WHERE x.IDSelektionsstamm_FS = s.IDSelektionsstamm) AS travelLinks,
|
||||||
|
(SELECT COUNT(*) FROM dbo.ReisezielSelektionsstamm x WHERE x.IDSelektionsstamm_FS = s.IDSelektionsstamm) AS destinationLinks
|
||||||
|
FROM dbo.Selektionsstamm s
|
||||||
|
WHERE s.IDSelektionsstamm IN (1070,1156,1292,1293,1299,1304,1377,1459,1461,1462,1477,1478,1064)
|
||||||
|
ORDER BY s.IDSelektionsstamm;
|
||||||
|
```
|
||||||
|
|
||||||
|
The decisive comparison is `1377` (`aenderbar="False"`) vs `1462` (`"True"`) vs `1478`
|
||||||
|
(omitted). If all three rows are identical here too, then **no per-selection property in
|
||||||
|
this database distinguishes them**, and the rule is not in the schema at all — go to
|
||||||
|
"Where this leaves the investigation" below.
|
||||||
|
|
||||||
|
**Result (2026-08-04): all link counts are zero** for every row queried, except
|
||||||
|
`travelLinks` on `1070` (11) and `1156` (3) via `ReiseSelektionen` — neither of which
|
||||||
|
correlates with `aenderbar` or with being emitted. Assignment counts don't explain
|
||||||
|
anything either:
|
||||||
|
|
||||||
|
| id | assignments | emitted? | `aenderbar` |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1377 Hausleitung KHH | **0** | yes | False |
|
||||||
|
| 1462 Hausleitung AGR | 1 | yes | True |
|
||||||
|
| 1478 Preisrechner Admin | **3** | **no** | — |
|
||||||
|
|
||||||
|
`1377` is published with zero assignments; `1478` has three and is not published. This
|
||||||
|
exhausts every FK path into `Selektionsstamm` in the schema. **Stop querying the
|
||||||
|
database** — the answer is not in it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Query C — locate the publish gate
|
||||||
|
|
||||||
|
Point (1) means some table or config outside the three core tables decides what gets
|
||||||
|
emitted. Run these in order; the first hit explains everything.
|
||||||
|
|
||||||
|
**C1 — what distinguishes a published group from an unpublished one.** Groups `0`, `10`,
|
||||||
|
`83` are emitted; `91`, `92` are not:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT IDSelektionsgruppenstamm, Selektionsgruppe, Status, System, Ausgrenzung, Position
|
||||||
|
FROM dbo.Selektionsgruppenstamm
|
||||||
|
ORDER BY Position;
|
||||||
|
```
|
||||||
|
|
||||||
|
If exactly the emitted groups share a `Status`/`System`/`Position` value that `91`/`92`
|
||||||
|
lack, that's the group gate. It still won't explain `1478` (same group as `1462`), but
|
||||||
|
it pins down half the rule — and tells you whether `aenderbar` tracks membership in one
|
||||||
|
of these system groups.
|
||||||
|
|
||||||
|
**C2 — hunt for a published-selection whitelist in the config stores.** This is the
|
||||||
|
leading theory for `1478`: a config value enumerating exposed selection ids that was
|
||||||
|
maintained up to `1462` and never extended. This database does store id lists in
|
||||||
|
delimited string columns (`CRMAktion.IDsAgenturEingrenzung`,
|
||||||
|
`ImportAdressen.ZuweisungSelektionIDs`, `AdresseHistorie.SelektionsIDs`), so the pattern
|
||||||
|
exists. Search for a known-published id:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM dbo.Einstellung
|
||||||
|
WHERE Wert LIKE '%1462%' OR Wert LIKE '%1292%'
|
||||||
|
OR Bezeichnung LIKE '%elektion%' OR Beschreibung LIKE '%elektion%';
|
||||||
|
|
||||||
|
SELECT * FROM dbo.ProgrammEinstellung
|
||||||
|
WHERE Wert LIKE '%1462%' OR Wert LIKE '%1292%' OR Variable LIKE '%elektion%';
|
||||||
|
|
||||||
|
SELECT * FROM dbo.ConnectEinstellung
|
||||||
|
WHERE WertString LIKE '%1462%' OR WertString LIKE '%1292%';
|
||||||
|
```
|
||||||
|
|
||||||
|
**C3 — brute-force the whole schema** if C1/C2 come up empty. Find every wide text
|
||||||
|
column that mentions a published selection id:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DECLARE @needle nvarchar(20) = '1462';
|
||||||
|
DECLARE @sql nvarchar(max) = '';
|
||||||
|
|
||||||
|
SELECT @sql = @sql + 'SELECT ''' + QUOTENAME(t.name) + ''' AS tbl, ''' + QUOTENAME(c.name)
|
||||||
|
+ ''' AS col, COUNT(*) AS hits FROM dbo.' + QUOTENAME(t.name)
|
||||||
|
+ ' WHERE CAST(' + QUOTENAME(c.name) + ' AS nvarchar(max)) LIKE ''%'
|
||||||
|
+ @needle + '%'' HAVING COUNT(*) > 0;' + CHAR(10)
|
||||||
|
FROM sys.tables t
|
||||||
|
JOIN sys.columns c ON c.object_id = t.object_id
|
||||||
|
JOIN sys.types ty ON ty.user_type_id = c.user_type_id
|
||||||
|
WHERE ty.name IN ('nvarchar', 'varchar', 'nchar', 'char')
|
||||||
|
AND (c.max_length = -1 OR c.max_length >= 100)
|
||||||
|
AND t.name NOT LIKE 'Selektion%';
|
||||||
|
|
||||||
|
EXEC sp_executesql @sql; -- read-only; slow, run off-peak
|
||||||
|
```
|
||||||
|
|
||||||
|
**C4 — rule out staleness/caching first, it's free.** `1478` is structurally identical
|
||||||
|
to an emitted row, which is also what a cached catalog looks like. Re-fetch the XML now
|
||||||
|
and confirm the sample postdates the insert:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// bin/console or a scratch script
|
||||||
|
$client->getCrmAttributes($email, $password);
|
||||||
|
```
|
||||||
|
|
||||||
|
If BusPro caches the catalog in-process, an application-pool/service restart on the
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where this leaves the investigation
|
||||||
|
|
||||||
|
**Conclusion as of 2026-08-04, after Queries A–F: the schema search is exhausted.**
|
||||||
|
|
||||||
|
- `1478` and `1462` are identical on every column and every link the schema exposes —
|
||||||
|
same blank `Status`, same `System = 1`, same single membership in group `10`, adjacent
|
||||||
|
positions (76 vs 74), zero links in all five side tables. `1462` is emitted, `1478` is
|
||||||
|
not.
|
||||||
|
- `1377` (`aenderbar="False"`) and `1462` (`"True"`) are identical by the same measure.
|
||||||
|
Opposite flag.
|
||||||
|
- Assignment count is not it either: `1377` is published with zero assignments, `1478`
|
||||||
|
has three and is not published.
|
||||||
|
|
||||||
|
Rows that are indistinguishable in the database behave differently in the response. Every
|
||||||
|
column of the three core tables, every FK path into `Selektionsstamm`, and a
|
||||||
|
whole-schema column-name search have now been checked. So **the publish set and the
|
||||||
|
`aenderbar` flag are not derived from this database** — they live in the BusProNet
|
||||||
|
service layer: its own configuration, or an in-process cache of the catalog.
|
||||||
|
|
||||||
|
Only C2/C3 (a text search of the config stores, then of every wide text column) remain,
|
||||||
|
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 `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.
|
||||||
|
2. **The UI experiment.** Create a throwaway selection in the TEAM group *through the
|
||||||
|
BusPro UI*, then diff its rows against `1478` across `Selektionsstamm` /
|
||||||
|
`Selektionsgruppe` / `Selektionen`. If it appears in the XML, the diff names exactly
|
||||||
|
what a manual `INSERT` misses. If it does not, the gate is provably config/cache and
|
||||||
|
not per-row data.
|
||||||
|
3. **Ask the vendor.** "What determines which `Selektionsstamm` entries `SelektionCRM`
|
||||||
|
publishes, given group membership is not sufficient — and what drives `aenderbar`?"
|
||||||
|
Both are one-sentence answers for them and may not be discoverable from the schema at
|
||||||
|
all. Worth bundling into the open BPN thread.
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
- **`aenderbar` has no known source.** No such column exists in any of the 1023 tables;
|
||||||
|
`Selektionsstamm.System` (Query B), the agency whitelist (Query D) and group membership
|
||||||
|
(Query E) are all falsified. Treat it as unmodellable in a `bpn-connect` endpoint until
|
||||||
|
the vendor explains it — if `bpn-connect` must serve this response, the safe move is to
|
||||||
|
keep `SelektionCRM` on the socket API rather than guess, exactly as
|
||||||
|
`live-api-queries.md` recommends for `getMutableData()`.
|
||||||
|
- Groups `91` and `92` are known to exist and are never emitted; what they are is still
|
||||||
|
unexplained (Query C1). Record the answer here when you have it.
|
||||||
|
- **The publish gate is not identified.** Group membership is necessary but not
|
||||||
|
sufficient: `1478` sits in the published TEAM group with an assignment and is still
|
||||||
|
omitted. Query C is the open investigation.
|
||||||
|
- Consequently Query A **over-reports**: it will return `1477`/`1478` (and anything in
|
||||||
|
groups `91`/`92`) that the real XML omits. It reconstructs the shape of the response,
|
||||||
|
not yet its exact membership.
|
||||||
|
- `dbo.Selektionen` has no status column and no exported primary key, so a duplicate
|
||||||
|
assignment row may well be insertable. Check before inserting.
|
||||||
|
- The schema exports carry no PK/index catalog (noted in
|
||||||
|
`../bpn-connect-query-reference.md`); none of the queries here are
|
||||||
|
performance-sensitive, but a `bpn-connect` endpoint built on them would want indexes
|
||||||
|
on `Selektionen.IDAdresse_FS` and `Selektionsgruppe.IDSelektionsstamm_FS` confirmed.
|
||||||
|
- `Adresse` vs. `AdressePerson`: assignments hang off `IDAdresse`, not `IDPerson`.
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# Live socket-API endpoints — direct-MSSQL feasibility & queries
|
||||||
|
|
||||||
|
> **Authority: [`docs/buspro-database-reference.md`](../buspro-database-reference.md).**
|
||||||
|
> Schema semantics live there and win on any conflict. This file is retained for the
|
||||||
|
> 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 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"
|
||||||
|
counts* are computed by BusPro's allocation logic (not a flat column) and should either
|
||||||
|
stay on the socket API or be reproduced with an explicitly-approximated query;
|
||||||
|
`getMutableData()` is pure business-rule logic — keep it on the socket API.
|
||||||
|
|
||||||
|
| Endpoint (type) | Tier | Replicable in SQL? |
|
||||||
|
|---|---|---|
|
||||||
|
| `getProducts()` (`PRODUKTE`) | 1 | ✅ trivial |
|
||||||
|
| `getAgencies()` (`AGENTUREN`) | 1 | ✅ structural (confirm agency filter + address joins) |
|
||||||
|
| `getTravelData()` (`PRODUKTDATEN`) | 1 | ✅ = `travel-queries.md` Query B |
|
||||||
|
| `getAvailabilities()` (`VERFUEGBARKEIT`) | 2 | ⚠ structure yes; `frei` count is computed |
|
||||||
|
| `getAvailabilitiesExtended()` (`VERFUEGBARKEIT2`) | 2 | ⚠ structure yes (we have the extra fields); `frei` computed |
|
||||||
|
| `getHotelAvailability()` (`VERFUEGBARKEITHOTEL`) | 2 | ⚠ `frei` = contingent − active bookings |
|
||||||
|
| `getMutableData()` (`MOEGLICHEAENDERUNGEN`) | 3 | ✋ business rules — keep on socket API |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 1 — structural, replicate as-is
|
||||||
|
|
||||||
|
### `getProducts()` → `Product{id, code, name}`
|
||||||
|
Parser keeps only rows with a non-empty `code` (`ProductsParser`).
|
||||||
|
```sql
|
||||||
|
SELECT IDProdukt AS id, Code AS code, Bezeichnung AS name
|
||||||
|
FROM dbo.Produkt
|
||||||
|
WHERE Code IS NOT NULL AND Code <> ''
|
||||||
|
ORDER BY Bezeichnung;
|
||||||
|
```
|
||||||
|
|
||||||
|
### `getTravelData(idprodukt[, hotelId])` → `Travel`
|
||||||
|
This is the same payload as `travel-queries.md` **Query B** (header + services +
|
||||||
|
pickups + hotels/rooms + tags). The *static* structure is fully covered there. Its
|
||||||
|
embedded live `preis`/`frei` values overlap Tier 2 — source those the same way you
|
||||||
|
decide to handle availability below. No new SQL needed; reuse Query B.
|
||||||
|
|
||||||
|
### `getAgencies()` → `Agency{id, name, code, street, postCode, city, phone}`
|
||||||
|
`name/code` map to `Adresse.SuchName` + a code column; `street/postCode/city` live in
|
||||||
|
`AdresseAnschrift`, `phone` in `AdresseKommunikation` (1-to-N → pick the primary row).
|
||||||
|
Two things to pin with the discovery queries below: (1) **which `Adresse` rows are
|
||||||
|
agencies**, and (2) the exact `AdresseAnschrift`/`AdresseKommunikation` column + primary-row flag.
|
||||||
|
```sql
|
||||||
|
-- skeleton (fill the agency filter + verified column names from discovery):
|
||||||
|
SELECT a.IDAdresse AS id,
|
||||||
|
a.SuchName AS name,
|
||||||
|
a.Code AS code, -- ⚠ confirm code column on Adresse
|
||||||
|
an.Strasse AS street, -- ⚠ from AdresseAnschrift
|
||||||
|
an.Plz AS postCode,
|
||||||
|
an.Ort AS city,
|
||||||
|
ak.Wert AS phone -- ⚠ from AdresseKommunikation (primary tel)
|
||||||
|
FROM dbo.Adresse a
|
||||||
|
LEFT JOIN dbo.AdresseAnschrift an ON an.IDAdresse_FS = a.IDAdresse /* AND an.IsPrimary */
|
||||||
|
LEFT JOIN dbo.AdresseKommunikation ak ON ak.IDAdresse_FS = a.IDAdresse /* AND ak.Art = 'TEL' */
|
||||||
|
WHERE /* a is an agency — see discovery */ 1 = 0;
|
||||||
|
```
|
||||||
|
Discovery:
|
||||||
|
```sql
|
||||||
|
-- (1) how agencies are flagged (inspect the org/role tables):
|
||||||
|
SELECT TOP 20 * FROM dbo.AdresseAgenturOrganisation;
|
||||||
|
SELECT IDArtAgenturOrganisation, * FROM dbo.ArtAgenturOrganisation; -- the agency "types"
|
||||||
|
-- (2) the address + comm column names / primary-row flags:
|
||||||
|
SELECT TOP 5 * FROM dbo.AdresseAnschrift;
|
||||||
|
SELECT TOP 5 * FROM dbo.AdresseKommunikation;
|
||||||
|
SELECT TOP 5 * FROM dbo.Adresse; -- find the 'code' column
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 2 — availability: structure is flat, the **`frei` count is computed**
|
||||||
|
|
||||||
|
The response models (`Availability`, `ExtendedAvailability`, `Room`) carry per-item
|
||||||
|
`status`, `preis`, structural attrs (dates/ages/mandatory/board) **and** `frei`. The
|
||||||
|
first set is a flat read; **`frei` is not stored**.
|
||||||
|
|
||||||
|
**Why `frei` is computed (validated):** `Leistung.Kontingent` is a fixed planning
|
||||||
|
allotment, not the remaining free count — e.g. service `155234` has `Kontingent=2500`
|
||||||
|
but `2945` booking rows (bookings *exceed* the allotment). So:
|
||||||
|
```
|
||||||
|
frei = Kontingent − (active, non-cancelled, non-expired bookings) [± pooled contingent]
|
||||||
|
```
|
||||||
|
with three real complications:
|
||||||
|
- **Cancellations**: `BuchungTNLeistung` has no own status; join up via `IDBuchungTN_FS`
|
||||||
|
→ `BuchungTN` → `Buchung` and exclude Storno (the `BuchungStatus*` flags / Storno date).
|
||||||
|
- **Option expiry**: `BuchungTNLeistung.OptionsDatum` + `Leistung.KontingentVerfallTage`
|
||||||
|
— expired options free their slots back.
|
||||||
|
- **Pooled contingents**: `Leistung.IDLeistung_FS_KontingentAUS` / `KontingentAusPartner`
|
||||||
|
— a service may draw from another service's pool (or a `PartnerKontingent`), so its
|
||||||
|
`frei` depends on a sibling, not just its own bookings.
|
||||||
|
|
||||||
|
> **Recommendation:** keep the *authoritative* `frei` on the socket API
|
||||||
|
> (`getAvailabilities`/`getHotelAvailability`). If you want it in SQL, treat it as a
|
||||||
|
> documented **approximation** and validate row-by-row against the socket API before
|
||||||
|
> trusting it for booking decisions. The structural fields below are safe to serve from SQL.
|
||||||
|
|
||||||
|
### `getAvailabilities()` / `getAvailabilitiesExtended()` — structural part
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
l.IDLeistung AS serviceId,
|
||||||
|
l.Status AS status,
|
||||||
|
lp.Preis AS price,
|
||||||
|
l.Kontingent AS allotment, -- NOT 'frei' (see calc above)
|
||||||
|
-- extended-only fields (VERFUEGBARKEIT2):
|
||||||
|
l.Termin_VON AS dateFrom, l.Termin_BIS AS dateTo,
|
||||||
|
l.Alter_VON AS ageFrom, l.Alter_BIS AS ageTo,
|
||||||
|
l.Pflichtmerkmal AS mandatory
|
||||||
|
FROM dbo.ReiseLeistung rl
|
||||||
|
JOIN dbo.Leistung l ON l.IDLeistung = rl.IDLeistung_FS
|
||||||
|
LEFT JOIN dbo.LeistungPreis lp ON lp.IDLeistung_FS = l.IDLeistung AND lp.IDArtPreisSchema_FS = 1
|
||||||
|
WHERE rl.IDReise_FS = @DateId
|
||||||
|
ORDER BY l.Art, l.Unterart;
|
||||||
|
-- travel-level 'buchungstatusmoeglich' / 'status' come from the Reise + the allowed
|
||||||
|
-- ArtBuchungsStatus set; the extended 'uhrzeit_von'/'hinweis' come from the pickup
|
||||||
|
-- time plan (ZustiegZeitplan) and a Leistung text — see travel-queries.md B2/B2b.
|
||||||
|
```
|
||||||
|
The live `frei` per `serviceId` = the calc above (see recommendation).
|
||||||
|
|
||||||
|
### `getHotelAvailability(idreise, idpartner, terminbis)` → `Room{frei, status, preis, …}`
|
||||||
|
Rooms/price/`status` are the `travel-queries.md` **B3** chain;
|
||||||
|
`status` = `PartnerKontingent.BuchungsStatus` (`''`/`S`/`A`). `frei` = `PartnerKontingent.Anzahl`
|
||||||
|
**minus active hotel bookings** for that `(IDAdresse, IDZimmer, date-span)` — same
|
||||||
|
cancellation/expiry caveats as above.
|
||||||
|
|
||||||
|
### Discovery — quantify the `frei` calc against the socket API
|
||||||
|
```sql
|
||||||
|
-- active (non-storno) booking rows for a service, with option dates:
|
||||||
|
SELECT btl.IDLeistung_FS, COUNT(*) AS rows_total,
|
||||||
|
SUM(CASE WHEN btl.OptionsDatum IS NULL OR btl.OptionsDatum >= GETDATE() THEN 1 ELSE 0 END) AS rows_live
|
||||||
|
FROM dbo.BuchungTNLeistung btl
|
||||||
|
JOIN dbo.BuchungTN bt ON bt.IDBuchungTN = btl.IDBuchungTN_FS
|
||||||
|
JOIN dbo.Buchung b ON b.IDBuchung = bt.IDBuchung_FS -- ⚠ confirm FK name
|
||||||
|
WHERE btl.IDLeistung_FS = @ServiceId
|
||||||
|
/* AND b.<storno flag> = 0 */ -- ⚠ confirm Storno column
|
||||||
|
GROUP BY btl.IDLeistung_FS;
|
||||||
|
-- then compare Leistung.Kontingent − rows_live vs the socket-API 'frei' for the same service.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 3 — `getMutableData()` — keep on the socket API
|
||||||
|
|
||||||
|
Returns per-aspect `möglich` (bool) + `möglichbiszum` (deadline) for: participant
|
||||||
|
count, transportation, pickup, accommodation, additional services, participant data.
|
||||||
|
These are **derived from booking rules / change-cutoff config / departure timing**
|
||||||
|
(not a flat table). Reproducing them in SQL would mean re-implementing BusPro's
|
||||||
|
change-deadline logic and risks silent divergence. Recommendation: leave this on the
|
||||||
|
socket API; don't migrate it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary for the Go proxy
|
||||||
|
- **Migrate now (SQL):** `getProducts`, `getAgencies` (after the agency-filter discovery),
|
||||||
|
`getTravelData` (reuse Query B), and the **structural** fields of the two availability
|
||||||
|
endpoints.
|
||||||
|
- **Keep computed:** the live `frei` counts — socket API, or an explicitly-validated
|
||||||
|
approximation.
|
||||||
|
- **Keep on socket:** `getMutableData`.
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# Schema refresh & Zustieg-pricing verification queries
|
||||||
|
|
||||||
|
> **Superseded by [`docs/buspro-database-reference.md`](../buspro-database-reference.md) §12**,
|
||||||
|
> which carries the refresh queries and the verification log. Retained only for the
|
||||||
|
> 2026-08-04 run detail; safe to delete once that is no longer of interest.
|
||||||
|
|
||||||
|
Read-only queries to (a) check whether the June schema dump still matches
|
||||||
|
production after a BusPro update, (b) regenerate it if not, and (c) resolve the
|
||||||
|
open `Abfahrtsortzuschlag (Rückfahrt)` question from the database side.
|
||||||
|
|
||||||
|
Current dump: `BusPronet_20260601-1517.csv` (columns),
|
||||||
|
`BusPronet_20260601-1518.csv` (foreign keys), exported 2026-06-01.
|
||||||
|
|
||||||
|
**All queries are `SELECT`-only.** Run in order — Query 1 decides whether 2–4 are
|
||||||
|
needed at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Drift check — run this first
|
||||||
|
|
||||||
|
Cheapest, highest signal. If it returns nothing, the June dump is still accurate
|
||||||
|
and the re-export can be skipped.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT s.name AS schema_name, o.name AS object_name, o.type_desc,
|
||||||
|
o.create_date, o.modify_date
|
||||||
|
FROM sys.objects o
|
||||||
|
JOIN sys.schemas s ON s.schema_id = o.schema_id
|
||||||
|
WHERE o.is_ms_shipped = 0
|
||||||
|
AND (o.create_date > '2026-06-01' OR o.modify_date > '2026-06-01')
|
||||||
|
ORDER BY o.modify_date DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `modify_date` moves on metadata changes, so a long list is expected after a
|
||||||
|
version upgrade. What matters is whether anything under `Zustieg*`, `Leistung*`,
|
||||||
|
`Buchung*` appears.
|
||||||
|
|
||||||
|
**Result 2026-08-04.** Every object reports `create_date` **2026-06-03** — the
|
||||||
|
database was restored/rebuilt that day, so `create_date` is a restore artifact and
|
||||||
|
the `> '2026-06-01'` filter matches everything. Use `modify_date` after the restore
|
||||||
|
instead. Only two genuine clusters exist:
|
||||||
|
|
||||||
|
- **2026-06-11** — six new tables, all absent from `…-1517.csv`: `Reichweite`,
|
||||||
|
`ReichweiteZeitraum`, `ReichweiteZeitraumVonBis`, `ReichweiteRestFarbe`,
|
||||||
|
`LadungTankungBetriebshof`, `ConnectViafintechSlip`.
|
||||||
|
- **2026-07-02** — modified: `Tachostand`, `GutscheinAktion`,
|
||||||
|
`AdressePersonalZusatz`, `BlockPlanung`, `Block`, `LeistungZusatz`,
|
||||||
|
`ConnectViafintechSlip`, `TourPlanung`.
|
||||||
|
|
||||||
|
**Nothing under `Zustieg*`, `ZustiegPreis`, `ArtZustiegPreis`,
|
||||||
|
`LeistungBefoerderung` or `BuchungTNZuschlag` changed after the restore.** The June
|
||||||
|
dump is accurate for the booking and boarding areas; the only staleness is the six
|
||||||
|
fleet/payment tables above. For future runs, filter on `o.modify_date > '2026-06-03'`
|
||||||
|
to skip the restore baseline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Columns — regenerates `…-1517.csv`
|
||||||
|
|
||||||
|
Column order and names match the existing header exactly, so the output is a
|
||||||
|
drop-in replacement. Export semicolon-separated with quoted values, same settings
|
||||||
|
as the June file.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT s.name AS schema_name,
|
||||||
|
t.name AS table_name,
|
||||||
|
c.name AS column_name,
|
||||||
|
ty.name AS data_type,
|
||||||
|
c.max_length,
|
||||||
|
c.precision,
|
||||||
|
c.scale,
|
||||||
|
c.is_nullable,
|
||||||
|
c.is_identity,
|
||||||
|
dc.definition AS default_definition
|
||||||
|
FROM sys.tables t
|
||||||
|
JOIN sys.schemas s ON s.schema_id = t.schema_id
|
||||||
|
JOIN sys.columns c ON c.object_id = t.object_id
|
||||||
|
JOIN sys.types ty ON ty.user_type_id = c.user_type_id
|
||||||
|
LEFT JOIN sys.default_constraints dc ON dc.object_id = c.default_object_id
|
||||||
|
ORDER BY t.name, c.column_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Foreign keys — regenerates `…-1518.csv`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT fk.name AS foreign_key_name,
|
||||||
|
cs.name AS child_schema, ct.name AS child_table, cc.name AS child_column,
|
||||||
|
ps.name AS parent_schema, pt.name AS parent_table, pc.name AS parent_column
|
||||||
|
FROM sys.foreign_keys fk
|
||||||
|
JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id
|
||||||
|
JOIN sys.tables ct ON ct.object_id = fkc.parent_object_id
|
||||||
|
JOIN sys.schemas cs ON cs.schema_id = ct.schema_id
|
||||||
|
JOIN sys.columns cc ON cc.object_id = fkc.parent_object_id
|
||||||
|
AND cc.column_id = fkc.parent_column_id
|
||||||
|
JOIN sys.tables pt ON pt.object_id = fkc.referenced_object_id
|
||||||
|
JOIN sys.schemas ps ON ps.schema_id = pt.schema_id
|
||||||
|
JOIN sys.columns pc ON pc.object_id = fkc.referenced_object_id
|
||||||
|
AND pc.column_id = fkc.referenced_column_id
|
||||||
|
ORDER BY ct.name, fk.name, fkc.constraint_column_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Primary keys & indexes — the documented gap
|
||||||
|
|
||||||
|
`bpn-connect-query-reference.md` records that PK/index data was expected but
|
||||||
|
missing, and that identity flags are not a reliable PK substitute here. This closes
|
||||||
|
it. Suggested filename: `BusPronet_<date>-pk-index.csv`.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT s.name AS schema_name,
|
||||||
|
t.name AS table_name,
|
||||||
|
i.name AS index_name,
|
||||||
|
i.type_desc,
|
||||||
|
i.is_primary_key,
|
||||||
|
i.is_unique,
|
||||||
|
i.is_unique_constraint,
|
||||||
|
ic.key_ordinal,
|
||||||
|
c.name AS column_name,
|
||||||
|
ic.is_included_column,
|
||||||
|
ic.is_descending_key
|
||||||
|
FROM sys.indexes i
|
||||||
|
JOIN sys.tables t ON t.object_id = i.object_id
|
||||||
|
JOIN sys.schemas s ON s.schema_id = t.schema_id
|
||||||
|
JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
|
||||||
|
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
|
||||||
|
WHERE i.type > 0 -- exclude heaps
|
||||||
|
ORDER BY t.name, i.is_primary_key DESC, i.name, ic.key_ordinal;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Boarding-price types — `ArtZustiegPreis`
|
||||||
|
|
||||||
|
**Answered 2026-08-04:** this is a **tariff-scheme** lookup, *not* a direction
|
||||||
|
lookup. Active schemes are 36 `Woche26/27`, 37 `WE26/27`, 38 `Event26/27`,
|
||||||
|
39 `SER26/27` (plus legacy A–T at `Status = 'L'`). There is no Hinfahrt/Rückfahrt
|
||||||
|
concept in this table.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT IDArtZustiegPreis, Code, Kurztext, Langtext, Status, Hierarchie
|
||||||
|
FROM dbo.ArtZustiegPreis
|
||||||
|
ORDER BY Hierarchie, IDArtZustiegPreis;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Configured prices for boarding point 18
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT zp.IDZustiegPreis,
|
||||||
|
zp.IDZustieg_FS,
|
||||||
|
z.Zustiegcode, z.Ort, z.Plz,
|
||||||
|
zp.IDArtZustiegPreis_FS,
|
||||||
|
ap.Code AS PreisArtCode, ap.Kurztext AS PreisArt,
|
||||||
|
zp.Preis, zp.PreisProvisionPflichtig,
|
||||||
|
zp.Sockelbetrag, zp.SockelbetragProvisionPflichtig
|
||||||
|
FROM dbo.ZustiegPreis zp
|
||||||
|
JOIN dbo.Zustieg z ON z.IDZustieg = zp.IDZustieg_FS
|
||||||
|
LEFT JOIN dbo.ArtZustiegPreis ap ON ap.IDArtZustiegPreis = zp.IDArtZustiegPreis_FS
|
||||||
|
WHERE zp.IDZustieg_FS = 18
|
||||||
|
ORDER BY zp.IDArtZustiegPreis_FS;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Answered 2026-08-04:** three rows for point 18 (`ESS-Route`, Essen), one per
|
||||||
|
active tariff scheme — 36 → **17,90**, 37 → 12,90, 38 → 19,90. Prices are keyed by
|
||||||
|
(point, tariff scheme), **not** by direction. The export's
|
||||||
|
`<zustiege_rueck preis="17,90">` is therefore the same row surfaced through the
|
||||||
|
return service, not a separately configured return price.
|
||||||
|
|
||||||
|
## 7. How the two bus services resolve their boarding price — the mechanism
|
||||||
|
|
||||||
|
`LeistungBefoerderung` carries **`IDArtZustiegPreis_FS`** and
|
||||||
|
**`Richtung_Hin_Rueck`**, so each transport service selects which price type
|
||||||
|
applies. This query shows what outbound service 194779 and return service 194776
|
||||||
|
are configured to use.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT l.IDLeistung, l.Bezeichnung, l.Art, l.Unterart,
|
||||||
|
lb.Richtung_Hin_Rueck,
|
||||||
|
lb.IDArtZustiegPreis_FS,
|
||||||
|
ap.Code AS PreisArtCode, ap.Kurztext AS PreisArt,
|
||||||
|
lb.IDArtZustiegZeitplan_FS,
|
||||||
|
lb.ZustiegeGesperrt,
|
||||||
|
lb.IDZustieg_FS_Basisstelle,
|
||||||
|
lb.ZustiegIDs
|
||||||
|
FROM dbo.LeistungBefoerderung lb
|
||||||
|
JOIN dbo.Leistung l ON l.IDLeistung = lb.IDLeistung_FS
|
||||||
|
LEFT JOIN dbo.ArtZustiegPreis ap ON ap.IDArtZustiegPreis = lb.IDArtZustiegPreis_FS
|
||||||
|
WHERE l.IDLeistung IN (194779, 194776);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Answered 2026-08-04 — configuration is symmetric, config cause excluded:**
|
||||||
|
|
||||||
|
| Leistung | Richtung | `IDArtZustiegPreis_FS` | `ZustiegeGesperrt` | `ZustiegIDs` |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 194779 Bus-Hinfahrt | H | 36 (Woche26/27) | 0 | 3,4,18,86,320,470,473 |
|
||||||
|
| 194776 Bus-Rückfahrt | R | 36 (Woche26/27) | 0 | 3,4,18,86,320,470,473 |
|
||||||
|
|
||||||
|
Both legs resolve to the same 17,90 and both have point 18 enabled. The differing
|
||||||
|
BusPro behaviour is therefore not explained by product configuration.
|
||||||
|
|
||||||
|
## 8. Surcharge types — `ArtZuschlag`
|
||||||
|
|
||||||
|
**Answered 2026-08-04:** a discounts/fees lookup (Zwei-Wochen-Rabatt,
|
||||||
|
Kinderermäßigung, Bearbeitungsgebühr, Preiskorrektur, …). No direction concept and
|
||||||
|
**no `Abfahrtsortzuschlag` entry** — so the `SON`/`TRA` positions in the booking
|
||||||
|
response are computed transport surcharges, not `BuchungTNZuschlag` rows. Query 9
|
||||||
|
below is aimed at the wrong table and can be skipped.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT IDArtZuschlag, Code, Kurztext, Langtext, Status, Hierarchie
|
||||||
|
FROM dbo.ArtZuschlag
|
||||||
|
ORDER BY Hierarchie, IDArtZuschlag;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Booked surcharge rows for an affected booking
|
||||||
|
|
||||||
|
The decisive one *if* a completed booking exhibiting the fault is available: does
|
||||||
|
the working booking persist two `BuchungTNZuschlag` rows and the failing one only
|
||||||
|
one?
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT b.Vorgang,
|
||||||
|
tn.IDBuchungTN,
|
||||||
|
z.IDBuchungTNZuschlag,
|
||||||
|
z.BZText, z.Text,
|
||||||
|
z.Art,
|
||||||
|
z.IDZustieg_FS,
|
||||||
|
az.Code AS ZuschlagArtCode, az.Kurztext AS ZuschlagArt,
|
||||||
|
z.BZPreisMitProvision, z.BZPreisOhneProvision
|
||||||
|
FROM dbo.BuchungTNZuschlag z
|
||||||
|
JOIN dbo.BuchungTN tn ON tn.IDBuchungTN = z.IDBuchungTN_FS
|
||||||
|
JOIN dbo.Buchung b ON b.IDBuchung = tn.IDBuchung_FS
|
||||||
|
LEFT JOIN dbo.ArtZuschlag az ON az.IDArtZuschlag = z.IDArtZuschlag_FS
|
||||||
|
WHERE b.Vorgang IN ('<Vorgang-1>', '<Vorgang-2>')
|
||||||
|
ORDER BY b.Vorgang, tn.IDBuchungTN, z.IDBuchungTNZuschlag;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Caveat:** both captured cases were `<buchungsart>Anfrage</buchungsart>` inquiries,
|
||||||
|
and our flow only commits at step 4 — so these rows may not exist for them. Use the
|
||||||
|
`Vorgang` of an actually completed booking that showed the short total. If none
|
||||||
|
exists, skip this query; queries 5–7 carry the argument on their own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to send back
|
||||||
|
|
||||||
|
Query 1's result, plus 5, 6, 7 and 8 as plain result sets — those four are small and
|
||||||
|
answer the pricing question directly. Queries 2–4 only if query 1 shows drift, as
|
||||||
|
CSV files replacing the ones in this directory.
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
# Travel catalog — direct MSSQL queries (to replace the XML export)
|
||||||
|
|
||||||
|
> **Authority: [`docs/buspro-database-reference.md`](../buspro-database-reference.md).**
|
||||||
|
> Schema semantics, conventions and confidence marks live there and win on any conflict.
|
||||||
|
> This file is retained as a **query cookbook** — it holds worked SQL (included-services
|
||||||
|
> A2a/A2b, the full room-pricing chain B3) not reproduced in the reference.
|
||||||
|
|
||||||
|
Purpose: reproduce, straight from the BusPro MSSQL tables, the data the Symfony app
|
||||||
|
currently parses from the `Ziel_*.xml` exports, so the Go proxy can serve it without
|
||||||
|
the SFTP/XML round-trip. Two read paths:
|
||||||
|
|
||||||
|
- **Query A — travel list:** every bookable departure + product + dates (+ hotels,
|
||||||
|
+ a minimal bookable-services list).
|
||||||
|
- **Query B — detail:** everything for one selected `(date, hotel)` combination.
|
||||||
|
|
||||||
|
Validated against the live DB using the sample `var/xmlexport/Ziel_1121.xml`
|
||||||
|
(Produkt `1121`, departure `Reise.IDReise=12665`, hotel `Adresse.IDAdresse=224923`,
|
||||||
|
room-type `Zimmerart 1413`, pickup `Zustieg 494`). Columns verified in
|
||||||
|
`docs/buspronet-schema/*.csv`. Header, services, pickups, hotels, room pricing,
|
||||||
|
availability and tags are all confirmed against live data; the "included services"
|
||||||
|
list for Query A (§A2) is defined as price-`0.00` services + the 10 hardcoded
|
||||||
|
"Leistungen" selection groups (ids baked in). Nothing left to resolve.
|
||||||
|
|
||||||
|
## The naming trap (read first)
|
||||||
|
|
||||||
|
The XML export **renames** the core entities — do not map by name:
|
||||||
|
|
||||||
|
| XML term | Real table | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `<reise idbuspro=1121>` | **`Produkt`** | the travel *product*: `IDProdukt`, `Code`, `Bezeichnung` |
|
||||||
|
| `<termin idprodukt=1121 idbuspro=12665>` | **`Reise`** | the **dated departure**: `IDReise=12665`, `IDProdukt_FS=1121`, `Code`, `TerminVon/Bis`, `ReiseArt`, `Status`, `StornoDatum` |
|
||||||
|
| DB `Termin` table | — | **ignore**: generic appointments (odometer/repairs) |
|
||||||
|
| `<leistung>` (lei_befoerderung / lei_sonstiges) | `ReiseLeistung → Leistung` | `Leistung.Art` = `BEF` / `SON`; price via `LeistungPreis` |
|
||||||
|
| `<hotel idbuspro=224923>` + rooms | `ReisePartner` (+ `Partner*` subsystem) | **not** `ReiseLeistung`; keyed by the hotel `Adresse` |
|
||||||
|
| `<zustieg>` | `LeistungBefoerderung.ZustiegIDs` → `Zustieg` (+ `ZustiegZeitplan` for the time) | |
|
||||||
|
| `<selektion>` / `<selektiongruppe>` | `ReiseSelektionen → Selektionsstamm` (+ `Selektionsgruppe → Selektionsgruppenstamm`) | |
|
||||||
|
|
||||||
|
`Produkt 1—N Reise`. **`Reise.IDReise` is the "date id"** and the anchor for all
|
||||||
|
per-departure data.
|
||||||
|
|
||||||
|
**Two distinct status axes** — don't conflate them:
|
||||||
|
1. **Departure lifecycle** — `Reise.Status` (`nvarchar(2)`), observed `''` ≈ 9767,
|
||||||
|
`S` ≈ 945, `L` ≈ 845. Confirmed against the live DB:
|
||||||
|
- `''` = active/bookable.
|
||||||
|
- `S` = **Storno** (cancelled): every `S` row has `StornoDatum` set.
|
||||||
|
- `L` = **template / future master** (not a real sellable departure): every `L`
|
||||||
|
row has `StornoDatum NULL`, far-future `TerminVon` (2029–2035) and template-style
|
||||||
|
`Code`s (`MRAGRMP`, no date suffix). `Leistung.Status` mirrors this (`''` = 196 504,
|
||||||
|
`L` = 1 672 — the leistungen attached to those template departures).
|
||||||
|
2. **Availability / booking status** — booking-state codes, **not** `Leistung.Status`
|
||||||
|
(which only holds `''`/`L`). Canonical dictionary = **`dbo.ArtBuchungsStatus`**
|
||||||
|
(`Code`), confirmed values:
|
||||||
|
|
||||||
|
| Code | Kurztext | meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `F` | Fest | firm/final booking (available) |
|
||||||
|
| `O` | Option | held option |
|
||||||
|
| `A` | Anfrage | on request / inquiry-only (groups-inquiry path) |
|
||||||
|
| `W` | Warteliste | waitlist |
|
||||||
|
| `S` | Storno | cancelled |
|
||||||
|
| `U` | Umbuchung | rebooking |
|
||||||
|
|
||||||
|
The **working column** for hotel availability is **`PartnerKontingent.BuchungsStatus`**
|
||||||
|
(`nvarchar(2)`) — confirmed to carry these: observed `''` ≈ 19 664 (free/Fest),
|
||||||
|
`S` ≈ 12 215 (stop), `A` ≈ 2 060 (on request). So per room/date: `''` = bookable,
|
||||||
|
`S` = not bookable, `A` = inquiry-only. (The same codes also appear via
|
||||||
|
`…IDArtBuchungsstatus_FS` on individual bookings such as `ZusatzZimmer`.) Transport
|
||||||
|
`<status_hin>`/`<status_rueck>` is not in `Leistung.Status`; treat it as derived from
|
||||||
|
the transport `Kontingent` count.
|
||||||
|
|
||||||
|
Bookable filter (departure-level): `Reise.Status = '' AND Reise.StornoDatum IS NULL`
|
||||||
|
(excludes both `S` Storno and `L` templates), then apply `PartnerKontingent.BuchungsStatus`
|
||||||
|
per room/date (exclude `S`, treat `A` as inquiry-only).
|
||||||
|
|
||||||
|
Note the XML repeats `lei_sonstiges` under each hotel; in the DB the services exist
|
||||||
|
**once** on the departure (`ReiseLeistung`).
|
||||||
|
|
||||||
|
Conventions: ids `bigint`→`int64`; prices `decimal(18,2)`; `_FS` = foreign key (many
|
||||||
|
soft/unenforced — join defensively); bracket non-ASCII columns (`[Reisebüro]`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Query A — travel list
|
||||||
|
|
||||||
|
### A1. Departures + product + dates (header)
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
p.IDProdukt AS ProductId, p.Code AS ProductCode, p.Bezeichnung AS ProductLabel,
|
||||||
|
r.IDReise AS DateId, r.Code AS DateCode,
|
||||||
|
r.ReiseArt AS TravelType, -- XML reiseart, e.g. 'F'
|
||||||
|
r.Bezeichnung AS DateLabel,
|
||||||
|
r.TerminVon AS DateFrom, r.TerminBis AS DateTo
|
||||||
|
FROM dbo.Reise AS r
|
||||||
|
JOIN dbo.Produkt AS p ON p.IDProdukt = r.IDProdukt_FS
|
||||||
|
WHERE r.Status = '' AND r.StornoDatum IS NULL -- bookable (confirm status codes)
|
||||||
|
ORDER BY r.TerminVon, p.Code;
|
||||||
|
```
|
||||||
|
Customer description (XML `<text>`) = `dbo.ReiseText.ExternText` (`IDReise_FS`) — join if wanted.
|
||||||
|
|
||||||
|
### A2. Included-services list per departure
|
||||||
|
"Minimum services" = the **included** services (no surcharge), from two sources that
|
||||||
|
get merged into one plain list per departure.
|
||||||
|
|
||||||
|
**A2a — included `Leistung`s (sales price = 0.00).** Priced services are optional
|
||||||
|
add-ons; the package-included ones have a `0.00` sales price (schema 1).
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
rl.IDReise_FS AS DateId,
|
||||||
|
l.IDLeistung AS ServiceId,
|
||||||
|
l.Art AS Type, -- 'BEF' (transport) | 'SON' (other)
|
||||||
|
l.Unterart AS SubType, -- BUS/PKW/SPA/VER/SON/LVS/...
|
||||||
|
l.Bezeichnung AS Label
|
||||||
|
FROM dbo.ReiseLeistung rl
|
||||||
|
JOIN dbo.Leistung l ON l.IDLeistung = rl.IDLeistung_FS
|
||||||
|
JOIN dbo.LeistungPreis lp ON lp.IDLeistung_FS = l.IDLeistung
|
||||||
|
AND lp.IDArtPreisSchema_FS = 1 -- standard sales price (confirmed)
|
||||||
|
WHERE rl.IDReise_FS IN (@DateIds)
|
||||||
|
AND lp.Preis = 0.00 -- included = no surcharge
|
||||||
|
ORDER BY rl.IDReise_FS, l.Art, l.Unterart;
|
||||||
|
```
|
||||||
|
> Note: a `LEFT JOIN` would also surface services with **no** price row (NULL). Those
|
||||||
|
> are *not* the same as priced-at-zero — keep the inner join so "included" means an
|
||||||
|
> explicit `0.00`. (If BusPro models some included items as price-less rows, widen to
|
||||||
|
> `lp.Preis = 0.00 OR lp.IDLeistungPreis IS NULL` — confirm with the discovery query.)
|
||||||
|
|
||||||
|
**A2b — "Leistungen…" selection groups (plain list).** The catalog also lists included
|
||||||
|
services as selection tags under the "Leistungen" groups. Filter by a **hardcoded list
|
||||||
|
of `Selektionsgruppenstamm` ids** (configured in the proxy) rather than a label match —
|
||||||
|
the labels can be renamed, the ids are stable. Same join as B4; emit `Selektion` as the
|
||||||
|
plain text item.
|
||||||
|
```sql
|
||||||
|
SELECT rs.IDReise_FS AS DateId,
|
||||||
|
gs.IDSelektionsgruppenstamm AS GroupId,
|
||||||
|
gs.Selektionsgruppe AS GroupLabel, -- e.g. 'Leistungen inklusive'
|
||||||
|
s.Selektion AS Item
|
||||||
|
FROM dbo.ReiseSelektionen rs
|
||||||
|
JOIN dbo.Selektionsstamm s ON s.IDSelektionsstamm = rs.IDSelektionsstamm_FS
|
||||||
|
JOIN dbo.Selektionsgruppe sg ON sg.IDSelektionsstamm_FS = s.IDSelektionsstamm
|
||||||
|
JOIN dbo.Selektionsgruppenstamm gs ON gs.IDSelektionsgruppenstamm = sg.IDSelektionsgruppenstamm_FS
|
||||||
|
WHERE rs.IDReise_FS IN (@DateIds)
|
||||||
|
AND gs.IDSelektionsgruppenstamm IN (70, 89, 87, 88, 86, 7, 8, 90, 85, 71)
|
||||||
|
ORDER BY rs.IDReise_FS, gs.Position, sg.Position;
|
||||||
|
```
|
||||||
|
> The "Leistungen" `Selektionsgruppenstamm` ids (captured from the live DB — hardcode in
|
||||||
|
> the proxy; re-check if groups are added/renamed):
|
||||||
|
>
|
||||||
|
> | id | group | id | group |
|
||||||
|
> |---|---|---|---|
|
||||||
|
> | 70 | Leistungen | 86 | Leistungen Programm |
|
||||||
|
> | 89 | Leistungen Events | 7 | Leistungen Skipass |
|
||||||
|
> | 87 | Leistungen Kurse | 8 | Leistungen Sonstige |
|
||||||
|
> | 88 | Leistungen Personal | 90 | Leistungen Transport |
|
||||||
|
> | 85 | Leistungen Übernachtung | 71 | Leistungen Verpflegung |
|
||||||
|
> `LeistungPreis` has one row per `(Leistung, IDArtPreisSchema_FS)`; schema `1` is the
|
||||||
|
> standard sales price and reproduced the XML service prices exactly (13.90, 50.00, …).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Query B — detail for one `(@DateId [, @HotelId])`
|
||||||
|
|
||||||
|
Run a header query + one query per child collection; assemble in Go.
|
||||||
|
|
||||||
|
### B1. Header — A1 with `WHERE r.IDReise = @DateId` (+ `ReiseText.ExternText`).
|
||||||
|
|
||||||
|
### B2. Services (transport + other, with direction & pickups) — **confirmed**
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
l.IDLeistung AS ServiceId,
|
||||||
|
l.Art AS Type, -- BEF | SON
|
||||||
|
l.Unterart AS SubType,
|
||||||
|
l.Bezeichnung AS Label,
|
||||||
|
l.Pflichtmerkmal AS Mandatory,
|
||||||
|
l.Kontingent AS Available,
|
||||||
|
l.Alter_VON AS AgeFrom, l.Alter_BIS AS AgeTo,
|
||||||
|
l.Termin_VON AS DateFrom, l.Termin_BIS AS DateTo,
|
||||||
|
lb.Richtung_Hin_Rueck AS Direction, -- 'H' | 'R' (transport only)
|
||||||
|
lb.ZustiegIDs AS PickupIds, -- delimited list of dbo.Zustieg ids
|
||||||
|
lb.IDArtZustiegZeitplan_FS AS PickupTimePlanId,
|
||||||
|
lp.Preis AS Price
|
||||||
|
FROM dbo.ReiseLeistung rl
|
||||||
|
JOIN dbo.Leistung l ON l.IDLeistung = rl.IDLeistung_FS
|
||||||
|
LEFT JOIN dbo.LeistungBefoerderung lb ON lb.IDLeistung_FS = l.IDLeistung
|
||||||
|
LEFT JOIN dbo.LeistungPreis lp ON lp.IDLeistung_FS = l.IDLeistung
|
||||||
|
AND lp.IDArtPreisSchema_FS = 1
|
||||||
|
WHERE rl.IDReise_FS = @DateId
|
||||||
|
ORDER BY l.Art, l.Unterart, l.Bezeichnung;
|
||||||
|
```
|
||||||
|
`LeistungBefoerderung` present ⇒ transport (`lei_befoerderung`), else `lei_sonstiges`.
|
||||||
|
English texts (`<fremdsprache>`) = `dbo.LeistungText.ExternText` (validate).
|
||||||
|
|
||||||
|
### B2b. Pickup detail (resolve the `ZustiegIDs` list + time) — **confirmed**
|
||||||
|
For each id parsed from `lb.ZustiegIDs`:
|
||||||
|
```sql
|
||||||
|
SELECT z.IDZustieg AS PickupId, z.Zustiegcode AS Code,
|
||||||
|
z.Plz AS PostalCode, z.Ort AS City, z.Strasse AS Street,
|
||||||
|
CONVERT(time, zp.Uhrzeit) AS PickupTime
|
||||||
|
FROM dbo.Zustieg z
|
||||||
|
LEFT JOIN dbo.ZustiegZeitplan zp
|
||||||
|
ON zp.IDZustieg_FS = z.IDZustieg
|
||||||
|
AND zp.IDArtZustiegZeitplan_FS = @PickupTimePlanId -- from B2 (0 ⇒ no time)
|
||||||
|
WHERE z.IDZustieg = @PickupId;
|
||||||
|
```
|
||||||
|
Only the **time-of-day** of `Uhrzeit` is meaningful (the date part is a placeholder).
|
||||||
|
Verified: zustieg `494`, plan `2546` → `05:15`.
|
||||||
|
|
||||||
|
### B3. Hotels + rooms + board — **Partner subsystem**
|
||||||
|
The XML `<hotel idbuspro=224923>` is a hotel **`Adresse.IDAdresse`** (224923 =
|
||||||
|
"NEU Sportclub Waldschlössli – Gruppen 2"), **not** a `ReisePartner`. Hotels are
|
||||||
|
assigned at the **product** level via the join table `PartnerProdukt
|
||||||
|
(IDProdukt_FS, IDAdresse_FS)` — that's why a departure (`IDReise`) has no `ReisePartner`
|
||||||
|
row. (`ReisePartner`/`IDArtVerpflegung_FS` is the booking-side hotel; for the *catalog*
|
||||||
|
use `PartnerProdukt`. `ProduktBeschreibung.IDAdresse_FS_Hotel` = the single featured hotel.)
|
||||||
|
```sql
|
||||||
|
-- hotels of the product (each IDAdresse_FS = an XML <hotel idbuspro>)
|
||||||
|
SELECT pp.IDAdresse_FS AS HotelAddressId, a.SuchName AS HotelName
|
||||||
|
FROM dbo.PartnerProdukt pp
|
||||||
|
JOIN dbo.Adresse a ON a.IDAdresse = pp.IDAdresse_FS
|
||||||
|
WHERE pp.IDProdukt_FS = @ProductId -- e.g. 1121
|
||||||
|
-- AND pp.IDAdresse_FS = @HotelAddressId
|
||||||
|
ORDER BY a.SuchName;
|
||||||
|
```
|
||||||
|
Board (`IDArtVerpflegung`) and stay dates aren't on `PartnerProdukt`; board comes from
|
||||||
|
the `PartnerPreisKategorie` rows below, and the stay span = the departure dates
|
||||||
|
(`Reise.TerminVon/TerminBis`, here `11.–13.12.2026` ⇒ `Naechte = 2`).
|
||||||
|
Rooms / price / availability for a hotel address. The pricing fans out over
|
||||||
|
`room → price-category → season tafel → nights`; **validated** against the live DB
|
||||||
|
for hotel `224923`, room-type `Zimmerart 1413` (Code `MBZ`) → `IDArtPartnerPreisCode 38`
|
||||||
|
→ category `9478`, season `15420` (covers `2026-12-11`), `Naechte=2`, `EK_VK='VK'`,
|
||||||
|
`IDArtPreisSchema_FS=1` → **`269,00`** (single clean row; same schema id `1` as the
|
||||||
|
`LeistungPreis` side). Map a room to its price category via `IDArtPartnerPreisCode_FS`.
|
||||||
|
(The XML headline `abpreis` of `289,00` = this room price + mandatory services, i.e.
|
||||||
|
computed — see note below; the flat per-room price is `269,00`.)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1) rooms the hotel offers
|
||||||
|
-- PartnerZimmer ⋈ PartnerPreisKategorie on (IDAdresse_FS, IDArtPartnerPreisCode_FS)
|
||||||
|
SELECT pz.IDPartnerZimmer AS RoomId, za.Code AS RoomCode, za.Bezeichnung AS RoomLabel,
|
||||||
|
za.PaxMin, za.PaxMax, pz.IDArtPartnerPreisCode_FS AS PriceCode
|
||||||
|
FROM dbo.PartnerZimmer pz
|
||||||
|
JOIN dbo.Zimmerart za ON za.IDZimmerart = pz.IDZimmerart_FS
|
||||||
|
WHERE pz.IDAdresse_FS = @HotelAddressId;
|
||||||
|
|
||||||
|
-- 2) which season tafel covers the arrival date
|
||||||
|
SELECT IDPartnerSaisonTafel_FS FROM dbo.PartnerSaisonzeit
|
||||||
|
WHERE IDAdresse_FS = @HotelAddressId
|
||||||
|
AND TerminVon <= @Anreise AND TerminBis >= @Anreise
|
||||||
|
AND EK_VK = 'VK'; -- sale side
|
||||||
|
|
||||||
|
-- 3) price for the room's category × that season × nights
|
||||||
|
SELECT pp.Preis AS RoomPrice, pp.Naechte, pp.Verlaengerung
|
||||||
|
FROM dbo.PartnerPreisKategorie pk
|
||||||
|
JOIN dbo.PartnerPreis pp ON pp.IDPartnerPreisKategorie_FS = pk.IDPartnerPreisKategorie
|
||||||
|
WHERE pk.IDAdresse_FS = @HotelAddressId
|
||||||
|
AND pk.IDArtPartnerPreisCode_FS = @PriceCode -- from step 1 (room → category)
|
||||||
|
AND pk.IDArtVerpflegung_FS = @BoardId -- ReisePartner.IDArtVerpflegung_FS
|
||||||
|
AND pk.EK_VK = 'VK'
|
||||||
|
AND pp.IDPartnerSaisonTafel_FS = @SaisonTafelId -- from step 2
|
||||||
|
AND pp.Naechte = @Nights; -- nights of the stay
|
||||||
|
```
|
||||||
|
> `EK_VK` distinguishes purchase (`EK`) from sale (`VK`) — always filter `VK` for the
|
||||||
|
> customer price. `PartnerZimmer.PreisJeZimmer` flags per-room vs. per-person pricing.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- availability (XML 'verfuegbar') — VALIDATED: = PartnerKontingent.Anzahl for the
|
||||||
|
-- (hotel, room-type, date-span). IDZimmer_FS = the XML idbuspro_zimmer (a Zimmerart).
|
||||||
|
-- Sample: IDAdresse_FS=224923, IDZimmer_FS=1413, 2026-12-11 → Anzahl=25 = XML verfuegbar.
|
||||||
|
SELECT IDZimmer_FS, Anzahl, BuchungsStatus FROM dbo.PartnerKontingent
|
||||||
|
WHERE IDAdresse_FS = @HotelAddressId
|
||||||
|
AND IDZimmer_FS = @ZimmerartId -- the room type (XML idbuspro_zimmer)
|
||||||
|
AND TerminVon <= @Abreise AND TerminBis >= @Anreise;
|
||||||
|
```
|
||||||
|
> `verfuegbar` is a **flat column** (`PartnerKontingent.Anzahl`) — reproducible by SQL.
|
||||||
|
> Gate it with `BuchungsStatus` (`''` bookable, `S` stop, `A` on-request).
|
||||||
|
> `<abpreis>` (the headline "from" price) is still **computed** (cheapest valid room +
|
||||||
|
> mandatory services) — recompute in the proxy from the per-room prices above.
|
||||||
|
|
||||||
|
### B4. Selection tags + groups — **confirmed**
|
||||||
|
```sql
|
||||||
|
SELECT gs.Selektionsgruppe AS GroupLabel, gs.Position AS GroupPos,
|
||||||
|
s.IDSelektionsstamm AS SelectionId, s.Selektion AS SelectionLabel
|
||||||
|
FROM dbo.ReiseSelektionen rs
|
||||||
|
JOIN dbo.Selektionsstamm s ON s.IDSelektionsstamm = rs.IDSelektionsstamm_FS
|
||||||
|
JOIN dbo.Selektionsgruppe sg ON sg.IDSelektionsstamm_FS = s.IDSelektionsstamm
|
||||||
|
JOIN dbo.Selektionsgruppenstamm gs ON gs.IDSelektionsgruppenstamm = sg.IDSelektionsgruppenstamm_FS
|
||||||
|
WHERE rs.IDReise_FS = @DateId
|
||||||
|
ORDER BY gs.Position, sg.Position;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status / open decisions
|
||||||
|
1. ~~**Status codes** (A1)~~ — ✅ confirmed: `Reise.Status` `''` = bookable, `S` = Storno
|
||||||
|
(cancelled, `StornoDatum` set), `L` = template/future master (exclude both).
|
||||||
|
2. ~~**F/A/O/S column**~~ — ✅ codes in `dbo.ArtBuchungsStatus` (F/O/A/W/S/U); the
|
||||||
|
working hotel-availability column is `PartnerKontingent.BuchungsStatus` (`''`/`S`/`A`).
|
||||||
|
3. ~~**Hotel link**~~ — ✅ XML `<hotel idbuspro>` = `Adresse.IDAdresse`; departure→hotel
|
||||||
|
via `PartnerProdukt(IDProdukt_FS, IDAdresse_FS)` at product level (not `ReisePartner`).
|
||||||
|
4. ~~**Partner room pricing**~~ — ✅ fully validated end-to-end: room `1413` (MBZ) →
|
||||||
|
code `38` → category `9478` → season `15420` × `Naechte=2` × `VK` × schema `1` =
|
||||||
|
**`269,00`**. (XML `abpreis 289,00` = room + mandatory services = computed.)
|
||||||
|
5. ~~**`verfuegbar`**~~ — ✅ flat: `PartnerKontingent.Anzahl` for `(IDAdresse_FS, IDZimmer_FS,
|
||||||
|
date-span)`; `1413 / 2026-12-11 → 25`. `<abpreis>` remains computed (recompute in proxy).
|
||||||
|
6. ~~**Minimum-services filter**~~ — ✅ defined: "included services" =
|
||||||
|
(A2a) `Leistung`s with sales price `0.00` (schema 1) **+** (A2b) selection items from
|
||||||
|
the hardcoded "Leistungen" `Selektionsgruppenstamm` ids, merged into one plain list.
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
# EP Reisen Mobile App — React Native Plan
|
||||||
|
|
||||||
|
**Copy this file into the React Native repository** (suggested: `docs/plan.md`). It is
|
||||||
|
written to be self-contained: you do not need access to the backend codebase to work from
|
||||||
|
it. Where it says "the contract", it means the API guide and fixtures shipped by the
|
||||||
|
backend team — see §3.
|
||||||
|
|
||||||
|
**Companion (backend side, not needed here):** `docs/mobile-app/backend-plan.md` in the
|
||||||
|
`myep` Symfony repository.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What this app is
|
||||||
|
|
||||||
|
EP Reisen sells group travel — primarily ski and coach trips. A booking is not "one person
|
||||||
|
buys one thing": it is **one applicant booking a set of rooms for a group of participants**,
|
||||||
|
where each participant independently selects ski passes, equipment rental, board, courses,
|
||||||
|
transport, insurance and more, with prices and eligibility varying per person (notably by
|
||||||
|
age).
|
||||||
|
|
||||||
|
The existing web product is a server-rendered Symfony application. This app covers its
|
||||||
|
**public** surface. The admin area is explicitly out of scope and will remain web-only.
|
||||||
|
|
||||||
|
The app is being built for App Store presence and a native-feeling booking flow. Push
|
||||||
|
notifications are **not** currently a requirement — do not build infrastructure for them
|
||||||
|
without asking.
|
||||||
|
|
||||||
|
### The two systems behind the API
|
||||||
|
|
||||||
|
You will see this distinction leak into behaviour, so it is worth knowing:
|
||||||
|
|
||||||
|
- **BusProNet (BPN)** — an external, third-party XML system that is the system of record
|
||||||
|
for individual travel bookings. It is slow, occasionally unavailable, and its rules are
|
||||||
|
not ours to change. Endpoints backed by it can fail in ways local data cannot.
|
||||||
|
- **Local database** — system of record for group/accommodation bookings, user accounts,
|
||||||
|
newsletter consent, and booking drafts. Fast and reliable.
|
||||||
|
|
||||||
|
Increments are deliberately ordered local-data-first so you are not fighting BPN latency
|
||||||
|
while also debugging your own foundations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The one architectural rule
|
||||||
|
|
||||||
|
> **Business rules live on the server. The app renders what it is told.**
|
||||||
|
|
||||||
|
This is not a stylistic preference; it is the reason the project is viable. The backend
|
||||||
|
already contains, in one place, 27 condition classes and 25 field handlers that decide —
|
||||||
|
per participant, per field — whether a field is visible, editable, required, or shown as
|
||||||
|
read-only text, and which choices it offers. Reimplementing any of that in TypeScript
|
||||||
|
creates two sources of truth that will silently diverge.
|
||||||
|
|
||||||
|
**Concretely, never write code in this repo that:**
|
||||||
|
|
||||||
|
- decides whether a field is shown, e.g. inferring that a licence-plate input appears
|
||||||
|
because transport is by car;
|
||||||
|
- computes or re-computes a price, total, discount or voucher deduction from line items;
|
||||||
|
- decides whether a participant is eligible for a service, especially by age;
|
||||||
|
- validates a business constraint (as opposed to a formatting constraint like "this looks
|
||||||
|
like an email").
|
||||||
|
|
||||||
|
**If the app appears to need one of those, the schema is missing information. That is a
|
||||||
|
backend bug.** Report it; do not patch around it. This single discipline is what the whole
|
||||||
|
architecture buys, and it is lost the first time it is violated.
|
||||||
|
|
||||||
|
Client-side validation is limited to immediate input affordances — keyboard type, max
|
||||||
|
length, obviously-malformed email — purely for responsiveness. The server's verdict is
|
||||||
|
always authoritative and always re-checked on submit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The contract
|
||||||
|
|
||||||
|
The backend ships two artifacts. Together they are your ground truth.
|
||||||
|
|
||||||
|
1. **The API guide** — a self-contained markdown document describing every endpoint:
|
||||||
|
method, path, auth scope, request shape, response shape, error shapes. The backend
|
||||||
|
team updates it in the same commit as the endpoint.
|
||||||
|
2. **Fixtures** — real JSON responses, written out by the backend's own tests. Because
|
||||||
|
they are test *output*, they cannot silently go stale.
|
||||||
|
|
||||||
|
### How to work with them
|
||||||
|
|
||||||
|
- **Build against fixtures first**, then point at a live backend. This means app work
|
||||||
|
never blocks on a deployed endpoint.
|
||||||
|
- **Generate your TypeScript types from the contract** rather than hand-writing them, so
|
||||||
|
a schema change surfaces as a compile error instead of a runtime surprise.
|
||||||
|
- **When the guide is ambiguous, ask — do not guess.** A guess that happens to work
|
||||||
|
becomes an undocumented dependency on an accident.
|
||||||
|
|
||||||
|
### Error shapes to expect
|
||||||
|
|
||||||
|
The existing API does not have one universal error envelope. Handle all of these:
|
||||||
|
|
||||||
|
- `{"message": "..."}` — typically with 404 or 400.
|
||||||
|
- `{"error": "...", "type": "api_error"}` — upstream (BPN) failure. **Some endpoints
|
||||||
|
return this with HTTP 200.** Check for the presence of an `error` key, not only the
|
||||||
|
status code.
|
||||||
|
- `{"success": false, "message": "..."}` — some write endpoints.
|
||||||
|
- **400 with a constraint-violation body** — validation failures. These are the ones you
|
||||||
|
map back onto form fields; see §5.
|
||||||
|
|
||||||
|
`401` means the token is missing or invalid (refresh, then retry once). `403` means
|
||||||
|
insufficient scope — a bug, not a user-recoverable state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Authentication
|
||||||
|
|
||||||
|
OAuth2 **authorization_code with PKCE**, against the backend's `/token` endpoint. The
|
||||||
|
password and implicit grants are disabled server-side and are not available to you.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Use the system browser / `ASWebAuthenticationSession` / Custom Tabs — **not** a
|
||||||
|
`WebView`. App Store review and OAuth best practice both require this.
|
||||||
|
- Store tokens in the platform secure store (Keychain / Keystore), never in
|
||||||
|
`AsyncStorage`.
|
||||||
|
- **Access tokens are short-lived** — 10 minutes today, likely 1 hour by the time you
|
||||||
|
build. Confirm with the backend team in Increment 0. Either way the refresh-token grant
|
||||||
|
is enabled and you must use it.
|
||||||
|
- **Refresh-token rotation is ON.** Each refresh returns a *new* refresh token and revokes
|
||||||
|
the one you just used. This has two consequences you must design for, not discover:
|
||||||
|
- **Single-flight refresh is mandatory.** If two requests 401 at the same time and both
|
||||||
|
try to refresh, one wins and the other presents an already-revoked token — logging the
|
||||||
|
user out mid-booking. Put a mutex around refresh and queue every other 401 behind it,
|
||||||
|
then replay them with the new token. This is the most common OAuth bug in mobile apps
|
||||||
|
and the booking flow's per-field schema refreshes will find it immediately.
|
||||||
|
- **Persist the rotated token atomically before using it.** A crash between receiving a
|
||||||
|
new refresh token and storing it logs the user out permanently.
|
||||||
|
- The refresh TTL is a sliding window (currently ~1 month, likely 3), so an active user
|
||||||
|
should never be forced to re-authenticate. If yours are, the refresh path is broken —
|
||||||
|
treat it as a bug, not as expected behaviour.
|
||||||
|
- The redirect URI must be registered server-side against the exact host the app calls.
|
||||||
|
This bites during local development — see §8.
|
||||||
|
|
||||||
|
**You will never handle credentials for the upstream BPN system.** The server resolves
|
||||||
|
those internally. If any task seems to require the app to send a BPN password, stop and
|
||||||
|
ask; the answer is no.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Server-driven UI
|
||||||
|
|
||||||
|
The dynamic parts of the booking flow are rendered from a schema the server computes. You
|
||||||
|
build **one generic renderer**, not per-screen field logic.
|
||||||
|
|
||||||
|
### Expected schema shape
|
||||||
|
|
||||||
|
Confirm against the fixtures — they are authoritative — but the agreed shape is:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "skiPass",
|
||||||
|
"type": "choice",
|
||||||
|
"label": "Skipass",
|
||||||
|
"required": true,
|
||||||
|
"value": 881,
|
||||||
|
"choices": [
|
||||||
|
{"id": 881, "label": "6 Tage", "price": 249.0}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{"name": "licensePlate", "type": "text", "hidden": true},
|
||||||
|
{"name": "pickup", "type": "choice", "readonly": true, "value": 12, "choices": []},
|
||||||
|
{"name": "firstName", "type": "text", "staticText": true, "value": "Anna"}
|
||||||
|
],
|
||||||
|
"dependsOn": {
|
||||||
|
"licensePlate": ["transportationOutbound"],
|
||||||
|
"pickup": ["transportationOutbound"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field states — all four are distinct
|
||||||
|
|
||||||
|
| State | Meaning | Render as |
|
||||||
|
|---|---|---|
|
||||||
|
| `hidden` | Not applicable at all | Nothing |
|
||||||
|
| `staticText` | Value matters, user may not change it | Read-only text, no input affordance |
|
||||||
|
| `readonly` | Input shown but not editable | Disabled-looking input |
|
||||||
|
| `disabled` | Temporarily not interactive | Greyed input |
|
||||||
|
|
||||||
|
`hidden` and `staticText` are genuinely different and the backend treats them
|
||||||
|
differently — `staticText` is used, for example, when personal data is locked because
|
||||||
|
changing it would create a duplicate customer record upstream. Collapsing the two will
|
||||||
|
produce wrong screens.
|
||||||
|
|
||||||
|
### `dependsOn`
|
||||||
|
|
||||||
|
Maps a field to the fields whose changes invalidate it. When the user changes field *X*,
|
||||||
|
any field listing *X* in its `dependsOn` may now have different visibility, state, or
|
||||||
|
choices. **Re-fetch the schema from the server** — do not attempt to recompute locally.
|
||||||
|
This is the mechanism that keeps rule evaluation server-side.
|
||||||
|
|
||||||
|
Debounce these refreshes and keep the UI responsive during them, but never predict the
|
||||||
|
result optimistically for anything that affects price or eligibility.
|
||||||
|
|
||||||
|
### Field types you must support
|
||||||
|
|
||||||
|
`text`, `email`, `tel`, `date`, `choice` (single), `multichoice`, `checkbox`, `address`
|
||||||
|
(composite), plus a `static` presentation mode. Confirm the final list against the
|
||||||
|
contract before building.
|
||||||
|
|
||||||
|
### Mapping validation errors back
|
||||||
|
|
||||||
|
The server returns violations keyed by a property path (e.g.
|
||||||
|
`participants[2].dateOfBirth`). Your renderer must resolve those onto the right field on
|
||||||
|
the right participant. Design for this from the start — retrofitting it is painful.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Domain glossary
|
||||||
|
|
||||||
|
Enough to read the API without guessing.
|
||||||
|
|
||||||
|
- **Travel** — a dated departure of a product, at a specific hotel. Identified by a
|
||||||
|
numeric `dateId` + `hotelId`, or by string codes. Carries the available rooms, services,
|
||||||
|
pickups, insurances.
|
||||||
|
- **Applicant** — participant index 0. The person who books and is billed. Subject to
|
||||||
|
extra rules the others are not; their personal data is frequently locked (`staticText`).
|
||||||
|
- **Participant** — one traveller. Most selections live per participant, not per booking.
|
||||||
|
- **Room selection** — how many of which room type. This determines the participant count,
|
||||||
|
which is why step 1 precedes step 2.
|
||||||
|
- **Service** — anything bookable alongside: ski pass, equipment rental, board, courses,
|
||||||
|
transport, parking, additional services. Some are mandatory, some auto-booked with
|
||||||
|
opt-out, some age-restricted.
|
||||||
|
- **Booking status** — `F` firm booking, `O` option (held), `A` inquiry. Determined by
|
||||||
|
server rules, not user choice.
|
||||||
|
- **Payment method** — `transfer` or `debit`. Debit requires bank account details.
|
||||||
|
- **Voucher** — purchase vouchers (a balance), goodwill vouchers, and promo vouchers
|
||||||
|
(fixed or percentage, per-participant or per-booking). **Always** server-validated;
|
||||||
|
never compute a discount locally.
|
||||||
|
- **Baby** — participants aged 0–2 are a real special case in eligibility and pricing.
|
||||||
|
- **Mutability** — for existing bookings, BPN decides per field whether a change is still
|
||||||
|
permitted. Surfaces as `staticText`/`readonly`. Never override it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Increments
|
||||||
|
|
||||||
|
Each increment is a working loop with the backend session; see §9. Do not start an
|
||||||
|
increment before its backend endpoints and fixtures exist.
|
||||||
|
|
||||||
|
### Increment 0 — Foundations
|
||||||
|
|
||||||
|
- RN app skeleton, navigation, theming, error boundary.
|
||||||
|
- OAuth2 authorization_code + PKCE, secure token storage, silent refresh with request
|
||||||
|
queuing.
|
||||||
|
- API client: auth injection, 401-refresh-retry, the error shapes in §3, and a fixture
|
||||||
|
mock mode toggled by env.
|
||||||
|
- **The generic SDUI renderer** — consumes `{fields, dependsOn}`, renders every field type,
|
||||||
|
emits changes, maps server violations onto fields.
|
||||||
|
- Type generation from the contract wired into the build.
|
||||||
|
- **Done when:** Increment 1 renders entirely through the generic renderer.
|
||||||
|
|
||||||
|
### Increment 1 — Accommodation & group offers (pilot)
|
||||||
|
|
||||||
|
Local data, fast, low risk. Deliberately first so the foundations are exercised end-to-end
|
||||||
|
before anything hard.
|
||||||
|
|
||||||
|
- Offer list for the signed-in user, offer detail, accept an offer.
|
||||||
|
- **Validates:** auth, error handling, navigation, the fixture pipeline.
|
||||||
|
|
||||||
|
### Increment 2 — Account, personal data, newsletter, registration
|
||||||
|
|
||||||
|
- Sign-up, password reset, profile view/edit, newsletter preferences.
|
||||||
|
- **First real use of SDUI** on a small form, before betting the booking flow on it. Expect
|
||||||
|
to find gaps in the renderer here — that is the point of doing it now.
|
||||||
|
|
||||||
|
### Increment 3 — Booking read
|
||||||
|
|
||||||
|
- "My travels": booking list, booking detail with participants, services and price
|
||||||
|
breakdown, document/invoice download.
|
||||||
|
- First BPN-backed screens. Expect slower responses and genuine upstream failures — build
|
||||||
|
proper loading and error states here, and reuse them everywhere after.
|
||||||
|
|
||||||
|
### Increment 4 — *(backend only)*
|
||||||
|
|
||||||
|
An internal backend refactor that produces the participant field schema. No app work.
|
||||||
|
Good moment to pay down debt or firm up the renderer.
|
||||||
|
|
||||||
|
### Increment 5 — Booking edit
|
||||||
|
|
||||||
|
- Edit an existing booking through a server-persisted draft: load draft, edit participants
|
||||||
|
via SDUI, review changes, submit.
|
||||||
|
- Honour mutability strictly — many fields will be `staticText`, and that is correct.
|
||||||
|
- Drafts are server-side, so edits survive app restarts. Design the UX around that.
|
||||||
|
|
||||||
|
### Increment 6 — Booking create
|
||||||
|
|
||||||
|
The full four-step flow, last, once everything above is proven.
|
||||||
|
|
||||||
|
1. **Rooms** — select room types and quantities. Determines participant count.
|
||||||
|
2. **Participants** — one card per participant, each opening an SDUI form. Groups can be
|
||||||
|
large (50+); the web version lazy-loads per participant and you should too.
|
||||||
|
3. **Payment** — method, and bank details when debit.
|
||||||
|
4. **Confirmation** — full summary, terms, submit.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- State lives in a **server-side draft keyed by UUID**, not on the device. The app holds
|
||||||
|
the draft id and renders server state.
|
||||||
|
- Price refreshes are server calls. Never compute totals locally, not even for a
|
||||||
|
"preview".
|
||||||
|
- Submission goes to BPN and can fail or partially succeed. Treat the submit step as
|
||||||
|
genuinely fallible and make retry safe.
|
||||||
|
|
||||||
|
### Increment 7 — Discovery *(not yet scoped)*
|
||||||
|
|
||||||
|
Browsing travels and hotels currently lives in a separate CMS website, not in the backend
|
||||||
|
this app talks to. If the app must be a standalone entry point rather than something users
|
||||||
|
deep-link into, that is a second integration project. Do not assume it is in scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Local development
|
||||||
|
|
||||||
|
- The backend runs in DDEV. A **simulator** can reach it via the host; a **physical
|
||||||
|
device** cannot resolve `*.ddev.site`. The backend team will expose a shareable URL
|
||||||
|
(`ddev share` / ngrok).
|
||||||
|
- OAuth2 redirect URIs are registered per host, so switching between simulator and device
|
||||||
|
URLs needs a backend-side change. Agree the setup in Increment 0 rather than discovering
|
||||||
|
it mid-increment.
|
||||||
|
- Keep fixture-mock mode working permanently, not just as scaffolding — it is how you stay
|
||||||
|
unblocked and how you write deterministic tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Working agreement with the backend session
|
||||||
|
|
||||||
|
The backend lives in a separate repository and a separate sandboxed session. Neither side
|
||||||
|
can see the other's code. The contract is the only coupling point, and keeping it that way
|
||||||
|
is a shared discipline.
|
||||||
|
|
||||||
|
**Per-increment loop:**
|
||||||
|
|
||||||
|
1. Backend ships: endpoints + tests + fixtures + updated guide.
|
||||||
|
2. Handoff brief: what is live, which fixtures to mock, what changed in the guide.
|
||||||
|
3. App builds against fixtures, then against a live backend.
|
||||||
|
4. Joint end-to-end verification.
|
||||||
|
|
||||||
|
The two sessions do not work the same increment concurrently — they overlap by one stage,
|
||||||
|
so the backend starts *N+1* while the app builds *N*.
|
||||||
|
|
||||||
|
**Escalate to the backend rather than solving locally when:**
|
||||||
|
|
||||||
|
- a rule seems missing from the schema;
|
||||||
|
- a response shape is undocumented or contradicts the guide;
|
||||||
|
- you need a field the API does not expose;
|
||||||
|
- you are tempted to hardcode a business constant.
|
||||||
|
|
||||||
|
Each of those is a contract gap. Fixing it in the app is the failure mode this entire
|
||||||
|
architecture exists to prevent.
|
||||||
|
|
||||||
|
**Suggested `AGENTS.md` for this repo**, so future sessions inherit the rule:
|
||||||
|
|
||||||
|
> Business rules live server-side. This app renders a server-provided schema and never
|
||||||
|
> decides field visibility, eligibility, or pricing. If a rule appears to be missing,
|
||||||
|
> that is a backend bug — report it, do not implement it here. Never recompute prices
|
||||||
|
> from line items or infer field visibility from other field values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Verification
|
||||||
|
|
||||||
|
- **Unit:** the SDUI renderer against every field type and state combination, including
|
||||||
|
`hidden` vs `staticText`, and violation-path mapping onto nested participant fields.
|
||||||
|
- **Auth:** an explicit test that fires several concurrent requests against an expired
|
||||||
|
access token and asserts *exactly one* refresh call is made, all requests succeed, and
|
||||||
|
the rotated refresh token is persisted. Rotation makes this a correctness test, not a
|
||||||
|
performance one.
|
||||||
|
- **Integration:** screens against committed fixtures — deterministic, no backend needed.
|
||||||
|
- **Contract:** types regenerate cleanly and compile against current fixtures. A failure
|
||||||
|
here is an early warning of drift; treat it as urgent.
|
||||||
|
- **End-to-end:** at the close of Increment 6, complete one booking through the app
|
||||||
|
against a real backend. The backend team verifies the resulting upstream payload is
|
||||||
|
**identical** to the same booking made through the web flow. That equivalence is the
|
||||||
|
project's actual acceptance criterion — everything else is a proxy for it.
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
# Mobile App — Backend Plan (myep / Symfony)
|
||||||
|
|
||||||
|
**Audience:** sessions working in *this* repository (`myep`, Symfony 6.4).
|
||||||
|
**Companion:** `docs/mobile-app/app-plan.md` — the React Native side. That document is
|
||||||
|
written to be self-contained and is meant to be **copied into the RN repository**, whose
|
||||||
|
sessions have no access to this codebase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
`myep` is the booking engine behind the separate CMS marketing site (`ep-reisen.de`;
|
||||||
|
see `BookingSessionManager::DEFAULT_RETURN_URL` and `CmsDataProvider`). It serves:
|
||||||
|
|
||||||
|
- **Individual travel bookings** — system of record is **BusProNet** (BPN), an external
|
||||||
|
XML service reached through `src/BusProNet/ApiClient.php`.
|
||||||
|
- **Groups / accommodation bookings** — system of record is local Doctrine entities under
|
||||||
|
`src/Entity/Groups/`.
|
||||||
|
|
||||||
|
The public UI is Twig + HTMX + Tailwind. The admin UI under `/admin` is **out of scope**
|
||||||
|
for the mobile app and stays web-only.
|
||||||
|
|
||||||
|
### What we are building and why
|
||||||
|
|
||||||
|
A React Native app covering the **full public surface, including the booking create
|
||||||
|
flow**. Drivers: App Store presence/brand and a native-feeling booking experience. A PWA
|
||||||
|
was explicitly ruled out. Push notifications were *not* named as a driver — worth
|
||||||
|
remembering, since push is normally what makes native unarguable; the payoff here rests
|
||||||
|
on brand and UX.
|
||||||
|
|
||||||
|
### The decision that shapes everything
|
||||||
|
|
||||||
|
**The sibling project is the React Native app only. The API stays in this repository.**
|
||||||
|
|
||||||
|
A second backend would recreate exactly the duplication of validation and business rules
|
||||||
|
this plan exists to prevent. The API is a new `src/Controller/Api/App/` namespace reusing
|
||||||
|
the existing services and DTOs.
|
||||||
|
|
||||||
|
**Client strategy: server-driven UI (SDUI).** The server emits a JSON field schema per
|
||||||
|
participant/step; RN renders it generically. Business rules never leave PHP.
|
||||||
|
|
||||||
|
### Why this codebase can actually support that
|
||||||
|
|
||||||
|
The rules are already headless:
|
||||||
|
|
||||||
|
- **Validation lives on DTOs, not form types.** `src/Form/Model/ParticipantDto.php` (21
|
||||||
|
constraints), `BookingDto.php`, `BankAccountDto.php`, `RegistrationDto.php` carry
|
||||||
|
`Assert\*` plus custom `App\Validator\Constraints\Participant`, with validation groups
|
||||||
|
(`booking_create`, `booking_edit`, `strict_required`, `applicant_address`). An API
|
||||||
|
endpoint can denormalize → `validate($dto, groups: [...])` and get identical results.
|
||||||
|
- **Dynamic field logic is a rules engine.** `src/Form/Service/` holds
|
||||||
|
`Contract/FieldStateProviderInterface` (hidden / readonly / disabled / required /
|
||||||
|
static_text), `Contract/FieldOptionsProviderInterface` (labels + choices),
|
||||||
|
`ParticipantFieldHandlerRegistry` (25 handlers) and 27 reusable `Condition/` classes.
|
||||||
|
- **Orchestration is in services, not controllers.** `BookingConfigurator` (652 lines),
|
||||||
|
`RoomAssigner`, `BookingPricingAssembler`, `BookingSummaryAssembler`,
|
||||||
|
`VoucherValidator`, `BookingEditSubmitGuard`, `BookingEditSubmitter`. Controllers are
|
||||||
|
thin — `Step2Controller` is 147 lines, mostly session handling and redirects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The four real obstacles
|
||||||
|
|
||||||
|
Everything else is mechanical. These need design work before the increments that depend
|
||||||
|
on them.
|
||||||
|
|
||||||
|
### Obstacle 1 — The create flow is session-bound
|
||||||
|
|
||||||
|
`src/Service/BookingSessionManager.php` keys the entire create flow off
|
||||||
|
`$request->getSession()` (`booking_create`, `booking_create_baseline_snapshot`,
|
||||||
|
`booking_return_url`). Stateless OAuth2 clients have no session.
|
||||||
|
|
||||||
|
**Fix:** a server-persisted draft addressed by UUID. The pattern already exists for edit —
|
||||||
|
`src/Entity/BookingEditDraft.php` plus `BookingEditDraftManager` / `BookingEditDraftMerger`.
|
||||||
|
Generalise into a `BookingDraft` store and put `BookingStateStoreInterface` in front, with
|
||||||
|
two backends: session (web, unchanged) and draft (app). Do **not** fork the flow logic.
|
||||||
|
|
||||||
|
### Obstacle 2 — `FieldStateProviderInterface` leaks Symfony Form
|
||||||
|
|
||||||
|
Two leaks:
|
||||||
|
|
||||||
|
- `getBookingDtoFromForm(FormInterface $form)` is pure Form coupling and belongs in a
|
||||||
|
form-side adapter, not the interface.
|
||||||
|
- `getFieldState()` returns **Symfony form options** (`disabled`, `required`,
|
||||||
|
`attr => ['readonly' => true]`), and `ParticipantFieldOptionsProvider` returns `choices`
|
||||||
|
as domain objects (`Service`, `Pickup`, `Insurance`) plus occasional `choice_loader`.
|
||||||
|
|
||||||
|
**Fix:** a neutral `FieldDescriptor` value object — name, type, label, required, readonly,
|
||||||
|
disabled, hidden, staticText, choices, value. Both `BookingParticipantType` and the new
|
||||||
|
JSON serializer consume it. Conditions and handlers are untouched.
|
||||||
|
|
||||||
|
Note the `hidden` vs `static_text` distinction documented in `AbstractFieldStateProvider`:
|
||||||
|
`hidden` means excluded entirely; `static_text` means "show the value, don't let them edit
|
||||||
|
it". The app must honour both, so `FieldDescriptor` has to carry them separately.
|
||||||
|
|
||||||
|
### Obstacle 3 — Token lifetimes and refresh-token rotation
|
||||||
|
|
||||||
|
`config/packages/league_oauth2_server.yaml` sets `access_token_ttl: PT10M` — deliberately,
|
||||||
|
since the bundle's own default is `PT1H`. Find out why before changing it.
|
||||||
|
|
||||||
|
**TTLs are global.** They live under `authorization_server`; the bundle has no per-client
|
||||||
|
TTL. Any change affects the CMS and M2M clients too. "Raise it just for the app" is not
|
||||||
|
available without a custom grant or response type.
|
||||||
|
|
||||||
|
Two values are unset and defaulted, and both matter:
|
||||||
|
|
||||||
|
- `refresh_token_ttl` → **`P1M`** (1 month)
|
||||||
|
- `revoke_refresh_tokens` → **`true`**, so **refresh-token rotation is on**
|
||||||
|
|
||||||
|
Rotation is correct security, and it is also the main source of mobile auth bugs: two
|
||||||
|
requests 401 concurrently, both refresh, one rotates, the second presents a revoked
|
||||||
|
refresh token and the user is logged out mid-booking. At a 10-minute TTL with step 2
|
||||||
|
firing schema refreshes per field change, this *will* happen. The mitigation is client-side
|
||||||
|
— single-flight refresh — and it is a hard requirement in `app-plan.md` §4.
|
||||||
|
|
||||||
|
A longer access-token TTL does **not** create an unrevocable window here:
|
||||||
|
`persist_access_token` defaults to true and `BearerTokenValidator` checks
|
||||||
|
`isAccessTokenRevoked()` on every request, so tokens can be killed immediately. The usual
|
||||||
|
argument for a very short TTL does not apply.
|
||||||
|
|
||||||
|
**Recommendation:** `access_token_ttl: PT1H`, `refresh_token_ttl: P3M` set explicitly,
|
||||||
|
rotation left on. Rotation issues a fresh refresh token each time, so the refresh TTL is a
|
||||||
|
sliding window — active users never re-auth, only ~3 months of true inactivity forces it.
|
||||||
|
M2M clients get fewer token round-trips. Extra token rows are already handled by
|
||||||
|
`league:oauth2-server:clear-expired-tokens`, scheduled in `config/packages/zenstruck_schedule.yaml`.
|
||||||
|
|
||||||
|
**Settle this before the RN auth layer is written, not after.**
|
||||||
|
|
||||||
|
Otherwise auth is ready: `enable_auth_code_grant: true` with
|
||||||
|
`require_code_challenge_for_public_clients: true` (PKCE), and
|
||||||
|
`enable_password_grant: false` / `enable_implicit_grant: false` — correct for a native app.
|
||||||
|
|
||||||
|
### Obstacle 4 — BPN per-user calls need the user's plaintext password
|
||||||
|
|
||||||
|
`ApiClient::getBookings()`, `getBooking()`, `getPersonalData()`, `updatePersonalData()`,
|
||||||
|
`updateNewsletterRegistration()` all take `(string $email, string $password)`.
|
||||||
|
`src/Controller/Booking/IndexController.php` supplies them via
|
||||||
|
`$this->crypt->decrypt($user->getPassword())` — the local `User` stores the BPN password
|
||||||
|
RSA-encrypted (`src/Security/Crypt.php`).
|
||||||
|
|
||||||
|
Consequences:
|
||||||
|
|
||||||
|
- **The app must never see BPN credentials.** The API layer resolves them server-side from
|
||||||
|
the authenticated `User`, exactly as the web controllers do. This is not a change, but
|
||||||
|
it must be stated so nobody "simplifies" it by proxying credentials.
|
||||||
|
- **BPN-backed user data only works for users with a local `User` record** carrying the
|
||||||
|
encrypted password. Guest flows and app-only accounts cannot read BPN bookings. Confirm
|
||||||
|
how registration-via-app populates this before Increment 3.
|
||||||
|
- The OAuth2 token identifies the user; it does **not** carry BPN authority. Every
|
||||||
|
BPN-backed endpoint needs an explicit ownership check, not just a valid token.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Increments
|
||||||
|
|
||||||
|
Ordered local-data-first, BPN-last, so each stage ships something usable and the riskiest
|
||||||
|
integration lands after the SDUI machinery is proven.
|
||||||
|
|
||||||
|
### Increment 0 — Foundations
|
||||||
|
|
||||||
|
No user-visible feature. Backend side:
|
||||||
|
|
||||||
|
- Register an OAuth2 client for the app (authorization_code + PKCE, native redirect URI).
|
||||||
|
- Resolve Obstacle 3 (TTL).
|
||||||
|
- Resolve device→DDEV access: `.ddev/config.yaml` has `ngrok_args`; `ddev share` is the
|
||||||
|
path. Redirect URIs must be registered against whatever host the app actually calls.
|
||||||
|
- Create `docs/app-api/` with the contract skeleton and the fixtures directory.
|
||||||
|
|
||||||
|
### Increment 1 — Accommodation & group offers (pilot)
|
||||||
|
|
||||||
|
Local Doctrine data, UUID-addressed, and a write endpoint already exists. Zero BPN risk.
|
||||||
|
|
||||||
|
- Reuse `GET /api/accommodation-bookings/{uuid}` and
|
||||||
|
`POST /api/accommodation-bookings/{uuid}/accept`
|
||||||
|
(`src/Controller/Api/AccommodationBookingController.php`).
|
||||||
|
- Add a list endpoint scoped to the authenticated user.
|
||||||
|
- Mirrors `src/Controller/Groups/Offer/IndexController.php`; reuse
|
||||||
|
`AccommodationBookingService`, `AccommodationBookingBreakdownCalculator`,
|
||||||
|
`AccommodationBookingApiResponse`.
|
||||||
|
- **Validates:** auth, serialization conventions, RN navigation, the fixture pipeline.
|
||||||
|
|
||||||
|
### Increment 2 — Account, personal data, newsletter, registration
|
||||||
|
|
||||||
|
- Mirror `src/Controller/Account/`, `RegistrationController`, `ResetPasswordController`,
|
||||||
|
`src/Controller/Newsletter/`.
|
||||||
|
- Reuse `RegistrationDto`, the `PersonalDataType` DTO, `NewsletterManager`,
|
||||||
|
`ProfileCompletenessChecker`, and existing `/api/newsletter-subscriptions`,
|
||||||
|
`/api/userinfo`, `/api/crm-attributes`.
|
||||||
|
- **First real SDUI use**, on a small low-risk form, before betting the booking flow on it.
|
||||||
|
- Registration here is what makes Obstacle 4 tractable — verify the encrypted-password
|
||||||
|
path is populated for app-registered users.
|
||||||
|
|
||||||
|
### Increment 3 — Booking read
|
||||||
|
|
||||||
|
- `GET /api/app/bookings` and `/api/app/bookings/{id}`, mirroring
|
||||||
|
`src/Controller/Booking/IndexController.php` (note its `TravelLoader::patchBookings()`
|
||||||
|
enrichment step).
|
||||||
|
- Documents/invoice, mirroring `src/Controller/Booking/DownloadController.php`.
|
||||||
|
- Reuse `BookingSummaryAssembler`, `BookingPricingAssembler`, `BookingEditDataLoader`.
|
||||||
|
- BPN **reads only, no writes** — the safe way to shake out `BusProNet\Model`
|
||||||
|
normalization (`Travel`, `Booking`, `Service`, `Room`, `Pickup`, `Insurance`), which
|
||||||
|
every later increment depends on.
|
||||||
|
|
||||||
|
### Increment 4 — `FieldDescriptor` refactor
|
||||||
|
|
||||||
|
Pure internal refactor. No new endpoints. Covered by the existing 155 test files.
|
||||||
|
|
||||||
|
- Introduce `FieldDescriptor`; split `getBookingDtoFromForm` out of
|
||||||
|
`FieldStateProviderInterface`.
|
||||||
|
- Adapt `BookingParticipantType::mergeFieldState()` / `addDynamicFields()` to consume it.
|
||||||
|
- Add `ParticipantFieldSchemaSerializer` producing the JSON schema, including the
|
||||||
|
`dependsOn` map derived from `FieldConditionInterface::getDependentFields()` and
|
||||||
|
`ParticipantFieldHandlerInterface::getDependencies()`.
|
||||||
|
- **Ship criterion:** web behaviour byte-identical, Cypress specs green, **no test file
|
||||||
|
changed**. A required test change means a behaviour regression.
|
||||||
|
|
||||||
|
### Increment 5 — Booking edit
|
||||||
|
|
||||||
|
Before create, because drafts already exist here.
|
||||||
|
|
||||||
|
- Endpoints over `BookingEditDraftManager`, `BookingEditPreFlightChecker`,
|
||||||
|
`BookingEditSubmitGuard`, `BookingEditSubmitter`, `BookingChangeTracker`.
|
||||||
|
- Participant schema endpoint from Increment 4.
|
||||||
|
- Honour `BookingMutabilityDto` and BPN's `aenderungmoeglich` mutability rules — these
|
||||||
|
already drive `static_text`, so SDUI inherits them.
|
||||||
|
- **Caution:** the participant[0]/applicant block is on hold pending BPN vendor
|
||||||
|
confirmation about the shared customer id. Do not try to resolve it here.
|
||||||
|
|
||||||
|
### Increment 6 — Booking create
|
||||||
|
|
||||||
|
The full flow, last. Web routes being mirrored:
|
||||||
|
|
||||||
|
| Step | Web route | Controller |
|
||||||
|
|---|---|---|
|
||||||
|
| init | `/bookings/create`, `/bookings/create/init` | `Create/IndexController.php` |
|
||||||
|
| 1 — rooms | `/bookings/create/rooms` (+ `/refresh`) | `Create/Step1Controller.php` |
|
||||||
|
| 2 — participants | `/bookings/create/participants` (+ `/{index}`, `/{index}/refresh`) | `Create/Step2Controller.php`, `Step2ParticipantController.php` |
|
||||||
|
| 3 — payment | `/bookings/create/payment` (+ `/refresh`) | `Create/Step3Controller.php` |
|
||||||
|
| 4 — confirmation | `/bookings/create/confirmation` | `Create/Step4Controller.php` |
|
||||||
|
| success | `/bookings/create/success` | `Create/SuccessController.php` |
|
||||||
|
|
||||||
|
- Generalise draft storage behind `BookingStateStoreInterface` (Obstacle 1).
|
||||||
|
- Reuse `BookingConfigurator` (`startFreshBooking`, `preselectDefaultServices`,
|
||||||
|
`applyCreateBookingStatusRules`, `updateBookingStatusFromRoomSelection`), `RoomAssigner`,
|
||||||
|
`ParticipantDataPrefiller`, `TravelDataProvider`, `VoucherValidator`,
|
||||||
|
`FamilyInsuranceAvailabilityChecker`.
|
||||||
|
- Submit via `BusProNet\ApiClient::createBooking()` / `createBookingInquiry()` with
|
||||||
|
`BookingPayloadBuilder`, exactly as `Step4Controller` does.
|
||||||
|
- Every HTMX refresh route gets a JSON sibling returning recomputed schema + pricing.
|
||||||
|
|
||||||
|
### Increment 7 — Discovery (scoping decision, not yet planned)
|
||||||
|
|
||||||
|
Travel/hotel browsing lives in the **CMS**, not here — this repo only proxies it via
|
||||||
|
`CmsDataProvider` and `/api/hotels`, `/api/products`, `/api/travels`. If the app is to be
|
||||||
|
a standalone entry point rather than a deep-link target, that is a second integration
|
||||||
|
project and should be scoped separately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Files
|
||||||
|
|
||||||
|
**New (representative):**
|
||||||
|
|
||||||
|
- `src/Controller/Api/App/` — one controller per resource, mirroring the public tree
|
||||||
|
- `src/Form/Service/Model/FieldDescriptor.php`
|
||||||
|
- `src/Form/Service/ParticipantFieldSchemaSerializer.php`
|
||||||
|
- `src/Service/BookingStateStoreInterface.php` + draft-backed implementation
|
||||||
|
- `src/Entity/BookingDraft.php` (generalised from `BookingEditDraft`) + migration
|
||||||
|
- Normalizers for `BusProNet\Model\*` value objects
|
||||||
|
- `tests/Controller/Api/App/`
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
|
||||||
|
- `src/Form/Service/Contract/FieldStateProviderInterface.php` — drop the Form-coupled method
|
||||||
|
- `src/Form/Service/Abstract/AbstractFieldStateProvider.php`, `CreateFieldStateProvider.php`,
|
||||||
|
`EditFieldStateProvider.php`
|
||||||
|
- `src/Form/BookingParticipantType.php` — consume `FieldDescriptor`
|
||||||
|
- `src/Service/BookingSessionManager.php` — implement the store interface
|
||||||
|
- `config/packages/league_oauth2_server.yaml` — TTL, app client, scopes
|
||||||
|
- `docs/api-consumer-guide.md` / `docs/app-api/` — the contract
|
||||||
|
|
||||||
|
**Unchanged by design:** every `src/Form/Service/Condition/*`, every
|
||||||
|
`src/Form/Service/Participant*FieldHandler.php`, every `src/Validator/Constraints/*`, and
|
||||||
|
all pricing services. If an increment starts wanting to edit these, the design has drifted
|
||||||
|
— stop and reconsider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Working across two repos and two sessions
|
||||||
|
|
||||||
|
The RN work happens in a separate repo and a separate sandboxed session. The risk that
|
||||||
|
creates is **contract drift**: the app session guessing at response shapes, or worse,
|
||||||
|
re-deriving business rules in TypeScript because the JSON was ambiguous. That is the
|
||||||
|
original duplication problem arriving through a side door.
|
||||||
|
|
||||||
|
### The contract is an artifact in this repo
|
||||||
|
|
||||||
|
`docs/api-consumer-guide.md` already opens with *"It is self-contained: no access to this
|
||||||
|
codebase is required to use it."* That is the format. Extend it — or split out
|
||||||
|
`docs/app-api/guide.md` once it outgrows one file — **in the same commit as the endpoint**,
|
||||||
|
every time.
|
||||||
|
|
||||||
|
Rule: **the RN session never reads this PHP repo.** Its ground truth is the guide plus
|
||||||
|
fixtures. If it has to ask "what does the server send here?", the guide has a gap — fix
|
||||||
|
the guide, not the app.
|
||||||
|
|
||||||
|
### Fixtures decouple the sessions
|
||||||
|
|
||||||
|
Parity tests in `tests/Controller/Api/App/` write their JSON responses to
|
||||||
|
`docs/app-api/fixtures/*.json` (mirroring the existing `tests/Resources/*.xml`
|
||||||
|
convention). Commit them. The RN session mocks against those files, so app work never
|
||||||
|
waits on a deployed endpoint — and because they are test *output*, they cannot silently
|
||||||
|
go stale.
|
||||||
|
|
||||||
|
### Per-increment rhythm
|
||||||
|
|
||||||
|
1. **Backend session (here):** endpoint + parity test + fixtures + guide update.
|
||||||
|
2. **Handoff:** a short brief — endpoints now live, fixtures to mock, what changed in the
|
||||||
|
guide since last time.
|
||||||
|
3. **App session (RN repo):** build against fixtures first, then against DDEV.
|
||||||
|
4. **Joint verification:** run the increment end-to-end against a real backend.
|
||||||
|
|
||||||
|
Do not run both sessions on the same increment concurrently. Overlap by one stage: while
|
||||||
|
the app session builds increment *N*, start *N+1* here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Verification
|
||||||
|
|
||||||
|
Per increment:
|
||||||
|
|
||||||
|
1. `vendor/bin/phpunit` — 155 existing test files, notably `tests/Form/`, `tests/Service/`,
|
||||||
|
`tests/BusProNet/`. **Increment 4 must pass with no test changes.**
|
||||||
|
2. `vendor/bin/phpstan analyse` and `vendor/bin/php-cs-fixer fix --dry-run`.
|
||||||
|
3. `npx cypress run` — existing specs cover the web booking flow and guard against SDUI
|
||||||
|
refactors leaking into Twig rendering.
|
||||||
|
4. **Parity tests** under `tests/Controller/Api/App/`, asserting the JSON schema matches
|
||||||
|
what `BookingParticipantType` builds for the same `BookingDto`. This is the single most
|
||||||
|
valuable artifact in the project — it mechanically proves web and app share one rule set.
|
||||||
|
5. Manual: extend the existing `api.http` / `http-client.env.json` collection per increment.
|
||||||
|
6. End-to-end: run one booking through the RN client against a DDEV backend and confirm the
|
||||||
|
BPN payload is identical to a web-flow booking with the same inputs — compare via
|
||||||
|
`src/Controller/Admin/Log/XmlDumpController.php`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Open questions before Increment 6
|
||||||
|
|
||||||
|
- App-client access token TTL (Obstacle 3).
|
||||||
|
- How app registration populates the encrypted BPN password (Obstacle 4), and whether
|
||||||
|
guest/unauthenticated create is supported in the app as it is on the web via
|
||||||
|
`/bookings/create/init`.
|
||||||
|
- Draft retention and expiry policy.
|
||||||
|
- Whether the app deep-links from the CMS site or must own discovery (Increment 7).
|
||||||
File diff suppressed because it is too large.
Load diff
+15
-3
@@ -41,7 +41,11 @@
|
|||||||
"Scope": "api"
|
"Scope": "api"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"clickup_baseurl": "https://api.clickup.com/api/v2",
|
||||||
|
"clickup_api_token": "abcdef123456789",
|
||||||
|
"clickup_list_id": "12345",
|
||||||
|
"clickup_template_id": "t-12345"
|
||||||
},
|
},
|
||||||
"staging": {
|
"staging": {
|
||||||
"Security": {
|
"Security": {
|
||||||
@@ -85,7 +89,11 @@
|
|||||||
"Scope": "api"
|
"Scope": "api"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"clickup_baseurl": "https://api.clickup.com/api/v2",
|
||||||
|
"clickup_api_token": "abcdef123456789",
|
||||||
|
"clickup_list_id": "12345",
|
||||||
|
"clickup_template_id": "t-12345"
|
||||||
},
|
},
|
||||||
"prod": {
|
"prod": {
|
||||||
"Security": {
|
"Security": {
|
||||||
@@ -129,6 +137,10 @@
|
|||||||
"Scope": "api"
|
"Scope": "api"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"clickup_baseurl": "https://api.clickup.com/api/v2",
|
||||||
|
"clickup_api_token": "abcdef123456789",
|
||||||
|
"clickup_list_id": "12345",
|
||||||
|
"clickup_template_id": "t-12345"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260804120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add exclusion group and exclusive flag to additional services';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// The default only backfills existing rows; it is dropped again so the column matches the mapping.
|
||||||
|
$this->addSql('ALTER TABLE additional_service ADD exclusion_group VARCHAR(128) DEFAULT NULL, ADD is_exclusive TINYINT(1) DEFAULT 0 NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE additional_service ALTER COLUMN is_exclusive DROP DEFAULT');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE additional_service DROP exclusion_group, DROP is_exclusive');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260804140000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add manual ordering position to additional services';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// The default only backfills existing rows; it is dropped again so the column matches the mapping.
|
||||||
|
$this->addSql('ALTER TABLE additional_service ADD position INT DEFAULT 0 NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE additional_service ALTER COLUMN position DROP DEFAULT');
|
||||||
|
|
||||||
|
// Seed the positions with the ordering the admin overview used before, so nothing changes
|
||||||
|
// visually until someone actually drags a row.
|
||||||
|
$this->addSql('
|
||||||
|
UPDATE additional_service s
|
||||||
|
JOIN (
|
||||||
|
SELECT id, ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY accommodation_id
|
||||||
|
ORDER BY date_from ASC, (price <> 0) ASC, label ASC
|
||||||
|
) AS rn
|
||||||
|
FROM additional_service
|
||||||
|
) o ON o.id = s.id
|
||||||
|
SET s.position = o.rn
|
||||||
|
');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE additional_service DROP position');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260804152215 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Make accommodation booking contact fields nullable (filled on the edit page after creation)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking CHANGE first_name first_name VARCHAR(100) DEFAULT NULL, CHANGE last_name last_name VARCHAR(100) DEFAULT NULL, CHANGE email email VARCHAR(255) DEFAULT NULL, CHANGE group_name group_name VARCHAR(255) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking CHANGE group_name group_name VARCHAR(255) NOT NULL, CHANGE first_name first_name VARCHAR(100) NOT NULL, CHANGE last_name last_name VARCHAR(100) NOT NULL, CHANGE email email VARCHAR(255) NOT NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?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 Version20260804153855 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Replace the accommodation booking is_inquiry flag with a four-value status';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking ADD status VARCHAR(20) DEFAULT NULL');
|
||||||
|
$this->addSql("UPDATE accommodation_booking SET status = IF(is_inquiry = 1, 'inquiry', 'booking')");
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking CHANGE status status VARCHAR(20) NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking DROP is_inquiry');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking ADD is_inquiry TINYINT(1) DEFAULT NULL');
|
||||||
|
$this->addSql("UPDATE accommodation_booking SET is_inquiry = IF(status = 'inquiry', 1, 0)");
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking CHANGE is_inquiry is_inquiry TINYINT(1) NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking DROP status');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260805102251 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add country to accommodation, defaulting existing rows to DE';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Added with a default so existing rows backfill to DE, then dropped again to match the mapping
|
||||||
|
$this->addSql("ALTER TABLE accommodation ADD country VARCHAR(2) DEFAULT 'DE' NOT NULL");
|
||||||
|
$this->addSql('ALTER TABLE accommodation ALTER country DROP DEFAULT');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE accommodation DROP country');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260806120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Split the accommodation booking status into a status and a type';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking ADD type VARCHAR(20) DEFAULT NULL');
|
||||||
|
|
||||||
|
// An old 'booking' row with an accepted_at came from an accepted offer, so it is an
|
||||||
|
// Anfrage; without one it was placed directly. Rows in 'draft'/'discarded' carry no
|
||||||
|
// trace of their kind any more — they are mapped to 'inquiry', which is where the
|
||||||
|
// office path produces them.
|
||||||
|
$this->addSql("UPDATE accommodation_booking SET type = CASE
|
||||||
|
WHEN status = 'booking' AND accepted_at IS NULL THEN 'booking'
|
||||||
|
ELSE 'inquiry' END");
|
||||||
|
|
||||||
|
$this->addSql("UPDATE accommodation_booking SET status = CASE
|
||||||
|
WHEN status = 'inquiry' THEN 'open'
|
||||||
|
WHEN status = 'booking' THEN 'accepted'
|
||||||
|
ELSE status END");
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking CHANGE type type VARCHAR(20) NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql("UPDATE accommodation_booking SET status = CASE
|
||||||
|
WHEN status IN ('draft', 'discarded') THEN status
|
||||||
|
WHEN type = 'inquiry' AND status = 'open' THEN 'inquiry'
|
||||||
|
ELSE 'booking' END");
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking DROP type');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260817090000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Store the BusPro first and last name on the user';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Left empty on purpose: the names are synced from BPN on the next login of each account.
|
||||||
|
$this->addSql('ALTER TABLE user ADD first_name VARCHAR(100) DEFAULT NULL, ADD last_name VARCHAR(100) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE user DROP first_name, DROP last_name');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260818103004 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Record when the office confirmed an accommodation booking, separately from when the customer committed to it';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking ADD confirmed_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking DROP confirmed_at');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260819120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Split the ambiguous Offen status into Angefragt and Offen, and turn the booking type into an immutable origin';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Offen used to mean both "inquiry just arrived" and "offer is out with the customer",
|
||||||
|
// told apart only by the access link. An Offen row without a link is exactly the
|
||||||
|
// former case and becomes Angefragt; rows with a link are genuinely awaiting the
|
||||||
|
// customer's acceptance and keep their status.
|
||||||
|
$this->addSql("UPDATE accommodation_booking SET status = 'requested' WHERE status = 'open' AND access_link_issued_at IS NULL");
|
||||||
|
|
||||||
|
// The type described the lifecycle a second time and contradicted the status once an
|
||||||
|
// offer had been accepted. It now records only where the booking came from, which is
|
||||||
|
// true at every point of its life: an old `inquiry` went through an offer however far
|
||||||
|
// it got, an old `booking` was placed directly.
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking CHANGE type origin VARCHAR(20) NOT NULL');
|
||||||
|
$this->addSql("UPDATE accommodation_booking SET origin = 'offer' WHERE origin = 'inquiry'");
|
||||||
|
$this->addSql("UPDATE accommodation_booking SET origin = 'direct' WHERE origin = 'booking'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql("UPDATE accommodation_booking SET origin = 'inquiry' WHERE origin = 'offer'");
|
||||||
|
$this->addSql("UPDATE accommodation_booking SET origin = 'booking' WHERE origin = 'direct'");
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking CHANGE origin type VARCHAR(20) NOT NULL');
|
||||||
|
$this->addSql("UPDATE accommodation_booking SET status = 'open' WHERE status = 'requested'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260820080235 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Adds the local contingent snapshot (contingent_day) and its sync bookkeeping (contingent_sync_state).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE contingent_day (id INT AUTO_INCREMENT NOT NULL, accommodation_id INT NOT NULL, date DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', status VARCHAR(20) NOT NULL, INDEX IDX_8ACC3A068F3692CD (accommodation_id), UNIQUE INDEX uniq_contingent_day_accommodation_date (accommodation_id, date), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||||
|
$this->addSql('CREATE TABLE contingent_sync_state (id INT AUTO_INCREMENT NOT NULL, accommodation_id INT NOT NULL, content_hash VARCHAR(64) NOT NULL, changed_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', synced_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', horizon_to DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', last_error LONGTEXT DEFAULT NULL, failure_count INT NOT NULL, UNIQUE INDEX UNIQ_737F167C8F3692CD (accommodation_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||||
|
$this->addSql('ALTER TABLE contingent_day ADD CONSTRAINT FK_8ACC3A068F3692CD FOREIGN KEY (accommodation_id) REFERENCES accommodation (id) ON DELETE CASCADE');
|
||||||
|
$this->addSql('ALTER TABLE contingent_sync_state ADD CONSTRAINT FK_737F167C8F3692CD FOREIGN KEY (accommodation_id) REFERENCES accommodation (id) ON DELETE CASCADE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE contingent_day DROP FOREIGN KEY FK_8ACC3A068F3692CD');
|
||||||
|
$this->addSql('ALTER TABLE contingent_sync_state DROP FOREIGN KEY FK_737F167C8F3692CD');
|
||||||
|
$this->addSql('DROP TABLE contingent_day');
|
||||||
|
$this->addSql('DROP TABLE contingent_sync_state');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260903153641 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE accommodation ADD product_code VARCHAR(16) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE accommodation DROP product_code');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260908145156 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add a generic labelled discount on the accommodation booking total';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking ADD total_discount INT DEFAULT NULL, ADD total_discount_label VARCHAR(255) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking DROP total_discount, DROP total_discount_label');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The percentage column is dropped rather than converted: it shipped with Version20260908145156
|
||||||
|
* but no booking was ever given a value, so there is nothing to carry over.
|
||||||
|
*/
|
||||||
|
final class Version20260908161500 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Store the accommodation booking total discount as an absolute amount in minor units instead of a percentage';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking DROP total_discount, ADD total_discount_amount INT DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE accommodation_booking DROP total_discount_amount, ADD total_discount INT DEFAULT NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260911170000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Drops contingent_sync_state.content_hash: the rolling sync window makes a snapshot digest unusable as a change signal.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE contingent_sync_state DROP content_hash');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE contingent_sync_state ADD content_hash VARCHAR(64) NOT NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-10
@@ -1,20 +1,20 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
|
<!-- https://docs.phpunit.de/en/12.5/configuration.html -->
|
||||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
backupGlobals="false"
|
backupGlobals="false"
|
||||||
colors="true"
|
colors="true"
|
||||||
bootstrap="tests/bootstrap.php"
|
bootstrap="tests/bootstrap.php"
|
||||||
convertDeprecationsToExceptions="false"
|
cacheDirectory=".phpunit.cache"
|
||||||
|
displayDetailsOnTestsThatTriggerDeprecations="true"
|
||||||
|
displayDetailsOnPhpunitDeprecations="true"
|
||||||
>
|
>
|
||||||
<php>
|
<php>
|
||||||
<ini name="display_errors" value="1" />
|
<ini name="display_errors" value="1" />
|
||||||
<ini name="error_reporting" value="-1" />
|
<ini name="error_reporting" value="-1" />
|
||||||
<server name="APP_ENV" value="test" force="true" />
|
<server name="APP_ENV" value="test" force="true" />
|
||||||
<server name="SHELL_VERBOSITY" value="-1" />
|
<server name="SHELL_VERBOSITY" value="-1" />
|
||||||
<server name="SYMFONY_PHPUNIT_REMOVE" value="" />
|
|
||||||
<server name="SYMFONY_PHPUNIT_VERSION" value="9.5" />
|
|
||||||
</php>
|
</php>
|
||||||
|
|
||||||
<testsuites>
|
<testsuites>
|
||||||
@@ -23,16 +23,13 @@
|
|||||||
</testsuite>
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
|
|
||||||
<coverage processUncoveredFiles="true">
|
<source>
|
||||||
<include>
|
<include>
|
||||||
<directory suffix=".php">src</directory>
|
<directory suffix=".php">src</directory>
|
||||||
</include>
|
</include>
|
||||||
</coverage>
|
</source>
|
||||||
|
|
||||||
<listeners>
|
|
||||||
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
|
|
||||||
</listeners>
|
|
||||||
|
|
||||||
<extensions>
|
<extensions>
|
||||||
|
<bootstrap class="Symfony\Bridge\PhpUnit\SymfonyExtension" />
|
||||||
</extensions>
|
</extensions>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
@@ -11,6 +11,12 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|||||||
|
|
||||||
abstract class AbstractApiClient
|
abstract class AbstractApiClient
|
||||||
{
|
{
|
||||||
|
/** Maximum time to wait between two bytes of the response. */
|
||||||
|
private const int IDLE_TIMEOUT_SECONDS = 10;
|
||||||
|
|
||||||
|
/** Hard ceiling on a single request, including a slow-but-alive upstream. */
|
||||||
|
private const int MAX_DURATION_SECONDS = 30;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected readonly HttpClientInterface $httpClient,
|
protected readonly HttpClientInterface $httpClient,
|
||||||
protected readonly LoggerInterface $logger,
|
protected readonly LoggerInterface $logger,
|
||||||
@@ -21,6 +27,7 @@ abstract class AbstractApiClient
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $query
|
* @param array<string, mixed> $query
|
||||||
|
*
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
protected function request(string $path, array $query = []): array
|
protected function request(string $path, array $query = []): array
|
||||||
@@ -33,6 +40,11 @@ abstract class AbstractApiClient
|
|||||||
'X-API-KEY' => $this->apiKey,
|
'X-API-KEY' => $this->apiKey,
|
||||||
],
|
],
|
||||||
'query' => $query,
|
'query' => $query,
|
||||||
|
// Without these a stalled upstream hangs the caller indefinitely. That matters
|
||||||
|
// most for the scheduled contingent sync, which holds a lock: one hung run would
|
||||||
|
// block every subsequent one and silently let the snapshot rot.
|
||||||
|
'timeout' => self::IDLE_TIMEOUT_SECONDS,
|
||||||
|
'max_duration' => self::MAX_DURATION_SECONDS,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $response->toArray();
|
return $response->toArray();
|
||||||
|
|||||||
+73
-24
@@ -602,10 +602,12 @@ class ApiClient
|
|||||||
'port' => $this->selectedPort,
|
'port' => $this->selectedPort,
|
||||||
]);
|
]);
|
||||||
$this->send($socket, $body);
|
$this->send($socket, $body);
|
||||||
$response = $this->receive($socket);
|
|
||||||
$this->disconnect($socket);
|
|
||||||
|
|
||||||
$responseXml = substr($response, 10);
|
try {
|
||||||
|
$responseXml = $this->receive($socket);
|
||||||
|
} finally {
|
||||||
|
$this->disconnect($socket);
|
||||||
|
}
|
||||||
|
|
||||||
if (true === $debug || true === $this->config['debug']) {
|
if (true === $debug || true === $this->config['debug']) {
|
||||||
$this->dumpXmlToFile('response', $requestId, $responseXml);
|
$this->dumpXmlToFile('response', $requestId, $responseXml);
|
||||||
@@ -714,18 +716,12 @@ class ApiClient
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->send($socket, $body);
|
$this->send($socket, $body);
|
||||||
$response = $this->receive($socket);
|
|
||||||
$this->disconnect($socket);
|
|
||||||
|
|
||||||
// message length (10 bytes) is prepended to actual message
|
try {
|
||||||
$xml = substr($response, 10);
|
// the length header is consumed and verified by receive(), this is the payload
|
||||||
|
$xml = $this->receive($socket);
|
||||||
if ('' === $xml) {
|
} finally {
|
||||||
$this->logger->error('Empty response body received from API (header-only response)', [
|
$this->disconnect($socket);
|
||||||
'request_id' => $requestId,
|
|
||||||
'raw_length' => strlen($response),
|
|
||||||
]);
|
|
||||||
throw new ApiClientException('Empty response body received from API');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (true === $debug || true === $this->config['debug']) {
|
if (true === $debug || true === $this->config['debug']) {
|
||||||
@@ -735,14 +731,19 @@ class ApiClient
|
|||||||
try {
|
try {
|
||||||
return $this->responseParser->parseXmlString($type, $xml, $additionalArgs);
|
return $this->responseParser->parseXmlString($type, $xml, $additionalArgs);
|
||||||
} catch (ResponseParserException $e) {
|
} catch (ResponseParserException $e) {
|
||||||
|
// Keep the evidence even outside debug mode: without the raw response a parse
|
||||||
|
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
|
||||||
$this->dumpXmlToFile('response', $requestId, $xml);
|
$this->dumpXmlToFile('response', $requestId, $xml);
|
||||||
|
|
||||||
|
$this->logger->error('Unable to parse response received from API', [
|
||||||
|
'request_id' => $requestId,
|
||||||
|
'type' => $type,
|
||||||
|
'response_length' => strlen($xml),
|
||||||
|
'error_message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new ApiClientException('Unable to parse response received from API: '.$e->getMessage(), 0, $e);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logger->error('Unexpected response received from API', [
|
|
||||||
'request_id' => $requestId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw new ApiClientException('Unexpected response received from API');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -773,10 +774,12 @@ class ApiClient
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->send($socket, $body);
|
$this->send($socket, $body);
|
||||||
$response = $this->receive($socket);
|
|
||||||
$this->disconnect($socket);
|
|
||||||
|
|
||||||
$xml = substr($response, 10);
|
try {
|
||||||
|
$xml = $this->receive($socket);
|
||||||
|
} finally {
|
||||||
|
$this->disconnect($socket);
|
||||||
|
}
|
||||||
|
|
||||||
if (true === $this->config['debug']) {
|
if (true === $this->config['debug']) {
|
||||||
$this->dumpXmlToFile('response', $requestId, $xml);
|
$this->dumpXmlToFile('response', $requestId, $xml);
|
||||||
@@ -898,8 +901,14 @@ class ApiClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Reads a single response message and returns its payload. The protocol prepends the
|
||||||
|
* payload length as a 10 byte header, so the announced length is compared against what
|
||||||
|
* actually arrived: a peer closing mid-stream would otherwise yield a silently
|
||||||
|
* truncated body that only surfaces later as an unexplained parse failure.
|
||||||
|
*
|
||||||
* @param resource $socket
|
* @param resource $socket
|
||||||
*
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
* @throws TimeoutException
|
* @throws TimeoutException
|
||||||
* @throws ImmediateConnectionCloseException
|
* @throws ImmediateConnectionCloseException
|
||||||
*/
|
*/
|
||||||
@@ -963,7 +972,47 @@ class ApiClient
|
|||||||
throw new ImmediateConnectionCloseException('Server closed connection without sending data');
|
throw new ImmediateConnectionCloseException('Server closed connection without sending data');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $response;
|
return $this->extractPayload($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
private function extractPayload(string $response): string
|
||||||
|
{
|
||||||
|
if (10 > strlen($response)) {
|
||||||
|
$this->logger->error('Incomplete response header received from API', [
|
||||||
|
'bytes_received' => strlen($response),
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new ApiClientException(sprintf('Incomplete response header, got %d of 10 bytes', strlen($response)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = substr($response, 0, 10);
|
||||||
|
$announcedLength = (int) trim($header);
|
||||||
|
$payload = substr($response, 10);
|
||||||
|
|
||||||
|
if (1 > $announcedLength) {
|
||||||
|
$this->logger->error('Empty response body announced by API', [
|
||||||
|
'header' => trim($header),
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new ApiClientException(sprintf('Response announced an empty body (header "%s")', trim($header)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($payload) !== $announcedLength) {
|
||||||
|
$this->logger->error('Truncated response received from API', [
|
||||||
|
'bytes_received' => strlen($payload),
|
||||||
|
'announced_length' => $announcedLength,
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new ApiClientException(sprintf('Truncated response, got %d of %d announced bytes', strlen($payload), $announcedLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param resource $socket */
|
/** @param resource $socket */
|
||||||
|
|||||||
@@ -6,15 +6,17 @@ namespace App\BusProNet\DataProcessor;
|
|||||||
|
|
||||||
use App\BusProNet\Constants;
|
use App\BusProNet\Constants;
|
||||||
use App\BusProNet\Model\Booking;
|
use App\BusProNet\Model\Booking;
|
||||||
|
use App\BusProNet\Model\Insurance;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\BusProNet\Utility\DirectionMapper;
|
use App\BusProNet\Utility\DirectionMapper;
|
||||||
use App\BusProNet\XmlLoader\AgencyLoader;
|
|
||||||
use App\Form\Model\BankAccountDto;
|
use App\Form\Model\BankAccountDto;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Form\Model\RoomSelectionDto;
|
use App\Form\Model\RoomSelectionDto;
|
||||||
|
use App\Service\ApplicantInsuranceCascade;
|
||||||
use App\Service\BookingPriceCalculator;
|
use App\Service\BookingPriceCalculator;
|
||||||
use App\Service\InsuranceManager;
|
use App\Service\InsuranceManager;
|
||||||
|
|
||||||
use function Symfony\Component\String\u;
|
use function Symfony\Component\String\u;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,6 +35,7 @@ class BookingDataProcessor
|
|||||||
private readonly ParticipantServiceProcessor $serviceProcessor,
|
private readonly ParticipantServiceProcessor $serviceProcessor,
|
||||||
private readonly BookingPayloadBuilder $payloadBuilder,
|
private readonly BookingPayloadBuilder $payloadBuilder,
|
||||||
private readonly PersonalDataSynchronizer $personalDataSynchronizer,
|
private readonly PersonalDataSynchronizer $personalDataSynchronizer,
|
||||||
|
private readonly ApplicantInsuranceCascade $applicantInsuranceCascade,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,8 +304,8 @@ class BookingDataProcessor
|
|||||||
*/
|
*/
|
||||||
public function createUpdateRequestPayload(?BookingDto $formData): array
|
public function createUpdateRequestPayload(?BookingDto $formData): array
|
||||||
{
|
{
|
||||||
// Apply bulk insurance if enabled (modifies DTO in place)
|
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
||||||
$this->applyBulkInsuranceIfActive($formData);
|
$this->applicantInsuranceCascade->apply($formData);
|
||||||
|
|
||||||
$bookingData = $formData->booking;
|
$bookingData = $formData->booking;
|
||||||
$travelData = $formData->travel;
|
$travelData = $formData->travel;
|
||||||
@@ -362,71 +365,94 @@ class BookingDataProcessor
|
|||||||
*/
|
*/
|
||||||
public function createBookingRequestPayload(BookingDto $bookingDto, string $bookingType): array
|
public function createBookingRequestPayload(BookingDto $bookingDto, string $bookingType): array
|
||||||
{
|
{
|
||||||
// Apply bulk insurance if enabled (modifies DTO in place)
|
// Re-validate each participant's insurance against current eligibility (price tier,
|
||||||
$this->applyBulkInsuranceIfActive($bookingDto);
|
// family constraint, etc.) before submission - selections can go stale between the
|
||||||
|
// moment they were chosen and final submission, since users can freely revisit
|
||||||
|
// earlier steps and change composition/services without ever re-opening the
|
||||||
|
// insurance field. Must run before the applicant-to-dependents propagation below,
|
||||||
|
// so that propagation is based on an already-corrected applicant selection.
|
||||||
|
$this->reconcileInsuranceEligibility($bookingDto);
|
||||||
|
|
||||||
|
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
||||||
|
$this->applicantInsuranceCascade->apply($bookingDto);
|
||||||
|
|
||||||
return $this->payloadBuilder->buildCreatePayload($bookingDto, $bookingType);
|
return $this->payloadBuilder->buildCreatePayload($bookingDto, $bookingType);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies bulk insurance assignment if the applicant has enabled it.
|
* Re-validates every participant's currently selected insurance against present-day
|
||||||
|
* eligibility (price tier, family constraint, age, travel/booking dates, duration) and
|
||||||
|
* corrects any selection that's gone stale.
|
||||||
*
|
*
|
||||||
* When bulk insurance is active (applicant's bulkInsuranceBooking = true), this method
|
* Insurance eligibility is normally re-checked when a participant's own card is
|
||||||
* assigns the applicant's insurance TYPE to all dependent participants with automatic
|
* resubmitted (see ParticipantInsuranceFieldHandler::processField()), but nothing forces
|
||||||
* price tier adjustment based on each participant's total cost.
|
* that to happen again if the user changes composition or services on an earlier step
|
||||||
|
* after insurance was already chosen, then jumps straight to submission. Left unchecked,
|
||||||
|
* a stale insurance ID (e.g. a family-tariff price tier that no longer matches the
|
||||||
|
* current total booking price) would reach the BPN API and be rejected as a mismatch.
|
||||||
*
|
*
|
||||||
* IMPORTANT: In edit mode, bulk insurance only applies to participants who:
|
* Mirrors the reassign-or-clear logic already established in
|
||||||
* 1. Currently have NO insurance assigned (insurance === null)
|
* ParticipantInsuranceFieldHandler for the resubmission case, applied here across all
|
||||||
* 2. OR whose insurance was already assigned via previous bulk operation
|
* participants right before the payload is built.
|
||||||
*
|
|
||||||
* This prevents overriding individually selected insurances that are locked.
|
|
||||||
*
|
|
||||||
* @param BookingDto $bookingDto The booking DTO (create or edit flow)
|
|
||||||
*/
|
*/
|
||||||
private function applyBulkInsuranceIfActive(BookingDto $bookingDto): void
|
private function reconcileInsuranceEligibility(BookingDto $bookingDto): bool
|
||||||
{
|
{
|
||||||
$applicant = $bookingDto->getParticipant(0);
|
|
||||||
|
|
||||||
// Check if bulk insurance is enabled and applicant has selected an insurance
|
|
||||||
if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get selectable (non-complementary) insurances with caching
|
|
||||||
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
|
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
|
||||||
|
$changed = false;
|
||||||
|
|
||||||
// Calculate travel prices for all participants (excluding insurance)
|
|
||||||
$participantPrices = [];
|
|
||||||
foreach ($bookingDto->getParticipants() as $index => $participant) {
|
foreach ($bookingDto->getParticipants() as $index => $participant) {
|
||||||
$participantPrices[$index] = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $index);
|
$currentInsurance = $participant->insurance;
|
||||||
}
|
if (null === $currentInsurance || $currentInsurance->isNoInsurance()) {
|
||||||
|
|
||||||
// Use InsuranceManager for proper type-based assignment with price tier matching
|
|
||||||
$assignments = $this->insuranceService->batchAssignInsuranceToParticipants(
|
|
||||||
$selectableInsurances,
|
|
||||||
$applicant->insurance,
|
|
||||||
$bookingDto,
|
|
||||||
$participantPrices
|
|
||||||
);
|
|
||||||
|
|
||||||
// Apply assignments to dependent participants (skip applicant at index 0)
|
|
||||||
foreach ($assignments as $index => $insurance) {
|
|
||||||
if (0 === $index) {
|
|
||||||
continue; // Skip applicant
|
|
||||||
}
|
|
||||||
|
|
||||||
$participant = $bookingDto->getParticipant($index);
|
|
||||||
if (null === $participant) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In edit mode: only apply bulk insurance if participant has no insurance
|
$travelPrice = $this->priceCalculatorService->resolveInsuranceTravelPrice($bookingDto, $index, $currentInsurance);
|
||||||
// This respects the rule that once assigned, insurance cannot be changed
|
$eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice);
|
||||||
if (BookingDto::MODE_EDIT === $bookingDto->getMode() && null !== $participant->insurance) {
|
|
||||||
continue; // Skip participants with existing insurance assignment
|
if ($this->isInsuranceInList($currentInsurance, $eligibleInsurances)) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$participant->insurance = $insurance;
|
$reassignedInsurance = $this->insuranceService->reassignInsuranceForPriceChange(
|
||||||
|
$selectableInsurances,
|
||||||
|
$currentInsurance,
|
||||||
|
$participant,
|
||||||
|
$bookingDto,
|
||||||
|
$travelPrice
|
||||||
|
);
|
||||||
|
|
||||||
|
if (null !== $reassignedInsurance) {
|
||||||
|
$participant->addNotification(
|
||||||
|
'warning',
|
||||||
|
sprintf('Versicherung vor der Übermittlung automatisch angepasst: %s', $reassignedInsurance->label)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$participant->addNotification(
|
||||||
|
'warning',
|
||||||
|
'Die gewählte Versicherung ist für diese Buchung nicht mehr verfügbar und wurde entfernt. Bitte erneut auswählen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$participant->insurance = $reassignedInsurance;
|
||||||
|
$changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a specific insurance exists in a list of insurances (by id).
|
||||||
|
*
|
||||||
|
* @param array<Insurance> $insuranceList
|
||||||
|
*/
|
||||||
|
private function isInsuranceInList(Insurance $targetInsurance, array $insuranceList): bool
|
||||||
|
{
|
||||||
|
foreach ($insuranceList as $insurance) {
|
||||||
|
if ($insurance->id === $targetInsurance->id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ class BookingPayloadBuilder
|
|||||||
foreach ($bookingData->participants as $index => $participant) {
|
foreach ($bookingData->participants as $index => $participant) {
|
||||||
$participantPayload = [
|
$participantPayload = [
|
||||||
'@id' => $index + 1,
|
'@id' => $index + 1,
|
||||||
'status' => $bookingData->participantsStatus[$index],
|
'status' => $bookingData->participantsStatus[$index] ?? null,
|
||||||
...$participant->toPayload(),
|
...$participant->toPayload(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\BusProNet\Model;
|
namespace App\BusProNet\Model;
|
||||||
|
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
use function Symfony\Component\String\u;
|
use function Symfony\Component\String\u;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -143,6 +143,31 @@ class Booking
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a participant already holds a service in this booking.
|
||||||
|
*
|
||||||
|
* This is the BusPro-side baseline, not the working edit selection: it answers
|
||||||
|
* "did the participant have this service when the booking was last read back".
|
||||||
|
* Services already held may be kept regardless of their current status or
|
||||||
|
* contingent - withdrawing one produces "Anzahl Leistung stimmt nicht mit
|
||||||
|
* Teilnehmerzuordnung überein" on the next update.
|
||||||
|
*
|
||||||
|
* @param int $participantIndex The participant index to search for
|
||||||
|
* @param int $serviceId The service ID to look for
|
||||||
|
*
|
||||||
|
* @return bool True if the participant already holds the service
|
||||||
|
*/
|
||||||
|
public function hasServiceForParticipant(int $participantIndex, int $serviceId): bool
|
||||||
|
{
|
||||||
|
foreach ([...$this->additionalServices, ...$this->transportationServices] as $service) {
|
||||||
|
if ($service->id === $serviceId && in_array($participantIndex, $service->mapping)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves transportation service for a specific participant and direction.
|
* Retrieves transportation service for a specific participant and direction.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -35,4 +35,15 @@ class Hotel
|
|||||||
|
|
||||||
#[Groups(['api:list', 'api:single', 'snapshot'])]
|
#[Groups(['api:list', 'api:single', 'snapshot'])]
|
||||||
public ?string $country = null;
|
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;
|
||||||
}
|
}
|
||||||
@@ -61,6 +61,13 @@ class PersonalData
|
|||||||
/** @var list<string> */
|
/** @var list<string> */
|
||||||
public array $hotelCodes = [];
|
public array $hotelCodes = [];
|
||||||
|
|
||||||
|
// The authenticated account, not BusPro's idea of this person. One BusPro person may sign in
|
||||||
|
// under any of the addresses on its record, and each of those is a separate local account with
|
||||||
|
// its own roles, so neither the BusPro ids nor the communication email identifies one. Patched
|
||||||
|
// on by the caller in the same way as the roles and hotel codes above.
|
||||||
|
public ?string $subject = null;
|
||||||
|
public ?string $loginEmail = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->address = new Address();
|
$this->address = new Address();
|
||||||
@@ -132,13 +139,21 @@ class PersonalData
|
|||||||
* Creates a structured array containing user profile information
|
* Creates a structured array containing user profile information
|
||||||
* suitable for JWT claims or user session data.
|
* suitable for JWT claims or user session data.
|
||||||
*
|
*
|
||||||
|
* `sub` and `email` describe the account that authenticated and are read from the patched-on
|
||||||
|
* fields, never from BusPro: `person_id`/`address_id` are shared by every account of the same
|
||||||
|
* person, and `profile.communication.email` is the first contact address on the BusPro record,
|
||||||
|
* which is not necessarily the one signed in with. Deliberately without a fallback to that
|
||||||
|
* address — a caller that forgets to patch them gets null rather than a wrong identity.
|
||||||
|
*
|
||||||
* @return array<string, mixed> The claims array with user profile data
|
* @return array<string, mixed> The claims array with user profile data
|
||||||
*/
|
*/
|
||||||
public function getClaims(): array
|
public function getClaims(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $this->personId,
|
'sub' => $this->subject,
|
||||||
'email' => $this->communication->email,
|
'person_id' => $this->personId,
|
||||||
|
'address_id' => $this->addressId,
|
||||||
|
'email' => $this->loginEmail,
|
||||||
'roles' => $this->roles,
|
'roles' => $this->roles,
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'first_name' => $this->firstName,
|
'first_name' => $this->firstName,
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\Utility;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps BusPro's `hotel/land` token onto a lowercase ISO 3166-1 alpha-2 code.
|
||||||
|
*
|
||||||
|
* BusPro has no country vocabulary: the field is free text that has accumulated three
|
||||||
|
* conventions side by side — ISO-2 (`CH`, `NL`), the European vehicle registration codes
|
||||||
|
* (`D`, `A`, `F`, `S`, `FL`) and ISO-3 (`AUT`, `PRT`, `HRV`), with the same country often
|
||||||
|
* under several tokens. The table below is therefore part of the API contract rather than
|
||||||
|
* a derivation: consumers render a flag from it, and a wrong entry mislabels a destination
|
||||||
|
* silently, which is worse than no flag at all. An unknown token maps to null — never to a
|
||||||
|
* guess, never to an empty string.
|
||||||
|
*/
|
||||||
|
class CountryCodeMapper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Keys are upper-cased tokens, values lowercase ISO-2.
|
||||||
|
*
|
||||||
|
* Entries worth their explanation:
|
||||||
|
* - `IR` is Ireland, not Iran. Every row carrying it is Dublin or Carlow.
|
||||||
|
* - `SLO` is Slovakia here, not Slovenia: its only row is a Slovak company, and
|
||||||
|
* Slovenia already arrives under its own `SI`.
|
||||||
|
* - `S`, `N`, `FL` are vehicle codes (Sweden, Norway, Liechtenstein), consistent with
|
||||||
|
* the `D`/`A`/`F`/`I`/`E`/`B` in the same column.
|
||||||
|
* - `UK` is the colloquial form of the ISO `GB`.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private const MAP = [
|
||||||
|
'A' => 'at',
|
||||||
|
'AT' => 'at',
|
||||||
|
'AUT' => 'at',
|
||||||
|
'B' => 'be',
|
||||||
|
'CA' => 'ca',
|
||||||
|
'CH' => 'ch',
|
||||||
|
'CZ' => 'cz',
|
||||||
|
'D' => 'de',
|
||||||
|
'DEU' => 'de',
|
||||||
|
'DK' => 'dk',
|
||||||
|
'E' => 'es',
|
||||||
|
'ES' => 'es',
|
||||||
|
'F' => 'fr',
|
||||||
|
'FI' => 'fi',
|
||||||
|
'FL' => 'li',
|
||||||
|
'FR' => 'fr',
|
||||||
|
'GR' => 'gr',
|
||||||
|
'GRE' => 'gr',
|
||||||
|
'HR' => 'hr',
|
||||||
|
'HRV' => 'hr',
|
||||||
|
'HU' => 'hu',
|
||||||
|
'I' => 'it',
|
||||||
|
'IL' => 'il',
|
||||||
|
'IR' => 'ie',
|
||||||
|
'IS' => 'is',
|
||||||
|
'IT' => 'it',
|
||||||
|
'LT' => 'lt',
|
||||||
|
'LV' => 'lv',
|
||||||
|
'MT' => 'mt',
|
||||||
|
'N' => 'no',
|
||||||
|
'NL' => 'nl',
|
||||||
|
'PL' => 'pl',
|
||||||
|
'PRT' => 'pt',
|
||||||
|
'PT' => 'pt',
|
||||||
|
'RUS' => 'ru',
|
||||||
|
'S' => 'se',
|
||||||
|
'SI' => 'si',
|
||||||
|
'SLO' => 'sk',
|
||||||
|
'UK' => 'gb',
|
||||||
|
'USA' => 'us',
|
||||||
|
'ZA' => 'za',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens already reported in this process, so a master data file full of the same
|
||||||
|
* unknown token produces one log line rather than one per hotel.
|
||||||
|
*
|
||||||
|
* @var array<string, true>
|
||||||
|
*/
|
||||||
|
private array $reported = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $token the raw `hotel/land` value
|
||||||
|
*
|
||||||
|
* @return string|null lowercase ISO-2, or null when absent, empty or unmappable
|
||||||
|
*/
|
||||||
|
public function map(?string $token): ?string
|
||||||
|
{
|
||||||
|
if (null === $token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = strtoupper(trim($token));
|
||||||
|
|
||||||
|
if ('' === $normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset(self::MAP[$normalized])) {
|
||||||
|
return self::MAP[$normalized];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === isset($this->reported[$normalized])) {
|
||||||
|
$this->reported[$normalized] = true;
|
||||||
|
$this->logger->warning('Unmapped BusPro country token', ['token' => $token]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\BusProNet;
|
||||||
|
|
||||||
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a Crawler over BusPro's XML payloads.
|
||||||
|
*
|
||||||
|
* The Crawler constructor parses its content as HTML5, which flattens the
|
||||||
|
* nesting BusPro's exports and API responses rely on, so XML has to be added
|
||||||
|
* through addXmlContent() instead.
|
||||||
|
*/
|
||||||
|
final class XmlCrawlerFactory
|
||||||
|
{
|
||||||
|
public static function create(string $xml): Crawler
|
||||||
|
{
|
||||||
|
$crawler = new Crawler();
|
||||||
|
$crawler->addXmlContent($xml);
|
||||||
|
|
||||||
|
return $crawler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explains why create() returned an empty Crawler. addXmlContent() discards the
|
||||||
|
* libxml errors, so an empty, a malformed and a truncated payload are
|
||||||
|
* indistinguishable afterwards; this re-parses to recover the reason. Only worth
|
||||||
|
* calling on the failure path.
|
||||||
|
*/
|
||||||
|
public static function diagnose(string $xml): string
|
||||||
|
{
|
||||||
|
if ('' === trim($xml)) {
|
||||||
|
return sprintf('empty response (%d bytes)', strlen($xml));
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousUseErrors = libxml_use_internal_errors(true);
|
||||||
|
libxml_clear_errors();
|
||||||
|
|
||||||
|
try {
|
||||||
|
simplexml_load_string($xml);
|
||||||
|
|
||||||
|
$messages = [];
|
||||||
|
foreach (libxml_get_errors() as $error) {
|
||||||
|
$messages[] = sprintf('%s (line %d, column %d)', trim($error->message), $error->line, $error->column);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] === $messages) {
|
||||||
|
return sprintf('unknown XML error (%d bytes)', strlen($xml));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%s (%d bytes)', implode('; ', array_unique($messages)), strlen($xml));
|
||||||
|
} finally {
|
||||||
|
libxml_clear_errors();
|
||||||
|
libxml_use_internal_errors($previousUseErrors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,25 @@ namespace App\BusProNet\XmlLoader;
|
|||||||
|
|
||||||
use App\BusProNet\Model\Hotel;
|
use App\BusProNet\Model\Hotel;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\BusProNet\Utility\CountryCodeMapper;
|
||||||
|
use App\BusProNet\XmlCrawlerFactory;
|
||||||
use App\Exception\HotelNotFoundException;
|
use App\Exception\HotelNotFoundException;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Symfony\Component\DomCrawler\Crawler;
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
use Symfony\Contracts\Cache\ItemInterface;
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
|
|
||||||
class HotelLoader extends AbstractLoader
|
class HotelLoader extends AbstractLoader
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CountryCodeMapper $countryCodeMapper,
|
||||||
|
CacheInterface $cache,
|
||||||
|
FilesystemOperator $xmlExport,
|
||||||
|
) {
|
||||||
|
parent::__construct($cache, $xmlExport);
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<int, Hotel> */
|
/** @return array<int, Hotel> */
|
||||||
public function loadAll(?string $filename = 'hotel.xml'): array
|
public function loadAll(?string $filename = 'hotel.xml'): array
|
||||||
{
|
{
|
||||||
@@ -73,7 +85,7 @@ class HotelLoader extends AbstractLoader
|
|||||||
{
|
{
|
||||||
$xml = $this->xmlExport->read($filename);
|
$xml = $this->xmlExport->read($filename);
|
||||||
|
|
||||||
return new Crawler($xml);
|
return XmlCrawlerFactory::create($xml);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function parseXml(Crawler $node): Hotel
|
public function parseXml(Crawler $node): Hotel
|
||||||
@@ -86,6 +98,10 @@ class HotelLoader extends AbstractLoader
|
|||||||
$hotel->country = $this->getStringOrNullValue($node->filterXPath('//land'));
|
$hotel->country = $this->getStringOrNullValue($node->filterXPath('//land'));
|
||||||
$hotel->street = $this->getStringOrNullValue($node->filterXPath('//strasse'));
|
$hotel->street = $this->getStringOrNullValue($node->filterXPath('//strasse'));
|
||||||
$hotel->phone = $this->getStringOrNullValue($node->filterXPath('//telefon'));
|
$hotel->phone = $this->getStringOrNullValue($node->filterXPath('//telefon'));
|
||||||
|
// String, never int: see Hotel::$costUnit.
|
||||||
|
$hotel->costUnit = $this->getStringOrNullValue($node->filterXPath('//kostenstelle'));
|
||||||
|
// `country` itself stays the raw token — it is the join key into /api/countries[].token.
|
||||||
|
$hotel->countryCode = $this->countryCodeMapper->map($hotel->country);
|
||||||
|
|
||||||
return $hotel;
|
return $hotel;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\BusProNet\XmlLoader;
|
namespace App\BusProNet\XmlLoader;
|
||||||
|
|
||||||
use App\BusProNet\Model\Insurance;
|
use App\BusProNet\Model\Insurance;
|
||||||
|
use App\BusProNet\XmlCrawlerFactory;
|
||||||
use App\BusProNet\XmlParser\InsuranceParser;
|
use App\BusProNet\XmlParser\InsuranceParser;
|
||||||
use League\Flysystem\FilesystemOperator;
|
use League\Flysystem\FilesystemOperator;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
@@ -50,6 +51,6 @@ class InsuranceLoader extends AbstractLoader
|
|||||||
{
|
{
|
||||||
$xml = $this->xmlExport->read($filename);
|
$xml = $this->xmlExport->read($filename);
|
||||||
|
|
||||||
return new Crawler($xml);
|
return XmlCrawlerFactory::create($xml);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\BusProNet\XmlLoader;
|
|||||||
|
|
||||||
use App\BusProNet\Model\Pickup;
|
use App\BusProNet\Model\Pickup;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\BusProNet\XmlCrawlerFactory;
|
||||||
use Psr\Cache\InvalidArgumentException;
|
use Psr\Cache\InvalidArgumentException;
|
||||||
use Symfony\Component\DomCrawler\Crawler;
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
use Symfony\Contracts\Cache\ItemInterface;
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
@@ -46,7 +47,7 @@ class PickupLoader extends AbstractLoader
|
|||||||
{
|
{
|
||||||
$xml = $this->xmlExport->read($filename);
|
$xml = $this->xmlExport->read($filename);
|
||||||
|
|
||||||
return new Crawler($xml);
|
return XmlCrawlerFactory::create($xml);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function parseXml(Crawler $node): Pickup
|
public function parseXml(Crawler $node): Pickup
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\BusProNet\Model\MutableData;
|
|||||||
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\BusProNet\Utility\DateCodeUtility;
|
use App\BusProNet\Utility\DateCodeUtility;
|
||||||
|
use App\BusProNet\XmlCrawlerFactory;
|
||||||
use App\BusProNet\XmlParser\TravelParser;
|
use App\BusProNet\XmlParser\TravelParser;
|
||||||
use App\Exception\HotelNotInTravelException;
|
use App\Exception\HotelNotInTravelException;
|
||||||
use App\Exception\TravelNotFoundException;
|
use App\Exception\TravelNotFoundException;
|
||||||
@@ -71,7 +72,7 @@ class TravelLoader extends AbstractLoader
|
|||||||
foreach ($xmlFiles as $file) {
|
foreach ($xmlFiles as $file) {
|
||||||
$xml = $this->xmlExport->read($file->path());
|
$xml = $this->xmlExport->read($file->path());
|
||||||
|
|
||||||
$crawler = new Crawler($xml);
|
$crawler = XmlCrawlerFactory::create($xml);
|
||||||
$travelDataNodes = $crawler->filterXPath('//reisen/reise/termin');
|
$travelDataNodes = $crawler->filterXPath('//reisen/reise/termin');
|
||||||
|
|
||||||
$travelDataNodes->each(function (Crawler $node) use (&$mapping, $hotels, $file) {
|
$travelDataNodes->each(function (Crawler $node) use (&$mapping, $hotels, $file) {
|
||||||
@@ -91,6 +92,13 @@ class TravelLoader extends AbstractLoader
|
|||||||
->each(function (Crawler $hotelNode) use (&$mapping, $hotels, $travelId) {
|
->each(function (Crawler $hotelNode) use (&$mapping, $hotels, $travelId) {
|
||||||
$hotelId = (int) $hotelNode->attr('idbuspro');
|
$hotelId = (int) $hotelNode->attr('idbuspro');
|
||||||
|
|
||||||
|
// A travel may reference a hotel that hotel.xml does not carry.
|
||||||
|
// Reading ->name off the miss would fatal the whole mapping, not
|
||||||
|
// just this entry, and take /api/travels down with it.
|
||||||
|
if (false === isset($hotels[$hotelId])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$mapping[$travelId]['hotels'][$hotelId] = [
|
$mapping[$travelId]['hotels'][$hotelId] = [
|
||||||
'id' => $hotelId,
|
'id' => $hotelId,
|
||||||
'code' => $hotelNode->attr('code'),
|
'code' => $hotelNode->attr('code'),
|
||||||
@@ -218,7 +226,7 @@ class TravelLoader extends AbstractLoader
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$xml = $this->xmlExport->read($filename);
|
$xml = $this->xmlExport->read($filename);
|
||||||
$crawler = new Crawler($xml);
|
$crawler = XmlCrawlerFactory::create($xml);
|
||||||
|
|
||||||
$travelNode = $crawler->filterXPath(sprintf('//reise/termin[@idbuspro="%d"]', $dateId));
|
$travelNode = $crawler->filterXPath(sprintf('//reise/termin[@idbuspro="%d"]', $dateId));
|
||||||
|
|
||||||
@@ -280,9 +288,9 @@ class TravelLoader extends AbstractLoader
|
|||||||
/**
|
/**
|
||||||
* Apply availability data to travel services.
|
* Apply availability data to travel services.
|
||||||
*
|
*
|
||||||
* Updates the availability status of additional and transportation
|
* Updates the remaining contingent and the live status of additional and
|
||||||
* services, and the allowed booking status, based on the provided
|
* transportation services, and the allowed booking status, based on the
|
||||||
* availability data.
|
* provided availability data.
|
||||||
*
|
*
|
||||||
* @param Travel $travel The travel object to update
|
* @param Travel $travel The travel object to update
|
||||||
* @param ServiceAvailabilityResponse $availabilities The availability data for services
|
* @param ServiceAvailabilityResponse $availabilities The availability data for services
|
||||||
@@ -292,8 +300,19 @@ class TravelLoader extends AbstractLoader
|
|||||||
$serviceAvailabilities = $availabilities->getServices();
|
$serviceAvailabilities = $availabilities->getServices();
|
||||||
|
|
||||||
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
|
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
|
||||||
if (array_key_exists($service->id, $serviceAvailabilities)) {
|
if (false === array_key_exists($service->id, $serviceAvailabilities)) {
|
||||||
$service->available = $serviceAvailabilities[$service->id]->available;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$availability = $serviceAvailabilities[$service->id];
|
||||||
|
$service->available = $availability->available;
|
||||||
|
|
||||||
|
// The live status is the only signal that a contingent has run out: BusPro moves a
|
||||||
|
// Leistung to 'Anfrage' without necessarily reporting frei="0". Only overwrite when the
|
||||||
|
// response actually carries one, so a response that omits the attribute does not wipe
|
||||||
|
// the value parsed from the XML export. Mirrors TravelSnapshotManager::applyExtendedToService().
|
||||||
|
if (null !== $availability->status && '' !== trim($availability->status)) {
|
||||||
|
$service->status = $availability->status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,6 @@ abstract class AbstractParser
|
|||||||
{
|
{
|
||||||
use TypeConversionTrait;
|
use TypeConversionTrait;
|
||||||
|
|
||||||
protected function getIntOrNullAttribute(?string $value): ?int
|
|
||||||
{
|
|
||||||
if (null === $value || '' === $value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getStringOrNullValue(Crawler $node): ?string
|
protected function getStringOrNullValue(Crawler $node): ?string
|
||||||
{
|
{
|
||||||
return 0 < $node->count() ? $node->text() : null;
|
return 0 < $node->count() ? $node->text() : null;
|
||||||
@@ -64,7 +55,27 @@ abstract class AbstractParser
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $node->attr($attribute);
|
$value = $node->attr($attribute);
|
||||||
|
if (null !== $value) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BusPro mixes casing (MinPax in the travel export, minpax in booking
|
||||||
|
// responses) and addXmlContent() makes attr() case-sensitive.
|
||||||
|
foreach ($node->getNode(0)->attributes ?? [] as $candidate) {
|
||||||
|
if (0 === strcasecmp($candidate->name, $attribute)) {
|
||||||
|
return $candidate->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getIntAttrOrNullValue(Crawler $node, string $attribute): ?int
|
||||||
|
{
|
||||||
|
$value = $this->getAttrOrNullValue($node, $attribute);
|
||||||
|
|
||||||
|
return null !== $value && '' !== $value ? (int) $value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getRequiredAttrValue(Crawler $node, string $attribute, string $context): string
|
protected function getRequiredAttrValue(Crawler $node, string $attribute, string $context): string
|
||||||
|
|||||||
@@ -4,20 +4,25 @@ namespace App\BusProNet\XmlParser;
|
|||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\ApiClient;
|
||||||
use App\BusProNet\Exception\ResponseParserException;
|
use App\BusProNet\Exception\ResponseParserException;
|
||||||
use Symfony\Component\DomCrawler\Crawler;
|
use App\BusProNet\XmlCrawlerFactory;
|
||||||
|
|
||||||
class ApiResponseParser extends AbstractParser
|
class ApiResponseParser extends AbstractParser
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TravelParser $travelParser,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws ResponseParserException
|
* @throws ResponseParserException
|
||||||
*/
|
*/
|
||||||
/** @param array<string, mixed> $additionalArgs */
|
/** @param array<string, mixed> $additionalArgs */
|
||||||
public function parseXmlString(string $type, string $xml, array $additionalArgs = []): mixed
|
public function parseXmlString(string $type, string $xml, array $additionalArgs = []): mixed
|
||||||
{
|
{
|
||||||
$crawler = new Crawler($xml);
|
$crawler = XmlCrawlerFactory::create($xml);
|
||||||
|
|
||||||
if (0 === $crawler->count()) {
|
if (0 === $crawler->count()) {
|
||||||
throw new ResponseParserException('Empty response received from server');
|
throw new ResponseParserException('Unable to parse XML response: '.XmlCrawlerFactory::diagnose($xml));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override type unless present in XML to catch error responses
|
// Override type unless present in XML to catch error responses
|
||||||
@@ -56,7 +61,8 @@ class ApiResponseParser extends AbstractParser
|
|||||||
case 'Vorgangdruck':
|
case 'Vorgangdruck':
|
||||||
return (new DocumentsParser())->parseConfirmation($resultNode);
|
return (new DocumentsParser())->parseConfirmation($resultNode);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
|
throw new ResponseParserException(sprintf('Unrecognised customer data subtype "%s"', $subType));
|
||||||
case ApiClient::TYPE_BASE_DATA_COUNTRIES:
|
case ApiClient::TYPE_BASE_DATA_COUNTRIES:
|
||||||
return (new CountriesParser())->parse($resultNode);
|
return (new CountriesParser())->parse($resultNode);
|
||||||
case ApiClient::TYPE_MUTABLE_DATA:
|
case ApiClient::TYPE_MUTABLE_DATA:
|
||||||
@@ -80,7 +86,7 @@ class ApiResponseParser extends AbstractParser
|
|||||||
throw new ResponseParserException('No travel data node found in product data response');
|
throw new ResponseParserException('No travel data node found in product data response');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (new TravelParser())->parse($travelNode->first(), ...$additionalArgs);
|
return $this->travelParser->parse($travelNode->first(), ...$additionalArgs);
|
||||||
case ApiClient::TYPE_AGENCIES:
|
case ApiClient::TYPE_AGENCIES:
|
||||||
return (new AgencyParser())->parse($resultNode);
|
return (new AgencyParser())->parse($resultNode);
|
||||||
case ApiClient::TYPE_PURCHASE_VOUCHER:
|
case ApiClient::TYPE_PURCHASE_VOUCHER:
|
||||||
@@ -92,6 +98,6 @@ class ApiResponseParser extends AbstractParser
|
|||||||
throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e);
|
throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ResponseParserException('Unable to parse XML response');
|
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,11 +49,13 @@ class BookingParser extends AbstractParser
|
|||||||
|
|
||||||
$booking->applicant = $this->parsePersonalData($node->filterXPath('//anmelder'));
|
$booking->applicant = $this->parsePersonalData($node->filterXPath('//anmelder'));
|
||||||
|
|
||||||
$participantsStatus = $this->getArrayValue($node->filterXPath('//status_teilnehmer'), '/');
|
|
||||||
$booking->participantsStatus = array_combine(range(0, count($participantsStatus) - 1), $participantsStatus);
|
|
||||||
|
|
||||||
$booking->participants = $this->parseParticipants($node->filterXPath('//teilnehmerliste/teilnehmer'));
|
$booking->participants = $this->parseParticipants($node->filterXPath('//teilnehmerliste/teilnehmer'));
|
||||||
|
|
||||||
|
$booking->participantsStatus = $this->mapParticipantsStatus(
|
||||||
|
array_keys($booking->participants),
|
||||||
|
$this->getArrayValue($node->filterXPath('//status_teilnehmer'), '/'),
|
||||||
|
);
|
||||||
|
|
||||||
$paymentData = $node->filterXPath('//zahlung');
|
$paymentData = $node->filterXPath('//zahlung');
|
||||||
$booking->paymentId = $this->getAttrOrNullValue($paymentData, 'idzahlungsart');
|
$booking->paymentId = $this->getAttrOrNullValue($paymentData, 'idzahlungsart');
|
||||||
$booking->paymentLabel = $this->getAttrOrNullValue($paymentData, 'bezeichnung');
|
$booking->paymentLabel = $this->getAttrOrNullValue($paymentData, 'bezeichnung');
|
||||||
@@ -129,6 +131,38 @@ class BookingParser extends AbstractParser
|
|||||||
return $participants;
|
return $participants;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the positional status_teilnehmer list onto the participant keys.
|
||||||
|
*
|
||||||
|
* BusPro sends the statuses as a slash-separated list in teilnehmerliste order and
|
||||||
|
* without ids, while participants are keyed by "BusPro participant id - 1" (see
|
||||||
|
* parseParticipants()). Zipping the two in document order keeps both arrays on the same
|
||||||
|
* keys even when the ids are not contiguous from 1, which BookingPayloadBuilder relies
|
||||||
|
* on when it reads participantsStatus[$index] while iterating participants, and which
|
||||||
|
* gates editing in ParticipantController::isParticipantCanceled().
|
||||||
|
*
|
||||||
|
* Surplus entries on either side are dropped rather than shifting the remaining ones
|
||||||
|
* onto the wrong participant.
|
||||||
|
*
|
||||||
|
* @param list<int> $participantKeys Participant array keys, in document order
|
||||||
|
* @param list<string> $statusValues Status codes, in document order
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function mapParticipantsStatus(array $participantKeys, array $statusValues): array
|
||||||
|
{
|
||||||
|
$count = min(count($participantKeys), count($statusValues));
|
||||||
|
|
||||||
|
if (0 === $count) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_combine(
|
||||||
|
array_slice($participantKeys, 0, $count),
|
||||||
|
array_slice($statusValues, 0, $count),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function parsePersonalData(Crawler $node): PersonalData
|
private function parsePersonalData(Crawler $node): PersonalData
|
||||||
{
|
{
|
||||||
$personalData = new PersonalData();
|
$personalData = new PersonalData();
|
||||||
|
|||||||
@@ -7,18 +7,48 @@ use App\BusProNet\Model\CrmAttributes;
|
|||||||
use App\BusProNet\Model\CrmSelection;
|
use App\BusProNet\Model\CrmSelection;
|
||||||
use App\BusProNet\Model\CrmSelectionGroup;
|
use App\BusProNet\Model\CrmSelectionGroup;
|
||||||
use App\BusProNet\Traits\TypeConversionTrait;
|
use App\BusProNet\Traits\TypeConversionTrait;
|
||||||
|
use App\Security\Role;
|
||||||
use Symfony\Component\DomCrawler\Crawler;
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the SelektionCRM response into the roles and hotel codes the CRM claims for an account.
|
||||||
|
*
|
||||||
|
* This reports what BusPro says and nothing more: no fallback role, no default hotel code. What
|
||||||
|
* is made of the claims — which are granted outright and which only nominate — is Role::sync()'s
|
||||||
|
* business.
|
||||||
|
*
|
||||||
|
* The selection ids below are deployment-critical. BusPro always returns the full attribute tree
|
||||||
|
* and expresses membership through the `auswahl` flag, so a wrong or unset id yields a perfectly
|
||||||
|
* well-formed response in which nobody holds anything, and every user logging in is demoted one
|
||||||
|
* at a time. There is no signal inside the response that tells that apart from a real revocation.
|
||||||
|
*/
|
||||||
class CrmAttributesResponseParser
|
class CrmAttributesResponseParser
|
||||||
{
|
{
|
||||||
use TypeConversionTrait;
|
use TypeConversionTrait;
|
||||||
|
|
||||||
private const BPN_CRM_ID_ADMIN = 1292;
|
/**
|
||||||
private const BPN_CRM_ID_MANAGER = 1293;
|
* The CRM selections that stand for a role, by selection id. "Hausleitung" is deliberately not
|
||||||
private const BPN_CRM_ID_TEAMER = 1070;
|
* in here: its ids are deployment configuration (see $houseManagerIds) and carry a hotel code
|
||||||
private const BPN_CRM_ID_GROUPS_MANAGER = 1477;
|
* on top of the role.
|
||||||
private const BPN_CRM_ID_GROUPS_ADMIN = 1478;
|
*
|
||||||
private const BPN_DEFAULT_HOTEL_CODE = 'SSL';
|
* @var array<int, string> selection id => role
|
||||||
|
*/
|
||||||
|
private const ROLE_BY_CRM_ID = [
|
||||||
|
1070 => Role::TEAMER,
|
||||||
|
1292 => Role::ADMIN,
|
||||||
|
1293 => Role::MANAGER,
|
||||||
|
1477 => Role::GROUPS_MANAGER,
|
||||||
|
1478 => Role::GROUPS_ADMIN,
|
||||||
|
1483 => Role::CUSTOMER_EXPERT,
|
||||||
|
1484 => Role::TEAM_ADMIN,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int|string, string> $houseManagerIds "Hausleitung" selection id => hotel code
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly array $houseManagerIds = [])
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public function parse(Crawler $result): CrmAttributes
|
public function parse(Crawler $result): CrmAttributes
|
||||||
{
|
{
|
||||||
@@ -29,7 +59,7 @@ class CrmAttributesResponseParser
|
|||||||
|
|
||||||
$result
|
$result
|
||||||
->filterXPath('//selektionsmerkmale/selektionsgruppe')
|
->filterXPath('//selektionsmerkmale/selektionsgruppe')
|
||||||
->each(function (Crawler $node) use (&$groups, &$roles) {
|
->each(function (Crawler $node) use (&$groups, &$roles, &$hotelCodes) {
|
||||||
$group = new CrmSelectionGroup();
|
$group = new CrmSelectionGroup();
|
||||||
$group->id = (int) $node->attr('id');
|
$group->id = (int) $node->attr('id');
|
||||||
$group->label = $node->attr('bezeichnung');
|
$group->label = $node->attr('bezeichnung');
|
||||||
@@ -45,25 +75,19 @@ class CrmAttributesResponseParser
|
|||||||
$attribute->mutable = $this->stringToBool($node->attr('aenderbar'));
|
$attribute->mutable = $this->stringToBool($node->attr('aenderbar'));
|
||||||
$attribute->selected = $this->stringToBool($node->attr('auswahl'));
|
$attribute->selected = $this->stringToBool($node->attr('auswahl'));
|
||||||
|
|
||||||
if (1 === preg_match('/^Hausleitung ([A-Z0-9]+)$/', $attribute->label, $matches) && true === $attribute->selected) {
|
if (true === $attribute->selected) {
|
||||||
$roles[] = 'ROLE_HOUSE_MANAGER';
|
$hotelCode = $this->houseManagerIds[$attribute->id] ?? null;
|
||||||
$hotelCodes[] = $matches[1];
|
|
||||||
}
|
if (null !== $hotelCode) {
|
||||||
if (self::BPN_CRM_ID_ADMIN === $attribute->id && true === $attribute->selected) {
|
$roles[] = Role::HOUSE_MANAGER;
|
||||||
$roles[] = 'ROLE_ADMIN';
|
$hotelCodes[] = $hotelCode;
|
||||||
$roles[] = 'ROLE_HOUSE_MANAGER';
|
}
|
||||||
}
|
|
||||||
if (self::BPN_CRM_ID_MANAGER === $attribute->id && true === $attribute->selected) {
|
$role = self::ROLE_BY_CRM_ID[$attribute->id] ?? null;
|
||||||
$roles[] = 'ROLE_MANAGER';
|
|
||||||
}
|
if (null !== $role) {
|
||||||
if (self::BPN_CRM_ID_TEAMER === $attribute->id && true === $attribute->selected) {
|
$roles[] = $role;
|
||||||
$roles[] = 'ROLE_TEAMER';
|
}
|
||||||
}
|
|
||||||
if (self::BPN_CRM_ID_GROUPS_MANAGER === $attribute->id && true === $attribute->selected) {
|
|
||||||
$roles[] = 'ROLE_GROUPS_MANAGER';
|
|
||||||
}
|
|
||||||
if (self::BPN_CRM_ID_GROUPS_ADMIN === $attribute->id && true === $attribute->selected) {
|
|
||||||
$roles[] = 'ROLE_GROUPS_ADMIN';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$attributes[] = $attribute;
|
$attributes[] = $attribute;
|
||||||
@@ -75,13 +99,6 @@ class CrmAttributesResponseParser
|
|||||||
})
|
})
|
||||||
;
|
;
|
||||||
|
|
||||||
$roles = array_unique($roles);
|
|
||||||
|
|
||||||
// Assign default role if none could be resolved
|
|
||||||
if (0 === count($roles)) {
|
|
||||||
$roles = ['ROLE_CUSTOMER'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$result
|
$result
|
||||||
->filterXPath('//crmaktionen/crmaktion')
|
->filterXPath('//crmaktionen/crmaktion')
|
||||||
->each(function (Crawler $node) use (&$actions) {
|
->each(function (Crawler $node) use (&$actions) {
|
||||||
@@ -97,15 +114,11 @@ class CrmAttributesResponseParser
|
|||||||
})
|
})
|
||||||
;
|
;
|
||||||
|
|
||||||
if (true === in_array('ROLE_ADMIN', $roles, true)) {
|
|
||||||
$hotelCodes[] = self::BPN_DEFAULT_HOTEL_CODE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = new CrmAttributes();
|
$response = new CrmAttributes();
|
||||||
$response->selectionGroups = $groups;
|
$response->selectionGroups = $groups;
|
||||||
$response->crmActions = $actions;
|
$response->crmActions = $actions;
|
||||||
$response->roles = $roles;
|
$response->roles = array_values(array_unique($roles));
|
||||||
$response->hotelCodes = $hotelCodes;
|
$response->hotelCodes = array_values(array_unique($hotelCodes));
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,20 @@ class TravelParser extends AbstractParser
|
|||||||
Constants::PSEUDO_PRICE_ROOM_CODE,
|
Constants::PSEUDO_PRICE_ROOM_CODE,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `$levelBoardingPointPrices` carries no default on purpose: a hand-built parser would
|
||||||
|
* otherwise silently ignore APP_BPN_LEVEL_BOARDING_POINT_PRICES and price one travel tier
|
||||||
|
* differently from the others. The registry gave up its own default along with it, so that
|
||||||
|
* there is one way to build this class — through the container, or explicitly in a test.
|
||||||
|
*
|
||||||
|
* @param bool $levelBoardingPointPrices Whether to compensate the BusPro export defect that
|
||||||
|
* omits a boarding point's return-leg price. The
|
||||||
|
* container wires it from
|
||||||
|
* APP_BPN_LEVEL_BOARDING_POINT_PRICES.
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AgeConstraintParserRegistry $ageConstraintRegistry = new AgeConstraintParserRegistry(),
|
private readonly AgeConstraintParserRegistry $ageConstraintRegistry,
|
||||||
|
private readonly bool $levelBoardingPointPrices,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +99,10 @@ class TravelParser extends AbstractParser
|
|||||||
$travel->pickups = $this->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true);
|
$travel->pickups = $this->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true);
|
||||||
$travel->dropOffs = $this->getPickups($node->filterXPath('.//zustiege_rueck/zustieg_rueck'), $dateTo, false);
|
$travel->dropOffs = $this->getPickups($node->filterXPath('.//zustiege_rueck/zustieg_rueck'), $dateTo, false);
|
||||||
|
|
||||||
// Merge drop-off prices into pickups for split pricing support
|
// A boarding point costs the same in both directions
|
||||||
$this->mergeDropOffPricesIntoPickups($travel->pickups, $travel->dropOffs);
|
if ($this->levelBoardingPointPrices) {
|
||||||
|
$this->applyBoardingPointPriceLevelling($travel->pickups, $travel->dropOffs);
|
||||||
|
}
|
||||||
|
|
||||||
$travel->guide = $this->getGuide($node);
|
$travel->guide = $this->getGuide($node);
|
||||||
|
|
||||||
@@ -381,8 +395,8 @@ class TravelParser extends AbstractParser
|
|||||||
$room->category = $this->getAttrOrNullValue($roomNode, 'kat');
|
$room->category = $this->getAttrOrNullValue($roomNode, 'kat');
|
||||||
$room->boardId = (int) $this->getAttrOrNullValue($roomNode, 'idbuspro_vp');
|
$room->boardId = (int) $this->getAttrOrNullValue($roomNode, 'idbuspro_vp');
|
||||||
$room->label = $this->getAttrOrNullValue($roomNode, 'zimmertext');
|
$room->label = $this->getAttrOrNullValue($roomNode, 'zimmertext');
|
||||||
$room->minPax = (int) $this->getAttrOrNullValue($roomNode, 'minpax');
|
$room->minPax = $this->getIntAttrOrNullValue($roomNode, 'minpax');
|
||||||
$room->maxPax = (int) $this->getAttrOrNullValue($roomNode, 'maxpax');
|
$room->maxPax = $this->getIntAttrOrNullValue($roomNode, 'maxpax');
|
||||||
$room->nights = (int) $this->getAttrOrNullValue($roomNode, 'naechte');
|
$room->nights = (int) $this->getAttrOrNullValue($roomNode, 'naechte');
|
||||||
$roomPrice = $this->getAttrOrNullValue($roomNode, 'preis');
|
$roomPrice = $this->getAttrOrNullValue($roomNode, 'preis');
|
||||||
$room->price = null !== $roomPrice && '' !== $roomPrice
|
$room->price = null !== $roomPrice && '' !== $roomPrice
|
||||||
@@ -453,22 +467,43 @@ class TravelParser extends AbstractParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges drop-off prices into pickups for split pricing support.
|
* Levels the price of a boarding point across both travel directions.
|
||||||
*
|
*
|
||||||
* For each pickup, finds the matching drop-off by ID and copies
|
* A boarding point is either free or costs the same in both directions:
|
||||||
* the inbound price to the pickup's priceInbound property. This enables
|
* `ZustiegPreis` is keyed by (point, tariff scheme) and holds no per-leg price, so
|
||||||
* split pricing calculations where costs are distributed between outbound
|
* BusPro bills an `Abfahrtsortzuschlag` per leg at one and the same rate. An
|
||||||
* and inbound transportation.
|
* asymmetric pair is therefore always an export defect — on some products a
|
||||||
|
* `<zustieg_rueck>` carries no `preis` although the engine still charges the return
|
||||||
|
* leg — and the stated price is the one that applies to both legs.
|
||||||
|
*
|
||||||
|
* Where both directions state a price they agree in practice; the outbound one wins
|
||||||
|
* to keep the outcome deterministic. Points without a counterpart are left as they are.
|
||||||
|
*
|
||||||
|
* Compensating the defect locally is provisional, so it is switchable: with
|
||||||
|
* `$levelBoardingPointPrices` off the export is reported verbatim and the affected
|
||||||
|
* travels undercharge the return leg again. See
|
||||||
|
* `docs/buspro-database-reference.md` section A7.
|
||||||
*
|
*
|
||||||
* @param array<int, Pickup> $pickups Pickups indexed by ID (modified in place)
|
* @param array<int, Pickup> $pickups Pickups indexed by ID (modified in place)
|
||||||
* @param array<int, Pickup> $dropOffs Drop-offs indexed by ID
|
* @param array<int, Pickup> $dropOffs Drop-offs indexed by ID (modified in place)
|
||||||
*/
|
*/
|
||||||
private function mergeDropOffPricesIntoPickups(array &$pickups, array $dropOffs): void
|
private function applyBoardingPointPriceLevelling(array $pickups, array $dropOffs): void
|
||||||
{
|
{
|
||||||
foreach ($pickups as $pickupId => $pickup) {
|
foreach ($dropOffs as $pickupId => $dropOff) {
|
||||||
if (isset($dropOffs[$pickupId])) {
|
if (false === isset($pickups[$pickupId])) {
|
||||||
$pickup->priceInbound = $dropOffs[$pickupId]->priceInbound;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$pickup = $pickups[$pickupId];
|
||||||
|
$price = $pickup->price ?? $dropOff->price;
|
||||||
|
|
||||||
|
$pickup->price = $price;
|
||||||
|
$pickup->priceOutbound = $price;
|
||||||
|
$pickup->priceInbound = $price;
|
||||||
|
|
||||||
|
$dropOff->price = $price;
|
||||||
|
$dropOff->priceOutbound = $price;
|
||||||
|
$dropOff->priceInbound = $price;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ClickUp;
|
||||||
|
|
||||||
|
use App\ClickUp\Exception\ClickUpException;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
class ApiClient
|
||||||
|
{
|
||||||
|
private const string DEFAULT_BASE_URL = 'https://api.clickup.com/api/v2';
|
||||||
|
|
||||||
|
// Creating a task from a template regularly takes several seconds on ClickUp's side, so these
|
||||||
|
// ceilings sit well above the ones the BpnConnect client uses. They exist to stop a hung
|
||||||
|
// upstream from pinning a worker forever, not to enforce a latency budget.
|
||||||
|
private const int IDLE_TIMEOUT_SECONDS = 30;
|
||||||
|
private const int MAX_DURATION_SECONDS = 120;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly HttpClientInterface $httpClient,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
private readonly ?string $apiToken = null,
|
||||||
|
private readonly ?string $listId = null,
|
||||||
|
private readonly ?string $templateId = null,
|
||||||
|
private readonly ?string $baseUrl = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environments without ClickUp credentials (dev, test, staging) must not fail — callers check
|
||||||
|
* this instead of letting assertConfigured() blow up.
|
||||||
|
*/
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
return null !== $this->apiToken && '' !== $this->apiToken
|
||||||
|
&& null !== $this->listId && '' !== $this->listId
|
||||||
|
&& null !== $this->templateId && '' !== $this->templateId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a task from the configured template and returns the new task id.
|
||||||
|
*/
|
||||||
|
public function createTaskFromTemplate(string $name): string
|
||||||
|
{
|
||||||
|
$data = $this->request('POST', sprintf('/list/%s/taskTemplate/%s', $this->listId, $this->templateId), [
|
||||||
|
'name' => $name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$id = $data['id'] ?? null;
|
||||||
|
if (!is_string($id) || '' === $id) {
|
||||||
|
throw new ClickUpException('ClickUp did not return a task id for the created task.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateTaskStatus(string $taskId, string $status): void
|
||||||
|
{
|
||||||
|
$this->request('PUT', '/task/'.$taskId, ['status' => $status]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function request(string $method, string $path, array $payload): array
|
||||||
|
{
|
||||||
|
$this->assertConfigured();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->request($method, ($this->baseUrl ?? self::DEFAULT_BASE_URL).$path, [
|
||||||
|
'headers' => [
|
||||||
|
// ClickUp deviates from the usual scheme: the raw token goes into the
|
||||||
|
// Authorization header, without a "Bearer " prefix.
|
||||||
|
'Authorization' => $this->apiToken,
|
||||||
|
],
|
||||||
|
'json' => $payload,
|
||||||
|
'timeout' => self::IDLE_TIMEOUT_SECONDS,
|
||||||
|
'max_duration' => self::MAX_DURATION_SECONDS,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $response->toArray();
|
||||||
|
} catch (ExceptionInterface $e) {
|
||||||
|
$this->logger->error('ClickUp request failed', [
|
||||||
|
'method' => $method,
|
||||||
|
'path' => $path,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new ClickUpException('ClickUp request failed: '.$e->getMessage(), 0, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertConfigured(): void
|
||||||
|
{
|
||||||
|
if (!$this->isConfigured()) {
|
||||||
|
throw new ClickUpException('ClickUp client is not configured (missing CLICKUP_API_TOKEN, CLICKUP_LIST_ID or CLICKUP_TEMPLATE_ID).');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ClickUp\Exception;
|
||||||
|
|
||||||
|
class ClickUpException extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\BusProNet\ApiClient;
|
||||||
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||||
|
use App\BusProNet\Model\Booking;
|
||||||
|
use App\BusProNet\Model\Notification;
|
||||||
|
use App\BusProNet\Model\Service;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\Entity\BookingEditDraft;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\BookingEditDraftRepository;
|
||||||
|
use App\Security\Crypt;
|
||||||
|
use App\Service\BookingChangeTracker;
|
||||||
|
use App\Service\BookingEditDraftMerger;
|
||||||
|
use App\Service\TravelDataProvider;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repairs a booking edit draft that BusPro keeps rejecting.
|
||||||
|
*
|
||||||
|
* A draft survives every failed submission and is replayed on each re-entry into the edit flow.
|
||||||
|
* When one selection inside it has become unacceptable to BusPro - a Leistung whose status has
|
||||||
|
* drifted to "Anfrage" because its contingent ran out, say - the whole change is refused, nothing
|
||||||
|
* persists, and the customer is stuck in a loop they cannot edit their way out of. Booking 98787
|
||||||
|
* accumulated 37 such failures over two months.
|
||||||
|
*
|
||||||
|
* The valuable half of that draft is the personal data: names, dates of birth, contact details,
|
||||||
|
* addresses, room remarks. Re-entering it by hand for eighty participants is not a reasonable ask.
|
||||||
|
* So this command rebuilds the draft on top of the booking as BusPro currently holds it, keeps
|
||||||
|
* every field the customer typed, and reverts only the service selections that BusPro will not
|
||||||
|
* accept - naming each one, so the office can tell the customer what to pick again.
|
||||||
|
*
|
||||||
|
* Dry run by default. --apply writes a JSON backup of the original form data first.
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:booking:repair-draft',
|
||||||
|
description: 'Rebuild a rejected booking edit draft, keeping typed data and reverting unacceptable service selections',
|
||||||
|
)]
|
||||||
|
class BookingRepairDraftCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Draft `services` keys holding a single service id.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Date of birth the office writes into the empty slots of a template booking.
|
||||||
|
*/
|
||||||
|
private const PLACEHOLDER_DATE_OF_BIRTH = '2000-01-01';
|
||||||
|
|
||||||
|
private const SINGLE_SERVICE_KEYS = ['skiPass', 'veg', 'insurance', 'rentalInsurance'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draft `services` keys holding a list of service ids.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const LIST_SERVICE_KEYS = ['courses', 'board', 'rentals', 'additionalServices'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transport is reverted as a unit: a participant put back on their own arrival must lose the
|
||||||
|
* pickup and drop-off that only make sense on a coach.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const TRANSPORT_KEYS = ['transportationOutbound', 'transportationInbound', 'pickup', 'dropOff'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly BookingEditDraftRepository $draftRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly ApiClient $apiClient,
|
||||||
|
private readonly Crypt $crypt,
|
||||||
|
private readonly TravelDataProvider $travelDataProvider,
|
||||||
|
private readonly BookingDataProcessor $bookingDataProcessor,
|
||||||
|
private readonly BookingEditDraftMerger $draftMerger,
|
||||||
|
private readonly BookingChangeTracker $changeTracker,
|
||||||
|
#[Autowire('%kernel.project_dir%')]
|
||||||
|
private readonly string $projectDir,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('booking', 'b', InputOption::VALUE_REQUIRED, 'idbuchung whose draft should be repaired')
|
||||||
|
->addOption('apply', null, InputOption::VALUE_NONE, 'Write the repaired draft. Without this the command only reports')
|
||||||
|
->addOption('backup-dir', null, InputOption::VALUE_REQUIRED, 'Where to write the backup of the original form data', 'var/draft-backups')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$bookingId = $input->getOption('booking');
|
||||||
|
if (null === $bookingId) {
|
||||||
|
$io->error('--booking is required.');
|
||||||
|
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
$draft = $this->findSingleDraft($io, (int) $bookingId);
|
||||||
|
if (null === $draft) {
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $draft->getUser();
|
||||||
|
$booking = $this->fetchLiveBooking($io, $user, (int) $bookingId);
|
||||||
|
if (null === $booking) {
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$travel = $this->travelDataProvider->getTravelData((int) $booking->dateId, $booking->hotelId, true);
|
||||||
|
if (null === $travel) {
|
||||||
|
$io->error(sprintf('No travel data for date id %d.', (int) $booking->dateId));
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$formData = $draft->getFormData();
|
||||||
|
$draftParticipants = $formData['participants'] ?? [];
|
||||||
|
|
||||||
|
if (false === $this->assertAlignment($io, $booking, $draftParticipants)) {
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$blocked = $this->collectUnbookableServices($io, $travel);
|
||||||
|
|
||||||
|
// The booking as BusPro currently holds it is the only state known to be acceptable, so
|
||||||
|
// it is the base everything is rebuilt on.
|
||||||
|
$repairedDto = $this->bookingDataProcessor->createBookingDtoFromBooking(
|
||||||
|
$booking,
|
||||||
|
$travel,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$liveSelections = $this->changeTracker->extractUserData($repairedDto)['participants'] ?? [];
|
||||||
|
|
||||||
|
$reverted = [];
|
||||||
|
foreach ($draftParticipants as $index => $participantData) {
|
||||||
|
if (false === isset($repairedDto->participants[$index])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filtered = $this->filterServiceSelections(
|
||||||
|
$participantData,
|
||||||
|
$liveSelections[$index]['services'] ?? [],
|
||||||
|
$blocked,
|
||||||
|
$index,
|
||||||
|
$reverted,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->draftMerger->apply($repairedDto, $index, $repairedDto->participants[$index], $filtered, $travel);
|
||||||
|
}
|
||||||
|
|
||||||
|
$repaired = $this->changeTracker->extractUserData($repairedDto);
|
||||||
|
|
||||||
|
$this->report($io, $formData, $repaired, $reverted, $blocked);
|
||||||
|
|
||||||
|
if (false === $input->getOption('apply')) {
|
||||||
|
$io->note('Dry run. Re-run with --apply to write the repaired draft.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupPath = $this->writeBackup($draft, (string) $input->getOption('backup-dir'));
|
||||||
|
$io->success(sprintf('Original form data backed up to %s', $backupPath));
|
||||||
|
|
||||||
|
$draft->setFormData($repaired);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$io->success(sprintf('Draft %d repaired.', (int) $draft->getId()));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findSingleDraft(SymfonyStyle $io, int $bookingId): ?BookingEditDraft
|
||||||
|
{
|
||||||
|
$drafts = $this->draftRepository->findBy(['bookingId' => $bookingId]);
|
||||||
|
|
||||||
|
if ([] === $drafts) {
|
||||||
|
$io->error(sprintf('No draft found for booking %d.', $bookingId));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($drafts) > 1) {
|
||||||
|
$io->error(sprintf('Booking %d has %d drafts; resolve by hand.', $bookingId, count($drafts)));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$draft = $drafts[0];
|
||||||
|
$io->definitionList(
|
||||||
|
['Draft' => sprintf('%d (user %d)', (int) $draft->getId(), (int) $draft->getUser()->getId())],
|
||||||
|
['Created' => $draft->getCreatedAt()->format('Y-m-d H:i')],
|
||||||
|
['Last saved' => $draft->getUpdatedAt()->format('Y-m-d H:i')],
|
||||||
|
['Participants' => (string) count($draft->getFormData()['participants'] ?? [])],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchLiveBooking(SymfonyStyle $io, User $user, int $bookingId): ?Booking
|
||||||
|
{
|
||||||
|
$result = $this->apiClient->getBooking(
|
||||||
|
(string) $user->getEmail(),
|
||||||
|
$this->crypt->decrypt((string) $user->getPassword()),
|
||||||
|
$bookingId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result instanceof Notification) {
|
||||||
|
$io->error(sprintf('Vorgang_Details failed: %d %s', (int) $result->code, (string) $result->message));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses to repair a draft whose positions no longer line up with the live booking.
|
||||||
|
*
|
||||||
|
* Drafts are merged positionally, by array index, with no identity check. Empty template
|
||||||
|
* slots are interchangeable, so they prove nothing; the participants BusPro already knows by
|
||||||
|
* date of birth are the only anchors available. If one of those has moved, every later
|
||||||
|
* position is suspect and rebuilding would quietly graft data onto the wrong people.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $draftParticipants
|
||||||
|
*/
|
||||||
|
private function assertAlignment(SymfonyStyle $io, Booking $booking, array $draftParticipants): bool
|
||||||
|
{
|
||||||
|
$liveCount = count($booking->participants);
|
||||||
|
$draftCount = count($draftParticipants);
|
||||||
|
|
||||||
|
if ($liveCount !== $draftCount) {
|
||||||
|
$io->warning(sprintf(
|
||||||
|
'Live booking has %d participants, the draft has %d. Seats were added or removed since the draft was written.',
|
||||||
|
$liveCount,
|
||||||
|
$draftCount,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$anchors = 0;
|
||||||
|
$mismatches = [];
|
||||||
|
|
||||||
|
foreach (array_values($booking->participants) as $index => $participant) {
|
||||||
|
$liveDob = $participant->dateOfBirth?->format('Y-m-d');
|
||||||
|
if (null === $liveDob || self::PLACEHOLDER_DATE_OF_BIRTH === $liveDob) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
++$anchors;
|
||||||
|
$draftDob = $draftParticipants[$index]['personalData']['dateOfBirth'] ?? null;
|
||||||
|
|
||||||
|
if (null !== $draftDob && $draftDob !== $liveDob) {
|
||||||
|
$mismatches[] = sprintf('position %d: live %s, draft %s', $index + 1, $liveDob, $draftDob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] !== $mismatches) {
|
||||||
|
$io->error('Draft positions no longer match the live booking:');
|
||||||
|
$io->listing($mismatches);
|
||||||
|
$io->comment('Repairing would move typed data onto the wrong participants. Resolve by hand.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->text(sprintf('Alignment verified against %d participant(s) BusPro already knows.', $anchors));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects the services BusPro will not accept as an addition.
|
||||||
|
*
|
||||||
|
* A Leistung only takes new participants while its own status is "Frei"; anything else is the
|
||||||
|
* condition behind "Status der Leistung (A) ist unterschiedlich zum Status des Teilnehmers".
|
||||||
|
* Live availability is preferred over the travel data, which lags behind it.
|
||||||
|
*
|
||||||
|
* @return array<int, string> service id => status
|
||||||
|
*/
|
||||||
|
private function collectUnbookableServices(SymfonyStyle $io, Travel $travel): array
|
||||||
|
{
|
||||||
|
$statuses = [];
|
||||||
|
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
|
||||||
|
if (null !== $service->id && null !== $service->status) {
|
||||||
|
$statuses[$service->id] = $service->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$live = $this->apiClient->getAvailabilitiesExtended((int) $travel->id);
|
||||||
|
if ($live instanceof Notification) {
|
||||||
|
$io->warning(sprintf(
|
||||||
|
'VERFUEGBARKEIT2 failed (%d %s); falling back to travel data, which may be stale.',
|
||||||
|
(int) $live->code,
|
||||||
|
(string) $live->message,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
foreach ($live->getServices() as $availability) {
|
||||||
|
if (null !== $availability->serviceId && null !== $availability->status && '' !== trim($availability->status)) {
|
||||||
|
$statuses[$availability->serviceId] = $availability->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$blocked = array_filter($statuses, static fn (string $status): bool => Constants::STATUS_AVAILABLE !== $status);
|
||||||
|
|
||||||
|
if ([] === $blocked) {
|
||||||
|
$io->text('Every service on this travel is currently "Frei".');
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($blocked as $serviceId => $status) {
|
||||||
|
$service = $travel->additionalServices[$serviceId] ?? $travel->transportationServices[$serviceId] ?? null;
|
||||||
|
$label = $service instanceof Service ? (string) $service->label : '?';
|
||||||
|
$rows[] = [$serviceId, $label, $status];
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->section('Services that cannot take new participants');
|
||||||
|
$io->table(['id', 'Leistung', 'status'], $rows);
|
||||||
|
|
||||||
|
return $blocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the draft's selections that would add a participant to a service BusPro has closed.
|
||||||
|
*
|
||||||
|
* Removing a key leaves the live booking's own value in place, because the merger applies
|
||||||
|
* only the keys it is given. Selections BusPro still accepts - a different ski pass, a meal
|
||||||
|
* preference - are kept, so the customer loses as little as possible.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $participantData
|
||||||
|
* @param array<string, mixed> $liveServices
|
||||||
|
* @param array<int, string> $blocked
|
||||||
|
* @param list<string> $reverted
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function filterServiceSelections(
|
||||||
|
array $participantData,
|
||||||
|
array $liveServices,
|
||||||
|
array $blocked,
|
||||||
|
int $index,
|
||||||
|
array &$reverted,
|
||||||
|
): array {
|
||||||
|
if (false === isset($participantData['services']) || [] === $blocked) {
|
||||||
|
return $participantData;
|
||||||
|
}
|
||||||
|
|
||||||
|
$services = $participantData['services'];
|
||||||
|
$position = $index + 1;
|
||||||
|
|
||||||
|
foreach (self::SINGLE_SERVICE_KEYS as $key) {
|
||||||
|
$selected = $services[$key] ?? null;
|
||||||
|
if (null !== $selected && isset($blocked[$selected]) && ($liveServices[$key] ?? null) !== $selected) {
|
||||||
|
unset($services[$key]);
|
||||||
|
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $selected, $blocked[$selected]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::LIST_SERVICE_KEYS as $key) {
|
||||||
|
$selected = $services[$key] ?? null;
|
||||||
|
if (false === is_array($selected)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$liveList = $liveServices[$key] ?? [];
|
||||||
|
$kept = [];
|
||||||
|
foreach ($selected as $serviceId) {
|
||||||
|
if (isset($blocked[$serviceId]) && false === in_array($serviceId, $liveList, true)) {
|
||||||
|
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $serviceId, $blocked[$serviceId]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$kept[] = $serviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$services[$key] = $kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['transportationOutbound', 'transportationInbound'] as $key) {
|
||||||
|
$selected = $services[$key] ?? null;
|
||||||
|
if (null === $selected || false === isset($blocked[$selected]) || ($liveServices[$key] ?? null) === $selected) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $selected, $blocked[$selected]);
|
||||||
|
|
||||||
|
// Transport reverts as a unit, pickup and drop-off included.
|
||||||
|
foreach (self::TRANSPORT_KEYS as $transportKey) {
|
||||||
|
unset($services[$transportKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$participantData['services'] = $services;
|
||||||
|
|
||||||
|
return $participantData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $original
|
||||||
|
* @param array<string, mixed> $repaired
|
||||||
|
* @param list<string> $reverted
|
||||||
|
* @param array<int, string> $blocked
|
||||||
|
*/
|
||||||
|
private function report(SymfonyStyle $io, array $original, array $repaired, array $reverted, array $blocked): void
|
||||||
|
{
|
||||||
|
$io->section('Typed data preserved');
|
||||||
|
|
||||||
|
$fields = ['firstName', 'lastName', 'dateOfBirth', 'email', 'mobile'];
|
||||||
|
$kept = 0;
|
||||||
|
$lost = [];
|
||||||
|
|
||||||
|
foreach ($original['participants'] ?? [] as $index => $participantData) {
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
$before = $participantData['personalData'][$field] ?? null;
|
||||||
|
if (null === $before || '' === $before) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$after = $repaired['participants'][$index]['personalData'][$field] ?? null;
|
||||||
|
if ($before === $after) {
|
||||||
|
++$kept;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lost[] = sprintf('participant %d: %s', $index + 1, $field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->text(sprintf('<info>%d</info> personal-data field(s) carried over.', $kept));
|
||||||
|
|
||||||
|
if ([] !== $lost) {
|
||||||
|
$io->warning(sprintf('%d field(s) could NOT be carried over:', count($lost)));
|
||||||
|
$io->listing(array_slice($lost, 0, 25));
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->section('Service selections reverted');
|
||||||
|
|
||||||
|
if ([] === $reverted) {
|
||||||
|
$io->text([] === $blocked
|
||||||
|
? 'None - no service on this travel is closed.'
|
||||||
|
: 'None - the draft selects no closed service.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->warning(sprintf('%d selection(s) reverted to the booked state. The customer must choose again:', count($reverted)));
|
||||||
|
$io->listing($reverted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeBackup(BookingEditDraft $draft, string $backupDir): string
|
||||||
|
{
|
||||||
|
$directory = $this->projectDir.'/'.trim($backupDir, '/');
|
||||||
|
if (false === is_dir($directory)) {
|
||||||
|
mkdir($directory, 0o775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = sprintf(
|
||||||
|
'%s/draft-%d-booking-%d-%s.json',
|
||||||
|
$directory,
|
||||||
|
(int) $draft->getId(),
|
||||||
|
$draft->getBookingId(),
|
||||||
|
(new \DateTimeImmutable())->format('Ymd-His'),
|
||||||
|
);
|
||||||
|
|
||||||
|
file_put_contents($path, json_encode($draft->getFormData(), \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\Groups\Accommodation;
|
||||||
|
use App\Repository\Groups\AccommodationRepository;
|
||||||
|
use App\Repository\Groups\ContingentDayRepository;
|
||||||
|
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||||
|
use App\Service\ContingentSnapshotManager;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Command\LockableTrait;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:bpn:sync-contingents',
|
||||||
|
description: 'Refreshes the local contingent snapshots from bpn-connect.',
|
||||||
|
)]
|
||||||
|
/**
|
||||||
|
* Console entrypoint for the scheduled contingent snapshot sync.
|
||||||
|
*
|
||||||
|
* Scheduled over the full horizon throughout: every 15 minutes during peak hours, hourly
|
||||||
|
* outside them. The options exist for ad-hoc runs — a narrower --horizon-months, or --hotel
|
||||||
|
* to re-sync a single accommodation while debugging.
|
||||||
|
*/
|
||||||
|
class BpnSyncContingentsCommand extends Command
|
||||||
|
{
|
||||||
|
use LockableTrait;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly AccommodationRepository $accommodationRepository,
|
||||||
|
private readonly ContingentSyncStateRepository $syncStateRepository,
|
||||||
|
private readonly ContingentDayRepository $dayRepository,
|
||||||
|
private readonly ContingentSnapshotManager $snapshotManager,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures horizon, filtering and refresh-control options.
|
||||||
|
*/
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('horizon-months', null, InputOption::VALUE_REQUIRED, 'How many months ahead to sync (defaults to the full horizon)', '24')
|
||||||
|
->addOption('hotel', null, InputOption::VALUE_REQUIRED, 'Restrict the run to a single calendarCode')
|
||||||
|
->addOption('stale-after', null, InputOption::VALUE_REQUIRED, 'Minimum minutes since the last sync before an accommodation is eligible (ignored with --force)', '0')
|
||||||
|
->addOption('force', 'f', InputOption::VALUE_NONE, 'Sync even if the snapshot was refreshed recently')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes the sync flow and the retention cleanup.
|
||||||
|
*/
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
if (false === $this->lock('bpn_sync_contingents')) {
|
||||||
|
$this->logger->info('Contingent sync skipped: another run is still in progress');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$horizonMonths = max(1, (int) $input->getOption('horizon-months'));
|
||||||
|
$staleAfterMinutes = max(0, (int) $input->getOption('stale-after'));
|
||||||
|
$force = true === $input->getOption('force');
|
||||||
|
$hotelCode = $input->getOption('hotel');
|
||||||
|
|
||||||
|
$today = CarbonImmutable::now()->setTime(0, 0);
|
||||||
|
$dateFrom = $today->toDateTimeImmutable();
|
||||||
|
$dateTo = $today->addMonths($horizonMonths)->toDateTimeImmutable();
|
||||||
|
|
||||||
|
$accommodations = $this->resolveAccommodations(is_string($hotelCode) ? $hotelCode : null);
|
||||||
|
|
||||||
|
if ([] === $accommodations) {
|
||||||
|
$io->warning('No accommodation with a calendarCode matched.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$changed = 0;
|
||||||
|
$failedCodes = [];
|
||||||
|
|
||||||
|
foreach ($accommodations as $accommodation) {
|
||||||
|
if (!$force && $this->isFresh($accommodation, $staleAfterMinutes)) {
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->snapshotManager->sync($accommodation, $dateFrom, $dateTo);
|
||||||
|
++$processed;
|
||||||
|
|
||||||
|
if (!$result->successful) {
|
||||||
|
$failedCodes[] = (string) $accommodation->getCalendarCode();
|
||||||
|
$io->warning(sprintf('%s: %s', (string) $accommodation->getCalendarCode(), (string) $result->error));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->changed) {
|
||||||
|
++$changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->writeln(sprintf(
|
||||||
|
'<info>%s</info>: %s (+%d ~%d -%d, %d beyond the previous horizon)',
|
||||||
|
(string) $accommodation->getCalendarCode(),
|
||||||
|
$result->changed ? 'changed' : 'unchanged',
|
||||||
|
$result->added,
|
||||||
|
$result->updated,
|
||||||
|
$result->removed,
|
||||||
|
$result->extended,
|
||||||
|
), OutputInterface::VERBOSITY_VERBOSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
$failed = count($failedCodes);
|
||||||
|
|
||||||
|
$summary = [
|
||||||
|
'processed' => $processed,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'changed' => $changed,
|
||||||
|
'failed' => $failed,
|
||||||
|
'failedHotelCodes' => $failedCodes,
|
||||||
|
'dateFrom' => $dateFrom->format('Y-m-d'),
|
||||||
|
'dateTo' => $dateTo->format('Y-m-d'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$io->success(sprintf(
|
||||||
|
'Contingent sync complete: %d processed, %d skipped, %d changed, %d failed',
|
||||||
|
$processed,
|
||||||
|
$skipped,
|
||||||
|
$changed,
|
||||||
|
$failed,
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->logger->info('Contingent snapshot sync finished', $summary);
|
||||||
|
|
||||||
|
$deleted = $this->dayRepository->deleteBefore($today->subDay()->toDateTimeImmutable());
|
||||||
|
$io->note(sprintf('Deleted %d outdated snapshot days.', $deleted));
|
||||||
|
|
||||||
|
// Only a *total* failure is worth failing the task over: that means upstream is down or
|
||||||
|
// the API key is rejected, and the scheduler's failure mail is genuinely actionable.
|
||||||
|
// A single hotel breaking must stay quiet — this task runs every 15 minutes during the
|
||||||
|
// day, so failing on it would mail dozens of times a day until someone silenced the task.
|
||||||
|
// That hotel is not lost track of: its own calendar serves a 502, and
|
||||||
|
// contingent_sync_state records failure_count and last_error.
|
||||||
|
return $processed > 0 && $failed === $processed ? Command::FAILURE : Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Accommodation[]
|
||||||
|
*/
|
||||||
|
private function resolveAccommodations(?string $hotelCode): array
|
||||||
|
{
|
||||||
|
if (null === $hotelCode) {
|
||||||
|
return $this->accommodationRepository->findAllWithCalendarCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
$accommodation = $this->accommodationRepository->findOneByCalendarCode($hotelCode);
|
||||||
|
|
||||||
|
return null !== $accommodation ? [$accommodation] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isFresh(Accommodation $accommodation, int $staleAfterMinutes): bool
|
||||||
|
{
|
||||||
|
if (0 === $staleAfterMinutes) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt();
|
||||||
|
|
||||||
|
if (null === $syncedAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $syncedAt > CarbonImmutable::now()->subMinutes($staleAfterMinutes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,11 +50,12 @@ final class DbAnonymizeCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
$io->success(sprintf(
|
$io->success(sprintf(
|
||||||
'Anonymized %d users, %d newsletter consents, %d newsletter opt-in requests, and %d booking drafts.',
|
'Anonymized %d users, %d newsletter consents, %d newsletter opt-in requests, %d booking drafts, and %d accommodation bookings.',
|
||||||
$report['users'],
|
$report['users'],
|
||||||
$report['newsletterConsents'],
|
$report['newsletterConsents'],
|
||||||
$report['newsletterOptInRequests'],
|
$report['newsletterOptInRequests'],
|
||||||
$report['bookingEditDrafts'],
|
$report['bookingEditDrafts'],
|
||||||
|
$report['accommodationBookings'],
|
||||||
));
|
));
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
|
|||||||
@@ -334,10 +334,10 @@ class ImportGroupsLegacyDataCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<array<string, string>> $rows
|
* @param list<array<string, string>> $rows
|
||||||
* @param array<int, Accommodation> $accommodationByUid
|
* @param array<int, Accommodation> $accommodationByUid
|
||||||
* @param list<int> $knownUids
|
* @param list<int> $knownUids
|
||||||
* @param list<array{season: Season, dateFrom: \DateTimeImmutable, dateTo: \DateTimeImmutable}> $seasonRanges
|
* @param list<array{season: Season, dateFrom: \DateTimeImmutable, dateTo: \DateTimeImmutable}> $seasonRanges
|
||||||
*
|
*
|
||||||
* @return array{0: int, 1: list<int>, 2: int}
|
* @return array{0: int, 1: list<int>, 2: int}
|
||||||
*/
|
*/
|
||||||
@@ -526,11 +526,11 @@ class ImportGroupsLegacyDataCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, Accommodation> $accommodationByUid
|
* @param array<int, Accommodation> $accommodationByUid
|
||||||
* @param list<AdditionalService> $additionalServicesNeedingDefaultRange only these
|
* @param list<AdditionalService> $additionalServicesNeedingDefaultRange only these
|
||||||
* (titles without a leading year) get the accommodation-
|
* (titles without a leading year) get the accommodation-
|
||||||
* wide fallback range; year-prefixed ones already have
|
* wide fallback range; year-prefixed ones already have
|
||||||
* their scoped range set in importAdditionalServices().
|
* their scoped range set in importAdditionalServices()
|
||||||
*/
|
*/
|
||||||
private function applyDateRangeToServices(array $accommodationByUid, array $additionalServicesNeedingDefaultRange): void
|
private function applyDateRangeToServices(array $accommodationByUid, array $additionalServicesNeedingDefaultRange): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Command;
|
namespace App\Command;
|
||||||
|
|
||||||
use App\Exception\NewsletterProviderException;
|
use App\Exception\NewsletterProviderException;
|
||||||
use App\Service\MailjetApiClient;
|
use App\Mailjet\ApiClient;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
@@ -31,7 +31,7 @@ final class MailjetNewsletterWebhookCommand extends Command
|
|||||||
private const int WEBHOOK_VERSION = 2;
|
private const int WEBHOOK_VERSION = 2;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MailjetApiClient $mailjetApiClient,
|
private readonly ApiClient $mailjetApiClient,
|
||||||
#[Autowire(env: 'APP_BASE_URL')]
|
#[Autowire(env: 'APP_BASE_URL')]
|
||||||
private readonly string $defaultBaseUrl,
|
private readonly string $defaultBaseUrl,
|
||||||
#[Autowire(env: 'MAILJET_WEBHOOK_BASIC_PASSWORD')]
|
#[Autowire(env: 'MAILJET_WEBHOOK_BASIC_PASSWORD')]
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use App\BusProNet\ApiClient;
|
|||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
use App\BusProNet\Model\PersonalData;
|
use App\BusProNet\Model\PersonalData;
|
||||||
use App\BusProNet\Model\PersonalDataUpdateResponse;
|
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Exception\NewsletterProviderException;
|
use App\Exception\NewsletterProviderException;
|
||||||
use App\Form\PersonalDataType;
|
use App\Form\PersonalDataType;
|
||||||
@@ -84,11 +83,20 @@ class PersonalDataController extends AbstractController
|
|||||||
$newsletterSubscribed = $this->newsletterManager->hasConfirmedOptIn($email);
|
$newsletterSubscribed = $this->newsletterManager->hasConfirmedOptIn($email);
|
||||||
$newsletterPendingConfirmation = $this->newsletterManager->hasPendingConfirmation($email);
|
$newsletterPendingConfirmation = $this->newsletterManager->hasPendingConfirmation($email);
|
||||||
|
|
||||||
|
// The e-mail is deliberately locked here: BPN stores it on the address, where it doubles as
|
||||||
|
// the login identity of any person on that address. Accepting a submitted value would let
|
||||||
|
// one household member overwrite another's login. It is only ever echoed back to BPN as read.
|
||||||
$personalDataForm = $this->createForm(PersonalDataType::class, $personalData, [
|
$personalDataForm = $this->createForm(PersonalDataType::class, $personalData, [
|
||||||
'attr' => ['novalidate' => 'novalidate'],
|
'attr' => ['novalidate' => 'novalidate'],
|
||||||
'validation_groups' => ['personal_data'],
|
'validation_groups' => ['personal_data'],
|
||||||
|
'email_editable' => false,
|
||||||
]);
|
]);
|
||||||
$personalDataForm->handleRequest($request);
|
|
||||||
|
// A failed load yields an empty PersonalData. Updating from it would send blank values for
|
||||||
|
// fields we never read - including the e-mail - so the form must not accept a submission.
|
||||||
|
if (null !== $personalData->addressId) {
|
||||||
|
$personalDataForm->handleRequest($request);
|
||||||
|
}
|
||||||
|
|
||||||
if ($personalDataForm->isSubmitted() && $personalDataForm->isValid()) {
|
if ($personalDataForm->isSubmitted() && $personalDataForm->isValid()) {
|
||||||
try {
|
try {
|
||||||
@@ -120,7 +128,13 @@ class PersonalDataController extends AbstractController
|
|||||||
|
|
||||||
// Update profile completeness flag on user entity
|
// Update profile completeness flag on user entity
|
||||||
$isComplete = $this->completenessChecker->isComplete($personalData);
|
$isComplete = $this->completenessChecker->isComplete($personalData);
|
||||||
$user->setProfileComplete($isComplete);
|
$user
|
||||||
|
->setProfileComplete($isComplete)
|
||||||
|
// Otherwise a rename here would stay invisible in the backend until the next
|
||||||
|
// login, which is the only other place the name is synced from BPN.
|
||||||
|
->setFirstName($personalData->firstName)
|
||||||
|
->setLastName($personalData->name)
|
||||||
|
;
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
$this->addFlash('success', 'Deine persönlichen Daten wurden aktualisiert');
|
$this->addFlash('success', 'Deine persönlichen Daten wurden aktualisiert');
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class CalendarController extends AbstractController
|
|||||||
{
|
{
|
||||||
return $this->render('admin/accommodation/_calendar_section.html.twig', [
|
return $this->render('admin/accommodation/_calendar_section.html.twig', [
|
||||||
'accommodation' => $accommodation,
|
'accommodation' => $accommodation,
|
||||||
'startMonth' => $request->query->get('month'),
|
'startMonth' => $request->query->get('month'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ use App\Service\CmsDataProvider;
|
|||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -57,8 +58,8 @@ class EditController extends AbstractController
|
|||||||
$boardServices = $accommodation->getBoardServices()->toArray();
|
$boardServices = $accommodation->getBoardServices()->toArray();
|
||||||
usort($boardServices, $this->compareServices(...));
|
usort($boardServices, $this->compareServices(...));
|
||||||
|
|
||||||
|
// Additional services are ordered manually, see #[ORM\OrderBy] on the association.
|
||||||
$additionalServices = $accommodation->getAdditionalServices()->toArray();
|
$additionalServices = $accommodation->getAdditionalServices()->toArray();
|
||||||
usort($additionalServices, $this->compareServices(...));
|
|
||||||
|
|
||||||
return $this->render('admin/accommodation/edit.html.twig', [
|
return $this->render('admin/accommodation/edit.html.twig', [
|
||||||
'accommodation' => $accommodation,
|
'accommodation' => $accommodation,
|
||||||
@@ -69,10 +70,59 @@ class EditController extends AbstractController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives the additional service ids in their new order and renumbers the positions.
|
||||||
|
*
|
||||||
|
* Ids are only accepted when they belong to the given accommodation; services missing from the
|
||||||
|
* payload (e.g. created in another tab in the meantime) are appended in their previous order.
|
||||||
|
*/
|
||||||
|
#[Route('/admin/accommodation/{id}/additional-services/order', name: 'app_admin_accommodation_additionalservices_order', methods: ['POST'])]
|
||||||
|
public function orderAdditionalServices(Accommodation $accommodation, Request $request): Response
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = json_decode($request->getContent(), true, 512, \JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException) {
|
||||||
|
return new JsonResponse(['error' => 'Malformed payload'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!\is_array($payload) || !\is_array($payload['ids'] ?? null)) {
|
||||||
|
return new JsonResponse(['error' => 'Malformed payload'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<int, AdditionalService> $remaining */
|
||||||
|
$remaining = [];
|
||||||
|
|
||||||
|
foreach ($accommodation->getAdditionalServices() as $additionalService) {
|
||||||
|
$remaining[$additionalService->getId()] = $additionalService;
|
||||||
|
}
|
||||||
|
|
||||||
|
$position = 0;
|
||||||
|
|
||||||
|
foreach ($payload['ids'] as $id) {
|
||||||
|
$id = \is_numeric($id) ? (int) $id : null;
|
||||||
|
|
||||||
|
if (null === $id || !isset($remaining[$id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remaining[$id]->setPosition(++$position);
|
||||||
|
unset($remaining[$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whatever was not part of the payload keeps its relative order behind the sorted ones.
|
||||||
|
foreach ($remaining as $additionalService) {
|
||||||
|
$additionalService->setPosition(++$position);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return new JsonResponse(['status' => 'ok']);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DateFrom ascending, then free (price 0) before paid, then alphabetically by label.
|
* DateFrom ascending, then free (price 0) before paid, then alphabetically by label.
|
||||||
*/
|
*/
|
||||||
private function compareServices(BoardService|AdditionalService $a, BoardService|AdditionalService $b): int
|
private function compareServices(BoardService $a, BoardService $b): int
|
||||||
{
|
{
|
||||||
$aFree = 0 === $a->getPrice() ? 0 : 1;
|
$aFree = 0 === $a->getPrice() ? 0 : 1;
|
||||||
$bFree = 0 === $b->getPrice() ? 0 : 1;
|
$bFree = 0 === $b->getPrice() ? 0 : 1;
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\AccommodationBooking;
|
||||||
|
|
||||||
|
use App\Entity\Groups\Accommodation;
|
||||||
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Exception\ImplausibleBookingPriceException;
|
||||||
|
use App\Form\Admin\Groups\AccommodationBookingChangeAccommodationType;
|
||||||
|
use App\Htmx\HxTrait;
|
||||||
|
use App\Service\AccommodationBookingService;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\Form\FormError;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||||
|
class ChangeAccommodationController extends AbstractController
|
||||||
|
{
|
||||||
|
use HxTrait;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly AccommodationBookingService $bookingService,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/accommodation-booking/{id}/change-accommodation', name: 'app_admin_accommodationbooking_change_accommodation')]
|
||||||
|
public function index(AccommodationBooking $booking, Request $request): Response
|
||||||
|
{
|
||||||
|
// Correcting the house is for a scratch record only. Once an offer is out, the customer
|
||||||
|
// can see it under their access link, and rewriting what they were offered is not
|
||||||
|
// something that belongs in an edit — such a booking gets discarded and redone instead.
|
||||||
|
if (!$booking->isDraft()) {
|
||||||
|
$this->addFlash('error', 'Das Gruppenhaus lässt sich nur bei einem Entwurf ändern.');
|
||||||
|
|
||||||
|
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
$form = $this->createForm(AccommodationBookingChangeAccommodationType::class, $booking, [
|
||||||
|
'current_accommodation' => $booking->getAccommodation(),
|
||||||
|
'hx_post' => $request->getRequestUri(),
|
||||||
|
]);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$selected = $form->get('accommodation')->getData();
|
||||||
|
|
||||||
|
if ($selected instanceof Accommodation && $selected !== $booking->getAccommodation()) {
|
||||||
|
try {
|
||||||
|
$this->bookingService->changeAccommodation($booking, $selected);
|
||||||
|
} catch (ImplausibleBookingPriceException $e) {
|
||||||
|
// The house that was picked prices this stay implausibly high, so the change
|
||||||
|
// did not happen. Reported in the modal the office is standing in front of —
|
||||||
|
// the same treatment the create and edit forms give it.
|
||||||
|
$this->logger->error('Refused to change accommodation over an implausible booking total', [
|
||||||
|
'exception' => $e,
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
'accommodation' => $selected->getCalendarCode(),
|
||||||
|
]);
|
||||||
|
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
|
||||||
|
|
||||||
|
return $this->render('admin/accommodation_booking/modal_change_accommodation.html.twig', [
|
||||||
|
'booking' => $booking,
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Das Gruppenhaus wurde geändert. Bitte wähle die Leistungen neu aus.');
|
||||||
|
|
||||||
|
$this->logger->info('Changed accommodation of accommodation booking', [
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
'accommodation' => $selected->getName(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back to the edit form, which is rebuilt from the new house: its board and
|
||||||
|
// additional service choices are resolved server-side and cannot be swapped in
|
||||||
|
// place, which is the whole reason this is a modal and not a field on that form.
|
||||||
|
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('admin/accommodation_booking/modal_change_accommodation.html.twig', [
|
||||||
|
'booking' => $booking,
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The return url is forwarded still encoded, the way return_url() handed it over, so that
|
||||||
|
* the edit page keeps leading back to the list the booking was opened from.
|
||||||
|
*/
|
||||||
|
private function editUrl(AccommodationBooking $booking, Request $request): string
|
||||||
|
{
|
||||||
|
$parameters = ['id' => $booking->getId()];
|
||||||
|
$returnUrl = $request->query->getString('r');
|
||||||
|
if ('' !== $returnUrl) {
|
||||||
|
$parameters['r'] = $returnUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->generateUrl('app_admin_accommodationbooking_edit', $parameters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\AccommodationBooking;
|
||||||
|
|
||||||
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Htmx\HxRedirectResponse;
|
||||||
|
use App\Message\CreateClickUpBookingTaskMessage;
|
||||||
|
use App\Service\AccommodationBookingService;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||||
|
class ConfirmController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AccommodationBookingService $bookingService,
|
||||||
|
private readonly MessageBusInterface $messageBus,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/accommodation-booking/{id}/confirm', name: 'app_admin_accommodationbooking_confirm')]
|
||||||
|
public function index(AccommodationBooking $booking, Request $request): Response
|
||||||
|
{
|
||||||
|
// Only a booking awaiting validation can be confirmed — everything else has either
|
||||||
|
// not been committed to by the customer yet or is already dealt with.
|
||||||
|
if (!$booking->isReceived()) {
|
||||||
|
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->isMethod(Request::METHOD_POST)) {
|
||||||
|
if (!$this->isCsrfTokenValid('confirm_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirming is what makes the booking binding for the customer, so it must not
|
||||||
|
// happen when the confirmation itself cannot be delivered.
|
||||||
|
if (null === $booking->getEmail() || '' === trim($booking->getEmail())) {
|
||||||
|
$this->addFlash('error', 'Für diese Buchung ist keine E-Mail-Adresse hinterlegt. Bitte ergänze sie, um die Buchung zu bestätigen.');
|
||||||
|
|
||||||
|
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_edit', ['id' => $booking->getId()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->bookingService->confirmBooking($booking);
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Die Buchung wurde bestätigt und dem Kunden per E-Mail zugestellt.');
|
||||||
|
|
||||||
|
$this->logger->info('Confirmed accommodation booking', [
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Creating the ClickUp task takes seconds, so it must not hang off this request.
|
||||||
|
$this->messageBus->dispatch(new CreateClickUpBookingTaskMessage((int) $booking->getId()));
|
||||||
|
|
||||||
|
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('admin/accommodation_booking/modal_confirm.html.twig', [
|
||||||
|
'booking' => $booking,
|
||||||
|
'csrf_token_id' => 'confirm_accommodation_booking_'.$booking->getId(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,16 @@ declare(strict_types=1);
|
|||||||
namespace App\Controller\Admin\AccommodationBooking;
|
namespace App\Controller\Admin\AccommodationBooking;
|
||||||
|
|
||||||
use App\Entity\Groups\AccommodationBooking;
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
use App\Form\Admin\Groups\AccommodationBookingType;
|
use App\Entity\User;
|
||||||
|
use App\Exception\ImplausibleBookingPriceException;
|
||||||
|
use App\Form\Admin\Groups\AccommodationBookingCreateType;
|
||||||
|
use App\Htmx\HxRedirectResponse;
|
||||||
use App\Service\AccommodationBookingService;
|
use App\Service\AccommodationBookingService;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\Form\FormError;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -30,31 +35,63 @@ class CreateController extends AbstractController
|
|||||||
{
|
{
|
||||||
$booking = new AccommodationBooking();
|
$booking = new AccommodationBooking();
|
||||||
|
|
||||||
$form = $this->createForm(AccommodationBookingType::class, $booking, [
|
// Whoever creates a booking in the backoffice looks after it until someone else is
|
||||||
'with_accommodation' => true,
|
// assigned in the edit form. The route is behind ROLE_GROUPS_MANAGER, so the creator
|
||||||
|
// is always eligible as Betreuer.
|
||||||
|
$user = $this->getUser();
|
||||||
|
if ($user instanceof User) {
|
||||||
|
$booking->setManagedBy($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
$form = $this->createForm(AccommodationBookingCreateType::class, $booking, [
|
||||||
|
'hx_post' => $request->getRequestUri(),
|
||||||
]);
|
]);
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid() && $this->refreshPriceSnapshot($form, $booking)) {
|
||||||
$this->bookingService->refreshPriceSnapshot($booking);
|
|
||||||
$this->entityManager->persist($booking);
|
$this->entityManager->persist($booking);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
$this->bookingService->issueAccessLinkForDirectBooking($booking);
|
|
||||||
$this->bookingService->sendCustomerConfirmationEmail($booking);
|
|
||||||
|
|
||||||
$this->addFlash('success', 'Die Buchung wurde erstellt');
|
$this->addFlash('success', 'Die Buchung wurde erstellt');
|
||||||
|
|
||||||
$this->logger->info('Created accommodation booking', [
|
$this->logger->info('Created accommodation booking', [
|
||||||
'id' => $booking->getId(),
|
'id' => $booking->getId(),
|
||||||
'groupName' => $booking->getGroupName(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->redirectToRoute('app_admin_accommodationbooking_edit', ['id' => $booking->getId()]);
|
return new HxRedirectResponse(
|
||||||
|
$this->generateUrl('app_admin_accommodationbooking_edit', ['id' => $booking->getId()])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->render('admin/accommodation_booking/create.html.twig', [
|
return $this->render('admin/accommodation_booking/modal_create.html.twig', [
|
||||||
'form' => $form,
|
'form' => $form,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The price is computed after validation, so an implausible total cannot be a constraint on
|
||||||
|
* the form — it is reported as one anyway, because the office's next move is to go and fix
|
||||||
|
* the house's prices, not to read an error page.
|
||||||
|
*
|
||||||
|
* @param FormInterface<AccommodationBooking> $form
|
||||||
|
*/
|
||||||
|
private function refreshPriceSnapshot(FormInterface $form, AccommodationBooking $booking): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->bookingService->refreshPriceSnapshot($booking);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (ImplausibleBookingPriceException $e) {
|
||||||
|
$this->logger->error('Refused to store an implausible accommodation booking total', [
|
||||||
|
'exception' => $e,
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
'accommodation' => $booking->getAccommodation()?->getCalendarCode(),
|
||||||
|
'paxCount' => $booking->getPaxCount(),
|
||||||
|
'nights' => $booking->getNights(),
|
||||||
|
]);
|
||||||
|
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\AccommodationBooking;
|
||||||
|
|
||||||
|
use App\Controller\Traits\ReturnUrlTrait;
|
||||||
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Htmx\HxTrait;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||||
|
class DeleteController extends AbstractController
|
||||||
|
{
|
||||||
|
use HxTrait;
|
||||||
|
use ReturnUrlTrait;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/accommodation-booking/{id}/delete', name: 'app_admin_accommodationbooking_delete')]
|
||||||
|
public function index(AccommodationBooking $booking, Request $request): Response
|
||||||
|
{
|
||||||
|
// Only a scratch record may be removed outright. Everything else has been in contact
|
||||||
|
// with the customer and has to stay on the books — an Absage is how those are closed,
|
||||||
|
// and it keeps the record readable afterwards.
|
||||||
|
if (!$booking->isDraft()) {
|
||||||
|
$this->addFlash('error', 'Nur ein Entwurf lässt sich löschen. Eine Buchung, die bereits beim Kunden war, wird abgesagt.');
|
||||||
|
|
||||||
|
return $this->htmxRedirect($request, $this->getReturnUrl($request, 'app_admin_accommodationbooking'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->isMethod(Request::METHOD_POST)) {
|
||||||
|
if (!$this->isCsrfTokenValid('delete_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info('Deleted accommodation booking draft', [
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
'groupName' => $booking->getGroupName(),
|
||||||
|
'accommodation' => $booking->getAccommodation()?->getName(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->entityManager->remove($booking);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Der Entwurf wurde gelöscht');
|
||||||
|
|
||||||
|
// Back to the list the delete was started from, so the filter and the page the
|
||||||
|
// manager was on survive the round trip.
|
||||||
|
return $this->htmxRedirect($request, $this->getReturnUrl($request, 'app_admin_accommodationbooking'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('admin/accommodation_booking/modal_delete.html.twig', [
|
||||||
|
'booking' => $booking,
|
||||||
|
'csrf_token_id' => 'delete_accommodation_booking_'.$booking->getId(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\AccommodationBooking;
|
||||||
|
|
||||||
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Htmx\HxRedirectResponse;
|
||||||
|
use App\Service\AccommodationBookingService;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||||
|
class DiscardController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AccommodationBookingService $bookingService,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/accommodation-booking/{id}/discard', name: 'app_admin_accommodationbooking_discard')]
|
||||||
|
public function index(AccommodationBooking $booking, Request $request): Response
|
||||||
|
{
|
||||||
|
// A confirmation that has gone out is binding, so a confirmed booking is not something
|
||||||
|
// the office can walk back here; an already discarded one has nothing left to do.
|
||||||
|
if ($booking->isConfirmed() || $booking->isDiscarded()) {
|
||||||
|
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->isMethod(Request::METHOD_POST)) {
|
||||||
|
if (!$this->isCsrfTokenValid('discard_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->bookingService->discardBooking($booking);
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Die Buchung wurde abgesagt.');
|
||||||
|
|
||||||
|
$this->logger->info('Discarded accommodation booking', [
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('admin/accommodation_booking/modal_discard.html.twig', [
|
||||||
|
'booking' => $booking,
|
||||||
|
'csrf_token_id' => 'discard_accommodation_booking_'.$booking->getId(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\AccommodationBooking;
|
||||||
|
|
||||||
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Htmx\HxRedirectResponse;
|
||||||
|
use App\Service\AccommodationBookingService;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copying is its own named action rather than a flag on the edit route the way it is for the
|
||||||
|
* catalog entities (see Admin\AdditionalService\EditController): a booking carries a lifecycle,
|
||||||
|
* so the copy has to be reset and written before anybody edits it, and the edit form is built
|
||||||
|
* from a persisted record's house and service catalog.
|
||||||
|
*
|
||||||
|
* Unlike absagen and löschen this has no status guard — the original is not touched, so a
|
||||||
|
* confirmed or discarded booking may be copied just as well as a draft.
|
||||||
|
*/
|
||||||
|
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||||
|
class DuplicateController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AccommodationBookingService $bookingService,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/accommodation-booking/{id}/duplicate', name: 'app_admin_accommodationbooking_duplicate')]
|
||||||
|
public function index(AccommodationBooking $booking, Request $request): Response
|
||||||
|
{
|
||||||
|
if ($request->isMethod(Request::METHOD_POST)) {
|
||||||
|
if (!$this->isCsrfTokenValid('duplicate_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$copy = $this->bookingService->duplicate($booking);
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Die Kopie wurde als neuer Entwurf angelegt.');
|
||||||
|
|
||||||
|
$this->logger->info('Duplicated accommodation booking', [
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
'copyId' => $copy->getId(),
|
||||||
|
'groupName' => $copy->getGroupName(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Straight into the copy's edit form — the copy exists to be changed, and that is
|
||||||
|
// the only reason it was made. The return url is forwarded still encoded, the way
|
||||||
|
// return_url() handed it over, so "zurück" still leads to the list this started from.
|
||||||
|
$parameters = ['id' => $copy->getId()];
|
||||||
|
$returnUrl = $request->query->getString('r');
|
||||||
|
if ('' !== $returnUrl) {
|
||||||
|
$parameters['r'] = $returnUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_edit', $parameters));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('admin/accommodation_booking/modal_duplicate.html.twig', [
|
||||||
|
'booking' => $booking,
|
||||||
|
'csrf_token_id' => 'duplicate_accommodation_booking_'.$booking->getId(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,13 +7,17 @@ namespace App\Controller\Admin\AccommodationBooking;
|
|||||||
use App\Entity\Groups\AccommodationBooking;
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
use App\Entity\Groups\AdditionalService;
|
use App\Entity\Groups\AdditionalService;
|
||||||
use App\Entity\Groups\BoardService;
|
use App\Entity\Groups\BoardService;
|
||||||
|
use App\Exception\ImplausibleBookingPriceException;
|
||||||
use App\Form\Admin\Groups\AccommodationBookingType;
|
use App\Form\Admin\Groups\AccommodationBookingType;
|
||||||
use App\Repository\Groups\AdditionalServiceRepository;
|
use App\Repository\Groups\AdditionalServiceRepository;
|
||||||
use App\Repository\Groups\BoardServiceRepository;
|
use App\Repository\Groups\BoardServiceRepository;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
use App\Service\AccommodationBookingService;
|
use App\Service\AccommodationBookingService;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\Form\FormError;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -27,6 +31,7 @@ class EditController extends AbstractController
|
|||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
private readonly BoardServiceRepository $boardServiceRepo,
|
private readonly BoardServiceRepository $boardServiceRepo,
|
||||||
private readonly AdditionalServiceRepository $additionalServiceRepo,
|
private readonly AdditionalServiceRepository $additionalServiceRepo,
|
||||||
|
private readonly UserRepository $userRepo,
|
||||||
private readonly AccommodationBookingService $bookingService,
|
private readonly AccommodationBookingService $bookingService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -68,15 +73,29 @@ class EditController extends AbstractController
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ROLE_GROUPS_ADMIN is granted explicitly since 6c1073e4 removed it from ROLE_ADMIN's
|
||||||
|
// role_hierarchy — an administrator who needs this has to hold it in their own right.
|
||||||
|
$assignableManagers = [];
|
||||||
|
if ($this->isGranted('ROLE_GROUPS_ADMIN')) {
|
||||||
|
$assignableManagers = $this->userRepo->findGroupsStaff();
|
||||||
|
|
||||||
|
// A user assigned earlier may have lost the role since — keep them in the choices
|
||||||
|
// so the field can render the current value instead of failing to transform it.
|
||||||
|
$currentManager = $booking->getManagedBy();
|
||||||
|
if (null !== $currentManager && !in_array($currentManager, $assignableManagers, true)) {
|
||||||
|
$assignableManagers[] = $currentManager;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$form = $this->createForm(AccommodationBookingType::class, $booking, [
|
$form = $this->createForm(AccommodationBookingType::class, $booking, [
|
||||||
'max_adolescent_age' => $accommodation?->getMaxAdolescentAge() ?? 0,
|
'max_adolescent_age' => $accommodation?->getMaxAdolescentAge() ?? 0,
|
||||||
'board_services' => $boardServices,
|
'board_services' => $boardServices,
|
||||||
'additional_services' => $additionalServices,
|
'additional_services' => $additionalServices,
|
||||||
'current_board_service' => $currentBoardService,
|
'current_board_service' => $currentBoardService,
|
||||||
'current_additional_services' => $currentAdditionalServices,
|
'current_additional_services' => $currentAdditionalServices,
|
||||||
|
'assignable_managers' => $assignableManagers,
|
||||||
|
'currency' => $booking->getPricingCurrency() ?? $accommodation?->getCurrency() ?? 'EUR',
|
||||||
]);
|
]);
|
||||||
$wasInquiry = $booking->isInquiry();
|
|
||||||
|
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
@@ -105,16 +124,22 @@ class EditController extends AbstractController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->bookingService->refreshPriceSnapshot($booking);
|
if (!$this->refreshPriceSnapshot($form, $booking)) {
|
||||||
|
// The submitted changes stay on the entity but are never flushed, so the form
|
||||||
|
// comes back filled in with the error on it. Logging is safe here: the database
|
||||||
|
// handler writes through DBAL, not the EntityManager.
|
||||||
|
return $this->render('admin/accommodation_booking/edit.html.twig', [
|
||||||
|
'booking' => $booking,
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editing never moves the booking on and never mails the customer: every status
|
||||||
|
// change is its own named action (Angebot senden, bestätigen, absagen), so the
|
||||||
|
// status, its timestamp and its email can no longer drift apart.
|
||||||
$this->entityManager->persist($booking);
|
$this->entityManager->persist($booking);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
if ($wasInquiry && !$booking->isInquiry()) {
|
|
||||||
$this->bookingService->issueAccessLinkForDirectBooking($booking);
|
|
||||||
$this->bookingService->sendCustomerConfirmationEmail($booking);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->addFlash('success', 'Die Buchung wurde aktualisiert');
|
$this->addFlash('success', 'Die Buchung wurde aktualisiert');
|
||||||
|
|
||||||
$this->logger->info('Updated accommodation booking', [
|
$this->logger->info('Updated accommodation booking', [
|
||||||
@@ -122,7 +147,17 @@ class EditController extends AbstractController
|
|||||||
'groupName' => $booking->getGroupName(),
|
'groupName' => $booking->getGroupName(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->redirectToRoute('app_admin_accommodationbooking_edit', ['id' => $booking->getId()]);
|
// Back to the detail page rather than the form: that is where every named status
|
||||||
|
// action lives, so it is the view a manager needs after saving. The return url is
|
||||||
|
// forwarded still encoded, the way return_url() handed it over, so that the detail
|
||||||
|
// page's "zurück" leads to the filtered list the edit was started from.
|
||||||
|
$parameters = ['id' => $booking->getId()];
|
||||||
|
$returnUrl = $request->query->getString('r');
|
||||||
|
if ('' !== $returnUrl) {
|
||||||
|
$parameters['r'] = $returnUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_admin_accommodationbooking_show', $parameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->render('admin/accommodation_booking/edit.html.twig', [
|
return $this->render('admin/accommodation_booking/edit.html.twig', [
|
||||||
@@ -130,4 +165,31 @@ class EditController extends AbstractController
|
|||||||
'form' => $form,
|
'form' => $form,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The price is computed after validation, so an implausible total cannot be a constraint on
|
||||||
|
* the form — it is reported as one anyway, because the office's next move is to go and fix
|
||||||
|
* the house's prices, not to read an error page.
|
||||||
|
*
|
||||||
|
* @param FormInterface<AccommodationBooking> $form
|
||||||
|
*/
|
||||||
|
private function refreshPriceSnapshot(FormInterface $form, AccommodationBooking $booking): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->bookingService->refreshPriceSnapshot($booking);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (ImplausibleBookingPriceException $e) {
|
||||||
|
$this->logger->error('Refused to store an implausible accommodation booking total', [
|
||||||
|
'exception' => $e,
|
||||||
|
'id' => $booking->getId(),
|
||||||
|
'accommodation' => $booking->getAccommodation()?->getCalendarCode(),
|
||||||
|
'paxCount' => $booking->getPaxCount(),
|
||||||
|
'nights' => $booking->getNights(),
|
||||||
|
]);
|
||||||
|
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\AccommodationBooking;
|
||||||
|
|
||||||
|
use App\Controller\Traits\ListFilterTrait;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Form\Admin\Filter\AccommodationBookingFilterType;
|
||||||
|
use App\Form\Model\Filter\AccommodationBookingFilterDto;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the filter modal, pre-filled with whatever the list is currently showing.
|
||||||
|
*
|
||||||
|
* There is no submit action to go with it: the form is a GET form pointing at the list itself,
|
||||||
|
* so applying a filter is a plain navigation.
|
||||||
|
*/
|
||||||
|
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||||
|
class FilterController extends AbstractController
|
||||||
|
{
|
||||||
|
use ListFilterTrait;
|
||||||
|
|
||||||
|
#[Route('/admin/accommodation-booking/filter', name: 'app_admin_accommodationbooking_filter', methods: ['GET'])]
|
||||||
|
public function filter(Request $request): Response
|
||||||
|
{
|
||||||
|
// ROLE_GROUPS_ADMIN is granted explicitly since 6c1073e4 removed it from ROLE_ADMIN's
|
||||||
|
// role_hierarchy — an administrator who needs this has to hold it in their own right.
|
||||||
|
$isGroupsAdmin = $this->isGranted('ROLE_GROUPS_ADMIN');
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
$filterView = $this->createListFilterView(
|
||||||
|
$request,
|
||||||
|
AccommodationBookingFilterType::class,
|
||||||
|
AccommodationBookingFilterDto::defaults($isGroupsAdmin, $user instanceof User ? $user : null),
|
||||||
|
'app_admin_accommodationbooking',
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->render('admin/accommodation_booking/modal_filter.html.twig', [
|
||||||
|
'form' => $filterView,
|
||||||
|
'route' => 'app_admin_accommodationbooking',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,16 +26,22 @@ class GenerateAccessLinkController extends AbstractController
|
|||||||
#[Route('/admin/accommodation-booking/{id}/generate-access-link', name: 'app_admin_accommodationbooking_generate_access_link')]
|
#[Route('/admin/accommodation-booking/{id}/generate-access-link', name: 'app_admin_accommodationbooking_generate_access_link')]
|
||||||
public function index(AccommodationBooking $booking, Request $request): Response
|
public function index(AccommodationBooking $booking, Request $request): Response
|
||||||
{
|
{
|
||||||
|
// Same guard as sendOffer() — only a booking still waiting for its offer can have a
|
||||||
|
// link generated for it.
|
||||||
|
if (!$booking->isDraft() && !$booking->isRequested()) {
|
||||||
|
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($request->isMethod(Request::METHOD_POST)) {
|
if ($request->isMethod(Request::METHOD_POST)) {
|
||||||
if (!$this->isCsrfTokenValid('generate_accommodation_booking_access_link_'.$booking->getId(), $request->request->getString('_token'))) {
|
if (!$this->isCsrfTokenValid('generate_access_link_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) {
|
||||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->bookingService->regenerateAccessLink($booking);
|
$this->bookingService->generateAccessLink($booking);
|
||||||
|
|
||||||
$this->addFlash('success', 'Der Zugangslink wurde neu generiert.');
|
$this->addFlash('success', 'Der Zugangslink wurde erzeugt. Die Buchung ist jetzt offen und wartet auf die Annahme durch den Kunden.');
|
||||||
|
|
||||||
$this->logger->info('Generated accommodation booking access link', [
|
$this->logger->info('Manually generated accommodation booking access link', [
|
||||||
'id' => $booking->getId(),
|
'id' => $booking->getId(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -44,7 +50,7 @@ class GenerateAccessLinkController extends AbstractController
|
|||||||
|
|
||||||
return $this->render('admin/accommodation_booking/modal_generate_access_link.html.twig', [
|
return $this->render('admin/accommodation_booking/modal_generate_access_link.html.twig', [
|
||||||
'booking' => $booking,
|
'booking' => $booking,
|
||||||
'csrf_token_id' => 'generate_accommodation_booking_access_link_'.$booking->getId(),
|
'csrf_token_id' => 'generate_access_link_accommodation_booking_'.$booking->getId(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controller\Admin\AccommodationBooking;
|
namespace App\Controller\Admin\AccommodationBooking;
|
||||||
|
|
||||||
|
use App\Controller\Traits\ListFilterTrait;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Form\Admin\Filter\AccommodationBookingFilterType;
|
||||||
|
use App\Form\Model\Filter\AccommodationBookingFilterDto;
|
||||||
use App\Repository\Groups\AccommodationBookingRepository;
|
use App\Repository\Groups\AccommodationBookingRepository;
|
||||||
use Knp\Component\Pager\PaginatorInterface;
|
use Knp\Component\Pager\PaginatorInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
@@ -15,6 +19,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||||
class IndexController extends AbstractController
|
class IndexController extends AbstractController
|
||||||
{
|
{
|
||||||
|
use ListFilterTrait;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AccommodationBookingRepository $bookingRepository,
|
private readonly AccommodationBookingRepository $bookingRepository,
|
||||||
private readonly PaginatorInterface $paginator,
|
private readonly PaginatorInterface $paginator,
|
||||||
@@ -24,19 +30,25 @@ class IndexController extends AbstractController
|
|||||||
#[Route('/admin/accommodation-booking', name: 'app_admin_accommodationbooking')]
|
#[Route('/admin/accommodation-booking', name: 'app_admin_accommodationbooking')]
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
$today = new \DateTimeImmutable('today');
|
// ROLE_GROUPS_ADMIN is granted explicitly since 6c1073e4 removed it from ROLE_ADMIN's
|
||||||
|
// role_hierarchy — an administrator who needs this has to hold it in their own right.
|
||||||
|
$isGroupsAdmin = $this->isGranted('ROLE_GROUPS_ADMIN');
|
||||||
|
$user = $this->getUser();
|
||||||
|
$user = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
$qb = $this
|
// The form writes into $filter, so after this call it holds either the defaults or
|
||||||
->bookingRepository
|
// whatever the query string asked for.
|
||||||
->createQueryBuilder('booking')
|
$filter = AccommodationBookingFilterDto::defaults($isGroupsAdmin, $user);
|
||||||
->leftJoin('booking.accommodation', 'accommodation')
|
|
||||||
->addSelect('accommodation')
|
$filterView = $this->createListFilterView(
|
||||||
->where('booking.dateTo >= :today')
|
$request,
|
||||||
->setParameter('today', $today)
|
AccommodationBookingFilterType::class,
|
||||||
;
|
$filter,
|
||||||
|
'app_admin_accommodationbooking',
|
||||||
|
);
|
||||||
|
|
||||||
$pagination = $this->paginator->paginate(
|
$pagination = $this->paginator->paginate(
|
||||||
$qb,
|
$this->bookingRepository->createFilteredQueryBuilder($filter),
|
||||||
$request->query->getInt('page', 1),
|
$request->query->getInt('page', 1),
|
||||||
$request->query->getInt('limit', 20),
|
$request->query->getInt('limit', 20),
|
||||||
[
|
[
|
||||||
@@ -47,6 +59,8 @@ class IndexController extends AbstractController
|
|||||||
|
|
||||||
return $this->render('admin/accommodation_booking/index.html.twig', [
|
return $this->render('admin/accommodation_booking/index.html.twig', [
|
||||||
'pagination' => $pagination,
|
'pagination' => $pagination,
|
||||||
|
'filter' => $filter,
|
||||||
|
'filter_form' => $filterView,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loaded 100 of 534 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user