wip: initial commit

This commit is contained in:
Björn Fromme
2024-11-20 18:32:09 +01:00
parent ae964198e9
commit d4c4e69d09
91 changed files with 32685 additions and 144 deletions
+281
View File
@@ -0,0 +1,281 @@
name: myep-next
type: php
docroot: public
php_version: "8.2"
webserver_type: nginx-fpm
xdebug_enabled: false
additional_hostnames: []
additional_fqdns: []
database:
type: mariadb
version: "10.11"
use_dns_when_possible: true
composer_version: "2"
web_environment: []
corepack_enable: false
# Key features of DDEV's config.yaml:
# name: <projectname> # Name of the project, automatically provides
# http://projectname.ddev.site and https://projectname.ddev.site
# type: <projecttype> # backdrop, craftcms, django4, drupal, drupal6, drupal7, laravel, magento, magento2, php, python, shopware6, silverstripe, typo3, wordpress
# See https://ddev.readthedocs.io/en/stable/users/quickstart/ for more
# information on the different project types
# "drupal" covers recent Drupal 8+
# docroot: <relative_path> # Relative path to the directory containing index.php.
# php_version: "8.2" # PHP version to use, "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4"
# You can explicitly specify the webimage but this
# is not recommended, as the images are often closely tied to DDEV's' behavior,
# so this can break upgrades.
# webimage: <docker_image> # nginx/php docker image.
# database:
# type: <dbtype> # mysql, mariadb, postgres
# version: <version> # database version, like "10.11" or "8.0"
# MariaDB versions can be 5.5-10.8, 10.11, and 11.4.
# MySQL versions can be 5.5-8.0.
# PostgreSQL versions can be 9-17.
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
# xdebug_enabled: false # Set to true to enable Xdebug and "ddev start" or "ddev restart"
# Note that for most people the commands
# "ddev xdebug" to enable Xdebug and "ddev xdebug off" to disable it work better,
# as leaving Xdebug enabled all the time is a big performance hit.
# xhprof_enabled: false # Set to true to enable Xhprof and "ddev start" or "ddev restart"
# Note that for most people the commands
# "ddev xhprof" to enable Xhprof and "ddev xhprof off" to disable it work better,
# as leaving Xhprof enabled all the time is a big performance hit.
# webserver_type: nginx-fpm, apache-fpm, or nginx-gunicorn
# timezone: Europe/Berlin
# If timezone is unset, DDEV will attempt to derive it from the host system timezone
# using the $TZ environment variable or the /etc/localtime symlink.
# This is the timezone used in the containers and by PHP;
# it can be set to any valid timezone,
# see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
# For example Europe/Dublin or MST7MDT
# composer_root: <relative_path>
# Relative path to the Composer root directory from the project root. This is
# the directory which contains the composer.json and where all Composer related
# commands are executed.
# composer_version: "2"
# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1
# to use the latest major version available at the time your container is built.
# It is also possible to use each other Composer version channel. This includes:
# - 2.2 (latest Composer LTS version)
# - stable
# - preview
# - snapshot
# Alternatively, an explicit Composer version may be specified, for example "2.2.18".
# To reinstall Composer after the image was built, run "ddev debug rebuild".
# nodejs_version: "20"
# change from the default system Node.js version to any other version.
# See https://ddev.readthedocs.io/en/stable/users/configuration/config/#nodejs_version for more information
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation,
# Note that using of 'ddev nvm' is discouraged because "nodejs_version" is much easier to use,
# can specify any version, and is more robust than using 'nvm'.
# corepack_enable: false
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
# additional_hostnames:
# - somename
# - someothername
# would provide http and https URLs for "somename.ddev.site"
# and "someothername.ddev.site".
# additional_fqdns:
# - example.com
# - sub1.example.com
# would provide http and https URLs for "example.com" and "sub1.example.com"
# Please take care with this because it can cause great confusion.
# upload_dirs: "custom/upload/dir"
#
# upload_dirs:
# - custom/upload/dir
# - ../private
#
# would set the destination paths for ddev import-files to <docroot>/custom/upload/dir
# When Mutagen is enabled this path is bind-mounted so that all the files
# in the upload_dirs don't have to be synced into Mutagen.
# disable_upload_dirs_warning: false
# If true, turns off the normal warning that says
# "You have Mutagen enabled and your 'php' project type doesn't have upload_dirs set"
# ddev_version_constraint: ""
# Example:
# ddev_version_constraint: ">= 1.22.4"
# This will enforce that the running ddev version is within this constraint.
# See https://github.com/Masterminds/semver#checking-version-constraints for
# supported constraint formats
# working_dir:
# web: /var/www/html
# db: /home
# would set the default working directory for the web and db services.
# These values specify the destination directory for ddev ssh and the
# directory in which commands passed into ddev exec are run.
# omit_containers: [db, ddev-ssh-agent]
# Currently only these containers are supported. Some containers can also be
# omitted globally in the ~/.ddev/global_config.yaml. Note that if you omit
# the "db" container, several standard features of DDEV that access the
# database container will be unusable. In the global configuration it is also
# possible to omit ddev-router, but not here.
# performance_mode: "global"
# DDEV offers performance optimization strategies to improve the filesystem
# performance depending on your host system. Should be configured globally.
#
# If set, will override the global config. Possible values are:
# - "global": uses the value from the global config.
# - "none": disables performance optimization for this project.
# - "mutagen": enables Mutagen for this project.
# - "nfs": enables NFS for this project.
#
# See https://ddev.readthedocs.io/en/stable/users/install/performance/#nfs
# See https://ddev.readthedocs.io/en/stable/users/install/performance/#mutagen
# fail_on_hook_fail: False
# Decide whether 'ddev start' should be interrupted by a failing hook
# host_https_port: "59002"
# The host port binding for https can be explicitly specified. It is
# dynamic unless otherwise specified.
# This is not used by most people, most people use the *router* instead
# of the localhost port.
# host_webserver_port: "59001"
# The host port binding for the ddev-webserver can be explicitly specified. It is
# dynamic unless otherwise specified.
# This is not used by most people, most people use the *router* instead
# of the localhost port.
# host_db_port: "59002"
# The host port binding for the ddev-dbserver can be explicitly specified. It is dynamic
# unless explicitly specified.
# mailpit_http_port: "8025"
# mailpit_https_port: "8026"
# The Mailpit ports can be changed from the default 8025 and 8026
# host_mailpit_port: "8025"
# The mailpit port is not normally bound on the host at all, instead being routed
# through ddev-router, but it can be bound directly to localhost if specified here.
# webimage_extra_packages: [php7.4-tidy, php-bcmath]
# Extra Debian packages that are needed in the webimage can be added here
# dbimage_extra_packages: [telnet,netcat]
# Extra Debian packages that are needed in the dbimage can be added here
# use_dns_when_possible: true
# If the host has internet access and the domain configured can
# successfully be looked up, DNS will be used for hostname resolution
# instead of editing /etc/hosts
# Defaults to true
# project_tld: ddev.site
# The top-level domain used for project URLs
# The default "ddev.site" allows DNS lookup via a wildcard
# If you prefer you can change this to "ddev.local" to preserve
# pre-v1.9 behavior.
# ngrok_args: --basic-auth username:pass1234
# Provide extra flags to the "ngrok http" command, see
# https://ngrok.com/docs/ngrok-agent/config or run "ngrok http -h"
# disable_settings_management: false
# If true, DDEV will not create CMS-specific settings files like
# Drupal's settings.php/settings.ddev.php or TYPO3's additional.php
# In this case the user must provide all such settings.
# You can inject environment variables into the web container with:
# web_environment:
# - SOMEENV=somevalue
# - SOMEOTHERENV=someothervalue
# no_project_mount: false
# (Experimental) If true, DDEV will not mount the project into the web container;
# the user is responsible for mounting it manually or via a script.
# This is to enable experimentation with alternate file mounting strategies.
# For advanced users only!
# bind_all_interfaces: false
# If true, host ports will be bound on all network interfaces,
# not the localhost interface only. This means that ports
# will be available on the local network if the host firewall
# allows it.
# default_container_timeout: 120
# The default time that DDEV waits for all containers to become ready can be increased from
# the default 120. This helps in importing huge databases, for example.
#web_extra_exposed_ports:
#- name: nodejs
# container_port: 3000
# http_port: 2999
# https_port: 3000
#- name: something
# container_port: 4000
# https_port: 4000
# http_port: 3999
# Allows a set of extra ports to be exposed via ddev-router
# Fill in all three fields even if you dont intend to use the https_port!
# If you dont add https_port, then it defaults to 0 and ddev-router will fail to start.
#
# The port behavior on the ddev-webserver must be arranged separately, for example
# using web_extra_daemons.
# For example, with a web app on port 3000 inside the container, this config would
# expose that web app on https://<project>.ddev.site:9999 and http://<project>.ddev.site:9998
# web_extra_exposed_ports:
# - name: myapp
# container_port: 3000
# http_port: 9998
# https_port: 9999
#web_extra_daemons:
#- name: "http-1"
# command: "/var/www/html/node_modules/.bin/http-server -p 3000"
# directory: /var/www/html
#- name: "http-2"
# command: "/var/www/html/node_modules/.bin/http-server /var/www/html/sub -p 3000"
# directory: /var/www/html
# override_config: false
# By default, config.*.yaml files are *merged* into the configuration
# But this means that some things can't be overridden
# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge
# and you can't erase existing hooks or all environment variables.
# However, with "override_config: true" in a particular config.*.yaml file,
# 'use_dns_when_possible: false' can override the existing values, and
# hooks:
# post-start: []
# or
# web_environment: []
# or
# additional_hostnames: []
# can have their intended affect. 'override_config' affects only behavior of the
# config.*.yaml file it exists in.
# Many DDEV commands can be extended to run tasks before or after the
# DDEV command is executed, for example "post-start", "post-import-db",
# "pre-composer", "post-composer"
# See https://ddev.readthedocs.io/en/stable/users/extend/custom-commands/ for more
# information on the commands that can be extended and the tasks you can define
# for them. Example:
#hooks:
+5
View File
@@ -39,3 +39,8 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###> symfony/mailer ###
MAILER_DSN=null://null
###< symfony/mailer ###
APP_BPN_USER=
APP_BPN_PASSWORD=
APP_BPN_ENDPOINT=
APP_BPN_DEBUG=false
+1 -2
View File
@@ -5,6 +5,5 @@ import './bootstrap.js';
* This file will be included onto the page via the importmap() Twig function,
* which should already be in your base.html.twig.
*/
import '@picocss/pico';
import './styles/app.css';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
+7
View File
@@ -3,3 +3,10 @@ import { startStimulusApp } from '@symfony/stimulus-bundle';
const app = startStimulusApp();
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);
import htmx from 'htmx.org'
window.htmx = htmx
htmx.config.includeIndicatorStyles = false
htmx.config.historyEnabled = false
htmx.config.historyCacheSize = 0
htmx.config.allowScriptTags = false
+1 -12
View File
@@ -1,15 +1,4 @@
{
"controllers": {
"@symfony/ux-turbo": {
"turbo-core": {
"enabled": true,
"fetch": "eager"
},
"mercure-turbo-stream": {
"enabled": false,
"fetch": "eager"
}
}
},
"controllers": [],
"entrypoints": []
}
-16
View File
@@ -1,16 +0,0 @@
import { Controller } from '@hotwired/stimulus';
/*
* This is an example Stimulus controller!
*
* Any element with a data-controller="hello" attribute will cause
* this controller to be executed. The name "hello" comes from the filename:
* hello_controller.js -> "hello"
*
* Delete this file or adapt it for your use!
*/
export default class extends Controller {
connect() {
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
}
}
+20 -2
View File
@@ -1,3 +1,21 @@
body {
background-color: skyblue;
#content {
position: relative;
}
#loading-indicator {
display: none;
}
#loading-indicator.htmx-request {
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: white;
}
[disabled] {
cursor: not-allowed;
}
-18
View File
@@ -1,18 +0,0 @@
services:
###> doctrine/doctrine-bundle ###
database:
ports:
- "5432"
###< doctrine/doctrine-bundle ###
###> symfony/mailer ###
mailer:
image: axllent/mailpit
ports:
- "1025"
- "8025"
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
###< symfony/mailer ###
-20
View File
@@ -1,20 +0,0 @@
services:
###> doctrine/doctrine-bundle ###
database:
image: postgres:${POSTGRES_VERSION:-16}-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-app}
# You should definitely change the password in production
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
POSTGRES_USER: ${POSTGRES_USER:-app}
volumes:
- database_data:/var/lib/postgresql/data:rw
# You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!
# - ./docker/db/data:/var/lib/postgresql/data:rw
###< doctrine/doctrine-bundle ###
volumes:
###> doctrine/doctrine-bundle ###
database_data:
###< doctrine/doctrine-bundle ###
+5 -1
View File
@@ -7,10 +7,13 @@
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"ext-simplexml": "*",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.13",
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^3.3",
"nelexa/zip": "^4.0",
"nesbot/carbon": "^3.8",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.0",
"symfony/asset": "6.4.*",
@@ -38,11 +41,12 @@
"symfony/string": "6.4.*",
"symfony/translation": "6.4.*",
"symfony/twig-bundle": "6.4.*",
"symfony/ux-turbo": "^2.21",
"symfony/uid": "6.4.*",
"symfony/validator": "6.4.*",
"symfony/web-link": "6.4.*",
"symfony/yaml": "6.4.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/intl-extra": "^3.13",
"twig/twig": "^2.12|^3.0"
},
"config": {
Generated
+475 -54
View File
@@ -4,8 +4,77 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "5e21ceb0ec486ae6f548b5a8e2f8730b",
"content-hash": "e573ad1efa56e3ccee4a3bc80c29334c",
"packages": [
{
"name": "carbonphp/carbon-doctrine-types",
"version": "2.1.0",
"source": {
"type": "git",
"url": "https://github.com/CarbonPHP/carbon-doctrine-types.git",
"reference": "99f76ffa36cce3b70a4a6abce41dba15ca2e84cb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/99f76ffa36cce3b70a4a6abce41dba15ca2e84cb",
"reference": "99f76ffa36cce3b70a4a6abce41dba15ca2e84cb",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"doctrine/dbal": "<3.7.0 || >=4.0.0"
},
"require-dev": {
"doctrine/dbal": "^3.7.0",
"nesbot/carbon": "^2.71.0 || ^3.0.0",
"phpunit/phpunit": "^10.3"
},
"type": "library",
"autoload": {
"psr-4": {
"Carbon\\Doctrine\\": "src/Carbon/Doctrine/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "KyleKatarn",
"email": "[email protected]"
}
],
"description": "Types to use Carbon in Doctrine",
"keywords": [
"carbon",
"date",
"datetime",
"doctrine",
"time"
],
"support": {
"issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues",
"source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/2.1.0"
},
"funding": [
{
"url": "https://github.com/kylekatarnls",
"type": "github"
},
{
"url": "https://opencollective.com/Carbon",
"type": "open_collective"
},
{
"url": "https://tidelift.com/funding/github/packagist/nesbot/carbon",
"type": "tidelift"
}
],
"time": "2023-12-11T17:09:12+00:00"
},
{
"name": "composer/semver",
"version": "3.4.3",
@@ -1483,6 +1552,185 @@
],
"time": "2024-11-12T13:57:08+00:00"
},
{
"name": "nelexa/zip",
"version": "4.0.2",
"source": {
"type": "git",
"url": "https://github.com/Ne-Lexa/php-zip.git",
"reference": "88a1b6549be813278ff2dd3b6b2ac188827634a7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Ne-Lexa/php-zip/zipball/88a1b6549be813278ff2dd3b6b2ac188827634a7",
"reference": "88a1b6549be813278ff2dd3b6b2ac188827634a7",
"shasum": ""
},
"require": {
"ext-zlib": "*",
"php": "^7.4 || ^8.0",
"psr/http-message": "*",
"symfony/finder": "*"
},
"require-dev": {
"ext-bz2": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-iconv": "*",
"ext-openssl": "*",
"ext-xml": "*",
"friendsofphp/php-cs-fixer": "^3.4.0",
"guzzlehttp/psr7": "^1.6",
"phpunit/phpunit": "^9",
"symfony/http-foundation": "*",
"symfony/var-dumper": "*",
"vimeo/psalm": "^4.6"
},
"suggest": {
"ext-bz2": "Needed to support BZIP2 compression",
"ext-fileinfo": "Needed to get mime-type file",
"ext-iconv": "Needed to support convert zip entry name to requested character encoding",
"ext-openssl": "Needed to support encrypt zip entries or use ext-mcrypt"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpZip\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ne-Lexa",
"email": "[email protected]",
"role": "Developer"
}
],
"description": "PhpZip is a php-library for extended work with ZIP-archives. Open, create, update, delete, extract and get info tool. Supports appending to existing ZIP files, WinZip AES encryption, Traditional PKWARE Encryption, BZIP2 compression, external file attributes and ZIP64 extensions. Alternative ZipArchive. It does not require php-zip extension.",
"homepage": "https://github.com/Ne-Lexa/php-zip",
"keywords": [
"archive",
"extract",
"unzip",
"winzip",
"zip",
"ziparchive"
],
"support": {
"issues": "https://github.com/Ne-Lexa/php-zip/issues",
"source": "https://github.com/Ne-Lexa/php-zip/tree/4.0.2"
},
"time": "2022-06-17T11:17:46+00:00"
},
{
"name": "nesbot/carbon",
"version": "3.8.2",
"source": {
"type": "git",
"url": "https://github.com/briannesbitt/Carbon.git",
"reference": "e1268cdbc486d97ce23fef2c666dc3c6b6de9947"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/e1268cdbc486d97ce23fef2c666dc3c6b6de9947",
"reference": "e1268cdbc486d97ce23fef2c666dc3c6b6de9947",
"shasum": ""
},
"require": {
"carbonphp/carbon-doctrine-types": "<100.0",
"ext-json": "*",
"php": "^8.1",
"psr/clock": "^1.0",
"symfony/clock": "^6.3 || ^7.0",
"symfony/polyfill-mbstring": "^1.0",
"symfony/translation": "^4.4.18 || ^5.2.1|| ^6.0 || ^7.0"
},
"provide": {
"psr/clock-implementation": "1.0"
},
"require-dev": {
"doctrine/dbal": "^3.6.3 || ^4.0",
"doctrine/orm": "^2.15.2 || ^3.0",
"friendsofphp/php-cs-fixer": "^3.57.2",
"kylekatarnls/multi-tester": "^2.5.3",
"ondrejmirtes/better-reflection": "^6.25.0.4",
"phpmd/phpmd": "^2.15.0",
"phpstan/extension-installer": "^1.3.1",
"phpstan/phpstan": "^1.11.2",
"phpunit/phpunit": "^10.5.20",
"squizlabs/php_codesniffer": "^3.9.0"
},
"bin": [
"bin/carbon"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev",
"dev-2.x": "2.x-dev"
},
"laravel": {
"providers": [
"Carbon\\Laravel\\ServiceProvider"
]
},
"phpstan": {
"includes": [
"extension.neon"
]
}
},
"autoload": {
"psr-4": {
"Carbon\\": "src/Carbon/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Brian Nesbitt",
"email": "[email protected]",
"homepage": "https://markido.com"
},
{
"name": "kylekatarnls",
"homepage": "https://github.com/kylekatarnls"
}
],
"description": "An API extension for DateTime that supports 281 different languages.",
"homepage": "https://carbon.nesbot.com",
"keywords": [
"date",
"datetime",
"time"
],
"support": {
"docs": "https://carbon.nesbot.com/docs",
"issues": "https://github.com/briannesbitt/Carbon/issues",
"source": "https://github.com/briannesbitt/Carbon"
},
"funding": [
{
"url": "https://github.com/sponsors/kylekatarnls",
"type": "github"
},
{
"url": "https://opencollective.com/Carbon#sponsor",
"type": "opencollective"
},
{
"url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme",
"type": "tidelift"
}
],
"time": "2024-11-07T17:46:48+00:00"
},
{
"name": "phpdocumentor/reflection-common",
"version": "2.2.0",
@@ -1905,6 +2153,59 @@
},
"time": "2019-01-08T18:20:26+00:00"
},
{
"name": "psr/http-message",
"version": "2.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP messages",
"homepage": "https://github.com/php-fig/http-message",
"keywords": [
"http",
"http-message",
"psr",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-message/tree/2.0"
},
"time": "2023-04-04T09:54:51+00:00"
},
{
"name": "psr/link",
"version": "2.0.1",
@@ -5271,6 +5572,85 @@
],
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/polyfill-uuid",
"version": "v1.31.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-uuid.git",
"reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2",
"reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"provide": {
"ext-uuid": "*"
},
"suggest": {
"ext-uuid": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"name": "symfony/polyfill",
"url": "https://github.com/symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Uuid\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Grégoire Pineau",
"email": "[email protected]"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for uuid functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"uuid"
],
"support": {
"source": "https://github.com/symfony/polyfill-uuid/tree/v1.31.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/process",
"version": "v6.4.15",
@@ -6773,59 +7153,34 @@
"time": "2024-09-25T14:18:03+00:00"
},
{
"name": "symfony/ux-turbo",
"version": "v2.21.0",
"name": "symfony/uid",
"version": "v6.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/ux-turbo.git",
"reference": "075c609e54fc421c6b1c1974e46e9a8b2d44277c"
"url": "https://github.com/symfony/uid.git",
"reference": "18eb207f0436a993fffbdd811b5b8fa35fa5e007"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ux-turbo/zipball/075c609e54fc421c6b1c1974e46e9a8b2d44277c",
"reference": "075c609e54fc421c6b1c1974e46e9a8b2d44277c",
"url": "https://api.github.com/repos/symfony/uid/zipball/18eb207f0436a993fffbdd811b5b8fa35fa5e007",
"reference": "18eb207f0436a993fffbdd811b5b8fa35fa5e007",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/stimulus-bundle": "^2.9.1"
},
"conflict": {
"symfony/flex": "<1.13"
"symfony/polyfill-uuid": "^1.15"
},
"require-dev": {
"dbrekelmans/bdi": "dev-main",
"doctrine/doctrine-bundle": "^2.4.3",
"doctrine/orm": "^2.8 | 3.0",
"phpstan/phpstan": "^1.10",
"symfony/asset-mapper": "^6.4|^7.0",
"symfony/debug-bundle": "^5.4|^6.0|^7.0",
"symfony/expression-language": "^5.4|^6.0|^7.0",
"symfony/form": "^5.4|^6.0|^7.0",
"symfony/framework-bundle": "^6.4|^7.0",
"symfony/mercure-bundle": "^0.3.7",
"symfony/messenger": "^5.4|^6.0|^7.0",
"symfony/panther": "^2.1",
"symfony/phpunit-bridge": "^5.4|^6.0|^7.0",
"symfony/process": "^5.4|6.3.*|^7.0",
"symfony/property-access": "^5.4|^6.0|^7.0",
"symfony/security-core": "^5.4|^6.0|^7.0",
"symfony/stopwatch": "^5.4|^6.0|^7.0",
"symfony/twig-bundle": "^6.4|^7.0",
"symfony/ux-twig-component": "^2.21",
"symfony/web-profiler-bundle": "^5.4|^6.0|^7.0"
},
"type": "symfony-bundle",
"extra": {
"thanks": {
"name": "symfony/ux",
"url": "https://github.com/symfony/ux"
}
"symfony/console": "^5.4|^6.0|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\UX\\Turbo\\": "src/"
}
"Symfony\\Component\\Uid\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
@@ -6833,26 +7188,27 @@
],
"authors": [
{
"name": "Kévin Dunglas",
"email": "[email protected]"
"name": "Grégoire Pineau",
"email": "[email protected]"
},
{
"name": "Nicolas Grekas",
"email": "[email protected]"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Hotwire Turbo integration for Symfony",
"description": "Provides an object-oriented API to generate and represent UIDs",
"homepage": "https://symfony.com",
"keywords": [
"hotwire",
"javascript",
"mercure",
"symfony-ux",
"turbo",
"turbo-stream"
"UID",
"ulid",
"uuid"
],
"support": {
"source": "https://github.com/symfony/ux-turbo/tree/v2.21.0"
"source": "https://github.com/symfony/uid/tree/v6.4.13"
},
"funding": [
{
@@ -6868,7 +7224,7 @@
"type": "tidelift"
}
],
"time": "2024-10-21T19:07:02+00:00"
"time": "2024-09-25T14:18:03+00:00"
},
{
"name": "symfony/validator",
@@ -7358,6 +7714,70 @@
],
"time": "2024-09-01T20:39:12+00:00"
},
{
"name": "twig/intl-extra",
"version": "v3.13.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/intl-extra.git",
"reference": "1b8d78c5db08bdc61015fd55009d2e84b3aa7e38"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/intl-extra/zipball/1b8d78c5db08bdc61015fd55009d2e84b3aa7e38",
"reference": "1b8d78c5db08bdc61015fd55009d2e84b3aa7e38",
"shasum": ""
},
"require": {
"php": ">=8.0.2",
"symfony/intl": "^5.4|^6.4|^7.0",
"twig/twig": "^3.13|^4.0"
},
"require-dev": {
"symfony/phpunit-bridge": "^6.4|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Twig\\Extra\\Intl\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "[email protected]",
"homepage": "http://fabien.potencier.org",
"role": "Lead Developer"
}
],
"description": "A Twig extension for Intl",
"homepage": "https://twig.symfony.com",
"keywords": [
"intl",
"twig"
],
"support": {
"source": "https://github.com/twigphp/intl-extra/tree/v3.13.0"
},
"funding": [
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/twig/twig",
"type": "tidelift"
}
],
"time": "2024-09-03T13:08:40+00:00"
},
{
"name": "twig/twig",
"version": "v3.14.2",
@@ -9768,14 +10188,15 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"stability-flags": [],
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*"
"ext-iconv": "*",
"ext-simplexml": "*"
},
"platform-dev": {},
"platform-dev": [],
"plugin-api-version": "2.6.0"
}
+1 -2
View File
@@ -7,10 +7,9 @@ return [
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
];
+11
View File
@@ -1,6 +1,7 @@
monolog:
channels:
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
- bpn
when@dev:
monolog:
@@ -10,6 +11,11 @@ when@dev:
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
bpn:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.bpn.log"
level: debug
channels: ["bpn"]
# uncomment to get logging in your browser
# you may have to allow bigger header sizes in your Web server configuration
#firephp:
@@ -46,6 +52,11 @@ when@prod:
handler: nested
excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
bpn:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.bpn.log"
level: debug
channels: ["bpn"]
nested:
type: stream
path: php://stderr
+13 -8
View File
@@ -4,20 +4,25 @@ security:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
bpn_user_provider:
id: App\BusProNet\Security\UserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: users_in_memory
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
provider: bpn_user_provider
custom_authenticator:
App\BusProNet\Security\Authenticator
logout:
path: app_logout
target: app_login
remember_me:
secret: '%kernel.secret%'
lifetime: 604800
token_provider:
doctrine: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
+1 -1
View File
@@ -1,5 +1,5 @@
framework:
default_locale: en
default_locale: de
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
+1
View File
@@ -1,5 +1,6 @@
twig:
file_name_pattern: '*.twig'
form_themes: ['forms.html.twig']
when@test:
twig:
+4
View File
@@ -0,0 +1,4 @@
framework:
uid:
default_uuid_version: 7
time_based_uuid_version: 7
+16 -2
View File
@@ -20,5 +20,19 @@ services:
- '../src/Entity/'
- '../src/Kernel.php'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
App\BusProNet\ApiClient:
arguments:
$logger: '@monolog.logger.bpn'
$options:
bpn_username: '%env(APP_BPN_USER)%'
bpn_password: '%env(APP_BPN_PASSWORD)%'
bpn_url: '%env(APP_BPN_ENDPOINT)%'
debug: '%env(bool:APP_BPN_DEBUG)%'
App\BusProNet\DataLoader\TravelDataLoader:
arguments:
$xmlPath: '%kernel.project_dir%/var/xmlexport'
App\BusProNet\DataLoader\PickupDataLoader:
arguments:
$xmlPath: '%kernel.project_dir%/var/xmlexport'
+9 -2
View File
@@ -22,7 +22,14 @@ return [
'@symfony/stimulus-bundle' => [
'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js',
],
'@hotwired/turbo' => [
'version' => '7.3.0',
'htmx.org' => [
'version' => '2.0.3',
],
'@picocss/pico' => [
'version' => '2.0.6',
],
'@picocss/pico/css/pico.min.css' => [
'version' => '2.0.6',
'type' => 'css',
],
];
+35
View File
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Welcome!</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
<link rel="stylesheet" href="/assets/styles/app-1096e610e7245dcf120e944be588862e.css">
<script type="importmap">
{
"imports": {
"app": "/assets/app-9054f64ab2a2772c478ec2945ad30999.js",
"/assets/bootstrap.js": "/assets/bootstrap-e26ed6d3c7cb4cfb56ee7c0ce7d93cb4.js",
"/assets/styles/app.css": "data:application/javascript,",
"@symfony/stimulus-bundle": "/assets/@symfony/stimulus-bundle/loader-870999a02e9fc147c034d522826ea70d.js",
"htmx.org": "/assets/vendor/htmx.org/htmx.org.index-d0ecb202633ad81a9803dfd539c21378.js",
"@hotwired/stimulus": "/assets/vendor/@hotwired/stimulus/stimulus.index-b5b1d00e42695b8959b4a1e94e3bc92a.js",
"/assets/@symfony/stimulus-bundle/controllers.js": "/assets/@symfony/stimulus-bundle/controllers-7cbfd57985d28bce7c7110b2ca6c3224.js"
}
}
</script>
<!-- ES Module Shims: Import maps polyfill for modules browsers without import maps support -->
<script async src="https://ga.jspm.io/npm:[email protected]/dist/es-module-shims.js"></script>
<link rel="modulepreload" href="/assets/app-9054f64ab2a2772c478ec2945ad30999.js">
<link rel="modulepreload" href="/assets/bootstrap-e26ed6d3c7cb4cfb56ee7c0ce7d93cb4.js">
<link rel="modulepreload" href="/assets/@symfony/stimulus-bundle/loader-870999a02e9fc147c034d522826ea70d.js">
<link rel="modulepreload" href="/assets/vendor/htmx.org/htmx.org.index-d0ecb202633ad81a9803dfd539c21378.js">
<link rel="modulepreload" href="/assets/vendor/@hotwired/stimulus/stimulus.index-b5b1d00e42695b8959b4a1e94e3bc92a.js">
<link rel="modulepreload" href="/assets/@symfony/stimulus-bundle/controllers-7cbfd57985d28bce7c7110b2ca6c3224.js">
<script type="module">import 'app';</script> </head>
<body>
<div hx-get="https://myep-next.ddev.site" hx-select="#main" hx-trigger="load"></div>
</body>
</html>
+309
View File
@@ -0,0 +1,309 @@
<?php
namespace App\BusProNet;
use App\BusProNet\ApiResponseParser\ResponseParser;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\File;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\Form\Model\BookingData;
use Psr\Log\LoggerInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Uid\Uuid;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ApiClient
{
public const TYPE_NOTIFICATION = 'HINWEIS';
public const TYPE_CUSTOMER_DATA = 'KUNDENKONTO';
public const TYPE_BASE_DATA_COUNTRIES = 'STAMMLAENDER';
public const TYPE_MUTABLE_FIELDS = 'MOEGLICHEAENDERUNGEN';
public const TYPE_AVAILABILITY = 'VERFUEGBARKEIT';
public const TYPE_BOOKING_UPDATE = 'BUCHUNGAENDERUNG';
private array $config;
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly SerializerInterface $serializer,
private readonly ResponseParser $responseParser,
private readonly LoggerInterface $logger,
array $options
) {
$this->config = $this->resolveOptions($options);
}
/**
* @throws ApiClientException
*/
public function getPersonalData(string $email, string $password): Notification|PersonalData
{
$data = [
'anfrage' => [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Adressdaten',
'email' => $email,
'passwort' => $password,
],
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
}
/**
* @throws ApiClientException
*/
public function updatePersonalData(string $email, string $password, PersonalData $personalData): Notification|PersonalData
{
$data = [
'anfrage' => [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Adressdaten_Ändern',
'email' => $email,
'passwort' => $password,
'idadresse' => $personalData->addressId,
'adressdaten' => $personalData->toPayload(),
],
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
}
/**
* @throws ApiClientException
*/
public function getBookings(string $email, string $password): Notification|BaseData
{
$data = [
'anfrage' => [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Vorgänge',
'email' => $email,
'passwort' => $password,
],
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
}
/**
* @throws ApiClientException
*/
public function getBooking(string $email, string $password, int $id): Notification|Booking
{
$data = [
'anfrage' => [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Vorgang_Details',
'email' => $email,
'passwort' => $password,
'idbuchung' => $id,
],
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
}
public function updateBooking(string $email, string $password, BookingData $formData): Notification
{
$booking = $formData->booking;
$data = [
'anfrage' => [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING_UPDATE),
'satz' => ['@typ' => static::TYPE_BOOKING_UPDATE],
'buchungsart' => 'Buchung',
'idbuchung' => $booking->id,
...$booking->toPayload($formData),
],
];
return $this->sendRequest(static::TYPE_BOOKING_UPDATE, $data);
}
/**
* @throws ApiClientException
*/
public function getMutableFields(int $id): Notification|BaseData
{
$data = [
'anfrage' => [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_MUTABLE_FIELDS),
'satz' => ['@typ' => static::TYPE_MUTABLE_FIELDS],
'art' => 'Vorgang_Details',
'idreise' => $id,
],
];
return $this->sendRequest(static::TYPE_MUTABLE_FIELDS, $data);
}
/**
* @throws ApiClientException
*/
public function getAvailabilities(int $id): Notification|BaseData
{
$data = [
'anfrage' => [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY),
'satz' => ['@typ' => static::TYPE_AVAILABILITY],
'idreise' => $id,
],
];
return $this->sendRequest(static::TYPE_AVAILABILITY, $data);
}
/**
* @throws ApiClientException
*/
public function resetPassword(string $email): Notification
{
$data = [
'anfrage' => [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Passwort_Anfrage',
'email' => $email,
],
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
}
/**
* @throws ApiClientException
*/
public function getCrmAttributes(string $email, string $password): Notification|CrmAttributes
{
$data = [
'anfrage' => [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'SelektionCRM',
'email' => $email,
'passwort' => $password,
],
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
}
/**
* @throws ApiClientException
*/
public function getBaseData(string $type): Notification|BaseData
{
$data = [
'anfrage' => [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], $type),
'satz' => ['@typ' => $type],
],
];
return $this->sendRequest($type, $data);
}
/**
* @return Notification|File|null
* @throws ApiClientException
*/
public function getDocuments(string $email, string $password, int $id, string $type): mixed
{
$data = [
'anfrage' => [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => $type,
'email' => $email,
'passwort' => $password,
'idbuchung' => $id,
],
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
}
/**
* @throws ApiClientException
*/
private function sendRequest(string $type, array $data): mixed
{
$requestId = (string) Uuid::v7();
$body = $this
->serializer
->serialize($data, 'xml')
;
if (true === $this->config['debug']) {
$this->logger->info('Request sent', [
'id' => $requestId,
'request' => $body,
]);
}
try {
$response = $this->httpClient->request('GET', $this->config['bpn_url'], [
'query' => [
'operation' => $body,
],
'verify_peer' => false,
'verify_host' => false,
]);
$xml = $response->getContent();
if (true === $this->config['debug']) {
$this->logger->info('Response received', [
'id' => $requestId,
'response' => $xml,
]);
}
return $this->responseParser->parseXmlString($type, $xml);
} catch (\Throwable $e) {
}
$this->logger->error('API error', ['error' => $e->getMessage()]);
throw new ApiClientException($e->getMessage());
}
private function createKey(string $username, string $password, string $type): string
{
$date = (new \DateTimeImmutable())->format('Ymd');
return md5($username.$password.$date.$type);
}
private function resolveOptions(array $options): array
{
$optionsResolver = new OptionsResolver();
$optionsResolver->setRequired(['bpn_url', 'bpn_username', 'bpn_password']);
$optionsResolver->setDefaults([
'debug' => false,
]);
return $optionsResolver->resolve($options);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\BusProNet\ApiResponseParser;
use App\BusProNet\Model\Availability;
use App\BusProNet\Model\BaseData;
class AvailabilitiesResponseParser
{
use ResponseParserTrait;
public function parse(\SimpleXMLElement $xml): BaseData
{
$availabilities = [];
foreach ($xml->leistungen->leistung as $item) {
$attributes = $item->attributes();
$id = (int) $attributes['id'];
$availability = new Availability();
$availability->serviceId = $id;
$availability->status = (string) $attributes['status'];
$availability->available = (int) $attributes['frei'];
$availability->price = $this->stringToFloat((string) $attributes['preis']);
$availabilities[$id] = $availability;
}
return new BaseData($availabilities);
}
}
@@ -0,0 +1,172 @@
<?php
namespace App\BusProNet\ApiResponseParser;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Communication;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Service;
class BookingResponseParser
{
use ResponseParserTrait;
public function parse(\SimpleXMLElement $xml): Booking
{
$travelData = $xml->reise;
$booking = new Booking();
$booking->id = (int) $xml->idbuchung;
$booking->bookingNumber = (int) $xml->vorgang;
$booking->invoiceNumber = (int) $xml->zahlungsdaten->rechnung;
$booking->totalPrice = $this->stringToFloat((string) $xml->zahlungsdaten->gesamtbetrag);
$booking->status = (string) $xml->status;
$booking->travel = (string) $travelData->attributes()['bezeichnung'];
$booking->travelId = (int) $xml->idreise;
$booking->travelCode = (string) $travelData->attributes()['code'];
$booking->travelDate = $this->stringToDate((string) $travelData->attributes()['termin']);
$booking->hotelId = (int) $xml->idpartner;
$booking->hotelName = (string) $xml->partner;
$booking->applicant = $this->parsePersonalData($xml->anmelder);
$booking->participantsStatus = $this->stringToArray((string) $xml->status_teilnehmer, '/');
$booking->participants = $this->parseParticipants($xml);
if ($xml->beförderungen) {
$booking->transportationServices = $this
->parseServices($xml->beförderungen->beförderung, Service::TYPE_TRANSPORTATION);
}
if ($xml->zusatzleistungen) {
$booking->additionalServices = $this
->parseServices($xml->zusatzleistungen->zusatzleistung, Service::TYPE_ADDITIONAL);
}
if ($xml->ferienzielunterbringungen) {
$booking->rooms = $this->parseRooms($xml->ferienzielunterbringungen->ferienzielunterbringung);
}
return $booking;
}
private function parseParticipants(\SimpleXMLElement $xml): array
{
$participants = [];
foreach ($xml->teilnehmerliste->teilnehmer as $participant) {
$id = (int) $participant->attributes()['id'];
$participants[$id] = $this->parsePersonalData($participant);
}
return $participants;
}
private function parsePersonalData(\SimpleXMLElement $xml): PersonalData
{
$personalData = new PersonalData();
$personalData->addressId = $xml->idadresse ? (int) $xml->idadresse : null;
$personalData->personId = (int) $xml->idadresseperson;
$personalData->name = (string) $xml->name;
$personalData->firstName = (string) $xml->vorname;
$personalData->salutation = (string) $xml->anrede;
$personalData->title = (string) $xml->titel;
$personalData->gender = $xml->geschlecht ? strtoupper((string) $xml->geschlecht) : null;
$personalData->dateOfBirth = $xml->geburtsdatum ? $this->stringToDate((string) $xml->geburtsdatum) : null;
$personalData->nationality = (string) $xml->nationalitaet;
$personalData->height = $xml->sonstiges1 ? (int) $xml->sonstiges1 : null;
$personalData->weight = $xml->sonstiges2 ? (int) $xml->sonstiges2 : null;
$personalData->shoeSize = $xml->sonstiges3 ? (int) $xml->sonstiges3 : null;
if ($xml->anschrift) {
$address = new Address();
$address->street = (string) $xml->anschrift->strasse;
$address->postCode = (string) $xml->anschrift->plz;
$address->city = (string) $xml->anschrift->ort;
$address->district = (string) $xml->anschrift->ortsteil;
$address->country = (string) $xml->anschrift->land;
$personalData->address = $address;
}
if ($xml->kommunikation) {
$communication = new Communication();
$communication->email = (string) $xml->kommunikation->email;
$communication->phone = (string) $xml->kommunikation->telefonprivat;
$communication->mobile = (string) $xml->kommunikation->telefonmobil;
$personalData->communication = $communication;
}
return $personalData;
}
private function parseServices(\SimpleXMLElement $xml, string $type): array
{
$services = [];
foreach ($xml as $service) {
$attributes = $service->attributes();
$id = (int) $attributes['idleistung'];
$service = new Service();
$service->id = $id;
$service->label = (string) $attributes['leistung'];
$service->dateFrom = $attributes['termin'] ? $this->stringToDate((string) $attributes['termin']) : null;
$service->dateTo = $attributes['terminbis'] ? $this->stringToDate((string) $attributes['terminbis']) : null;
$service->subType = (string) $attributes['unterart'];
$service->totalCount = (int) $attributes['anzahl'];
$mapping = $this->stringToArray((string) $attributes['zuordnung']);
$service->mapping = array_map('intval', $mapping);
$service->totalPrice = $this->stringToFloat((string) $attributes['gesamtpreis']);
$individualPrices = array_map(
function ($price) {
return $this->stringToFloat((string) $price);
}, $this->stringToArray((string) $attributes['einzelpreis'], '/')
);
$service->individualPrice = $this->arrayToOneBased($individualPrices);
if (Service::TYPE_TRANSPORTATION === $type) {
$service->direction = (string) $attributes['richtung'];
}
$services[$id] = $service;
}
return $services;
}
private function parseRooms(\SimpleXMLElement $xml): array
{
$rooms = [];
foreach ($xml as $item) {
$attributes = $item->attributes();
$id = (int) $attributes['idzimmer'];
$room = new Room();
$room->id = $id;
$room->label = (string) $attributes['zimmer'];
$room->dateFrom = $attributes['termin'] ? $this->stringToDate((string) $attributes['anreise']) : null;
$room->dateTo = $attributes['terminbis'] ? $this->stringToDate((string) $attributes['abreise']) : null;
$room->totalCount = (int) $attributes['anzahl'];
$room->minPax = (int) $attributes['minpax'];
$room->maxPax = (int) $attributes['maxpax'];
$room->board = (string) $attributes['verpflegung'];
$mapping = $this->stringToArray((string) $attributes['zuordnung']);
$room->mapping = array_map('intval', $mapping);
$room->totalPrice = $this->stringToFloat((string) $attributes['gesamtpreis']);
$individualPrices = array_map(
function ($price) {
return $this->stringToFloat((string) $price);
}, $this->stringToArray((string) $attributes['einzelpreis'], '/')
);
$room->individualPrice = $this->arrayToOneBased($individualPrices);
$rooms[$id] = $room;
}
return $rooms;
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\BusProNet\ApiResponseParser;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Booking;
class BookingsResponseParser
{
use ResponseParserTrait;
public function parse(\SimpleXMLElement $xml): BaseData
{
$bookings = [];
foreach ($xml->vorgaenge->vorgang as $item) {
$booking = new Booking();
$booking->id = (int) $item->id;
$booking->bookingNumber = (int) $item->vorgangsnummer;
$booking->status = (string) $item->status;
$booking->participantCount = (int) $item->personen;
$booking->price = $this->stringToFloat((string) $item->preis);
$booking->bookingDate = $this->stringToDateTime((string) $item->buchungsdatum);
$booking->travel = (string) $item->reise;
$booking->travelDate = $this->stringToDate((string) $item->reisedatum);
$booking->document = $this->stringToBool((string) $item->reisedokument);
$booking->payment = $this->stringToFloat((string) $item->zahlung);
$bookings[] = $booking;
}
return new BaseData($bookings);
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\BusProNet\ApiResponseParser;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Country;
class CountriesResponseParser
{
public function parse(\SimpleXMLElement $xml): BaseData
{
$countries = [];
foreach ($xml->laender->land as $item) {
$itemAttributes = $item->attributes();
$token = (string) $itemAttributes['kuerzel'];
$country = new Country();
$country->id = (int) $itemAttributes['id'];
$country->name = (string) $itemAttributes['bezeichnung'];
$country->token = (string) $itemAttributes['kuerzel'];
$country->nationality = (string) $itemAttributes['nationalitaet'];
$countries[$token] = $country;
}
return new BaseData($countries);
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\BusProNet\ApiResponseParser;
use App\BusProNet\Model\CrmAttribute;
use App\BusProNet\Model\CrmAttributeGroup;
use App\BusProNet\Model\CrmAttributes;
class CrmAttributesResponseParser
{
use ResponseParserTrait;
private const BPN_CRM_ID_ADMIN = 1292;
private const BPN_CRM_ID_MANAGER = 1293;
private const BPN_CRM_ID_TEAMER = 1070;
public function parse(\SimpleXMLElement $xml): CrmAttributes
{
$groups = [];
$isAdmin = $isManager = $isHouseManager = $isTeamer = false;
$hotelCode = null;
foreach ($xml->selektionsmerkmale->selektionsgruppe as $item) {
$group = new CrmAttributeGroup();
$group->label = (string) $item->attributes()['bezeichnung'];
$attributes = [];
foreach ($item->selektion as $subItem) {
$subItemAttributes = $subItem->attributes();
$attributeLabel = (string) $subItemAttributes['bezeichnung'];
$attribute = new CrmAttribute();
$attribute->id = (int) $subItemAttributes['id'];
$attribute->label = $attributeLabel;
$attribute->selected = $this->stringToBool((string) $subItemAttributes['auswahl']);
if (1 === preg_match('/^Hausleitung ([A-Z0-9]+)$/', $attributeLabel, $matches) && true === $attribute->selected) {
$isHouseManager = true;
$hotelCode = $matches[1];
}
if (static::BPN_CRM_ID_ADMIN === $attribute->id && true === $attribute->selected) {
$isAdmin = true;
}
if (static::BPN_CRM_ID_MANAGER === $attribute->id && true === $attribute->selected) {
$isManager = true;
}
if (static::BPN_CRM_ID_TEAMER === $attribute->id && true === $attribute->selected) {
$isTeamer = true;
}
$attributes[] = $attribute;
}
$group->attributes = $attributes;
$groups[] = $group;
}
$response = new CrmAttributes();
$response->attributeGroups = $groups;
$response->admin = $isAdmin;
$response->manager = $isManager;
$response->houseManager = $isHouseManager;
$response->teamer = $isTeamer;
$response->hotelCode = $hotelCode;
return $response;
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\BusProNet\ApiResponseParser;
use App\BusProNet\Model\File;
use PhpZip\Exception\ZipException;
use PhpZip\ZipFile;
class DocumentsResponseParser
{
public function parseConfirmation(\SimpleXMLElement $xml): ?File
{
$item = (string) $xml->bestaetigungpdf;
[, $pdfData] = explode(',', $item);
return new File('bestaetigung.pdf', base64_decode($pdfData), 'application/pdf');
}
public function parseDocuments(\SimpleXMLElement $xml): ?File
{
if (1 === count($xml->reisedokumente->reisedokument)) {
$item = $xml->reisedokumente->reisedokument[0];
[, $pdfData] = explode(',', $item->pdf);
return new File((string) $item->datei.'.pdf', base64_decode($pdfData), 'application/pdf');
}
// In case of multiple documents create ZIP file
try {
$zip = new ZipFile();
foreach ($xml->reisedokumente->reisedokument as $item) {
[, $pdfData] = explode(',', $item->pdf);
$zip->addFromString($item->datei.'.pdf', base64_decode($pdfData));
}
return new File('reisedokumente.zip', $zip->outputAsString(), 'application/zip');
}
catch (ZipException $e) {
return null;
}
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\BusProNet\ApiResponseParser;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\MutableField;
class MutableFieldsResponseParser
{
use ResponseParserTrait;
public function parse(\SimpleXMLElement $xml): BaseData
{
$mutableFields = [];
foreach ($xml->änderungen->änderung as $item) {
$attributes = $item->attributes();
$type = match ((string) $attributes['art']) {
'anzahl_teilnehmer' => 'participant_count',
'beförderung' => 'transportation',
'zustieg' => 'pickup',
'unterbringung' => 'accommodation',
'zusatzleistung' => 'services',
'teilnehmerdaten' => 'participant_data',
default => false,
};
if (false === $type) {
continue;
}
$mutableField = new MutableField($type, $this->stringToBool((string) $attributes['möglich']));
if ($attributes['möglichbiszum']) {
$mutableField->mutableBefore = $this->stringToDate((string) $attributes['möglichbiszum']);
}
$mutableFields[$type] = $mutableField;
}
return new BaseData($mutableFields);
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\BusProNet\ApiResponseParser;
use App\BusProNet\Model\Notification;
class NotificationResponseParser
{
public function parse(\SimpleXMLElement $xml): Notification
{
$recordXml = $xml->satz;
return new Notification((int) $recordXml->nr, (string) $recordXml->text);
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\BusProNet\ApiResponseParser;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Communication;
use App\BusProNet\Model\PersonalData;
class PersonalDataResponseParser
{
public function parse(\SimpleXMLElement $xml): PersonalData
{
$addressXml = $xml->adressdaten;
$dateString = (string) $addressXml->geburtsdatum;
$dateOfBirth = empty($dateString) ? null : \DateTimeImmutable::createFromFormat('d.m.Y', $dateString);
$personalData = new PersonalData();
$personalData->addressId = (int) $xml->idadresse;
$personalData->personId = (int) $xml->idperson;
$personalData->firstName = (string) $addressXml->vorname;
$personalData->name = (string) $addressXml->name;
$personalData->salutation = (string) $addressXml->anrede;
$personalData->title = (string) $addressXml->titel;
$personalData->gender = strtoupper((string) $addressXml->geschlecht);
$personalData->dateOfBirth = $dateOfBirth;
$personalData->height = $xml->sonstiges1 ? (int) $xml->sonstiges1 : null;
$personalData->weight = $xml->sonstiges2 ? (int) $xml->sonstiges2 : null;
$personalData->shoeSize = $xml->sonstiges3 ? (int) $xml->sonstiges3 : null;
$postalXml = $addressXml->anschrift;
$address = new Address();
$address->street = (string) $postalXml->strasse;
$address->postCode = (string) $postalXml->plz;
$address->city = (string) $postalXml->ort;
$address->country = (string) $postalXml->land;
$personalData->address = $address;
$contactXml = $addressXml->kommunikation;
$communication = new Communication();
$communication->phone = (string) $contactXml->telefonprivat;
$communication->mobile = (string) $contactXml->telefonmobil;
$communication->email = (string) $contactXml->email;
$personalData->communication = $communication;
return $personalData;
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\BusProNet\ApiResponseParser;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ResponseParserException;
class ResponseParser
{
/**
* @throws ResponseParserException
*/
public function parseXmlString(string $type, string $content): mixed
{
$xml = simplexml_load_string($content);
// Override type when present in XML to catch error responses
$responseType = $type;
$responseTypeXml = $xml->xpath('satz/@typ');
if (0 < count($responseTypeXml)) {
$responseType = (string) $xml->xpath('satz/@typ')[0];
}
switch ($responseType) {
case ApiClient::TYPE_NOTIFICATION:
return (new NotificationResponseParser())->parse($xml);
case ApiClient::TYPE_CUSTOMER_DATA:
$subType = (string) $xml->art;
switch ($subType) {
case 'Adressdaten':
case 'Adressdaten_Ändern':
return (new PersonalDataResponseParser())->parse($xml);
case 'SelektionCRM':
case 'SelektionCRM_Ändern':
return (new CrmAttributesResponseParser())->parse($xml);
case 'Vorgänge':
return (new BookingsResponseParser())->parse($xml);
case 'Vorgang_Details':
return (new BookingResponseParser())->parse($xml);
case 'Dokumentdruck':
return (new DocumentsResponseParser())->parseDocuments($xml);
case 'Vorgangdruck':
return (new DocumentsResponseParser())->parseConfirmation($xml);
}
break;
case ApiClient::TYPE_BASE_DATA_COUNTRIES:
return (new CountriesResponseParser())->parse($xml);
case ApiClient::TYPE_MUTABLE_FIELDS:
return (new MutableFieldsResponseParser())->parse($xml);
case ApiClient::TYPE_AVAILABILITY:
return (new AvailabilitiesResponseParser())->parse($xml);
}
throw new ResponseParserException('Unable to parse XML response');
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\BusProNet\ApiResponseParser;
use Carbon\Carbon;
use Carbon\Exceptions\InvalidFormatException;
trait ResponseParserTrait
{
private function arrayToOneBased(array $data): array
{
$keys = range(1, count($data));
return array_combine($keys, $data);
}
protected function stringToArray(string $string, string $separator = ','): array
{
return explode($separator, $string);
}
protected function stringToFloat(string $string): float
{
return (float) str_replace(['.', ','], ['', '.'], $string);
}
protected function stringToBool(string $string): bool
{
return 'True' === $string;
}
protected function stringToDate(string $string): ?\DateTimeImmutable
{
try {
$date = Carbon::createFromFormat('d.m.Y', $string)->startOfDay();
return $date->toDateTimeImmutable();
} catch (InvalidFormatException $e) {
return null;
}
}
protected function stringToDateTime(string $string): ?\DateTimeImmutable
{
try {
$dateTime = Carbon::createFromFormat('d.m.Y H:i', substr($string, 0, 16))->startOfDay();
return $dateTime->toDateTimeImmutable();
} catch (InvalidFormatException $e) {
return null;
}
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\BusProNet\DataLoader;
use App\BusProNet\ApiResponseParser\ResponseParserTrait;
use Symfony\Contracts\Cache\CacheInterface;
abstract class AbstractDataLoader
{
use ResponseParserTrait;
public function __construct(protected readonly CacheInterface $cache, protected readonly ?string $xmlPath = null)
{
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\BusProNet\DataLoader;
use App\BusProNet\Model\Hotel;
use Psr\Cache\InvalidArgumentException;
use Symfony\Contracts\Cache\ItemInterface;
class HotelDataLoader extends AbstractDataLoader
{
public function loadAll(?string $filename = 'hotel.xml'): array
{
try {
return $this->cache->get('bpn_hotels', function (ItemInterface $item) use ($filename) {
$item->expiresAfter(3600);
$xml = $this->loadXml($filename);
$hotels = [];
foreach ($xml->hotel as $hotel) {
$id = (int)$hotel->attributes()['idbuspro'];
$hotels[$id] = $this->parseXml($hotel);
}
return $hotels;
});
} catch (InvalidArgumentException $e) {
return [];
}
}
public function loadById(int $id, ?string $filename = 'hotel.xml'): ?Hotel
{
$hotels = $this->loadAll($filename);
return $hotels[$id] ?? null;
}
public function loadByCode(string $code, ?string $filename = 'hotel.xml'): ?Hotel
{
$hotels = $this->loadAll($filename);
foreach ($hotels as $hotel) {
if ($code === $hotel->code) {
return $hotel;
}
}
return null;
}
private function loadXml(?string $filename = 'hotel.xml'): \SimpleXMLElement
{
$filepath = $this->xmlPath.'/'.$filename;
return simplexml_load_file($filepath);
}
public function parseXml(\SimpleXMLElement $xml): Hotel
{
$attributes = $xml->attributes();
$hotel = new Hotel();
$hotel->id = (int) $attributes['idbuspro'];
$hotel->code = (string) $attributes['code'];
$hotel->name = (string) $xml->name;
$hotel->city = $xml->ort ? (string) $xml->ort : null;
$hotel->country = (string) $xml->land;
$hotel->street = (string) $xml->strasse;
$hotel->phone = $xml->telefon ? (string) $xml->telefon : null;
return $hotel;
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\BusProNet\DataLoader;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Travel;
use Psr\Cache\InvalidArgumentException;
use Symfony\Contracts\Cache\ItemInterface;
class PickupDataLoader extends AbstractDataLoader
{
public function loadAll(?string $filename = 'zustiege.xml'): array
{
try {
return $this->cache->get('bpn_pickups', function (ItemInterface $item) use ($filename) {
$item->expiresAfter(3600);
$xml = simplexml_load_file($this->xmlPath . '/' . $filename);
$pickups = [];
foreach ($xml->zustieg as $pickup) {
$id = (int)$pickup->attributes()['idbuspro'];
$pickups[$id] = $this->parseXml($pickup);
}
return $pickups;
});
} catch (InvalidArgumentException $e) {
return [];
}
}
public function loadById(int $id, ?string $filename = 'zustiege.xml'): ?Pickup
{
$pickups = $this->loadAll($filename);
return $pickups[$id] ?? null;
}
public function parseXml(\SimpleXMLElement $xml): Pickup
{
$attributes = $xml->attributes();
$pickup = new Pickup();
$pickup->id = (int) $attributes['idbuspro'];
$pickup->code = (string) $attributes['code'];
$pickup->city = (string) $xml->ort;
$pickup->postalCode = (string) $xml->plz;
$pickup->street = (string) $xml->strasse;
return $pickup;
}
public function enrichPickupsData(Travel $travel): void
{
foreach ($travel->pickups as $pickupId => $pickup) {
$pickupData = $this->loadById($pickupId);
$pickup->code = $pickupData->code;
$pickup->postalCode = $pickupData->postalCode;
$pickup->city = $pickupData->city;
$pickup->street = $pickupData->street;
}
}
}
@@ -0,0 +1,194 @@
<?php
namespace App\BusProNet\DataLoader;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\CrmAttribute;
use App\BusProNet\Model\CrmAttributeGroup;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use Symfony\Component\Finder\Finder;
class TravelDataLoader extends AbstractDataLoader
{
public function loadById(int $id, ?string $filename = null): ?Travel
{
if (null !== $filename) {
return $this->loadXml($id, $filename);
}
$finder = new Finder();
$finder->files()->in($this->xmlPath)->name('Ziel_*.xml');
foreach ($finder as $file) {
if (null !== $xml = $this->loadXml($id, $file->getRealPath())) {
return $xml;
}
}
return null;
}
private function loadXml(int $id, string $filename): ?Travel
{
$xml = simplexml_load_file($filename);
foreach ($xml->reise->termin as $travel) {
if ($id === (int) $travel->attributes()['idbuspro']) {
return $this->parseXml($travel);
}
}
return null;
}
public function parseXml(\SimpleXMLElement $xml): Travel
{
$attributes = $xml->attributes();
$travel = new Travel();
$travel->id = (int) $attributes['idbuspro'];
$travel->label = (string) $xml->text;
$travel->dateFrom = $this->stringToDate((string) $attributes['termin']);
$travel->dateTo = $this->stringToDate((string) $attributes['bis']);
$travel->code = (string) $attributes['code'];
$travel->type = (string) $attributes['reiseart'];
$travel->priceFrom = $this->stringToFloat((string) $xml->abpreis);
$travel->selectionGroups = $this->getSelectionGroups($xml);
$travel->additionalServices = $this->getAdditionalServices($xml);
$travel->transportationServices = $this->getTransportationServices($xml);
$travel->rooms = $this->getRooms($xml);
$travel->pickups = $this->getPickups($xml);
return $travel;
}
public function getSelectionGroups(\SimpleXMLElement $xml): array
{
$selectionGroups = [];
foreach ($xml->selektiongruppe as $item) {
$groupId = (int) $item->attributes()['idbuspro'];
$selectionGroup = new CrmAttributeGroup();
$selectionGroup->id = $groupId;
$selectionGroup->label = (string) $item->attributes()['bezeichnung'];
foreach ($item->selektion as $subItem) {
$selectionId = (int) $subItem->attributes()['idbuspro'];
$selection = new CrmAttribute();
$selection->id = $selectionId;
$selection->label = (string) $subItem->attributes()['bezeichnung'];
$selectionGroups[$groupId]['selections'][$selectionId] = (string) $subItem->attributes()['bezeichnung'];
$selectionGroup->attributes[] = $selection;
}
$selectionGroups[$groupId] = $selectionGroup;
}
return $selectionGroups;
}
public function getAdditionalServices(\SimpleXMLElement $xml): array
{
$additionalServices = [];
foreach ($xml->lei_sonstiges->leistung as $item) {
$attributes = $item->attributes();
$serviceId = (int) $attributes['idbuspro'];
$service = new Service();
$service->id = $serviceId;
$service->subType = (string) $attributes['unterart'];
$service->mandatory = $this->stringToBool((string) $attributes['pflicht']);
$service->dateFrom = $this->stringToDate((string) $attributes['termin']);
$service->dateTo = $this->stringToDate((string) $attributes['bis']);
$service->label = (string) $item->text;
$service->price = $this->stringToFloat((string) $item->preis);
$service->status = (string) $item->status;
$additionalServices[$serviceId] = $service;
}
return $additionalServices;
}
public function getTransportationServices(\SimpleXMLElement $xml): array
{
$transportationServices = [];
foreach ($xml->lei_befoerderung->leistung as $item) {
$attributes = $item->attributes();
$serviceId = (int) $attributes['idbuspro'];
$service = new Service();
$service->id = $serviceId;
$service->subType = (string) $attributes['unterart'];
$service->dateFrom = $this->stringToDate((string) $attributes['termin']);
$service->dateTo = $this->stringToDate((string) $attributes['bis']);
$service->label = (string) $item->text;
$service->price = $this->stringToFloat((string) $item->preis);
$service->direction = (string) $item->richtung;
$transportationServices[$serviceId] = $service;
}
return $transportationServices;
}
public function getPickups(\SimpleXMLElement $xml): array
{
$pickups = [];
foreach ($xml->zustiege->zustieg as $item) {
$attributes = $item->attributes();
$pickupId = (int) $attributes['idbuspro'];
$pickup = new Pickup();
$pickup->id = $pickupId;
$pickup->time = $this->stringToDateTime((string) $attributes['zeit']);
$pickup->price = $attributes['preis'] ? $this->stringToFloat((string) $attributes['preis']) : null;
$pickups[$pickupId] = $pickup;
}
return $pickups;
}
public function getRooms(\SimpleXMLElement $xml): array
{
$rooms = [];
foreach ($xml->hotel->zimmer->preis as $item) {
$attributes = $item->attributes();
$roomId = (int) $attributes['idbuspro_zimmer'];
$room = new Room();
$room->id = $roomId;
$room->code = (string) $item->attributes()['zimmercode'];
$room->label = (string) $item->attributes()['zimmertext'];
$room->minPax = (int) $item->attributes()['MinPax'];
$room->maxPax = (int) $item->attributes()['MaxPax'];
$room->nights = (int) $item->attributes()['naechte'];
$room->price = $attributes['preis'] ? $this->stringToFloat((string) $attributes['preis']) : null;
$room->status = (string) $item->status;
$room->available = (int) $item->attributes()['verfuegbar'];
$rooms[$roomId] = $room;
}
return $rooms;
}
public function enrichServicesData(Travel $travel, BaseData $availabilities): void
{
$serviceAvailabilities = $availabilities->getItems();
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
if (array_key_exists($service->id, $serviceAvailabilities)) {
$service->available = $serviceAvailabilities[$service->id]->available;
}
}
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\BusProNet\DataProvider;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Country;
use Psr\Cache\InvalidArgumentException;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class CountryDataProvider
{
public function __construct(private readonly ApiClient $apiClient, private readonly CacheInterface $cache)
{}
public function getAll(): array
{
try {
return $this->cache->get('bpn_countries', function (ItemInterface $item) {
$item->expiresAfter(3600);
return $this
->apiClient
->getBaseData(ApiClient::TYPE_BASE_DATA_COUNTRIES)
->getItems()
;
});
} catch (InvalidArgumentException $e) {
return [];
}
}
public function get(?string $token): ?Country
{
if (null === $token) {
return null;
}
return $this->getAll()[$token] ?? null;
}
}
@@ -0,0 +1,7 @@
<?php
namespace App\BusProNet\Exception;
class ApiClientException extends \Exception
{
}
@@ -0,0 +1,7 @@
<?php
namespace App\BusProNet\Exception;
class ResponseParserException extends \Exception
{
}
@@ -0,0 +1,40 @@
<?php
namespace App\BusProNet\Form\ChoiceLoader;
use App\BusProNet\DataProvider\CountryDataProvider;
use App\BusProNet\Model\Country;
use Symfony\Component\Form\ChoiceList\ArrayChoiceList;
use Symfony\Component\Form\ChoiceList\ChoiceListInterface;
use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface;
class CountryChoiceLoader implements ChoiceLoaderInterface
{
public function __construct(private readonly CountryDataProvider $countries, private readonly string $property)
{}
public function loadChoiceList(callable $value = null): ChoiceListInterface
{
$choices = [];
/** @var Country[] $countries */
$countries = $this->countries->getAll();
foreach ($countries as $country) {
$key = 'nationality' === $this->property ? $country->nationality : $country->name;
$choices[$key] = $country->token;
}
return new ArrayChoiceList($choices);
}
public function loadChoicesForValues(array $values, callable $value = null): array
{
return $values;
}
public function loadValuesForChoices(array $choices, callable $value = null): array
{
return $choices;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\BusProNet\Form;
use App\BusProNet\DataProvider\CountryDataProvider;
use App\BusProNet\Form\ChoiceLoader\CountryChoiceLoader;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\ChoiceList\ChoiceList;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CountryType extends AbstractType
{
public function __construct(private readonly CountryDataProvider $countries)
{}
public function getParent(): string
{
return ChoiceType::class;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefined(['property']);
$resolver->setAllowedValues('property', ['country', 'nationality']);
$resolver->setDefaults([
'property' => 'country',
'choice_loader' => function (Options $options) {
return ChoiceList::loader(
$this,
new CountryChoiceLoader($this->countries, $options['property']),
[$options['property']]
);
},
]);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\BusProNet\Model;
class Address
{
public ?string $street = null;
public ?string $postCode = null;
public ?string $city = null;
public ?string $district = null;
public ?string $country = null;
public function toPayload(): array
{
return [
'strasse' => $this->street,
'plz' => $this->postCode,
'ort' => $this->city,
'ortsteil' => $this->district,
'land' => $this->country,
];
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\BusProNet\Model;
class Availability
{
public ?int $serviceId = null;
public ?string $status = null;
public ?int $available = null;
public ?float $price = null;
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\BusProNet\Model;
class BaseData
{
public function __construct(private readonly array $items)
{}
public function getItems(): array
{
return $this->items;
}
}
+180
View File
@@ -0,0 +1,180 @@
<?php
namespace App\BusProNet\Model;
use App\Form\Model\BookingData;
class Booking
{
public ?int $id = null;
public ?int $bookingNumber = null;
public ?Travel $travelData = null;
public ?string $status = null;
public ?PersonalData $applicant = null;
public ?int $participantCount = null;
public ?float $price = null;
public ?\DateTimeImmutable $bookingDate = null;
public ?string $travel = null;
public ?int $travelId = null;
public ?string $travelCode = null;
public ?\DateTimeImmutable $travelDate = null;
public ?int $hotelId = null;
public ?string $hotelName = null;
public ?bool $document = null;
public ?float $payment = null;
public array $participantsStatus = [];
public array $participants = [];
public array $transportationServices = [];
public array $additionalServices = [];
public array $rooms = [];
public ?int $invoiceNumber = null;
public ?float $totalPrice = null;
public function getBalance(): float
{
if (null === $this->payment) {
return $this->price;
}
return $this->price - $this->payment;
}
public function getAdditionalServicesByGroup(mixed $group): array
{
$group = (array) $group;
return array_filter($this->additionalServices, function (Service $service) use ($group) {
return in_array($service->subType, $group);
});
}
public function getAdditionalServicesForParticipantByGroup(int $participantIndex, mixed $group): array
{
$services = $this->getAdditionalServicesByGroup($group);
return array_filter($services, function (Service $service) use ($participantIndex) {
return in_array($participantIndex, $service->mapping);
});
}
public function getTransportationServiceForParticipantAndDirection(int $participantIndex, string $direction): ?Service
{
foreach ($this->transportationServices as $service) {
if ($service->direction !== $direction) {
continue;
}
if (in_array($participantIndex, $service->mapping)) {
return $service;
}
}
return null;
}
public function getRoomForParticipant(int $participantIndex): ?Room
{
foreach ($this->rooms as $room) {
if (in_array($participantIndex, $room->mapping)) {
return $room;
}
return null;
}
}
public function getPriceForParticipant(int $participantIndex): float
{
$price = 0.0;
foreach ([...$this->transportationServices, ...$this->additionalServices] as $service) {
if (in_array($participantIndex, $service->mapping) && isset($service->individualPrice[$participantIndex])) {
$price += $service->individualPrice[$participantIndex];
} elseif ($service->price) {
$price += $service->price;
}
}
$room = $this->getRoomForParticipant($participantIndex);
if (isset($room->individualPrice[$participantIndex])) {
$price += $room->individualPrice[$participantIndex];
} elseif ($room->price) {
$price += $room->price;
}
return $price;
}
public function isEditable(): bool
{
return $this->travelDate > new \DateTimeImmutable() && false === in_array($this->status, ['S', 'U']);
}
public function toPayload(?BookingData $formData): array
{
// Reset services to participants mappings
foreach ([...$this->additionalServices, ...$this->transportationServices] as $service) {
$service->mapping = [];
}
// Update mappings and add previously unselected services
foreach ($formData->participants as $participant) {
$servicesToMap = [
...$participant->courses,
...$participant->additionalServices,
...$participant->skiPass,
...$participant->board,
...$participant->rentals,
];
foreach ($servicesToMap as $service) {
if (false === isset($this->additionalServices[$service->id])) {
$serviceToAdd = $this->travelData->additionalServices[$service->id];
$this->additionalServices[$service->id] = $serviceToAdd;
$this->additionalServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
}
$this->additionalServices[$service->id]->mapping[] = $participant->index;
}
foreach ([$participant->transportationServiceTo, $participant->transportationServiceFro] as $service) {
if (false === isset($this->transportationServices[$service->id])) {
$serviceToAdd = $this->travelData->transportationServices[$service->id];
$this->transportationServices[$service->id] = $serviceToAdd;
$this->transportationServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
}
$this->transportationServices[$service->id]->mapping[] = $participant->index;
}
}
// Remove services with empty mappings
foreach ($this->additionalServices as $service) {
if (0 === count($service->mapping)) {
unset($this->additionalServices[$service->id]);
}
}
foreach ($this->transportationServices as $service) {
if (0 === count($service->mapping)) {
unset($this->transportationServices[$service->id]);
}
}
// Update participants' personal data
foreach ($formData->participants as $participant) {
$this->participants[$participant->index]->firstName = $participant->firstName;
$this->participants[$participant->index]->lastName = $participant->lastName;
$this->participants[$participant->index]->dateOfBirth = $participant->dateOfBirth;
$this->participants[$participant->index]->gender = $participant->gender;
$this->participants[$participant->index]->nationality = $participant->nationality;
$this->participants[$participant->index]->height = $participant->height;
$this->participants[$participant->index]->weight = $participant->weight;
$this->participants[$participant->index]->shoeSize = $participant->shoeSize;
$this->participants[$participant->index]->communication->email = $participant->email;
$this->participants[$participant->index]->communication->mobile = $participant->mobile;
}
return [
'status' => $this->status,
'idreise' => $this->travelId,
'anmelder' => $this->applicant->toPayload(),
'teilnehmerliste' => [],
'beförderungen' => [],
'unterbringungen' => [],
'zusatzleistungen' => [],
'zustiege' => [],
];
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\BusProNet\Model;
class Communication
{
public ?string $phone = null;
public ?string $mobile = null;
public ?string $email = null;
public function toPayload(): array
{
return [
'email' => $this->email,
'telefonmobil' => $this->mobile,
'telefonprivat' => $this->phone,
];
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\BusProNet\Model;
class Country
{
public ?int $id = null;
public ?string $name = null;
public ?string $token = null;
public ?string $nationality = null;
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\BusProNet\Model;
class CrmAttribute
{
public ?int $id = null;
public ?string $label = null;
public bool $selected = false;
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\BusProNet\Model;
class CrmAttributeGroup
{
public ?int $id = null;
public ?string $label = null;
public ?array $attributes = null;
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\BusProNet\Model;
class CrmAttributes
{
public ?array $attributeGroups = null;
public bool $admin = false;
public bool $manager = false;
public bool $houseManager = false;
public bool $teamer = false;
public ?string $hotelCode = null;
public function toArray(): array
{
$crmSelections = [];
foreach ($this->attributeGroups as $group) {
/** @var CrmAttributeGroup $group */
if (false === isset($crmSelections[$group->label])) {
$crmSelections[$group->label] = [];
}
foreach ($group->attributes as $attribute) {
/** @var CrmAttribute $attribute */
if (false === $attribute->selected) {
continue;
}
$crmSelections[$group->label][] = $attribute->label;
}
}
return $crmSelections;
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
namespace App\BusProNet\Model;
class Error
{
private static array $errors = [
100 => 'Anfrageknoten fehlt',
101 => 'Satzknoten fehlt in Anfrageknoten',
102 => 'Ungültiger Satztyp [#1#]',
103 => 'User fehlt',
104 => 'User fehlerhaft',
105 => 'Key fehlt',
106 => 'Key fehlerhaft',
200 => 'Keine Einträge vorhanden',
201 => 'Keine Partner zur Auswahl gefunden',
202 => 'Keinen Partner mit der ID [#1#] gefunden',
203 => 'Ungültiger Termin',
300 => 'IDReise fehlt',
301 => 'Reise nicht gefunden',
302 => 'IDLeistung (Hin & Rück) fehlt',
303 => 'IDLeistung passt nicht zu IDReise',
304 => 'Es konnte nicht für alle Teilnehmer ein Sitzplatz ermittelt werden',
305 => 'Anzahl Personen fehlt',
306 => 'Leistungen mit ID #1# nicht gefunden',
307 => 'Leistungen mit ID #1# passen nicht zur Reise mit ID #2#',
308 => 'Keine Zahlungsarten gefunden',
400 => 'IDPartner fehlt',
401 => 'Partner nicht gefunden',
402 => 'Bis-Termin fehlt',
403 => 'Keine Unterbringungsleistungen gefunden',
500 => 'Art fehlt',
501 => 'Reise- und Buchungszeitraum fehlen',
502 => 'Keine Kunden gefunden',
600 => 'Keine Gutscheine gefunden',
601 => 'Gutschein-IDs fehlen',
602 => 'Gutscheine mit ID #1# nicht gefunden',
610 => 'Buchungsart fehlt',
611 => 'Buchungsart falsch',
612 => 'Gutscheinart fehlt',
613 => 'Gutscheinart falsch',
614 => 'Gutscheinstamm-ID fehlt',
615 => 'Gutschein (Stamm) mit ID #1# nicht gefunden',
616 => 'Kapazität beim Gutschein #1# nicht ausreichend',
617 => 'Rechnungsempfänger fehlt',
618 => 'Zahlungsart fehlt',
619 => 'Agentur-ID fehlt',
620 => 'Agentur mit ID #1# nicht gefunden',
650 => '#1#',
700 => 'Keine Agenturen gefunden',
701 => 'Agentur mit ID #1# nicht gefunden',
800 => 'Produkt-ID fehlt',
801 => 'Produkt mit ID #1# nicht gefunden',
805 => 'Es wurden keine Produkte gefunden',
810 => 'Stammdaten (#1#) nicht gefunden',
900 => 'Buchungsart fehlt',
901 => 'Buchungsart falsch',
902 => 'Status fehlt',
903 => 'Status falsch',
904 => 'Agentur-ID fehlt',
905 => 'Agentur mit ID #1# nicht gefunden',
906 => 'Reise-ID fehlt',
907 => 'Reise mit ID #1# nicht gefunden',
908 => 'Reise mit ID #1# ist storniert',
909 => 'Beförderungen fehlen',
910 => 'Leistung mit ID #1# gehört nicht zur Reise',
911 => 'Leistung mit ID #1# gehört nicht zum Produkt',
912 => 'Beförderungsleistung für die #1# fehlt',
913 => 'Unterbringungen fehlen',
914 => 'Partner-ID fehlt',
915 => 'Partner mit ID #1# nicht gefunden',
916 => 'Ferienziel-Unterbringungen fehlen',
917 => 'Ferienziel: Zimmer-IDZ fehlt',
918 => 'Ferienziel: Kategorie fehlt',
919 => 'Ferienziel: Verpflegungs-ID fehlt',
920 => 'Ferienziel: Anreise fehlt',
921 => 'Ferienziel: Anreise passt nicht zur Beförderungsleistung (Hinfahrt)',
922 => 'Ferienziel: Abreise fehlt',
923 => 'Ferienziel: Abreise passt nicht zur Beförderungsleistung (Rückfahrt)',
924 => 'Ferienziel: Keine Preise zu den Daten gefunden',
925 => 'Ferienziel: Preis zu den Daten nicht gefunden',
930 => 'Zustiege fehlen',
931 => 'Zustieg mit ID #1# nicht gefunden',
932 => 'Zustieg mit ID #1# bei Leistung #2# nicht freigegeben',
933 => 'Sitzplan mit ID #1# bei Leistung #2# nicht freigegeben',
935 => 'Versicherung mit ID #1# nicht gefunden',
940 => 'Zahlungsart fehlt',
941 => 'Zahlungsart-ID fehlt',
942 => 'Zahlungsart mit ID #1# nicht gültig',
950 => 'Anmelder fehlt',
951 => 'Teilnehmerliste fehlt',
952 => 'Teilnehmer-ID fehlt',
960 => 'Preisfehler: Struct #1# / Obj #2#',
961 => 'Buchung konnte nicht gespeichert werden',
980 => 'Reise ist fürs Internet gesperrt',
981 => 'Reise ist nicht mehr buchbar (#1#)',
982 => 'Optionsbuchungen nicht zugelassen',
983 => 'Anfragebuchung nicht zugelassen',
984 => 'Leistung mit ID #1# nicht fürs Internet buchbar',
985 => 'Zustieg mit ID #1# nicht fürs Internet buchbar',
999 => 'Systemfehler: #1#',
];
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\BusProNet\Model;
class File
{
public function __construct(string $filename, string $content, string $mimeType)
{
$this->filename = $filename;
$this->content = $content;
$this->mimeType = $mimeType;
}
public ?string $filename = null;
public ?string $content = null;
public ?string $mimeType = null;
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\BusProNet\Model;
class Hotel
{
public ?int $id = null;
public ?string $code = null;
public ?string $name = null;
public ?string $city = null;
public ?string $street = null;
public ?string $phone = null;
public ?string $country = null;
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\BusProNet\Model;
class MutableField
{
public function __construct(string $type, bool $mutable, ?\DateTimeImmutable $mutableUntil = null)
{
$this->type = $type;
$this->mutable = $mutable;
$this->mutableBefore = $mutableUntil;
}
public ?string $type = null;
public bool $mutable = false;
public ?\DateTimeImmutable $mutableBefore = null;
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\BusProNet\Model;
class Notification
{
public function __construct(int $code, string $message)
{
$this->code = $code;
$this->message = $message;
}
public ?int $code;
public ?string $message;
public function isSuccessful(): bool
{
// Successful responses don't carry codes and messages
if (null === $this->code && null === $this->message) {
return true;
}
// These weird and contradictory looking assertions are required because
// of the stupid API implementation that doesn't distinguish between error
// and success responses.
$responseIsError = 100 <= $this->code || $this->message === 'Daten konnten nicht gesendet werden.';
$responseIsSuccess = 650 === $this->code && false === stripos($this->message, 'fehler');
return false === $responseIsError && true === $responseIsSuccess;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\BusProNet\Model;
use Symfony\Component\Validator\Constraints as Assert;
class PersonalData
{
public ?int $addressId = null;
public ?int $personId = null;
#[Assert\NotBlank(message: 'Bitte angeben')]
public ?string $name = null;
#[Assert\NotBlank(message: 'Bitte angeben')]
public ?string $firstName = null;
public ?string $salutation = null;
public ?string $title = null;
public ?string $gender = null;
public ?string $nationality = null;
public ?string $height = null;
public ?string $shoeSize = null;
public ?string $weight = null;
public ?\DateTimeImmutable $dateOfBirth = null;
#[Assert\Valid()]
public ?Address $address = null;
#[Assert\Valid()]
public ?Communication $communication = null;
public function toPayload(): array
{
// Ensure date of birth is populated
if (null === $dob = $this->dateOfBirth) {
$dob = new \DateTimeImmutable('18 years ago');
}
return [
'idadresse' => $this->addressId,
'idadresseperson' => $this->personId,
'geburtsdatum' => $dob->format('d.m.Y'),
'anrede' => $this->salutation,
'geschlecht' => $this->gender,
'nationalitaet' => $this->nationality,
'titel' => $this->title,
'vorname' => $this->firstName,
'name' => $this->name,
'anschrift' => $this->address->toPayload(),
'kommunikation' => $this->communication->toPayload(),
];
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\BusProNet\Model;
class Pickup
{
public ?int $id = null;
public ?string $code = null;
public ?string $city = null;
public ?string $postalCode = null;
public ?string $street = null;
public ?\DateTimeImmutable $time = null;
public ?float $price = null;
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\BusProNet\Model;
class Room
{
public ?int $id = null;
public ?string $code = null;
public ?string $label = null;
public ?\DateTimeImmutable $dateFrom = null;
public ?\DateTimeImmutable $dateTo = null;
public ?int $totalCount = null;
public ?int $minPax = null;
public ?int $maxPax = null;
public ?int $nights = null;
public ?string $status = null;
public ?int $available = null;
public ?string $board = null;
public array $mapping = [];
public ?float $price = null;
public ?float $totalPrice = null;
public array $individualPrice = [];
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\BusProNet\Model;
class Service
{
public const TYPE_INCLUDED = 'included';
public const TYPE_ADDITIONAL = 'additional';
public const TYPE_TRANSPORTATION = 'transportation';
public const TOKEN_COURSES = 'KUR';
public const TOKEN_SKI_PASS = 'SPA';
public const TOKEN_ADDITIONAL = 'SON';
public const TOKEN_BOARD = 'VPF';
public const TOKEN_RENTALS = ['VER', 'VE2', 'VE3', 'VE4', 'VE5', 'VE6', 'VE7', 'VE8'];
public ?int $id = null;
public ?string $status = null;
public bool $mandatory = false;
public ?string $label = null;
public ?\DateTimeImmutable $dateFrom = null;
public ?\DateTimeImmutable $dateTo = null;
public ?string $subType = null;
public ?int $totalCount = null;
public array $mapping = [];
public ?float $price = null;
public ?float $totalPrice = null;
public array $individualPrice = [];
public ?string $direction = null;
public ?int $available = null;
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\BusProNet\Model;
class Travel
{
public ?int $id = null;
public ?string $code = null;
public ?string $label = null;
public ?\DateTimeImmutable $dateFrom = null;
public ?\DateTimeImmutable $dateTo = null;
public ?string $type = null;
public ?float $priceFrom = null;
public array $selectionGroups = [];
public array $additionalServices = [];
public array $transportationServices = [];
public array $pickups = [];
public array $rooms = [];
public function getAdditionalServicesByGroup(mixed $group, bool $availableOnly = true): array
{
$group = (array) $group;
return array_filter($this->additionalServices, function (Service $service) use ($group, $availableOnly) {
return in_array($service->subType, $group) && (false === $availableOnly || $service->available > 0);
});
}
public function getTransportationServicesByDirection(string $direction, bool $availableOnly = true): array
{
return array_filter($this->transportationServices, function (Service $service) use ($direction, $availableOnly) {
return $direction === $service->direction
&& $service->price >= 0
&& (false === $availableOnly || $service->available > 0);
});
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
namespace App\BusProNet\Security;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\CrmAttributesResponse;
use App\BusProNet\Model\PersonalData;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
class Authenticator extends AbstractLoginFormAuthenticator implements AuthenticationEntryPointInterface
{
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly ApiClient $apiClient,
private readonly LoggerInterface $logger,
) {
}
protected function getLoginUrl(Request $request): string
{
return $this->urlGenerator->generate('app_login');
}
public function authenticate(Request $request): Passport
{
$email = trim($request->request->getString('_username'));
$passwordPlain = trim($request->request->getString('_password'));
// Very lame hashing applied here as required by BPN
$password = md5($passwordPlain);
try {
$response = $this->apiClient->getPersonalData($email, $password);
} catch (ApiClientException $e) {
throw new CustomUserMessageAuthenticationException($e->getMessage());
}
if (false === $response instanceof PersonalData) {
throw new CustomUserMessageAuthenticationException('User not found');
}
$csrfToken = $request->request->getString('_csrf_token');
return new SelfValidatingPassport(
new UserBadge($email, function () use ($email, $password, $response, $request) {
$crmAttributes = $this->apiClient->getCrmAttributes($email, $password);
$roles = $this->collectRoles($crmAttributes);
$user = new User($email, $response->personId, $response->addressId, $password, $roles);
$request->getSession()->set('bpn_user', $user);
return $user;
}),
[
new CsrfTokenBadge('authenticate', $csrfToken),
new RememberMeBadge(),
]
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
$this->logger->info('Login', [
'email' => $token->getUserIdentifier(),
]);
return new RedirectResponse($this->urlGenerator->generate('app_personal_data'));
}
private function collectRoles(CrmAttributes $crmAttributes): array
{
// Collect user's roles from CRM attributes
$roles = [];
if ($crmAttributes->admin) {
$roles[] = 'ROLE_ADMIN';
} elseif ($crmAttributes->manager) {
$roles[] = 'ROLE_MANAGER';
} elseif ($crmAttributes->houseManager) {
$roles[] = 'ROLE_HOUSE_MANAGER';
}
if ($crmAttributes->teamer) {
$roles[] = 'ROLE_TEAMER';
}
return $roles;
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\BusProNet\Security;
use Symfony\Component\Security\Core\User\UserInterface;
class User implements UserInterface
{
public function __construct(
private readonly string $email,
private readonly int $personId,
private readonly int $addressId,
private readonly ?string $password = null,
private readonly array $roles = [],
) {
}
public function getEmail(): ?string
{
return $this->email;
}
public function getPassword(): ?string
{
return $this->password;
}
public function getPersonId(): ?int
{
return $this->personId;
}
public function getAddressId(): int
{
return $this->addressId;
}
public function getRoles(): array
{
return ['ROLE_USER', ...$this->roles];
}
public function eraseCredentials()
{
}
public function getUserIdentifier(): string
{
return $this->email;
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\BusProNet\Security;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\PersonalData;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
class UserProvider implements UserProviderInterface
{
public function __construct(private readonly ApiClient $apiClient, private readonly RequestStack $requestStack)
{
}
public function refreshUser(UserInterface $user): UserInterface
{
if (null !== $activeUuser = $this->requestStack->getSession()->get('bpn_user')) {
return $activeUuser;
}
return $this->loadUserByIdentifier($user->getUserIdentifier());
}
public function supportsClass(string $class): bool
{
return User::class === $class;
}
public function loadUserByIdentifier(string $identifier): UserInterface
{
if (null === $activeUser = $this->requestStack->getSession()->get('bpn_user')) {
throw new UserNotFoundException();
}
try {
$response = $this->apiClient->getPersonalData($activeUser->getEmail(), $activeUser->getPassword());
} catch (ApiClientException $e) {
throw new UserNotFoundException();
}
if (false === $response instanceof PersonalData) {
throw new UserNotFoundException();
}
return $activeUser;
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
namespace App\Controller;
use App\BusProNet\ApiClient;
use App\BusProNet\DataLoader\PickupDataLoader;
use App\BusProNet\DataLoader\TravelDataLoader;
use App\BusProNet\Model\File;
use App\BusProNet\Model\Notification;
use App\Form\BookingType;
use App\Form\Model\BookingData;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use function Symfony\Component\String\u;
class BookingController extends AbstractController
{
public function __construct(
private readonly ApiClient $apiClient,
private readonly TravelDataLoader $travelDataLoader,
private readonly PickupDataLoader $pickupDataLoader,
private readonly Security $security,
) {
}
#[Route('/bookings', name: 'app_bookings')]
#[IsGranted("ROLE_USER")]
public function index(Request $request): Response
{
$bpnUser = $request->getSession()->get('bpn_user');
if (null === $bpnUser) {
return $this->security->logout();
}
$bookings = $this->apiClient->getBookings($bpnUser->getEmail(), $bpnUser->getPassword());
return $this->render('booking/index.html.twig', [
'bookings' => $bookings->getItems(),
]);
}
#[Route('/bookings/{id}/edit', name: 'app_booking_edit', requirements: ['id' => '\d+'])]
#[IsGranted("ROLE_USER")]
public function edit(int $id, Request $request): Response
{
$bpnUser = $request->getSession()->get('bpn_user');
if (null === $bpnUser) {
return $this->security->logout();
}
$booking = $this->apiClient->getBooking($bpnUser->getEmail(), $bpnUser->getPassword(), $id);
//$this->denyAccessUnlessGranted('VIEW', $booking);
$travelData = $this->travelDataLoader->loadById($booking->travelId);
if (null === $travelData) {
$this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar');
return $this->redirectToRoute('app_bookings');
}
$mutableFields = $this->apiClient->getMutableFields($booking->travelId);
$availabilities = $this->apiClient->getAvailabilities($booking->travelId);
$this->travelDataLoader->enrichServicesData($travelData, $availabilities);
$this->pickupDataLoader->enrichPickupsData($travelData);
$formData = BookingData::fromBooking($booking);
$form = $this->createForm(BookingType::class, $formData, [
'hx_post' => $this->generateUrl('app_booking_edit', ['id' => $id]),
'hx_target' => '#app',
'hx_swap' => 'innerHTML show:top',
'attr' => ['novalidate' => 'novalidate'],
'travel' => $travelData,
'mutable_fields' => $mutableFields,
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->apiClient->updateBooking($bpnUser->getEmail(), $bpnUser->getPassword(), $formData);
}
return $this->render('booking/edit.html.twig', [
'booking' => $booking,
'travelData' => $travelData,
'mutableFields' => $mutableFields,
'availabilities' => $availabilities,
'form' => $form->createView(),
]);
}
#[Route('/bookings/{id}/documents', name: 'app_booking_documents', requirements: ['id' => '\d+'])]
#[IsGranted("ROLE_USER")]
public function documents(int $id, Request $request): Response
{
$bpnUser = $request->getSession()->get('bpn_user');
if (null === $bpnUser) {
return $this->security->logout();
}
$file = $this
->apiClient
->getDocuments($bpnUser->getEmail(), $bpnUser->getPassword(), $id, 'Dokumentdruck')
;
if (null === $file || $file instanceof Notification) {
$this->addFlash('error', 'Keine Dokumente vorhanden');
return $this->redirectToRoute('app_bookings');
}
return $this->createDownloadResponse($file);
}
#[Route('/bookings/{id}/confirmation', name: 'app_booking_confirmation', requirements: ['id' => '\d+'])]
#[IsGranted("ROLE_USER")]
public function confirmation(int $id, Request $request): Response
{
$bpnUser = $request->getSession()->get('bpn_user');
if (null === $bpnUser) {
return $this->security->logout();
}
$file = $this
->apiClient
->getDocuments($bpnUser->getEmail(), $bpnUser->getPassword(), $id, 'Vorgangdruck')
;
return $this->createDownloadResponse($file);
}
private function createDownloadResponse(File $file): Response
{
$filename = u($file->filename)->ascii();
$response = new Response($file->content);
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$filename,
md5($filename)
);
$response->headers->set('Content-Disposition', $disposition);
$response->headers->set('Content-Type', $file->mimeType);
return $response;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Controller;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\Form\PersonalDataType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class PersonalDataController extends AbstractController
{
public function __construct(private readonly ApiClient $apiClient, private readonly Security $security)
{}
#[Route('/personal-data', name: 'app_personal_data')]
#[IsGranted('ROLE_USER')]
public function index(Request $request): Response
{
$bpnUser = $request->getSession()->get('bpn_user');
if (null === $bpnUser) {
return $this->security->logout();
}
$personalData = $this
->apiClient
->getPersonalData($bpnUser->getEmail(), $bpnUser->getPassword())
;
$form = $this->createForm(PersonalDataType::class, $personalData, [
'attr' => ['novalidate' => 'novalidate'],
'hx_post' => $this->generateUrl('app_personal_data'),
'hx_target' => '#app',
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->apiClient->updatePersonalData($bpnUser->getEmail(), $bpnUser->getPassword(), $personalData);
$this->addFlash('success', 'Personal data updated.');
} catch (ApiClientException $e) {
$this->addFlash('error', $e->getMessage());
}
return $this->redirectToRoute('app_personal_data');
}
return $this->render('personal_data/index.html.twig', [
'personalData' => $personalData,
'form' => $form->createView(),
]);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
#[Route('/', name: 'app_login')]
#[IsGranted('PUBLIC_ACCESS')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
if (null !== $this->getUser()) {
return $this->redirectToRoute('app_personal_data');
}
$error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('security/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
]);
}
#[Route('/logout', name: 'app_logout')]
#[IsGranted('ROLE_USER')]
public function logout(): void
{}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Form;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingData;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class BookingType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('participants', CollectionType::class, [
'entry_type' => ParticipantType::class,
'entry_options' => [
'selectable_courses' => $options['travel']->getAdditionalServicesByGroup(Service::TOKEN_COURSES),
'selectable_ski_passes' => $options['travel']->getAdditionalServicesByGroup(Service::TOKEN_SKI_PASS),
'selectable_services' => $options['travel']->getAdditionalServicesByGroup(Service::TOKEN_ADDITIONAL),
'selectable_board' => $options['travel']->getAdditionalServicesByGroup(Service::TOKEN_BOARD),
'selectable_rentals' => $options['travel']->getAdditionalServicesByGroup(Service::TOKEN_RENTALS),
'selectable_transportation_services_to' => $options['travel']
->getTransportationServicesByDirection('HIN'),
'selectable_transportation_services_fro' => $options['travel']
->getTransportationServicesByDirection('RUECK'),
],
'allow_add' => false,
'allow_delete' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setDefaults([
'data_class' => BookingData::class,
'mutable_fields' => [],
])
->setRequired(['travel'])
->setAllowedTypes('travel', Travel::class)
;
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Form\Extension;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class HtmxSubmitExtension extends AbstractTypeExtension
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'hx_post' => null,
'hx_target' => '#htmx-modal',
'hx_swap' => 'outerHTML',
'hx_indicator' => '#loading-indicator',
'hx_trigger' => null,
'hx_select' => null,
]);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
if (null !== $options['hx_post']) {
$attr = [
'hx-post' => $options['hx_post'],
'hx-target' => $options['hx_target'],
'hx-swap' => $options['hx_swap'],
];
if (null !== $options['hx_indicator']) {
$attr['hx-indicator'] = $options['hx_indicator'];
}
if (null !== $options['hx_trigger']) {
$attr['hx-trigger'] = $options['hx_trigger'];
}
if (null !== $options['hx_select']) {
$attr['hx-select'] = $options['hx_select'];
}
$view->vars['attr'] = array_merge($view->vars['attr'], $attr);
}
}
public static function getExtendedTypes(): iterable
{
return [FormType::class];
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Form\Model;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\Service;
use Symfony\Component\Validator\Constraints as Assert;
class BookingData
{
public ?Booking $booking = null;
#[Assert\Valid]
public array $participants = [];
public static function fromBooking(Booking $booking): static
{
$instance = new static();
$instance->booking = $booking;
foreach ($booking->participants as $index => $participant) {
/** @var PersonalData $participant */
$participantData = ParticipantData::fromPersonalData($participant);
$participantData->index = $index;
$participantData->courses = $booking
->getAdditionalServicesForParticipantByGroup($index, Service::TOKEN_COURSES);
$participantData->skiPass = $booking
->getAdditionalServicesForParticipantByGroup($index, Service::TOKEN_SKI_PASS);
$participantData->additionalServices = $booking
->getAdditionalServicesForParticipantByGroup($index, Service::TOKEN_ADDITIONAL);
$participantData->board = $booking
->getAdditionalServicesForParticipantByGroup($index, Service::TOKEN_BOARD);
$participantData->rentals = $booking
->getAdditionalServicesForParticipantByGroup($index, Service::TOKEN_RENTALS);
// Different keys for direction used in booking data (H <=> HIN, R <=> RUECK)!
$participantData->transportationServiceTo = $booking
->getTransportationServiceForParticipantAndDirection($index, 'H');
$participantData->transportationServiceFro = $booking
->getTransportationServiceForParticipantAndDirection($index, 'R');
$instance->participants[$index] = $participantData;
}
return $instance;
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\Form\Model;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\Service;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
class ParticipantData
{
public ?int $index = null;
public ?int $addressId = null;
public ?int $personId = null;
public ?string $status = null;
#[Assert\NotBlank(message: 'Bitte angeben')]
public ?string $firstName = null;
#[Assert\NotBlank(message: 'Bitte angeben')]
public ?string $lastName = null;
public ?string $title = null;
public ?string $gender = null;
public ?string $nationality = null;
#[Assert\Range(notInRangeMessage: 'Bitte einen Wert im Bereich {{ min }}-{{ max }}cm angeben', min: 140, max: 210)]
public ?string $height = null;
#[Assert\Range(notInRangeMessage: 'Bitte einen Wert im Bereich {{ min }}-{{ max }} angeben', min: 35, max: 49)]
public ?string $shoeSize = null;
#[Assert\Range(notInRangeMessage: 'Bitte einen Wert im Bereich {{ min }}-{{ max }}kg angeben', min: 40, max: 130)]
public ?string $weight = null;
#[Assert\NotNull(message: 'Bitte angeben')]
public ?\DateTimeImmutable $dateOfBirth = null;
#[Assert\NotBlank(message: 'Bitte angeben')]
public ?string $email = null;
#[Assert\NotBlank(message: 'Bitte angeben')]
public ?string $mobile = null;
public array $courses = [];
public array $additionalServices = [];
public array $skiPass = [];
public array $board = [];
public array $rentals = [];
public ?Service $transportationServiceTo = null;
public ?Service $transportationServiceFro = null;
#[Assert\Callback]
public function assertBodyMeasurements(ExecutionContextInterface $context): void
{
if (0 === count($this->rentals)) {
return;
}
if (empty($this->height)) {
$context->buildViolation('Bitte angeben wegen Leihmaterial')
->atPath('height')
->addViolation()
;
}
if (empty($this->shoeSize)) {
$context->buildViolation('Bitte angeben Leihmaterial')
->atPath('shoeSize')
->addViolation()
;
}
if (empty($this->weight)) {
$context->buildViolation('Bitte angeben Leihmaterial')
->atPath('weight')
->addViolation()
;
}
}
public static function fromPersonalData(PersonalData $personalData): static
{
$instance = new static();
$instance->addressId = $personalData->addressId;
$instance->personId = $personalData->personId;
$instance->firstName = $personalData->firstName;
$instance->lastName = $personalData->name;
$instance->gender = $personalData->gender;
$instance->nationality = $personalData->nationality;
$instance->email = $personalData->communication->email;
$instance->mobile = $personalData->communication->mobile;
$instance->dateOfBirth = $personalData->dateOfBirth;
$instance->height = $personalData->height;
$instance->weight = $personalData->weight;
$instance->shoeSize = $personalData->shoeSize;
return $instance;
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
namespace App\Form;
use App\BusProNet\Form\CountryType;
use App\BusProNet\Model\Service;
use App\Form\Model\ParticipantData;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ParticipantType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('firstName', TextType::class, [
'label' => 'Vorname',
])
->add('lastName', TextType::class, [
'label' => 'Nachname',
])
->add('dateOfBirth', BirthdayType::class, [
'label' => 'Geburtsdatum',
'html5' => true,
'widget' => 'single_text',
'input' => 'datetime_immutable',
])
->add('gender', ChoiceType::class, [
'label' => 'Geschlecht',
'choices' => [
'männlich' => 'M',
'weiblich' => 'W',
'divers' => 'D',
],
])
->add('nationality', CountryType::class, [
'label' => 'Nationalität',
'property' => 'nationality',
'preferred_choices' => ['D', 'A', 'CH'],
])
->add('email', EmailType::class, [
'label' => 'Email',
])
->add('mobile', TextType::class, [
'label' => 'Telefon (mobil)',
])
->add('height', IntegerType::class, [
'label' => 'Körpergröße [cm]',
'required' => false,
])
->add('shoeSize', IntegerType::class, [
'label' => 'Schuhgröße',
'required' => false,
])
->add('weight', IntegerType::class, [
'label' => 'Gewicht [kg]',
'required' => false,
])
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) {
/** @var ParticipantData $participant */
$participant = $event->getData();
$participantIndex = $participant->index;
$form = $event->getForm();
$commonChoiceFieldOptions = [
'multiple' => true,
'expanded' => true,
'choice_value' => 'id',
'choice_label' => function (?Service $service) use ($participantIndex) {
if (null === $service) {
return null;
}
$price = $service->individualPrice[$participantIndex] ?? $service->price;
if (null === $price || 0.0 === $price) {
return $service->label;
}
return sprintf('%s (%s€)',
$service->label,
number_format($price, 2, ',', '.')
);
},
'choice_attr' => function (?Service $service) {
if (true === $service->mandatory) {
return [
'checked' => true,
'disabled' => true,
];
}
return [];
},
];
$form
->add('courses', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Kurse',
'choices' => $options['selectable_courses'],
])
->add('additionalServices', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Zusatzleistungen',
'choices' => $options['selectable_services'],
])
->add('skiPass', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Skipass',
'choices' => $options['selectable_ski_passes'],
])
->add('board', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Verpflegung',
'choices' => $options['selectable_board'],
])
->add('rentals', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Verleih',
'choices' => $options['selectable_rentals'],
])
->add('transportationServiceTo', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Anreise',
'multiple' => false,
'choices' => $options['selectable_transportation_services_to'],
])
->add('transportationServiceFro', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Rückreise',
'multiple' => false,
'choices' => $options['selectable_transportation_services_fro'],
])
;
})
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ParticipantData::class,
'selectable_courses' => [],
'selectable_ski_passes' => [],
'selectable_services' => [],
'selectable_board' => [],
'selectable_rentals' => [],
'selectable_transportation_services_to' => [],
'selectable_transportation_services_fro' => [],
]);
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Form;
use App\BusProNet\Form\CountryType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
class PersonalDataType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('gender', ChoiceType::class, [
'label' => 'Gender',
'choices' => [
'M' => 'M',
'W' => 'W',
'D' => 'D',
],
])
->add('firstName', TextType::class, [
'label' => 'Name',
])
->add('name', TextType::class, [
'label' => 'Nachname',
])
->add('dateOfBirth', BirthdayType::class, [
'label' => 'Geburtsdatum',
'html5' => true,
'widget' => 'single_text',
'input' => 'datetime_immutable',
])
->add('street', TextType::class, [
'label' => 'Straße',
'property_path' => 'address.street',
])
->add('postCode', TextType::class, [
'label' => 'PLZ',
'property_path' => 'address.postCode',
])
->add('city', TextType::class, [
'label' => 'Stadt',
'property_path' => 'address.city',
])
->add('country', CountryType::class, [
'label' => 'Land',
'property_path' => 'address.country',
])
->add('email', EmailType::class, [
'label' => 'E-Mail',
'property_path' => 'communication.email',
])
->add('phone', TextType::class, [
'label' => 'Telefon',
'property_path' => 'communication.phone',
])
->add('mobile', TextType::class, [
'label' => 'Mobil',
'property_path' => 'communication.mobile',
])
;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Htmx;
use Symfony\Component\HttpFoundation\Response;
class HxRedirectResponse extends Response
{
public function __construct(string $url)
{
return parent::__construct(null, Response::HTTP_OK, ['HX-Redirect' => $url]);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Htmx;
use Symfony\Component\HttpFoundation\Response;
class HxTriggerResponse extends Response
{
public function __construct(string $content, string $trigger)
{
return parent::__construct($content, Response::HTTP_OK, ['HX-Trigger' => $trigger]);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Security\Voter;
use App\BusProNet\Model\Booking;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class BookingVoter extends Voter
{
public const VIEW = 'VIEW';
public const EDIT = 'EDIT';
public function __construct(private readonly RequestStack $requestStack)
{
}
protected function supports(string $attribute, mixed $subject): bool
{
if (false === in_array($attribute, [self::VIEW, self::EDIT])) {
return false;
}
return $subject instanceof Booking;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$bpnUser = $this->requestStack->getSession()->get('bpn_user');
if (null === $bpnUser) {
return false;
}
/** @var Booking $booking */
$booking = $subject;
if ($booking->applicant->personId !== $bpnUser->getPersonId()) {
return false;
}
return $booking->isEditable();
}
}
+11 -2
View File
@@ -248,8 +248,17 @@
"templates/base.html.twig"
]
},
"symfony/ux-turbo": {
"version": "v2.21.0"
"symfony/uid": {
"version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.2",
"ref": "d294ad4add3e15d7eb1bae0221588ca89b38e558"
},
"files": [
"config/packages/uid.yaml"
]
},
"symfony/validator": {
"version": "6.4",
+4 -2
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title>
@@ -12,6 +12,8 @@
{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
<div class="container">
{% block body %}{% endblock %}
</div>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
{% extends 'layout.html.twig' %}
{% block content %}
{{ form_start(form) }}
{% for child in form.participants %}
{% set participant = child.vars.data %}
<details{% if not child.vars.valid %} open{% endif %}>
<summary role="button" class="outline">
Teilnehmer {{ participant.index }}
</summary>
<div class="grid">
<div>
<strong>
Unterkunft
</strong>
<br>
{{ booking.roomForParticipant(participant.index).label }}
</div>
<div>
<strong>
Preis
</strong>
<br>
{{ booking.priceForParticipant(participant.index)|format_currency('EUR') }}
</div>
</div>
<hr>
<fieldset class="grid">
{{ form_row(child.firstName) }}
{{ form_row(child.lastName) }}
{{ form_row(child.gender) }}
</fieldset>
<fieldset class="grid">
{{ form_row(child.dateOfBirth) }}
{{ form_row(child.nationality) }}
</fieldset>
<fieldset class="grid">
{{ form_row(child.email) }}
{{ form_row(child.mobile) }}
</fieldset>
<fieldset class="grid">
{{ form_row(child.height) }}
{{ form_row(child.shoeSize) }}
{{ form_row(child.weight) }}
</fieldset>
<fieldset class="grid">
{{ form_row(child.courses) }}
{{ form_row(child.skiPass) }}
</fieldset>
<fieldset class="grid">
{{ form_row(child.additionalServices) }}
{{ form_row(child.board) }}
</fieldset>
<fieldset class="grid">
{{ form_row(child.rentals) }}
</fieldset>
<fieldset class="grid">
{{ form_row(child.transportationServiceTo) }}
{{ form_row(child.transportationServiceFro) }}
</fieldset>
</details>
{% endfor %}
{{ form_rest(form) }}
<button type="submit">
Aktualisieren
</button>
{{ form_end(form) }}
<p>
<button type="button"
hx-get="{{ path('app_bookings') }}"
hx-target="#app"
hx-swap="innerHTML show:top"
hx-indicator="#loading-indicator">
zurück
</button>
</p>
{% endblock %}
+81
View File
@@ -0,0 +1,81 @@
{% extends 'layout.html.twig' %}
{% block content %}
<table>
<thead>
<tr>
<th scope="col">
Buchungsdatum
</th>
<th scope="col">
Reisedatum
</th>
<th scope="col">
Reise
</th>
<th scope="col">
Vorgangsnr.
</th>
<th scope="col">
Preis
</th>
<th scope="col">
offen
</th>
<th scope="col">
Status
</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for booking in bookings %}
<tr>
<td>
{{ booking.bookingDate | date('d.m.Y') }}
</td>
<td>
{{ booking.travelDate | date('d.m.Y') }}
</td>
<td>
{{ booking.travel }}
</td>
<td>
{{ booking.bookingNumber }}
</td>
<td>
{{ booking.price|format_currency('EUR') }}
</td>
<td>
{{ booking.balance ? booking.balance|format_currency('EUR') : '-' }}
</td>
<td>
{{ booking.status }}
</td>
<td>
{% if booking.editable %}
<button type="button"
hx-get="{{ path('app_booking_edit', { 'id': booking.id }) }}"
hx-target="#app"
hx-indicator="#loading-indicator">
bearbeiten
</button>
{% endif %}
<a href="{{ path('app_booking_confirmation', { 'id': booking.id }) }}" target="_blank">
Bestätigung
</a>
<a href="{{ path('app_booking_documents', { 'id': booking.id }) }}" target="_blank">
Reisedokumente
</a>
</td>
</tr>
{% else %}
<tr>
<td>
Keine Daten
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% use 'form_div_layout.html.twig' %}
{%- block form_widget_simple -%}
{%- set type = type|default('text') -%}
{%- if type == 'range' or type == 'color' -%}
{# Attribute "required" is not supported #}
{%- set required = false -%}
{%- endif -%}
{%- if errors|length > 0 -%}
{% set attr = attr|merge({ 'aria-invalid': 'true', 'aria-describedby': 'error-' ~ id }) %}
{%- endif -%}
<input type="{{ type }}" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value }}" {% endif %}/>
{%- endblock form_widget_simple -%}
{%- block form_row -%}
<fieldset{% with {attr: row_attr} %}{{ block('attributes') }}{% endwith %}>
<legend>
<strong>
{{- label -}}
</strong>
</legend>
{{- form_widget(form) -}}
{{- form_errors(form) -}}
{{- form_help(form) -}}
</fieldset>
{%- endblock form_row -%}
{%- block form_errors -%}
{%- if errors|length > 0 -%}
<small id="error-{{ id }}">
{%- for error in errors -%}
<div>{{ error.message }}</div>
{%- endfor -%}
</small>
{%- endif -%}
{%- endblock form_errors -%}
{%- block choice_widget_expanded -%}
{%- for child in form %}
{{- form_widget(child) -}}
{% endfor -%}
{%- endblock choice_widget_expanded -%}
{%- block checkbox_widget -%}
<label for="{{ id }}">
{{ parent() }}
{{ label|trans|raw }}
{%- if attr.disabled is defined and attr.disabled == true and attr.checked == true -%}
<input type="hidden" name="{{ form.vars.full_name }}" value="{{ form.vars.value }}">
{%- endif -%}
</label>
{%- endblock checkbox_widget -%}
{%- block radio_widget -%}
<label for="{{ id }}">
{{ parent() }}
{{ label|trans|raw }}
</label>
{%- if attr.disabled is defined and attr.disabled == true and attr.checked == true -%}
<input type="hidden" name="{{ form.vars.full_name }}" value="{{ form.vars.value }}">
{%- endif -%}
{%- endblock radio_widget -%}
+51
View File
@@ -0,0 +1,51 @@
{% extends 'base.html.twig' %}
{% block body %}
<div id="app">
{% if is_granted('ROLE_USER') %}
<nav>
<ul>
<li><strong>MyE&amp;P BETA</strong></li>
</ul>
<ul>
<li>
<a href="#"
class="contrast"
hx-get="{{ path('app_personal_data') }}"
hx-target="#app"
hx-swap="innerHTML"
hx-indicator="#loading-indicator">
Persönliche Daten
</a>
</li>
<li>
<a href="#"
class="contrast"
hx-get="{{ path('app_bookings') }}"
hx-target="#app"
hx-swap="innerHTML"
hx-indicator="#loading-indicator">
Buchungen
</a>
</li>
<li>
<a href="#"
class="contrast"
hx-get="{{ path('app_logout') }}"
hx-target="#app"
hx-swap="innerHTML"
hx-indicator="#loading-indicator">
Logout
</a>
</li>
</ul>
</nav>
{% endif %}
<div id="content">
{% block content %}{% endblock %}
<div id="loading-indicator">
<progress />
</div>
</div>
</div>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
{% extends 'layout.html.twig' %}
{% block content %}
<h2>
Persönliche Daten
</h2>
{{ form_start(form) }}
<fieldset class="grid">
{{ form_row(form.gender) }}
{{ form_row(form.firstName) }}
{{ form_row(form.name) }}
{{ form_row(form.dateOfBirth) }}
</fieldset>
<fieldset class="grid">
{{ form_row(form.street) }}
{{ form_row(form.postCode) }}
{{ form_row(form.city) }}
{{ form_row(form.country) }}
</fieldset>
<fieldset class="grid">
{{ form_row(form.email) }}
{{ form_row(form.phone) }}
{{ form_row(form.mobile) }}
</fieldset>
{{ form_rest(form) }}
<button type="submit">
Speichern
</button>
{{ form_end(form) }}
{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
{% extends 'layout.html.twig' %}
{% block content %}
{% if error %}
<div class="flex items-center space-x-1 bg-red-500 text-white p-2 rounded-md mb-6">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<span class="flex-1 text-sm">{{ error.messageKey|trans(error.messageData, 'security') }}</span>
</div>
{% endif %}
<form hx-post="{{ path('app_login') }}"
hx-target="#app"
hx-swap="innerHTML"
hx-indicator="#loading-indicator">
<div class="mb-6">
<label for="username" class="block leading-6 text-gray-900">
E-Mail:
</label>
<input type="email"
id="username"
name="_username"
value="{{ last_username }}"
class="mt-2 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6"
required
autofocus
autocomplete="username">
</div>
<div class="mb-6">
<label for="password" class="block leading-6 text-gray-900">
Passwort:
</label>
<input type="password"
id="password"
name="_password"
class="mt-2 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary sm:text-sm sm:leading-6"
required
autocomplete="current-password">
</div>
<div class="flex items-start mb-6">
<label for="rememberme" class="flex h-6 items-center">
<input type="checkbox" id="rememberme" name="_remember_me" class="h-4 w-4 rounded text-primary">
<span class="block ml-3 leading-6">angemeldet bleiben</span>
</label>
</div>
<button type="submit" class="btn w-full">
Login
</button>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
</form>
{% endblock %}
@@ -0,0 +1,30 @@
<?php
namespace App\Tests\BusProNet\DataLoader;
use App\BusProNet\DataLoader\HotelDataLoader;
use App\BusProNet\Model\Hotel;
use PHPUnit\Framework\TestCase;
class HotelDataLoaderTest extends TestCase
{
public function testParse(): void
{
$loader = new HotelDataLoader(__DIR__.'/../../Resources');
$hotelId = 163113;
$xml = $loader->loadById($hotelId, 'hotels_data.xml');
$this->assertInstanceOf(\SimpleXMLElement::class, $xml);
$hotel = $loader->parseXml($xml);
$this->assertInstanceOf(Hotel::class, $hotel);
$this->assertEquals($hotelId, $hotel->id);
$this->assertEquals('L\'Oxalys', $hotel->name);
$this->assertEquals('SBWOXA', $hotel->code);
$this->assertEquals('Val Thorens', $hotel->city);
$this->assertEquals('F', $hotel->country);
$this->assertEquals('Rue des Lacs', $hotel->street);
$this->assertEquals('', $hotel->phone);
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Tests\BusProNet\DataLoader;
use App\BusProNet\DataLoader\PickupDataLoader;
use App\BusProNet\Model\Pickup;
use PHPUnit\Framework\TestCase;
class PickupDataLoaderTest extends TestCase
{
public function testParse(): void
{
$loader = new PickupDataLoader();
$pickupId = 1;
$xml = $loader->loadById($pickupId, 'pickups_data.xml');
$this->assertInstanceOf(\SimpleXMLElement::class, $xml);
$pickup = $loader->parseXml($xml);
$this->assertInstanceOf(Pickup::class, $pickup);
$this->assertEquals($pickupId, $pickup->id);
$this->assertEquals('MS-Hbf', $pickup->code);
$this->assertEquals('Münster', $pickup->city);
$this->assertEquals('48143', $pickup->postalCode);
$this->assertEquals('Hafenstr/Ecke Friedrich-Ebert-Str', $pickup->street);
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Tests\BusProNet\DataLoader;
use App\BusProNet\DataLoader\TravelDataLoader;
use App\BusProNet\Model\Travel;
use PHPUnit\Framework\TestCase;
class TravelDataLoaderTest extends TestCase
{
public function testParse(): void
{
$loader = new TravelDataLoader('');
$filename = __DIR__.'/../../Resources/travel_data.xml';
$travelId = 11478;
$xml = $loader->loadById($travelId, $filename);
$this->assertInstanceOf(\SimpleXMLElement::class, $xml);
$travel = $loader->parseXml($xml);
$this->assertInstanceOf(Travel::class, $travel);
$this->assertEquals('DPWMP060125', $travel->code);
$this->assertEquals('F', $travel->type);
$this->assertInstanceOf(\DateTimeImmutable::class, $travel->dateFrom);
$this->assertInstanceOf(\DateTimeImmutable::class, $travel->dateTo);
$this->assertEquals('Davos - Sportclub Waldschlössli', $travel->label);
$this->assertEquals(534.2, $travel->priceFrom);
$this->assertCount(8, $travel->selectionGroups);
$this->assertCount(27, $travel->additionalServices);
$this->assertCount(5, $travel->transportationServices);
$this->assertCount(7, $travel->pickups);
$this->assertCount(12, $travel->rooms);
}
}
+388
View File
@@ -0,0 +1,388 @@
<?xml version="1.0" encoding="utf-8"?>
<ergebnis>
<satz typ="KUNDENKONTO"></satz>
<art>Vorgang_Details</art>
<idadresse>153283</idadresse>
<idperson>255752</idperson>
<idbuchung>74153</idbuchung>
<vorgang>86930</vorgang>
<status>F</status>
<status_teilnehmer>F/F/F/F/F/F/F/F/F/F/F/F/F/F/F</status_teilnehmer>
<inkasso>K</inkasso>
<unterlagenan>K</unterlagenan>
<idagentur>77883</idagentur>
<agentur>E&amp;P Internetagentur</agentur>
<idreise>11092</idreise>
<reise idreise="11092" reiseart="F" bezeichnung="Val Thorens - Ski &amp; Boarderweek" code="SBW/141224"
termin="14.12.2024"></reise>
<veranstalter idadresse="78875" name="E&amp;P Reisen und Events GmbH" code="EP"></veranstalter>
<produkt idprodukt="1591" bezeichnung="Val Thorens - Ski &amp; Boarderweek" code="SBW"></produkt>
<idpartner>163113</idpartner>
<partner>L'Oxalys</partner>
<anmelder>
<name>Brugger</name>
<vorname>Nico</vorname>
<anrede>Herr</anrede>
<titel></titel>
<geschlecht>M</geschlecht>
<nationalitaet>D</nationalitaet>
<geburtsdatum>12.08.1994</geburtsdatum>
<anschrift>
<strasse>Traubenstraße 17A</strasse>
<plz>79618</plz>
<ort>Rheinfelden</ort>
<ortsteil></ortsteil>
<land>D</land>
</anschrift>
<kommunikation>
<email>[email protected]</email>
<telefonprivat>017632174919</telefonprivat>
<telefonmobil>017632174919</telefonmobil>
</kommunikation>
<idadresse>153283</idadresse>
<idadresseperson>255752</idadresseperson>
</anmelder>
<teilnehmerliste>
<teilnehmer id="1">
<name>Brugger</name>
<vorname>Nico</vorname>
<anrede>Herr</anrede>
<titel></titel>
<geschlecht>M</geschlecht>
<nationalitaet>D</nationalitaet>
<geburtsdatum>12.08.1994</geburtsdatum>
<anschrift>
<strasse>Traubenstraße 17A</strasse>
<plz>79618</plz>
<ort>Rheinfelden</ort>
<ortsteil></ortsteil>
<land>D</land>
</anschrift>
<kommunikation>
<email>[email protected]</email>
<telefonprivat>017632174919</telefonprivat>
<telefonmobil>017632174919</telefonmobil>
</kommunikation>
<idadresseperson>255752</idadresseperson>
<status>F</status>
<buchungsdatum>24.05.2024</buchungsdatum>
</teilnehmer>
<teilnehmer id="2">
<name>Danielzik</name>
<vorname>Leon</vorname>
<anrede>Herr</anrede>
<titel></titel>
<geschlecht>M</geschlecht>
<nationalitaet>D</nationalitaet>
<geburtsdatum>13.05.2000</geburtsdatum>
<anschrift>
<land>D</land>
</anschrift>
<kommunikation>
<email>[email protected]</email>
</kommunikation>
<idadresseperson>306886</idadresseperson>
<status>F</status>
<buchungsdatum>24.05.2024</buchungsdatum>
</teilnehmer>
<teilnehmer id="3">
<name>Bregler</name>
<vorname>Lukas</vorname>
<anrede>Herr</anrede>
<titel></titel>
<geschlecht>M</geschlecht>
<nationalitaet>D</nationalitaet>
<geburtsdatum>14.03.1995</geburtsdatum>
<anschrift>
<land>D</land>
</anschrift>
<kommunikation>
<email>[email protected]</email>
</kommunikation>
<idadresseperson>306881</idadresseperson>
<status>F</status>
<buchungsdatum>24.05.2024</buchungsdatum>
</teilnehmer>
<teilnehmer id="4">
<name>Dreyer</name>
<vorname>Marwin</vorname>
<anrede>Herr</anrede>
<titel></titel>
<geschlecht>M</geschlecht>
<nationalitaet>D</nationalitaet>
<geburtsdatum>28.10.1993</geburtsdatum>
<anschrift>
<strasse>Zielgasse 4</strasse>
<plz>79618</plz>
<ort>Rheinfelden</ort>
<ortsteil></ortsteil>
<land>D</land>
</anschrift>
<kommunikation>
<telefonprivat>+4917661835282</telefonprivat>
<email>[email protected]</email>
</kommunikation>
<idadresseperson>287927</idadresseperson>
<status>F</status>
<buchungsdatum>24.05.2024</buchungsdatum>
</teilnehmer>
<teilnehmer id="5">
<name>Dürhammer</name>
<vorname>Daniel</vorname>
<anrede>Herr</anrede>
<titel></titel>
<geschlecht>M</geschlecht>
<nationalitaet>D</nationalitaet>
<geburtsdatum>03.04.1993</geburtsdatum>
<anschrift>
<strasse>Gartenstraße 7</strasse>
<plz>78462</plz>
<ort>Konstanz</ort>
<ortsteil></ortsteil>
<land>D</land>
</anschrift>
<kommunikation>
<telefonmobil>01711242675</telefonmobil>
<email>[email protected]</email>
</kommunikation>
<idadresseperson>204446</idadresseperson>
<status>F</status>
<buchungsdatum>24.05.2024</buchungsdatum>
</teilnehmer>
<teilnehmer id="6">
<name>Wolf</name>
<vorname>Peter</vorname>
<anrede>Herr</anrede>
<titel></titel>
<geschlecht>M</geschlecht>
<nationalitaet>D</nationalitaet>
<geburtsdatum>25.02.2004</geburtsdatum>
<anschrift>
<land>D</land>
</anschrift>
<kommunikation>
<email>[email protected]</email>
</kommunikation>
<idadresseperson>345347</idadresseperson>
<status>F</status>
<buchungsdatum>24.05.2024</buchungsdatum>
</teilnehmer>
<teilnehmer id="7">
<name>Schubbe</name>
<vorname>Dennis</vorname>
<anrede>Herr</anrede>
<titel></titel>
<geschlecht>M</geschlecht>
<nationalitaet>D</nationalitaet>
<geburtsdatum>30.05.1991</geburtsdatum>
<anschrift>
<strasse>Schulweg 14</strasse>
<plz>79618</plz>
<ort>Rheinfelden</ort>
<ortsteil></ortsteil>
<land>D</land>
</anschrift>
<kommunikation>
<email>[email protected]</email>
<telefonmobil>017643614883</telefonmobil>
</kommunikation>
<idadresseperson>225013</idadresseperson>
<status>F</status>
<buchungsdatum>24.05.2024</buchungsdatum>
</teilnehmer>
<teilnehmer id="8">
<name>Brugger</name>
<vorname>Tim</vorname>
<anrede>Herr</anrede>
<titel></titel>
<geschlecht>M</geschlecht>
<nationalitaet>D</nationalitaet>
<geburtsdatum>11.08.1997</geburtsdatum>
<anschrift>
<land>D</land>
</anschrift>
<kommunikation>
<email>[email protected]</email>
</kommunikation>
<idadresseperson>306885</idadresseperson>
<status>F</status>
<buchungsdatum>24.05.2024</buchungsdatum>
</teilnehmer>
<teilnehmer id="9">
<name>Zumkeller</name>
<vorname>Pascal</vorname>
<anrede>Herr</anrede>
<titel></titel>
<geschlecht>M</geschlecht>
<nationalitaet>D</nationalitaet>
<geburtsdatum>22.09.1996</geburtsdatum>
<anschrift>
<land>D</land>
</anschrift>
<kommunikation>
<email>[email protected]</email>
</kommunikation>
<idadresseperson>306887</idadresseperson>
<status>F</status>
<buchungsdatum>24.05.2024</buchungsdatum>
</teilnehmer>
<teilnehmer id="10">
<name>Franz</name>
<vorname>Pascal</vorname>
<anrede>Herr</anrede>
<titel></titel>
<geschlecht>M</geschlecht>
<nationalitaet>D</nationalitaet>
<geburtsdatum>11.07.1999</geburtsdatum>
<anschrift>
<strasse></strasse>
<plz>79618</plz>
<ort>Rheinfelden</ort>
<ortsteil></ortsteil>
<land>D</land>
</anschrift>
<kommunikation>
<email>[email protected]</email>
</kommunikation>
<idadresseperson>226567</idadresseperson>
<status>F</status>
<buchungsdatum>24.05.2024</buchungsdatum>
</teilnehmer>
<teilnehmer id="11">
<name>Schneider</name>
<vorname>Ole</vorname>
<anrede>Herr</anrede>
<titel></titel>
<geschlecht>M</geschlecht>
<nationalitaet>D</nationalitaet>
<geburtsdatum>03.09.1999</geburtsdatum>
<anschrift>
<land>D</land>
</anschrift>
<kommunikation>
<email>[email protected]</email>
</kommunikation>
<idadresseperson>306884</idadresseperson>
<status>F</status>
<buchungsdatum>24.05.2024</buchungsdatum>
</teilnehmer>
<teilnehmer id="12">
<name>Raiber</name>
<vorname>Jakob</vorname>
<anrede>Herr</anrede>
<titel></titel>
<geschlecht>M</geschlecht>
<nationalitaet>D</nationalitaet>
<geburtsdatum>14.07.2001</geburtsdatum>
<anschrift>
<land>D</land>
</anschrift>
<kommunikation>
<email>[email protected]</email>
</kommunikation>
<idadresseperson>345348</idadresseperson>
<status>F</status>
<buchungsdatum>24.05.2024</buchungsdatum>
</teilnehmer>
<teilnehmer id="13">
<name>Eckert</name>
<vorname>Valentin</vorname>
<anrede>Herr</anrede>
<titel></titel>
<geschlecht>M</geschlecht>
<nationalitaet>D</nationalitaet>
<geburtsdatum>10.12.1999</geburtsdatum>
<anschrift>
<land>D</land>
</anschrift>
<kommunikation>
<email>[email protected]</email>
</kommunikation>
<idadresseperson>345349</idadresseperson>
<status>F</status>
<buchungsdatum>24.05.2024</buchungsdatum>
</teilnehmer>
<teilnehmer id="14">
<name>Graß</name>
<vorname>Lukas</vorname>
<anrede>Herr</anrede>
<titel></titel>
<geschlecht>M</geschlecht>
<nationalitaet>D</nationalitaet>
<geburtsdatum>07.05.2003</geburtsdatum>
<anschrift>
<land>D</land>
</anschrift>
<kommunikation>
<email>[email protected]</email>
</kommunikation>
<idadresseperson>345350</idadresseperson>
<status>F</status>
<buchungsdatum>24.05.2024</buchungsdatum>
</teilnehmer>
<teilnehmer id="15">
<name>Wolf</name>
<vorname>Hannes</vorname>
<anrede>Herr</anrede>
<titel></titel>
<geschlecht>M</geschlecht>
<nationalitaet>D</nationalitaet>
<geburtsdatum>26.02.2001</geburtsdatum>
<anschrift>
<land>D</land>
</anschrift>
<kommunikation>
<email>[email protected]</email>
</kommunikation>
<idadresseperson>345351</idadresseperson>
<status>F</status>
<buchungsdatum>24.05.2024</buchungsdatum>
</teilnehmer>
</teilnehmerliste>
<beförderungen>
<beförderung idleistung="166440" leistung="Eigene Abreise" termin="21.12.2024" terminbis="21.12.2024"
unterart="PKW" richtung="R" anzahl="15" zuordnung="1,2,3,4,5,6,7,8,9,10,11,12,13,14,15"
gesamtpreis="0,00"
einzelpreis="0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00"></beförderung>
<beförderung idleistung="166394" leistung="Eigene Anreise" termin="14.12.2024" terminbis="14.12.2024"
unterart="PKW" richtung="H" anzahl="15" zuordnung="1,2,3,4,5,6,7,8,9,10,11,12,13,14,15"
gesamtpreis="0,00"
einzelpreis="0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00"></beförderung>
</beförderungen>
<zusatzleistungen>
<zusatzleistung idleistung="173916" leistung="Bingolinchen + 03l Zappes + Klopfer" termin="19.12.2024"
terminbis="19.12.2024" unterart="SON" anzahl="15"
zuordnung="1,2,3,4,5,6,7,8,9,10,11,12,13,14,15" gesamtpreis="418,50"
einzelpreis="27,90/27,90/27,90/27,90/27,90/27,90/27,90/27,90/27,90/27,90/27,90/27,90/27,90/27,90/27,90"></zusatzleistung>
<zusatzleistung idleistung="166406" leistung="Ski &amp; Boarderweek-Ticket" termin="14.12.2024"
terminbis="21.12.2024" unterart="SON" anzahl="15"
zuordnung="1,2,3,4,5,6,7,8,9,10,11,12,13,14,15" gesamtpreis="0,00"
einzelpreis="0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00"></zusatzleistung>
<zusatzleistung idleistung="166408" leistung="6 Tage (So-Fr) Skipass Val Thorens" termin="15.12.2024"
terminbis="20.12.2024" unterart="SPA" anzahl="15"
zuordnung="1,2,3,4,5,6,7,8,9,10,11,12,13,14,15" gesamtpreis="0,00"
einzelpreis="0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00/0,00"></zusatzleistung>
</zusatzleistungen>
<ferienzielunterbringungen>
<ferienzielunterbringung idzimmer="1891" zimmer="8er App. Superdeluxe L'Oxalys Süd mit 7 Personen (8persSUD)"
minpax="7" maxpax="7" kategorie="8er App. SD Oxa S 7P" idverpflegung="2"
verpflegung="Laut Ausschreibung" anreise="14.12.2024" abreise="21.12.2024" anzahl="1"
zuordnung="1,2,3,4,5,6,7" gesamtpreis="5.453,00"
einzelpreis="779,00/779,00/779,00/779,00/779,00/779,00/779,00"></ferienzielunterbringung>
<ferienzielunterbringung idzimmer="1399" zimmer="8er App. Superdeluxe L'Oxalys Süd (8persSUD)" minpax="8"
maxpax="8" kategorie="8er App. SD Oxa S" idverpflegung="2"
verpflegung="Laut Ausschreibung" anreise="14.12.2024" abreise="21.12.2024" anzahl="1"
zuordnung="8,9,10,11,12,13,14,15" gesamtpreis="5.592,00"
einzelpreis="699,00/699,00/699,00/699,00/699,00/699,00/699,00/699,00"></ferienzielunterbringung>
</ferienzielunterbringungen>
<zahlung idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"></zahlung>
<zahlungsdaten>
<rechnung>66458</rechnung>
<gesamtbetrag>11463,50</gesamtbetrag>
<zahlungen>
<zahlung datum="29.05.24" betrag="2209,00" zahlungsart="Überweisung"></zahlung>
</zahlungen>
<restbetrag>9254,50</restbetrag>
<faelligdatum>16.11.24</faelligdatum>
</zahlungsdaten>
</ergebnis>
+279
View File
@@ -0,0 +1,279 @@
<?xml version="1.0" encoding="utf-8"?>
<ergebnis>
<satz typ="KUNDENKONTO"></satz>
<art>Vorgänge</art>
<idadresse>141747</idadresse>
<idperson>224526</idperson>
<vorgaenge>
<vorgang>
<id>75353</id>
<vorgangsnummer>88130</vorgangsnummer>
<status>S</status>
<kundennamen>
<kunde id="1" art="Anmelder/Teilnehmer" name="Fromme" vorname="Björn"></kunde>
<kunde id="2" art="Teilnehmer" name="Fromme" vorname="Testperson 1"></kunde>
<kunde id="3" art="Teilnehmer" name="Fromme" vorname="Testperson 2"></kunde>
</kundennamen>
<personen>3</personen>
<preis>0,00</preis>
<buchungsdatum>03.09.2024 13:56:36</buchungsdatum>
<reise>Davos - Sportclub Waldschlössli - FOBI - Office</reise>
<reisedatum>14.11.2024</reisedatum>
<reisedokument>True</reisedokument>
<zahlungsart idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart>
</vorgang>
<vorgang>
<id>49513</id>
<vorgangsnummer>62290</vorgangsnummer>
<status>S</status>
<kundennamen>
<kunde id="1" art="Anmelder/Teilnehmer" name="Fromme" vorname="Björn"></kunde>
<kunde id="2" art="Teilnehmer" name="Rütten" vorname="Manuela"></kunde>
<kunde id="3" art="Teilnehmer" name="Wil" vorname="auch mit Oliver"></kunde>
</kundennamen>
<personen>3</personen>
<preis>0,00</preis>
<buchungsdatum>09.11.2021 14:42:08</buchungsdatum>
<reise>Davos - Sportclub Waldschlössli - Family</reise>
<reisedatum>09.04.2022</reisedatum>
<idrechnung>68303</idrechnung>
<rechnungsnummer>44044</rechnungsnummer>
<zahlung>0</zahlung>
<reisedokument>True</reisedokument>
<zahlungsart idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart>
</vorgang>
<vorgang>
<id>47496</id>
<vorgangsnummer>60273</vorgangsnummer>
<status>S</status>
<kundennamen>
<kunde id="1" art="Anmelder/Teilnehmer" name="Fromme" vorname="Björn"></kunde>
<kunde id="2" art="Teilnehmer" name="Rütten" vorname="Manuela"></kunde>
<kunde id="3" art="Teilnehmer" name="Test" vorname="Tori"></kunde>
<kunde id="4" art="Teilnehmer" name="Mit" vorname="dabei Jana"></kunde>
</kundennamen>
<personen>4</personen>
<preis>30,00</preis>
<buchungsdatum>03.11.2021 12:16:24</buchungsdatum>
<reise>Lenzerheide - Sportclub Jenatsch</reise>
<reisedatum>11.12.2021</reisedatum>
<idrechnung>66340</idrechnung>
<rechnungsnummer>42279</rechnungsnummer>
<zahlung>0</zahlung>
<reisedokument>True</reisedokument>
<zahlungsart idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart>
</vorgang>
<vorgang>
<id>47490</id>
<vorgangsnummer>60267</vorgangsnummer>
<status>S</status>
<kundennamen>
<kunde id="1" art="Anmelder/Teilnehmer" name="Fromme" vorname="Björn"></kunde>
<kunde id="2" art="Teilnehmer" name="Rütten" vorname="Manuela"></kunde>
<kunde id="3" art="Teilnehmer" name="Larch" vorname="Kathi"></kunde>
<kunde id="4" art="Teilnehmer" name="Schumsky" vorname="Jana"></kunde>
</kundennamen>
<personen>4</personen>
<preis>0,00</preis>
<buchungsdatum>05.10.2021 12:18:44</buchungsdatum>
<reise>Val Thorens - Ski &amp; Boarderweek</reise>
<reisedatum>11.12.2021</reisedatum>
<idrechnung>66332</idrechnung>
<rechnungsnummer>42277</rechnungsnummer>
<zahlung>0</zahlung>
<reisedokument>True</reisedokument>
<zahlungsart idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart>
</vorgang>
<vorgang>
<id>44860</id>
<vorgangsnummer>57637</vorgangsnummer>
<status>S</status>
<kundennamen>
<kunde id="1" art="Anmelder/Teilnehmer" name="Fromme" vorname="Björn"></kunde>
<kunde id="2" art="Teilnehmer" name="Larch" vorname="Katharina"></kunde>
<kunde id="3" art="Teilnehmer" name="Peters" vorname="Victoria"></kunde>
<kunde id="4" art="Teilnehmer" name="Schumsky" vorname="Jana"></kunde>
<kunde id="5" art="Teilnehmer" name="Rütten" vorname="Manuela"></kunde>
<kunde id="6" art="Teilnehmer" name="Dünzer" vorname="Maren Carolin"></kunde>
<kunde id="7" art="Teilnehmer" name="von" vorname="test Max"></kunde>
<kunde id="8" art="Teilnehmer" name="Doppel-Name" vorname="Rolf"></kunde>
<kunde id="9" art="Teilnehmer" name="Will" vorname="auch mit Oliver"></kunde>
<kunde id="10" art="Teilnehmer" name="Borosch" vorname="Niklas"></kunde>
<kunde id="11" art="Teilnehmer" name="Brückner" vorname="Andreas"></kunde>
<kunde id="12" art="Teilnehmer" name="Schlimgen" vorname="Flo"></kunde>
<kunde id="13" art="Teilnehmer" name="Lüffe" vorname="Tanja"></kunde>
<kunde id="14" art="Teilnehmer" name="Aisch" vorname="Sabine"></kunde>
<kunde id="15" art="Teilnehmer" name="Meier" vorname="Thomas"></kunde>
<kunde id="16" art="Teilnehmer" name="Müller" vorname="Max"></kunde>
<kunde id="17" art="Teilnehmer" name="Schmitz" vorname="Holger"></kunde>
<kunde id="18" art="Teilnehmer" name="noch" vorname="offen"></kunde>
</kundennamen>
<personen>20</personen>
<preis>0,00</preis>
<buchungsdatum>01.09.2020 12:07:11</buchungsdatum>
<reise>Davos Klosters - Sportclub Schwendi - Test Gruppenbuchung #3</reise>
<reisedatum>30.10.2021</reisedatum>
<idrechnung>63035</idrechnung>
<rechnungsnummer>39862</rechnungsnummer>
<zahlung>0</zahlung>
<reisedokument>False</reisedokument>
<zahlungsart idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart>
</vorgang>
<vorgang>
<id>44474</id>
<vorgangsnummer>57251</vorgangsnummer>
<status>S</status>
<kundennamen>
<kunde id="1" art="Anmelder/Teilnehmer" name="Fromme" vorname="Björn"></kunde>
<kunde id="2" art="Teilnehmer" name="Teamer" vorname="Muster"></kunde>
<kunde id="3" art="Teilnehmer" name="Mustermann" vorname="max"></kunde>
<kunde id="4" art="Teilnehmer" name="OPTION" vorname=""></kunde>
</kundennamen>
<personen>22</personen>
<preis>0,00</preis>
<buchungsdatum>30.06.2020 13:32:57</buchungsdatum>
<reise>Davos Klosters - Sportclub Schwendi - Test Gruppenbuchung #2</reise>
<reisedatum>05.09.2021</reisedatum>
<idrechnung>62524</idrechnung>
<rechnungsnummer>39500</rechnungsnummer>
<zahlung>0</zahlung>
<reisedokument>True</reisedokument>
<zahlungsart idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart>
</vorgang>
<vorgang>
<id>44192</id>
<vorgangsnummer>56969</vorgangsnummer>
<status>S</status>
<kundennamen>
<kunde id="1" art="Anmelder/Teilnehmer" name="Fromme" vorname="Björn"></kunde>
<kunde id="2" art="Teilnehmer" name="Rütten" vorname="Manu"></kunde>
<kunde id="3" art="Teilnehmer" name="Petersen" vorname="Rolf"></kunde>
<kunde id="4" art="Teilnehmer" name="Fromme" vorname="Vorname"></kunde>
</kundennamen>
<personen>30</personen>
<preis>0,00</preis>
<buchungsdatum>28.04.2020 14:43:59</buchungsdatum>
<reise>Davos Klosters - Sportclub Schwendi - Test Gruppenbuchung #2</reise>
<reisedatum>05.09.2021</reisedatum>
<idrechnung>62013</idrechnung>
<rechnungsnummer>39112</rechnungsnummer>
<zahlung>0</zahlung>
<reisedokument>True</reisedokument>
<zahlungsart idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart>
</vorgang>
<vorgang>
<id>41970</id>
<vorgangsnummer>54747</vorgangsnummer>
<status>S</status>
<kundennamen>
<kunde id="1" art="Anmelder/Teilnehmer" name="Fromme" vorname="Björn"></kunde>
<kunde id="2" art="Teilnehmer" name="Fromme" vorname="Vorname"></kunde>
<kunde id="3" art="Teilnehmer" name="Petersen" vorname="Rolf"></kunde>
</kundennamen>
<personen>3</personen>
<preis>0,00</preis>
<buchungsdatum>19.12.2019 13:11:01</buchungsdatum>
<reise>Davos Klosters - Sportclub Schwendi - Test Gruppenbuchung</reise>
<reisedatum>19.04.2020</reisedatum>
<idrechnung>58557</idrechnung>
<rechnungsnummer>37041</rechnungsnummer>
<zahlung>0</zahlung>
<reisedokument>False</reisedokument>
<zahlungsart idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart>
</vorgang>
<vorgang>
<id>41855</id>
<vorgangsnummer>54632</vorgangsnummer>
<status>S</status>
<kundennamen>
<kunde id="1" art="Anmelder/Teilnehmer" name="Fromme" vorname="Björn"></kunde>
</kundennamen>
<personen>1</personen>
<preis>0,00</preis>
<buchungsdatum>17.12.2019 12:47:46</buchungsdatum>
<reise>Davos Klosters - Sportclub Schwendi - Test Gruppenbuchung</reise>
<reisedatum>19.04.2020</reisedatum>
<idrechnung>58419</idrechnung>
<rechnungsnummer>36911</rechnungsnummer>
<zahlung>0</zahlung>
<reisedokument>False</reisedokument>
<zahlungsart idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart>
</vorgang>
<vorgang>
<id>41546</id>
<vorgangsnummer>54323</vorgangsnummer>
<status>S</status>
<kundennamen>
<kunde id="1" art="Anmelder/Teilnehmer" name="Fromme" vorname="Björn"></kunde>
<kunde id="2" art="Teilnehmer" name="Fromme" vorname="Vorname"></kunde>
</kundennamen>
<personen>4</personen>
<preis>0,00</preis>
<buchungsdatum>10.12.2019 15:56:40</buchungsdatum>
<reise>Davos Klosters - Sportclub Schwendi - Test Gruppenbuchung</reise>
<reisedatum>19.04.2020</reisedatum>
<idrechnung>58095</idrechnung>
<rechnungsnummer>36663</rechnungsnummer>
<zahlung>0</zahlung>
<reisedokument>False</reisedokument>
<zahlungsart idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart>
</vorgang>
<vorgang>
<id>41125</id>
<vorgangsnummer>53902</vorgangsnummer>
<status>S</status>
<kundennamen>
<kunde id="1" art="Anmelder" name="Fromme" vorname="Björn"></kunde>
<kunde id="2" art="Teilnehmer" name="Fromme" vorname="1"></kunde>
<kunde id="3" art="Teilnehmer" name="Fromme" vorname="2"></kunde>
<kunde id="4" art="Teilnehmer" name="Fromme" vorname="3"></kunde>
<kunde id="5" art="Teilnehmer" name="Fromme" vorname="4"></kunde>
<kunde id="6" art="Teilnehmer" name="Fromme" vorname="5"></kunde>
<kunde id="7" art="Teilnehmer" name="Fromme" vorname="6"></kunde>
<kunde id="8" art="Teilnehmer" name="Fromme" vorname="7"></kunde>
<kunde id="9" art="Teilnehmer" name="Fromme" vorname="8"></kunde>
<kunde id="10" art="Teilnehmer" name="Fromme" vorname="9"></kunde>
<kunde id="11" art="Teilnehmer" name="Fromme" vorname="10"></kunde>
<kunde id="12" art="Teilnehmer" name="Rütten" vorname="Manuela"></kunde>
<kunde id="13" art="Teilnehmer" name="Fromme" vorname="12"></kunde>
<kunde id="14" art="Teilnehmer" name="Fromme" vorname="13"></kunde>
<kunde id="15" art="Teilnehmer" name="Fromme" vorname="14"></kunde>
<kunde id="16" art="Teilnehmer" name="Fromme" vorname="15"></kunde>
<kunde id="17" art="Teilnehmer" name="Fromme" vorname="16"></kunde>
<kunde id="18" art="Teilnehmer" name="Bär" vorname="17"></kunde>
<kunde id="19" art="Teilnehmer" name="Duschen" vorname="18"></kunde>
<kunde id="20" art="Teilnehmer" name="Teilnehmer" vorname="b"></kunde>
<kunde id="21" art="Teilnehmer" name="Testinger" vorname="20"></kunde>
</kundennamen>
<personen>20</personen>
<preis>0,00</preis>
<buchungsdatum>29.11.2019 15:30:23</buchungsdatum>
<reise>Davos Klosters - Sportclub Schwendi - Test Gruppenbuchung</reise>
<reisedatum>19.04.2020</reisedatum>
<idrechnung>57649</idrechnung>
<rechnungsnummer>36305</rechnungsnummer>
<zahlung>0</zahlung>
<reisedokument>False</reisedokument>
<zahlungsart idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart>
</vorgang>
<vorgang>
<id>40067</id>
<vorgangsnummer>52844</vorgangsnummer>
<status>S</status>
<kundennamen>
<kunde id="1" art="Anmelder/Teilnehmer" name="Fromme" vorname="Björn"></kunde>
<kunde id="2" art="Teilnehmer" name="Fromme" vorname="Testgruppe"></kunde>
</kundennamen>
<personen>21</personen>
<preis>0,00</preis>
<buchungsdatum>29.11.2019 14:48:38</buchungsdatum>
<reise>Davos Klosters - Sportclub Schwendi - Test Gruppenbuchung</reise>
<reisedatum>19.04.2020</reisedatum>
<idrechnung>56436</idrechnung>
<rechnungsnummer>35200</rechnungsnummer>
<zahlung>0</zahlung>
<reisedokument>False</reisedokument>
<zahlungsart idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart>
</vorgang>
</vorgaenge>
</ergebnis>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff