Compare commits
103
Commits
5c57dc823f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c34cfa76a | ||
|
|
9d0cdd7f52 | ||
|
|
9583612fdc | ||
|
|
0bc77b7463 | ||
|
|
4ff963d1e2 | ||
|
|
f9d2ff084d | ||
|
|
7aefe88610 | ||
|
|
09eea86183 | ||
|
|
ddfaa4b440 | ||
|
|
d3b9b0611b | ||
|
|
fe0c854f1d | ||
|
|
0b736b51fb | ||
|
|
af97468b2d | ||
|
|
a0170f5952 | ||
|
|
2f4f9800dd | ||
|
|
9fedc5b5d3 | ||
|
|
a35f91ac63 | ||
|
|
890e04f8a6 | ||
|
|
5e4e574076 | ||
|
|
2a041d25a0 | ||
|
|
df6734c939 | ||
|
|
8070fda64d | ||
|
|
d06b647dac | ||
|
|
a84275b40e | ||
|
|
2ec2a4c93f | ||
|
|
de4aedd26e | ||
|
|
5090f72fab | ||
|
|
c00dd86eb4 | ||
|
|
300bb2058f | ||
|
|
3fe510e64e | ||
|
|
48691227cb | ||
|
|
34ff630b75 | ||
|
|
3f806669c6 | ||
|
|
e31f607f66 | ||
|
|
8c53676133 | ||
|
|
2de985b009 | ||
|
|
1cda9d378a | ||
|
|
9fcba26942 | ||
|
|
13e5b6c3af | ||
|
|
493a43d952 | ||
|
|
7493afde45 | ||
|
|
d159cb212c | ||
|
|
c7e0ad67e6 | ||
|
|
f0ad5374e9 | ||
|
|
96d49fc488 | ||
|
|
2a554bb7bf | ||
|
|
c16e4bf53f | ||
|
|
18fb3382c6 | ||
|
|
0e2021e96d | ||
|
|
2fc9806c69 | ||
|
|
3116d23199 | ||
|
|
4aa2d7a5e6 | ||
|
|
7e182e3d51 | ||
|
|
c04d9e0faf | ||
|
|
3188f5f3da | ||
|
|
57781ba347 | ||
|
|
849da8d471 | ||
|
|
da039d0527 | ||
|
|
1446211d22 | ||
|
|
838b8944a2 | ||
|
|
a4206f51eb | ||
|
|
2329fe17a0 | ||
|
|
72be779038 | ||
|
|
d8ff148f86 | ||
|
|
db529d2dc9 | ||
|
|
aa66c50979 | ||
|
|
0f76c5a7b6 | ||
|
|
5c7e702eab | ||
|
|
169a17e19c | ||
|
|
6c0a512c05 | ||
|
|
2e9328fc51 | ||
|
|
1505f9b50b | ||
|
|
392e67448e | ||
|
|
8da9af46f5 | ||
|
|
ca4a73eb4f | ||
|
|
1a7c272414 | ||
|
|
7cf39850e6 | ||
|
|
4cab57d136 | ||
|
|
80c6016da3 | ||
|
|
69b8e549ee | ||
|
|
77986776c2 | ||
|
|
cf876ed224 | ||
|
|
675cec1b69 | ||
|
|
e460b18661 | ||
|
|
5141673bcd | ||
|
|
b530679658 | ||
|
|
d5f4b12f0a | ||
|
|
aa728ddc99 | ||
|
|
19364df2db | ||
|
|
cb2d64f2cd | ||
|
|
f073a2014d | ||
|
|
30ad5e8488 | ||
|
|
f48e53fdd0 | ||
|
|
3f4586ce06 | ||
|
|
2a8649fc6d | ||
|
|
161cfb9e62 | ||
|
|
eac125517f | ||
|
|
af46c57bd1 | ||
|
|
d6327f033e | ||
|
|
752db081b3 | ||
|
|
518f3a46d0 | ||
|
|
a05143bfbe | ||
|
|
29cedd5916 |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# Tailwind 4's floor: browsers without cascade layers, or Chrome before 111, can't render the
|
||||
# app's CSS, so the JS isn't transpiled for them either.
|
||||
defaults and fully supports css-cascade-layers
|
||||
not chrome < 111
|
||||
+48
-33
@@ -1,24 +1,26 @@
|
||||
type: php
|
||||
type: symfony
|
||||
docroot: public
|
||||
php_version: "8.4"
|
||||
php_version: "8.5"
|
||||
webserver_type: apache-fpm
|
||||
xdebug_enabled: false
|
||||
additional_hostnames: []
|
||||
additional_fqdns: []
|
||||
database:
|
||||
type: mysql
|
||||
version: "8.4"
|
||||
type: mariadb
|
||||
version: "11.8"
|
||||
hooks:
|
||||
post-start:
|
||||
- exec: mysql -uroot -proot -hdb -e "CREATE DATABASE IF NOT EXISTS db_test CHARACTER SET utf8mb4; GRANT ALL ON db_test.* TO 'db'@'%';"
|
||||
- exec: php bin/console doctrine:schema:update --force --env=test --no-interaction
|
||||
omit_containers: [ddev-ssh-agent]
|
||||
use_dns_when_possible: true
|
||||
timezone: Europe/Berlin
|
||||
composer_version: "2"
|
||||
web_environment: []
|
||||
nodejs_version: "18"
|
||||
nodejs_version: "24"
|
||||
corepack_enable: false
|
||||
disable_upload_dirs_warning: true
|
||||
|
||||
xhgui_https_port: "8142"
|
||||
xhgui_http_port: "8143"
|
||||
|
||||
# Key features of DDEV's config.yaml:
|
||||
|
||||
# name: <projectname> # Name of the project, automatically provides
|
||||
@@ -26,16 +28,16 @@ xhgui_http_port: "8143"
|
||||
# If the name is omitted, the project will take the name of the enclosing directory,
|
||||
# which is useful if you want to have a copy of the project side by side with this one.
|
||||
|
||||
# type: <projecttype> # backdrop, cakephp, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, generic, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress
|
||||
# type: <projecttype> # asterios, backdrop, cakephp, codeigniter, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, drupal12, generic, joomla, laravel, magento, magento2, maho, modx, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock
|
||||
# See https://docs.ddev.com/en/stable/users/quickstart/ for more
|
||||
# information on the different project types
|
||||
|
||||
# docroot: <relative_path> # Relative path to the directory containing index.php.
|
||||
|
||||
# php_version: "8.3" # PHP version to use, "5.6" through "8.5"
|
||||
# php_version: "8.4" # PHP version to use, "5.6" through "8.5"
|
||||
|
||||
# You can explicitly specify the webimage but this
|
||||
# is not recommended, as the images are often closely tied to DDEV's' behavior,
|
||||
# is not recommended, as the images are often closely tied to DDEV's behavior,
|
||||
# so this can break upgrades.
|
||||
|
||||
# webimage: <docker_image>
|
||||
@@ -45,10 +47,18 @@ xhgui_http_port: "8143"
|
||||
# database:
|
||||
# type: <dbtype> # mysql, mariadb, postgres
|
||||
# version: <version> # database version, like "10.11" or "8.0"
|
||||
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8
|
||||
# MySQL versions can be 5.5-8.0, 8.4
|
||||
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8, 12.3
|
||||
# MySQL versions can be 5.5-8.0, 8.4, 9.7
|
||||
# PostgreSQL versions can be 9-18
|
||||
|
||||
# You can explicitly specify the dbimage but this
|
||||
# is not recommended, as the images are often closely tied to DDEV's behavior,
|
||||
# so this can break upgrades.
|
||||
|
||||
# dbimage: <docker_image>
|
||||
# It’s unusual to change this option, and we don’t recommend it without Docker experience and a good reason.
|
||||
# Typically, this means additions to the existing db image using a .ddev/db-build/Dockerfile.*
|
||||
|
||||
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
|
||||
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
|
||||
|
||||
@@ -68,8 +78,7 @@ xhgui_http_port: "8143"
|
||||
# bind_all_ports is used (normally with router disabled)
|
||||
|
||||
# xhprof_mode: [prepend|xhgui|global]
|
||||
# Set to "xhgui" to enable XHGui features
|
||||
# "xhgui" will become default in a future major release
|
||||
# Default is "xhgui"
|
||||
|
||||
# webserver_type: nginx-fpm, apache-fpm, generic
|
||||
|
||||
@@ -87,22 +96,24 @@ xhgui_http_port: "8143"
|
||||
# commands are executed.
|
||||
|
||||
# composer_version: "2"
|
||||
# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1
|
||||
# You can set it to "" or "2" (default) for Composer v2
|
||||
# to use the latest major version available at the time your container is built.
|
||||
# It is also possible to use each other Composer version channel. This includes:
|
||||
# It is also possible to use any other Composer version channel. This includes:
|
||||
# - 2.2 (latest Composer LTS version)
|
||||
# - 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".
|
||||
# To reinstall Composer after the image was built, run "ddev utility rebuild".
|
||||
|
||||
# nodejs_version: "22"
|
||||
# nodejs_root: <relative_path>
|
||||
# Relative path to the directory containing the Node.js version file from the
|
||||
# project root. Only used with "nodejs_version: auto" or "nodejs_version: engine".
|
||||
|
||||
# nodejs_version: "24"
|
||||
# change from the default system Node.js version to any other version.
|
||||
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
|
||||
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation,
|
||||
# Note that using of 'ddev nvm' is discouraged because "nodejs_version" is much easier to use,
|
||||
# can specify any version, and is more robust than using 'nvm'.
|
||||
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation.
|
||||
|
||||
# corepack_enable: false
|
||||
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
|
||||
@@ -162,9 +173,7 @@ xhgui_http_port: "8143"
|
||||
# - "global": uses the value from the global config.
|
||||
# - "none": disables performance optimization for this project.
|
||||
# - "mutagen": enables Mutagen for this project.
|
||||
# - "nfs": enables NFS for this project.
|
||||
#
|
||||
# See https://docs.ddev.com/en/stable/users/install/performance/#nfs
|
||||
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
|
||||
|
||||
# fail_on_hook_fail: False
|
||||
@@ -194,10 +203,10 @@ xhgui_http_port: "8143"
|
||||
# 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]
|
||||
# webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
|
||||
# Extra Debian packages that are needed in the webimage can be added here
|
||||
|
||||
# dbimage_extra_packages: [telnet,netcat]
|
||||
# dbimage_extra_packages: [netcat, telnet, sudo]
|
||||
# Extra Debian packages that are needed in the dbimage can be added here
|
||||
|
||||
# use_dns_when_possible: true
|
||||
@@ -209,12 +218,15 @@ xhgui_http_port: "8143"
|
||||
# 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/agent/config/v3/#agent-configuration or run "ngrok http -h"
|
||||
# share_default_provider: ngrok
|
||||
# The default share provider to use for "ddev share"
|
||||
# Defaults to global configuration, usually "ngrok"
|
||||
# Can be "ngrok" or "cloudflared" or the name of a custom provider from .ddev/share-providers/
|
||||
|
||||
# share_provider_args: --basic-auth username:pass1234
|
||||
# Provide extra flags to the share provider script
|
||||
# See https://docs.ddev.com/en/stable/users/configuration/config/#share_provider_args
|
||||
|
||||
# disable_settings_management: false
|
||||
# If true, DDEV will not create CMS-specific settings files like
|
||||
@@ -276,7 +288,7 @@ xhgui_http_port: "8143"
|
||||
# override_config: false
|
||||
# By default, config.*.yaml files are *merged* into the configuration
|
||||
# But this means that some things can't be overridden
|
||||
# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge
|
||||
# For example, if you have 'use_dns_when_possible: true' you can't override it with a merge
|
||||
# and you can't erase existing hooks or all environment variables.
|
||||
# However, with "override_config: true" in a particular config.*.yaml file,
|
||||
# 'use_dns_when_possible: false' can override the existing values, and
|
||||
@@ -286,7 +298,7 @@ xhgui_http_port: "8143"
|
||||
# web_environment: []
|
||||
# or
|
||||
# additional_hostnames: []
|
||||
# can have their intended affect. 'override_config' affects only behavior of the
|
||||
# can have their intended effect. 'override_config' affects only behavior of the
|
||||
# config.*.yaml file it exists in.
|
||||
|
||||
# Many DDEV commands can be extended to run tasks before or after the
|
||||
@@ -296,3 +308,6 @@ xhgui_http_port: "8143"
|
||||
# information on the commands that can be extended and the tasks you can define
|
||||
# for them. Example:
|
||||
#hooks:
|
||||
## Un-comment to consume async message.
|
||||
# post-start:
|
||||
# - exec: symfony run --daemon --watch=config,src,templates,vendor bin/console messenger:consume async -vv
|
||||
@@ -27,7 +27,10 @@ APP_SECRET=b3cb0285aea14bbffb57df0cc3aa84f4
|
||||
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
|
||||
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
|
||||
# DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=15&charset=utf8"
|
||||
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.4.5&charset=utf8mb4"
|
||||
# ddev runs MariaDB to match production. The serverVersion is what makes DBAL pick its
|
||||
# MariaDB platform - left at a MySQL version it speaks MySQL to a MariaDB server, and the
|
||||
# test suite then runs against a different platform than production.
|
||||
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=mariadb-11.8.9&charset=utf8mb4"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
###> symfony/messenger ###
|
||||
@@ -44,7 +47,12 @@ MAILER_DSN=null://null
|
||||
MAILING_MAILER_DSN=null://null
|
||||
###< symfony/mailer ###
|
||||
|
||||
APP_BASE_URI=https://myep-team.ddev.site
|
||||
APP_BASE_URI=https://epteam.ddev.site
|
||||
|
||||
# Regex matched against the Host header; requests for any other host are refused. The
|
||||
# OAuth2 redirect_uri is generated from the request, so this is what pins it. Production
|
||||
# sets its own value outside the repository, like APP_BASE_URI above.
|
||||
APP_TRUSTED_HOSTS=^epteam\.ddev\.site$
|
||||
|
||||
APP_BPN_USER=
|
||||
APP_BPN_PASSWORD=
|
||||
@@ -62,11 +70,21 @@ APP_BPN_CRM_ID_TEAMER=1070
|
||||
APP_DEFAULT_EMAIL_FROM=[email protected]
|
||||
APP_DEFAULT_EMAIL_TO=[email protected]
|
||||
|
||||
SFTP_XML_EXPORT_HOST=
|
||||
SFTP_XML_EXPORT_PORT=22
|
||||
SFTP_XML_EXPORT_USER=
|
||||
SFTP_XML_EXPORT_PASSWORD=
|
||||
SFTP_XML_EXPORT_ROOT=
|
||||
# Regular expressions of the only recipients a mail may actually reach. Everything else is
|
||||
# stripped from the envelope and a mail left without a recipient is not sent at all, see
|
||||
# App\EventListener\EmailRecipientWhitelistSubscriber. Empty means no filtering, which is what
|
||||
# production runs with. Set this on staging: it runs the real transports against real teamer
|
||||
# addresses. The patterns have to match the full address.
|
||||
# Example: APP_MAIL_RECIPIENT_WHITELIST='.*@ep-reisen\.de,qa@example\.com'
|
||||
APP_MAIL_RECIPIENT_WHITELIST=
|
||||
|
||||
|
||||
# Machine to machine access to the MyE&P API. This is a separate confidential client
|
||||
# from the login one below, so it can be rotated without locking anyone out. The base
|
||||
# url is the host root: both the token endpoint and the api paths hang off it.
|
||||
MYEP_API_BASE_URL=https://my.ep-reisen.de/
|
||||
MYEP_API_CLIENT_ID=
|
||||
MYEP_API_CLIENT_SECRET=
|
||||
|
||||
MYEP_OAUTH2_CLIENT_ID=
|
||||
MYEP_OAUTH2_CLIENT_SECRET=
|
||||
@@ -74,6 +92,20 @@ MYEP_OAUTH2_URL_AUTHORIZE=https://my.ep-reisen.de/authorize
|
||||
MYEP_OAUTH2_URL_ACCESS_TOKEN=https://my.ep-reisen.de/token
|
||||
MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS=https://my.ep-reisen.de/api/userinfo
|
||||
MYEP_OAUTH2_SCOPES=email,id,roles,profile
|
||||
# Shared key the sibling websites send in the X-Api-Key header. Empty here: set it per stage.
|
||||
APPLICATION_FORM_API_KEY=
|
||||
# Shared key n8n reads the assignments feed with, in the X-Api-Key header. Empty here: set it per stage.
|
||||
N8N_API_KEY=
|
||||
# Booking link for the Team-Infocall, substituted for {bookingUrl} in the application form
|
||||
# receipt mail. Empty here: set it per stage. While it is empty that mail is not sent at all -
|
||||
# an invitation with a dead booking link is worse than none.
|
||||
APPLICATION_FORM_BOOKING_URL=
|
||||
|
||||
# SaaS time tracking. Season teamers clock their hours here; app:saas:work-hours imports them.
|
||||
# The API key travels in the URL path, so it never belongs in the committed file - set both
|
||||
# values in .env.local / the server environment.
|
||||
APP_SAAS_BASE_URL=
|
||||
APP_SAAS_API_KEY=
|
||||
|
||||
###> symfony/mailjet-mailer ###
|
||||
# Bulk teamer mailings only, everything else goes out over MAILER_DSN. Set the real
|
||||
@@ -100,8 +132,7 @@ FEATURE_SANITIZE_UPLOADS=false
|
||||
FEATURE_STAMP_INVOICES=false
|
||||
FEATURE_DATEV_EMAIL=false
|
||||
FEATURE_MYEP_LOGIN=false
|
||||
|
||||
UPLOAD_REPLACEMENT_FILE=assets/pdf/chicken.pdf
|
||||
FEATURE_UPLOAD_REPLACEMENT=false
|
||||
|
||||
MAINTENANCE_MODE_ENABLED=false
|
||||
MAINTENANCE_MODE_IP_WHITELIST=188.245.62.21
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
# define your env variables for the test env here
|
||||
KERNEL_CLASS='App\Kernel'
|
||||
APP_SECRET='$ecretf0rt3st'
|
||||
SYMFONY_DEPRECATIONS_HELPER=999999
|
||||
SYMFONY_DEPRECATIONS_HELPER=max[self]=0
|
||||
PANTHER_APP_ENV=panther
|
||||
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
|
||||
# the test client requests http://localhost/
|
||||
APP_TRUSTED_HOSTS='^localhost$'
|
||||
# The api firewall rejects every request while no key is configured, and .env.local is not
|
||||
# loaded in the test environment - so the throwaway key the tests send belongs here.
|
||||
# Keep it in sync with ApplicationFormControllerTest::KEY.
|
||||
APPLICATION_FORM_API_KEY=test-application-form-api-key
|
||||
# Keep in sync with Api\Assignment\ListControllerTest::KEY.
|
||||
N8N_API_KEY=test-n8n-api-key
|
||||
# The receipt mail refuses to render without it, and several tests send that mail.
|
||||
APPLICATION_FORM_BOOKING_URL=https://bookings.example.com/team-infocall
|
||||
+4
-2
@@ -12,11 +12,11 @@
|
||||
|
||||
###> phpunit/phpunit ###
|
||||
/phpunit.xml
|
||||
.phpunit.result.cache
|
||||
/.phpunit.cache/
|
||||
###< phpunit/phpunit ###
|
||||
|
||||
###> symfony/phpunit-bridge ###
|
||||
.phpunit.result.cache
|
||||
/.phpunit.cache/
|
||||
/phpunit.xml
|
||||
###< symfony/phpunit-bridge ###
|
||||
|
||||
@@ -35,3 +35,5 @@ yarn-error.log
|
||||
/.php-cs-fixer.php
|
||||
/.php-cs-fixer.cache
|
||||
###< friendsofphp/php-cs-fixer ###
|
||||
|
||||
/http-client.private.env.json
|
||||
@@ -0,0 +1,57 @@
|
||||
### Full submission, as the TYPO3 ApplicationApiFinisher sends it.
|
||||
### The key is APPLICATION_FORM_API_KEY of the stage being called; keep the real values in
|
||||
### http-client.private.env.json, which is not in the repository.
|
||||
POST https://myep-team.ddev.site/api/application-forms
|
||||
Content-Type: application/json
|
||||
X-Api-Key: {{applicationFormApiKey}}
|
||||
|
||||
{
|
||||
"form": "applicationform",
|
||||
"formLabel": "Bewerbungsformular",
|
||||
"submittedAt": "2026-09-22T12:30:00+02:00",
|
||||
"pageUid": 1421,
|
||||
"pageUrl": "https://www.ep-reisen.de/jobs/bewerbung",
|
||||
"firstName": "Anna",
|
||||
"lastName": "Muster",
|
||||
"email": "[email protected]",
|
||||
"phone": "+49 30 1234567",
|
||||
"mobile": "+49 170 1234567",
|
||||
"birthDate": "01.01.1990",
|
||||
"street": "Musterstraße 1",
|
||||
"zipCode": "10115",
|
||||
"city": "Berlin",
|
||||
"details": {
|
||||
"Nationalität": "deutsch",
|
||||
"Führerschein": "ja",
|
||||
"Ausbildung/Studium": "Tourismusmanagement",
|
||||
"Bewerbung als": ["Reiseleitung", "Skilehrer"],
|
||||
"Ich fahre": ["Ski", "Snowboard"],
|
||||
"Wie gut fahre ich Ski": "fortgeschritten",
|
||||
"Skifahrer seit": "2004",
|
||||
"Einsatz datum": "ab 20.12.2026"
|
||||
}
|
||||
}
|
||||
|
||||
### A form that maps nothing but the basics - valid, everything else is absent, not empty.
|
||||
POST https://myep-team.ddev.site/api/application-forms
|
||||
Content-Type: application/json
|
||||
X-Api-Key: {{applicationFormApiKey}}
|
||||
|
||||
{
|
||||
"form": "contactform",
|
||||
"formLabel": "Kontaktformular",
|
||||
"submittedAt": "2026-09-22T12:30:00+02:00",
|
||||
"details": {}
|
||||
}
|
||||
|
||||
### Wrong key: 401, and the submission is dropped on the TYPO3 side without a retry.
|
||||
POST https://myep-team.ddev.site/api/application-forms
|
||||
Content-Type: application/json
|
||||
X-Api-Key: definitely-not-the-key
|
||||
|
||||
{
|
||||
"form": "applicationform",
|
||||
"formLabel": "Bewerbungsformular",
|
||||
"submittedAt": "2026-09-22T12:30:00+02:00",
|
||||
"details": {}
|
||||
}
|
||||
@@ -3,6 +3,15 @@ htmx.config.includeIndicatorStyles = false
|
||||
htmx.config.historyCacheSize = 0
|
||||
htmx.config.allowScriptTags = false
|
||||
|
||||
// A boosted link is a page navigation: show the error page like the browser would, instead of
|
||||
// swallowing the 4xx/5xx and leaving the click without effect.
|
||||
document.addEventListener('htmx:beforeSwap', (event) => {
|
||||
if (true === event.detail.boosted && event.detail.xhr.status >= 400) {
|
||||
event.detail.shouldSwap = true
|
||||
event.detail.isError = false
|
||||
}
|
||||
})
|
||||
|
||||
import './bootstrap.js';
|
||||
/*
|
||||
* Welcome to your app's main JavaScript file!
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
/*
|
||||
* Shows a questionnaire question only while its show_if holds, mirroring
|
||||
* App\Model\Questionnaire\Definition::isVisible(): the condition must be met and the question it
|
||||
* depends on must itself be shown.
|
||||
*
|
||||
* Every question row carries data-question-key; a conditional one also data-show-if, the JSON
|
||||
* {question, operator, value} that App\Form\QuestionnaireType writes. A hidden row stays in the
|
||||
* form and is submitted: a draft keeps its answer, and the server drops it on submit.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
|
||||
connect() {
|
||||
this.update()
|
||||
}
|
||||
|
||||
update() {
|
||||
const visible = {}
|
||||
|
||||
// Rows come in definition order, and a condition may only name an earlier question, so
|
||||
// the question a row depends on has always been decided by the time the row is reached.
|
||||
for (const row of this.element.querySelectorAll('[data-question-key]')) {
|
||||
const key = row.dataset.questionKey
|
||||
const condition = row.dataset.showIf ? JSON.parse(row.dataset.showIf) : null
|
||||
|
||||
visible[key] = null === condition
|
||||
|| (true === visible[condition.question] && this.isMet(condition))
|
||||
|
||||
row.hidden = false === visible[key]
|
||||
}
|
||||
}
|
||||
|
||||
isMet({ question, operator, value }) {
|
||||
const answer = this.answer(question)
|
||||
|
||||
if (null === answer) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (operator) {
|
||||
case 'equals':
|
||||
return answer === value
|
||||
case 'in':
|
||||
return value.includes(answer)
|
||||
case 'lte':
|
||||
return Number(answer) <= value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
answer(key) {
|
||||
const row = this.element.querySelector(`[data-question-key="${key}"]`)
|
||||
|
||||
if (null === row) {
|
||||
return null
|
||||
}
|
||||
|
||||
const checked = row.querySelector('input[type="radio"]:checked')
|
||||
|
||||
if (null !== checked) {
|
||||
return checked.value
|
||||
}
|
||||
|
||||
const select = row.querySelector('select')
|
||||
|
||||
if (null !== select) {
|
||||
return '' === select.value ? null : select.value
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -198,11 +198,11 @@
|
||||
<path d="M24 0C10.7 0 0 10.7 0 24S10.7 48 24 48h8V67c0 40.3 16 79 44.5 107.5L158.1 256 76.5 337.5C48 366 32 404.7 32 445v19H24c-13.3 0-24 10.7-24 24s10.7 24 24 24H360c13.3 0 24-10.7 24-24s-10.7-24-24-24h-8V445c0-40.3-16-79-44.5-107.5L225.9 256l81.5-81.5C336 146 352 107.3 352 67V48h8c13.3 0 24-10.7 24-24s-10.7-24-24-24H24zM192 289.9l81.5 81.5C293 391 304 417.4 304 445v19H80V445c0-27.6 11-54 30.5-73.5L192 289.9zm0-67.9l-81.5-81.5C91 121 80 94.6 80 67V48H304V67c0 27.6-11 54-30.5 73.5L192 222.1z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-car" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="64" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="192" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="88" y1="176" x2="168" y2="176" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M216,176h24a8,8,0,0,0,8-8V128a8,8,0,0,0-8-8H208L162.34,74.34A8,8,0,0,0,156.69,72H44.28a8,8,0,0,0-6.65,3.56L8,120v48a8,8,0,0,0,8,8H40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="208" y1="120" x2="8" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></symbol>
|
||||
<symbol id="icon-flag-a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#fff" d="M640 480H0V0h640z"/><path fill="#ed2939" d="M640 480H0V320h640zm0-319.9H0V.1h640z"/></g></symbol>
|
||||
<symbol id="icon-flag-at" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#fff" d="M640 480H0V0h640z"/><path fill="#ed2939" d="M640 480H0V320h640zm0-319.9H0V.1h640z"/></g></symbol>
|
||||
<symbol id="icon-flag-ch" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#d52b1e" d="M0 0h640v480H0z"/><g fill="#fff"><path d="M170 195h300v90H170z"/><path d="M275 90h90v300h-90z"/></g></g></symbol>
|
||||
<symbol id="icon-flag-d" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><path fill="#ffce00" d="M0 320h640v160H0z"/><path d="M0 0h640v160H0z"/><path fill="#d00" d="M0 160h640v160H0z"/></symbol>
|
||||
<symbol id="icon-flag-f" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#00267f" d="M0 0h213.3v480H0z"/><path fill="#f31830" d="M426.7 0H640v480H426.7z"/></g></symbol>
|
||||
<symbol id="icon-flag-i" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#009246" d="M0 0h213.3v480H0z"/><path fill="#ce2b37" d="M426.7 0H640v480H426.7z"/></g></symbol>
|
||||
<symbol id="icon-flag-de" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><path fill="#ffce00" d="M0 320h640v160H0z"/><path d="M0 0h640v160H0z"/><path fill="#d00" d="M0 160h640v160H0z"/></symbol>
|
||||
<symbol id="icon-flag-fr" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#00267f" d="M0 0h213.3v480H0z"/><path fill="#f31830" d="M426.7 0H640v480H426.7z"/></g></symbol>
|
||||
<symbol id="icon-flag-it" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd" stroke-width="1pt"><path fill="#fff" d="M0 0h640v480H0z"/><path fill="#009246" d="M0 0h213.3v480H0z"/><path fill="#ce2b37" d="M426.7 0H640v480H426.7z"/></g></symbol>
|
||||
<symbol id="icon-flag-nl" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480"><g fill-rule="evenodd"><path fill="#21468b" d="M0 320h640v160H0z"/><path fill="#fff" d="M0 160h640v160H0z"/><path fill="#ae1c28" d="M0 0h640v160H0z"/></g></symbol>
|
||||
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
|
||||
<symbol id="icon-spinner" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">
|
||||
@@ -241,4 +241,8 @@
|
||||
</circle>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="icon-typo3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
|
||||
<!--! Font Awesome Pro 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
|
||||
<path d="M178.7 78.4c0-24.7 5.4-32.4 13.9-39.4-69.5 8.5-149.3 34-176.3 66.4-5.4 7.7-9.3 20.8-9.3 37.1C7 246 113.8 480 191.1 480c36.3 0 97.3-59.5 146.7-139-7 2.3-11.6 2.3-18.5 2.3-57.2 0-140.6-198.5-140.6-264.9zM301.5 32c-30.1 0-41.7 5.4-41.7 36.3 0 66.4 53.8 198.5 101.7 198.5 26.3 0 78.8-99.7 78.8-182.3 0-40.9-67-52.5-138.8-52.5z"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
Binary file not shown.
@@ -1,9 +1,9 @@
|
||||
@import "components/badge.css";
|
||||
@import "components/autocomplete.css";
|
||||
@import "components/button.css";
|
||||
@import "components/datatable.css";
|
||||
@import "components/datepicker.css";
|
||||
@import "components/toast.css";
|
||||
@import "components/tooltip.css";
|
||||
@import "components/timeline.css";
|
||||
@import "components/mail-body.css";
|
||||
@import "./components/badge.css";
|
||||
@import "./components/autocomplete.css";
|
||||
@import "./components/button.css";
|
||||
@import "./components/datatable.css";
|
||||
@import "./components/datepicker.css";
|
||||
@import "./components/toast.css";
|
||||
@import "./components/tooltip.css";
|
||||
@import "./components/timeline.css";
|
||||
@import "./components/mail-body.css";
|
||||
+44
-5
@@ -1,7 +1,46 @@
|
||||
@import "tailwindcss/base";
|
||||
@import "_base.css";
|
||||
@import "tailwindcss";
|
||||
@import "./_base.css" layer(base);
|
||||
@import "./_components.css" layer(components);
|
||||
|
||||
@import "tailwindcss/components";
|
||||
@import "_components.css";
|
||||
@plugin "@tailwindcss/forms";
|
||||
|
||||
@import "tailwindcss/utilities";
|
||||
@source inline("rotate-90");
|
||||
|
||||
@theme {
|
||||
--font-sans: Lato, sans-serif;
|
||||
--font-weight-semibold: 700;
|
||||
--font-weight-bold: 900;
|
||||
--color-primary: #3d8ccb;
|
||||
--color-secondary: #f9c700;
|
||||
}
|
||||
|
||||
/*
|
||||
* Tailwind 3 defaults that Tailwind 4 changed: borders were gray-200 instead of currentColor,
|
||||
* and buttons had a pointer cursor.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Thin scrollbars without a track for inner scrolling areas. The page scrollbar takes its
|
||||
* style from <html>, which body * leaves out. Firefox and Chrome/Edge 121+ honour these;
|
||||
* Safari keeps its own overlay scrollbars.
|
||||
*/
|
||||
@layer base {
|
||||
body * {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-gray-300) transparent;
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,11 @@
|
||||
.flatpickr-day.selected,
|
||||
.flatpickr-day.selected:hover,
|
||||
.flatpickr-day.selected:focus {
|
||||
background-color: theme('colors.secondary');
|
||||
border-color: theme('colors.secondary');
|
||||
background-color: var(--color-secondary);
|
||||
border-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.flatpickr-monthDropdown-months,
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months {
|
||||
@apply form-select pt-0 pb-0 focus:outline-none focus:shadow-none;
|
||||
@apply form-select pt-0 pb-0 focus:outline-hidden focus:shadow-none;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
.scroll-container {
|
||||
@apply overflow-scroll;
|
||||
@apply overflow-auto;
|
||||
max-height: calc(100vh - 320px);
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
@import "toastify-js/src/toastify.css";
|
||||
|
||||
.toastify {
|
||||
@apply shadow-lg rounded;
|
||||
@apply shadow-lg rounded-sm;
|
||||
}
|
||||
|
||||
.toastify--success {
|
||||
@apply text-gray-100;
|
||||
background: theme('colors.emerald.600');
|
||||
background: var(--color-emerald-600);
|
||||
}
|
||||
|
||||
.toastify--warning {
|
||||
@apply text-gray-100;
|
||||
background: theme('colors.red.600');
|
||||
background: var(--color-red-600);
|
||||
}
|
||||
|
||||
.toastify--info {
|
||||
@apply text-gray-100;
|
||||
background: theme('colors.blue.600');
|
||||
background: var(--color-blue-600);
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
@import "tippy.js/dist/tippy.css";
|
||||
|
||||
.tippy-box {
|
||||
background-color: theme('colors.primary');
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tippy-box[data-placement^='top'] > .tippy-arrow::before {
|
||||
border-top-color: theme('colors.primary');
|
||||
border-top-color: var(--color-primary);
|
||||
}
|
||||
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
|
||||
border-bottom-color: theme('colors.primary');
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
.tippy-box[data-placement^='left'] > .tippy-arrow::before {
|
||||
border-left-color: theme('colors.primary');
|
||||
border-left-color: var(--color-primary);
|
||||
}
|
||||
.tippy-box[data-placement^='right'] > .tippy-arrow::before {
|
||||
border-right-color: theme('colors.primary');
|
||||
border-right-color: var(--color-primary);
|
||||
}
|
||||
+5
-11
@@ -5,15 +5,9 @@ if (!ini_get('date.timezone')) {
|
||||
ini_set('date.timezone', 'UTC');
|
||||
}
|
||||
|
||||
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
|
||||
define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php');
|
||||
require PHPUNIT_COMPOSER_INSTALL;
|
||||
PHPUnit\TextUI\Command::main();
|
||||
} else {
|
||||
if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) {
|
||||
echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php';
|
||||
if (!is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
|
||||
echo "Unable to find PHPUnit in `vendor/phpunit/phpunit/`. Run `composer install`.\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
|
||||
+3
-2
@@ -19,6 +19,7 @@
|
||||
"doctrine/doctrine-bundle": "^2.15",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.2",
|
||||
"doctrine/orm": "^3.6",
|
||||
"dompdf/dompdf": "^3.1",
|
||||
"flagception/flagception-bundle": "^6.0",
|
||||
"knplabs/knp-menu-bundle": "^3.2",
|
||||
"knplabs/knp-paginator-bundle": "^6.2",
|
||||
@@ -88,7 +89,7 @@
|
||||
},
|
||||
"sort-packages": true,
|
||||
"platform": {
|
||||
"php": "8.4.24"
|
||||
"php": "8.5.9"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -137,7 +138,7 @@
|
||||
"fakerphp/faker": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.84",
|
||||
"marcocesarato/php-conventional-changelog": "^1.17",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"phpunit/phpunit": "^12.5",
|
||||
"rector/rector": "^2.6",
|
||||
"symfony/browser-kit": "7.4.*",
|
||||
"symfony/css-selector": "7.4.*",
|
||||
|
||||
Generated
+1493
-1161
File diff suppressed because it is too large.
Load diff
@@ -51,6 +51,74 @@ texts:
|
||||
|
||||
Bitte wende dich bei Rückfragen an [email protected].
|
||||
|
||||
application_form_received:
|
||||
label: 'Eingegangenes Formular - Eingangsbestätigung'
|
||||
# Only bookingUrl, which is not caller-supplied like the others: it comes from
|
||||
# APPLICATION_FORM_BOOKING_URL and is substituted into the Markdown before it is parsed,
|
||||
# which is what lets it be a link target (see App\Email\EmailTextRenderer). The mailer
|
||||
# also passes firstName, lastName and formLabel, so an admin who adds one of those tokens
|
||||
# by hand still gets it substituted - they are just not advertised, because the wording
|
||||
# greets from Köln rather than by name and EmailTextCatalogTest insists every declared
|
||||
# placeholder appears in the default text.
|
||||
placeholders: ['bookingUrl', 'season']
|
||||
subject: 'Einladung zum Team-Infocall'
|
||||
headline: 'Hallo aus Köln,'
|
||||
body: |-
|
||||
vielen Dank für deine Bewerbung für die kommende Wintersaison {season}!
|
||||
|
||||
Die Teamplanung der Wintersaison läuft wieder an und um herauszufinden, wie du zu E&P Reisen passt, laden wir dich hiermit zum Team-Infocall ein.
|
||||
|
||||
Beim Team-Infocall erhältst du allgemeine Infos über E&P Reisen, welche Reisekonzepte und Destinationen wir anbieten und welche Jobs es dort gibt. Natürlich hast du auch die Gelegenheit offene Fragen zu stellen.
|
||||
|
||||
Der Termin wird ca. eine Stunde in Anspruch nehmen und mit mehreren Teilnehmenden als Videocall über Teams stattfinden. Nach der Buchung des Termins erhältst du alle Infos, um an dem Call teilzunehmen, das geht auch als Gast über einen Webbrowser.
|
||||
|
||||
Bitte buche dir einen passenden Termin unter [diesem Buchungslink]({bookingUrl}). Bis zu Beginn der Saison werden noch weitere Termine für eine Teilnahme online gestellt.
|
||||
|
||||
Folge uns doch gerne schon auf Instagram unter team_ep_reisen für kurzfristige Einsätze, relevante News und coolen Ski- und Boardcontent.
|
||||
|
||||
Vielleicht hast du auch Lust im Sommer schon für uns zu arbeiten? Schreibe uns gerne in einer separaten Mail 🙂
|
||||
|
||||
Wir freuen uns darauf, dich kennenzulernen und hoffen, dich bald in unserem Team begrüßen zu können!
|
||||
|
||||
application_form_follow_up:
|
||||
label: 'Eingegangenes Formular - Nachfassen'
|
||||
# No placeholders, same as the receipt: the wording greets from Köln, not by name, and
|
||||
# EmailTextCatalogTest insists every declared placeholder appears in the default text.
|
||||
#
|
||||
# This wording goes stale by design - it names concrete call dates and a Bookings outage.
|
||||
# It is the fallback for a fresh database only: the moment somebody edits the mail under
|
||||
# /admin/system/email-text, that override wins and this block stops being sent. Maintain
|
||||
# it there, not here.
|
||||
placeholders: []
|
||||
subject: 'Termine für die Wochenteam-Calls'
|
||||
headline: 'Hallo aus Köln,'
|
||||
body: |-
|
||||
schön, dass du bei unserem Digitalen Infotreffen dabei warst! Ich hoffe wir konnten bereits einige deiner Fragen beantworten. Aufgrund der Fülle an Informationen habe ich ein kleines [Handout](https://PLATZHALTER-HANDOUT) zusammengestellt, damit du die wichtigsten Infos nochmal auf einen Blick hast.
|
||||
|
||||
Nun geht es für dich in die 2. Runde! Wir wollen dich besser kennenlernen.
|
||||
|
||||
## Wochenteam
|
||||
|
||||
Leider gibt es aktuell technische Probleme mit Bookings, sodass ihr momentan keine Termine für die Wochenteam-Calls einsehen oder buchen könnt. Deshalb organisieren wir die Anmeldung vorübergehend manuell. Hier findest du alle Termine für die Wochenteam-Calls. Wenn du an einem der Termine teilnehmen möchtest, antworte bitte einfach auf diese E-Mail und teile uns deinen Wunschtermin mit. Wir melden dich dann manuell an und senden dir rechtzeitig die Zugangsdaten für den entsprechenden Call zu. **Wichtig:** Sobald das Wochenteam-Seminar vollständig besetzt ist, werden keine weiteren Wochenteam-Calls mehr angeboten.
|
||||
|
||||
Folgende Termine sind möglich:
|
||||
|
||||
- Mi 30.9 14:30-15:15Uhr
|
||||
- Fr 9.10 9:30-10:15Uhr
|
||||
- Fr 16.10 14:30-15:15Uhr
|
||||
- Mi 21.10 14:30-15:15Uhr
|
||||
- Fr 30.10 11-11:45Uhr
|
||||
|
||||
## Saisonpersonal
|
||||
|
||||
Interessierst du dich dafür als Saisonmitarbeiter\*in (die ganze Saison) in den Bergen mitzuarbeiten? Wir haben unser Saisonpersonal für diesen Winter bereits vollständig besetzt. Bei Interesse kannst du uns gerne eine E-Mail mit deinem Interesse und dem gewünschten Zeitraum schicken – falls es durch kurzfristige Änderungen, wie z. B. Verletzungen, zu einer Ausnahme kommt, würden wir darauf zurückkommen. Um dir sicherer noch in diesem Winter einen Platz in unserem Team und in den Bergen zu ermöglichen, empfehlen wir dir, Teil unseres Wochenteams zu werden.
|
||||
|
||||
Wenn du in der Zwischenzeit eine Frage hast, dann kannst du dich gerne per E-Mail oder telefonisch melden! Wir freuen uns darauf, dich besser kennenzulernen. 😊
|
||||
|
||||
Schöne Grüße aus Köln
|
||||
|
||||
Eure Personalabteilung
|
||||
|
||||
application_rejected:
|
||||
label: 'Bewerbung abgelehnt'
|
||||
placeholders: ['destination']
|
||||
@@ -108,6 +176,20 @@ texts:
|
||||
|
||||
**{destination}**
|
||||
|
||||
# Weekly, for as long as an entry of the season checklist is not ticked off. It is not tied
|
||||
# to a deadline, so it must not name one.
|
||||
reminder_season_tasks:
|
||||
label: 'Erinnerung: Saisonunterlagen'
|
||||
placeholders: ['destination', 'tasks']
|
||||
subject: 'Reminder: Fehlende Saisonunterlagen'
|
||||
headline: 'Hallo aus Köln,'
|
||||
body: |-
|
||||
für deinen Saisoneinsatz **{destination}** fehlen uns noch folgende Unterlagen:
|
||||
|
||||
{tasks}
|
||||
|
||||
Im E&P-Team Portal findest du unter "Saisonunterlagen" die Vorlagen zum Herunterladen und kannst deine unterschriebenen Unterlagen direkt hochladen.
|
||||
|
||||
reminder_invoice_upload:
|
||||
label: 'Erinnerung: Honorarnote hochladen'
|
||||
placeholders: ['destination', 'invoiceUploadDeadlineDays']
|
||||
@@ -146,7 +228,38 @@ texts:
|
||||
|
||||
# Descriptions shown next to the form fields. They describe the placeholder itself rather
|
||||
# than any one mail, so they are shared: a name means the same thing wherever it appears.
|
||||
# Daily, for as long as a work month has no signed Arbeitszeitnachweis on it. The month
|
||||
# is named rather than a deadline: there is none, the office simply wants it back.
|
||||
reminder_work_month_upload:
|
||||
label: 'Erinnerung: Arbeitszeitnachweis hochladen'
|
||||
placeholders: ['workMonth']
|
||||
subject: 'Reminder: Fehlender Arbeitszeitnachweis'
|
||||
headline: 'Hallo aus Köln,'
|
||||
body: |-
|
||||
bitte denke daran, deinen unterschriebenen Arbeitszeitnachweis hochzuladen. Ohne ihn können wir den Monat nicht abrechnen.
|
||||
|
||||
**{workMonth}**
|
||||
|
||||
Du findest ihn im Portal unter "Mein Profil - Arbeitszeiten": dort herunterladen, unterschreiben und wieder hochladen.
|
||||
|
||||
work_month_rejected:
|
||||
label: 'Arbeitszeitnachweis abgelehnt'
|
||||
placeholders: ['workMonth', 'comment']
|
||||
subject: 'Dein Arbeitszeitnachweis wurde abgelehnt'
|
||||
headline: 'Hallo aus Köln,'
|
||||
body: |-
|
||||
leider können wir deinen Arbeitszeitnachweis für **{workMonth}** so nicht annehmen.
|
||||
|
||||
Begründung: {comment}
|
||||
|
||||
Bitte lade ihn im Portal unter "Mein Profil - Arbeitszeiten" noch einmal hoch.
|
||||
|
||||
placeholders:
|
||||
bookingUrl: 'Buchungslink für den Team-Infocall, kommt aus der Konfiguration (APPLICATION_FORM_BOOKING_URL)'
|
||||
season: 'Kommende Saison in Kurzform, z. B. "26/27" - wird automatisch eingesetzt'
|
||||
firstName: 'Vorname der Person, die das Formular abgeschickt hat'
|
||||
lastName: 'Nachname der Person, die das Formular abgeschickt hat'
|
||||
formLabel: 'Name des Formulars, z. B. "Bewerbungsformular"'
|
||||
destination: 'Einsatz, z. B. "12.01.25 - 19.01.25 Skireise"'
|
||||
specialAgreements: 'Zusätzliche Absprachen zum Einsatz'
|
||||
contractUploadDeadlineDays: 'Frist zum Hochladen des Honorarvertrags in Tagen'
|
||||
@@ -159,3 +272,5 @@ placeholders:
|
||||
dateFrom: 'Beginn des Einsatzes'
|
||||
dateTo: 'Ende des Einsatzes'
|
||||
diffInDays: 'Tage bis zum Beginn des Einsatzes'
|
||||
tasks: 'Noch offene Punkte der Saison-Checkliste, kommagetrennt'
|
||||
workMonth: 'Monat der Arbeitszeiten, z. B. "März 2026"'
|
||||
@@ -37,6 +37,13 @@ when@test:
|
||||
dbal:
|
||||
# "TEST_TOKEN" is typically set by ParaTest
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
# schema:create otherwise emits MySQL 8's platform default (utf8mb4_0900_ai_ci),
|
||||
# while the migrations that built dev and production declare utf8mb4_unicode_ci.
|
||||
# Different collation means different string comparison and ORDER BY semantics,
|
||||
# so tests would sort and match differently than production.
|
||||
default_table_options:
|
||||
charset: utf8mb4
|
||||
collation: utf8mb4_unicode_ci
|
||||
|
||||
when@prod:
|
||||
doctrine:
|
||||
|
||||
@@ -13,3 +13,6 @@ flagception:
|
||||
myep_login:
|
||||
default: false
|
||||
env: FEATURE_MYEP_LOGIN
|
||||
upload_replacement:
|
||||
default: false
|
||||
env: FEATURE_UPLOAD_REPLACEMENT
|
||||
@@ -1,13 +1,6 @@
|
||||
# Read the documentation at https://github.com/thephpleague/flysystem-bundle/blob/master/docs/1-getting-started.md
|
||||
flysystem:
|
||||
storages:
|
||||
xml_export.storage:
|
||||
sftp:
|
||||
host: '%env(SFTP_XML_EXPORT_HOST)%'
|
||||
port: '%env(int:SFTP_XML_EXPORT_PORT)%'
|
||||
username: '%env(SFTP_XML_EXPORT_USER)%'
|
||||
password: '%env(SFTP_XML_EXPORT_PASSWORD)%'
|
||||
root: '%env(SFTP_XML_EXPORT_ROOT)%'
|
||||
xml_dump.storage:
|
||||
local:
|
||||
directory: '%kernel.project_dir%/var/bpn'
|
||||
@@ -5,6 +5,11 @@ framework:
|
||||
http_method_override: false
|
||||
handle_all_throwables: true
|
||||
|
||||
# The OAuth2 redirect_uri handed to MyE&P is generated from the incoming request, so an
|
||||
# unvalidated Host header would let a crafted request point the authorization code
|
||||
# somewhere else. Regex patterns, matched against the host without the scheme or port.
|
||||
trusted_hosts: ['%env(APP_TRUSTED_HOSTS)%']
|
||||
|
||||
# Enables session support. Note that the session will ONLY be started if you read or write from it.
|
||||
# Remove or comment this section to explicitly disable session support.
|
||||
session:
|
||||
@@ -23,6 +28,22 @@ framework:
|
||||
router:
|
||||
default_uri: '%env(APP_BASE_URI)%'
|
||||
|
||||
# Every call to the sibling MyE&P application, both the token request and the API
|
||||
# itself. Paths are given relative to the base uri, so without a leading slash.
|
||||
http_client:
|
||||
scoped_clients:
|
||||
myep.client:
|
||||
base_uri: '%env(MYEP_API_BASE_URL)%'
|
||||
timeout: 10
|
||||
max_duration: 30
|
||||
headers:
|
||||
Accept: application/json
|
||||
retry_failed:
|
||||
max_retries: 2
|
||||
# 401 is deliberately absent: it needs the cached token dropped
|
||||
# before the retry, which App\MyEp\Api\ApiClient does itself.
|
||||
http_codes: [423, 425, 429, 500, 502, 503, 504]
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
test: true
|
||||
|
||||
@@ -17,7 +17,13 @@ liip_imagine:
|
||||
format: jpeg
|
||||
jpeg_quality: 75
|
||||
filters:
|
||||
thumbnail: { size: [ 128, 128 ], mode: outbound }
|
||||
# Phone photos carry their orientation in EXIF rather than in the pixels, so
|
||||
# without this the table renders them sideways while every other filter set,
|
||||
# which has always had it, shows them upright.
|
||||
auto_rotate: ~
|
||||
# outbound crops to the box but never scales up, so a photo narrower than
|
||||
# 128px came out oblong and the rounded-full avatar with it.
|
||||
thumbnail: { size: [ 128, 128 ], mode: outbound, allow_upscale: true }
|
||||
profile:
|
||||
format: jpeg
|
||||
jpeg_quality: 75
|
||||
|
||||
@@ -2,7 +2,10 @@ monolog:
|
||||
channels:
|
||||
- deprecation
|
||||
- bpn
|
||||
- saas
|
||||
- myep
|
||||
- audit
|
||||
- api
|
||||
|
||||
handlers:
|
||||
main:
|
||||
@@ -11,7 +14,7 @@ monolog:
|
||||
handler: exceptions
|
||||
excluded_http_codes: [404, 405]
|
||||
buffer_size: 50
|
||||
channels: ["!bpn", "!audit"]
|
||||
channels: ["!bpn", "!saas", "!myep", "!audit", "!api"]
|
||||
exceptions:
|
||||
type: rotating_file
|
||||
path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
|
||||
@@ -23,12 +26,32 @@ monolog:
|
||||
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
|
||||
max_files: 5
|
||||
level: info
|
||||
saas:
|
||||
channels: ["saas"]
|
||||
type: rotating_file
|
||||
path: '%kernel.logs_dir%/saas.%kernel.environment%.log'
|
||||
max_files: 5
|
||||
level: info
|
||||
myep:
|
||||
channels: ["myep"]
|
||||
type: rotating_file
|
||||
path: '%kernel.logs_dir%/myep.%kernel.environment%.log'
|
||||
max_files: 5
|
||||
level: info
|
||||
audit:
|
||||
channels: [ "audit" ]
|
||||
type: rotating_file
|
||||
path: '%kernel.logs_dir%/audit.%kernel.environment%.log'
|
||||
max_files: 5
|
||||
level: info
|
||||
# Form submissions posted by the sibling websites. Own file because it is machine
|
||||
# traffic nobody watches between incidents, and it must stay out of the audit table.
|
||||
api:
|
||||
channels: [ "api" ]
|
||||
type: rotating_file
|
||||
path: '%kernel.logs_dir%/api.%kernel.environment%.log'
|
||||
max_files: 30
|
||||
level: info
|
||||
database:
|
||||
channels: ["audit"]
|
||||
type: service
|
||||
@@ -36,7 +59,7 @@ monolog:
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!bpn", "!doctrine", "!console"]
|
||||
channels: ["!event", "!bpn", "!saas", "!myep", "!api", "!doctrine", "!console"]
|
||||
|
||||
when@dev:
|
||||
monolog:
|
||||
@@ -73,7 +96,7 @@ when@prod:
|
||||
handler: exceptions
|
||||
excluded_http_codes: [404, 405]
|
||||
buffer_size: 50
|
||||
channels: ["!bpn", "!audit"]
|
||||
channels: ["!bpn", "!saas", "!myep", "!audit", "!api"]
|
||||
exceptions:
|
||||
type: rotating_file
|
||||
path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
|
||||
@@ -85,12 +108,30 @@ when@prod:
|
||||
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
|
||||
max_files: 5
|
||||
level: info
|
||||
saas:
|
||||
channels: ["saas"]
|
||||
type: rotating_file
|
||||
path: '%kernel.logs_dir%/saas.%kernel.environment%.log'
|
||||
max_files: 5
|
||||
level: info
|
||||
myep:
|
||||
channels: ["myep"]
|
||||
type: rotating_file
|
||||
path: '%kernel.logs_dir%/myep.%kernel.environment%.log'
|
||||
max_files: 5
|
||||
level: info
|
||||
audit:
|
||||
channels: [ "audit" ]
|
||||
type: rotating_file
|
||||
path: '%kernel.logs_dir%/audit.%kernel.environment%.log'
|
||||
max_files: 5
|
||||
level: info
|
||||
api:
|
||||
channels: [ "api" ]
|
||||
type: rotating_file
|
||||
path: '%kernel.logs_dir%/api.%kernel.environment%.log'
|
||||
max_files: 30
|
||||
level: info
|
||||
database:
|
||||
channels: ["audit"]
|
||||
type: service
|
||||
@@ -98,4 +139,4 @@ when@prod:
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine", "!bpn", "!audit"]
|
||||
channels: ["!event", "!doctrine", "!bpn", "!saas", "!myep", "!audit", "!api"]
|
||||
@@ -45,6 +45,29 @@ oneup_uploader:
|
||||
namer: app.upload_namer
|
||||
storage:
|
||||
directory: '%kernel.project_dir%/uploads/receipt'
|
||||
# Saisonunterlagen. Like receipt above, both the mapping key and the directory have to
|
||||
# be exactly the Upload::TYPE_* value - out for what the office issues to one teamer,
|
||||
# in for what that teamer sends back signed.
|
||||
season_document_out:
|
||||
frontend: dropzone
|
||||
use_orphanage: true
|
||||
namer: app.upload_namer
|
||||
storage:
|
||||
directory: '%kernel.project_dir%/uploads/season_document_out'
|
||||
season_document_in:
|
||||
frontend: dropzone
|
||||
use_orphanage: true
|
||||
namer: app.upload_namer
|
||||
storage:
|
||||
directory: '%kernel.project_dir%/uploads/season_document_in'
|
||||
# Signed Arbeitszeitnachweise coming back from a teamer. Same rule again: the key and
|
||||
# the directory are exactly Upload::TYPE_WORK_MONTH.
|
||||
work_month:
|
||||
frontend: dropzone
|
||||
use_orphanage: true
|
||||
namer: app.upload_namer
|
||||
storage:
|
||||
directory: '%kernel.project_dir%/uploads/work_month'
|
||||
chunks:
|
||||
maxage: 86400
|
||||
storage:
|
||||
|
||||
@@ -7,6 +7,13 @@ security:
|
||||
entity:
|
||||
class: App\Entity\User
|
||||
property: email
|
||||
# The sibling websites post as this one; it exists in configuration only. An API caller
|
||||
# must never be a row in the user table, where it would be a login to the UI as well.
|
||||
api_user_provider:
|
||||
memory:
|
||||
users:
|
||||
api: { roles: [ ROLE_API ] }
|
||||
n8n: { roles: [ ROLE_API_ASSIGNMENTS ] }
|
||||
|
||||
role_hierarchy:
|
||||
ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ]
|
||||
@@ -16,6 +23,17 @@ security:
|
||||
dev:
|
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||
security: false
|
||||
# Stateless and key-authenticated: a machine posts here inside a visitor's form
|
||||
# submission on another site, so there is no session, no login form and no redirect.
|
||||
# Must stay above "main", whose pattern is ^/.
|
||||
api:
|
||||
pattern: ^/api
|
||||
stateless: true
|
||||
provider: api_user_provider
|
||||
access_token:
|
||||
token_handler: App\Security\AccessToken\ApiKeyTokenHandler
|
||||
token_extractors:
|
||||
- App\Security\AccessToken\ApiKeyExtractor
|
||||
main:
|
||||
lazy: true
|
||||
provider: bpn_user_provider
|
||||
@@ -29,6 +47,8 @@ security:
|
||||
path: app_security_logout
|
||||
target: app_security_login
|
||||
access_control:
|
||||
# The firewall authenticates, the controller requires ROLE_API
|
||||
- { path: '^/api', roles: PUBLIC_ACCESS, requires_channel: https }
|
||||
- { path: '^/_uploader', roles: ROLE_USER, requires_channel: https }
|
||||
- { path: '^/', roles: PUBLIC_ACCESS, requires_channel: https }
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Enabled for #[MapRequestPayload] on the api controllers - nothing else in this application
|
||||
# serializes. ALLOW_EXTRA_ATTRIBUTES stays at its default (true) on purpose: the TYPO3 form
|
||||
# payload grows keys without a release here, and rejecting an unknown one would turn every
|
||||
# later mapping change into a 400.
|
||||
framework:
|
||||
serializer:
|
||||
name_converter: ~
|
||||
@@ -12,23 +12,61 @@ zenstruck_schedule:
|
||||
enabled: true
|
||||
to: [email protected]
|
||||
|
||||
tasks:
|
||||
- task: app:bpn-import
|
||||
frequency: '0 3 * * *'
|
||||
description: "Imports dates, products and hotels from BusProNet"
|
||||
when@prod:
|
||||
zenstruck_schedule:
|
||||
tasks:
|
||||
# 03:20 rather than 03:00: MyE&P mirrors from BusProNet hourly overnight, on the
|
||||
# hour, so an import at 03:00 can read a half written export.
|
||||
- task: app:myep-import
|
||||
frequency: '20 3 * * *'
|
||||
description: "Imports travel dates, products and hotels from the MyE&P API"
|
||||
|
||||
- task: app:cron
|
||||
frequency: '0 1 * * *'
|
||||
description: "Executes several tasks triggered on a daily basis"
|
||||
- task: app:cron
|
||||
frequency: '0 1 * * *'
|
||||
description: "Executes several tasks triggered on a daily basis"
|
||||
|
||||
- task: app:teamer-status
|
||||
frequency: '30 1 * * *'
|
||||
description: "Updates status of teamers depending on their disposition"
|
||||
- task: app:teamer-status
|
||||
frequency: '30 1 * * *'
|
||||
description: "Updates status of teamers depending on their disposition"
|
||||
|
||||
- task: oneup:uploader:clear-orphans
|
||||
frequency: '30 * * * *'
|
||||
description: "Removes orphaned uploads"
|
||||
- task: app:saas:work-hours
|
||||
frequency: '0 4 10 * *'
|
||||
description: "Imports last month's working hours of season teamers from SaaS"
|
||||
|
||||
- task: app:cleanup:xml-dumps
|
||||
frequency: "0 2 * * *"
|
||||
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging"
|
||||
- task: oneup:uploader:clear-orphans
|
||||
frequency: '30 * * * *'
|
||||
description: "Removes orphaned uploads"
|
||||
|
||||
- task: app:cleanup:xml-dumps
|
||||
frequency: '0 2 * * *'
|
||||
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging"
|
||||
|
||||
when@staging:
|
||||
zenstruck_schedule:
|
||||
mailer:
|
||||
subject_prefix: "[E&P-Team STAGING]"
|
||||
|
||||
tasks:
|
||||
- task: app:myep-import
|
||||
frequency: '50 3 * * *'
|
||||
description: "Imports travel dates, products and hotels from the MyE&P API"
|
||||
|
||||
- task: app:cron
|
||||
frequency: '30 2 * * *'
|
||||
description: "Executes several tasks triggered on a daily basis"
|
||||
|
||||
- task: app:teamer-status
|
||||
frequency: '30 1 * * *'
|
||||
description: "Updates status of teamers depending on their disposition"
|
||||
|
||||
- task: app:saas:work-hours
|
||||
frequency: '0 4 10 * *'
|
||||
description: "Imports last month's working hours of season teamers from SaaS"
|
||||
|
||||
- task: oneup:uploader:clear-orphans
|
||||
frequency: '30 * * * *'
|
||||
description: "Removes orphaned uploads"
|
||||
|
||||
- task: app:cleanup:xml-dumps
|
||||
frequency: '0 2 * * *'
|
||||
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging"
|
||||
@@ -0,0 +1,271 @@
|
||||
# Questionnaire a teamer fills in after a placement with bus escort, in addition to
|
||||
# teamer_feedback.yaml and on the same page (App\Service\Questionnaire\DispositionContext).
|
||||
#
|
||||
# Replaces the Google form "Bus-Feedback 25-26"; the question-by-question spec, with every decision
|
||||
# taken, is kept locally outside the repository. Questions whose answer the
|
||||
# disposition already knows (travel date, destination, Reiseleitung, first pickup) are not asked:
|
||||
# they are snapshotted into the response's context instead.
|
||||
#
|
||||
# Labels, help texts, section titles and option labels may be edited in place. Renaming or
|
||||
# removing a question or option key, or changing a type, needs a new version: copy this file to
|
||||
# bus_feedback.v1.yaml first, then bump `version` here. Submitted answers are keyed by it.
|
||||
#
|
||||
# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it.
|
||||
#
|
||||
# Question fields: key, label, type (text | textarea | choice | multi | date | rating),
|
||||
# required (default false), help, widget (choice only: radio | dropdown, default radio),
|
||||
# options (choice and multi only), show_if ({question, equals | in | lte}, earlier questions only).
|
||||
|
||||
key: bus_feedback
|
||||
version: 1
|
||||
title: "Bus-Feedback"
|
||||
|
||||
sections:
|
||||
- key: bus_trip
|
||||
title: "Busfahrt"
|
||||
questions:
|
||||
- key: direction
|
||||
label: "Hin- oder Rückfahrt"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: outbound, label: "Hinfahrt" }
|
||||
- { key: return, label: "Rückfahrt" }
|
||||
|
||||
- key: bus_company
|
||||
label: "Bus-Unternehmen:"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: boonk, label: "Boonk Reisen" }
|
||||
- { key: piccolonia, label: "Piccolonia" }
|
||||
- { key: heess, label: "Heeß" }
|
||||
- { key: finkbeiner, label: "Finkbeiner" }
|
||||
- { key: other, label: "Anderes Unternehmen" }
|
||||
|
||||
- key: bus_company_other
|
||||
label: "Name des Busunternehmens:"
|
||||
type: text
|
||||
required: true
|
||||
show_if: { question: bus_company, equals: other }
|
||||
|
||||
- key: bus_team
|
||||
title: "Busteam"
|
||||
questions:
|
||||
- key: driver_names
|
||||
label: "Busfahrer-Namen:"
|
||||
type: text
|
||||
required: true
|
||||
|
||||
- key: bus_first_pickup
|
||||
title: "1. Abfahrtsort"
|
||||
questions:
|
||||
- key: on_time_at_first_pickup
|
||||
label: "Bus pünktlich am 1. Abfahrtsort?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: delay_at_first_pickup
|
||||
label: "Wieviel Verspätung hatte der Bus am 1. Abfahrtsort?"
|
||||
type: text
|
||||
required: true
|
||||
show_if: { question: on_time_at_first_pickup, equals: "no" }
|
||||
|
||||
- key: bus_rating
|
||||
title: "Bewertung Bus & Fahrer"
|
||||
questions:
|
||||
- key: overall_impression
|
||||
label: "Gesamteindruck des Busses"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut" }
|
||||
- { key: good, label: "Gut" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_good, label: "nicht so gut" }
|
||||
- { key: bad, label: "schlecht" }
|
||||
|
||||
- key: vehicle_age
|
||||
label: "Alter des Fahrzeugs"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_new, label: "Sehr neu" }
|
||||
- { key: new, label: "neu" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: somewhat_old, label: "etwas älter" }
|
||||
- { key: very_old, label: "sehr alt" }
|
||||
|
||||
- key: seating_comfort
|
||||
label: "Sitzkomfort im Bus"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut" }
|
||||
- { key: good, label: "Gut" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_good, label: "nicht so gut" }
|
||||
- { key: bad, label: "schlecht" }
|
||||
|
||||
- key: driver_friendliness
|
||||
label: "Freundlichkeit der Fahrer"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_friendly, label: "Sehr freundlich" }
|
||||
- { key: friendly, label: "freundlich" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_friendly, label: "nicht so freundlich" }
|
||||
- { key: unfriendly, label: "unfreundlich" }
|
||||
|
||||
- key: breaks
|
||||
label: "Pausenzeiten"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut" }
|
||||
- { key: good, label: "Gut" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_good, label: "nicht so gut" }
|
||||
- { key: unacceptable, label: "geht gar nicht" }
|
||||
|
||||
- key: temperature
|
||||
label: "Temperatur im Bus"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut" }
|
||||
- { key: good, label: "Gut" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_good, label: "nicht so gut" }
|
||||
- { key: unacceptable, label: "geht gar nicht" }
|
||||
|
||||
- key: guest_behaviour
|
||||
label: "Verhalten der Gäste im Bus"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut" }
|
||||
- { key: good, label: "Gut" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_good, label: "nicht so gut" }
|
||||
- { key: unacceptable, label: "geht gar nicht" }
|
||||
|
||||
- key: bus_pickups
|
||||
title: "Ladestellen"
|
||||
questions:
|
||||
- key: drivers_knew_pickups
|
||||
label: "Waren den Fahrern alle Abfahrtsorte bekannt?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: unknown_pickups
|
||||
label: "Welche Abfahrtsorte waren nicht bekannt? Gab es dadurch Probleme?"
|
||||
type: textarea
|
||||
required: true
|
||||
show_if: { question: drivers_knew_pickups, equals: "no" }
|
||||
|
||||
- key: bus_destination
|
||||
title: "Zielkenntnis"
|
||||
questions:
|
||||
- key: drivers_knew_destination
|
||||
label: "War den Busfahrern der Zielort und die Ausladestelle bekannt?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: bus_equipment
|
||||
title: "Bus, Technik & Allgemeines"
|
||||
questions:
|
||||
- key: video
|
||||
label: "Video/DVD-Gerät"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: working, label: "funktionstüchtig" }
|
||||
- { key: broken, label: "defekt" }
|
||||
- { key: missing, label: "nicht vorhanden" }
|
||||
|
||||
- key: wc
|
||||
label: "WC"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: working, label: "funktionstüchtig" }
|
||||
- { key: broken, label: "defekt" }
|
||||
- { key: closed, label: "nicht geöffnet" }
|
||||
|
||||
- key: luggage
|
||||
label: "Gepäck"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: trailer, label: "Anhänger" }
|
||||
- { key: ski_box, label: "Skikoffer" }
|
||||
- { key: trailer_and_ski_box, label: "Anhänger und Skikoffer" }
|
||||
- { key: none, label: "Kein Anhänger oder Skikoffer" }
|
||||
|
||||
- key: smoking
|
||||
label: "Rauchen im Bus"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: driver, label: "Busfahrer hat im Bus geraucht" }
|
||||
- { key: driver_and_guests, label: "Busfahrer und Gäste haben im Bus geraucht" }
|
||||
- { key: nobody, label: "Es wurde nicht geraucht" }
|
||||
|
||||
- key: snow_chains
|
||||
label: "Schneeketten"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: fitted, label: "Fahrer hat Ketten aufgezogen" }
|
||||
- { key: not_needed, label: "Ketten wurden nicht gebraucht" }
|
||||
- { key: not_fitted, label: "Ketten nicht aufgezogen (trotz starkem Schneefall)" }
|
||||
|
||||
- key: bus_damage
|
||||
title: "Schäden"
|
||||
questions:
|
||||
- key: bus_damage
|
||||
label: "Entstanden während der Fahrt Schäden am Bus?"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: none, label: "Keine Schäden" }
|
||||
- { key: settled_with_driver, label: "Bereits mit Fahrer abgeklärt" }
|
||||
- { key: claim_expected, label: "Da wird vom Busunternehmen noch was kommen" }
|
||||
|
||||
- key: damage_description
|
||||
label: "Was ist passiert?"
|
||||
type: textarea
|
||||
required: true
|
||||
show_if: { question: bus_damage, equals: claim_expected }
|
||||
|
||||
- key: bus_remarks
|
||||
title: "Anmerkungen zur Busfahrt"
|
||||
questions:
|
||||
- key: bus_remarks
|
||||
label: "Das ging gar nicht / ist besonders hervorzuheben:"
|
||||
type: textarea
|
||||
@@ -0,0 +1,137 @@
|
||||
# Questionnaire every teamer fills in after a placement (Disposition, not SeasonAssignment).
|
||||
#
|
||||
# Replaces the Google form "RL-Feedback 25-26"; the question-by-question spec, with every decision
|
||||
# taken, is kept locally outside the repository. A teamer who escorted the bus
|
||||
# also gets bus_feedback.yaml, on the same page. Questions whose answer the disposition already
|
||||
# knows (name, job profile, house, arrival day) are not asked: they are snapshotted into the
|
||||
# response's context instead.
|
||||
#
|
||||
# Labels, help texts, section titles and option labels may be edited in place. Renaming or
|
||||
# removing a question or option key, or changing a type, needs a new version: copy this file to
|
||||
# teamer_feedback.v1.yaml first, then bump `version` here. Submitted answers are keyed by it.
|
||||
#
|
||||
# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it.
|
||||
#
|
||||
# Question fields: key, label, type (text | textarea | choice | multi | date | rating),
|
||||
# required (default false), help, widget (choice only: radio | dropdown, default radio),
|
||||
# options (choice and multi only), show_if ({question, equals | in | lte}, earlier questions only).
|
||||
|
||||
key: teamer_feedback
|
||||
version: 1
|
||||
title: "Wochenteamer:innen-Feedback"
|
||||
description: |-
|
||||
Nur durch euer Feedback können wir besser werden! 🙂
|
||||
|
||||
Bei Bewertunsskalen: 1= schlecht, 5 = sehr gut
|
||||
|
||||
sections:
|
||||
- key: organisation
|
||||
title: "Organisation & Ablauf"
|
||||
questions:
|
||||
- key: office_briefing_rating
|
||||
label: "Wie gut hast du dich vorab vom Büro gebrieft gefühlt?"
|
||||
type: rating
|
||||
required: true
|
||||
|
||||
- key: office_briefing_low_reason
|
||||
label: "Wenn 1 - 3 Sterne Bewertung, warum?"
|
||||
type: textarea
|
||||
required: true
|
||||
show_if: { question: office_briefing_rating, lte: 3 }
|
||||
|
||||
- key: team_week_seminar
|
||||
label: "War das Wochenteam-Seminar hilfreich für dich? Warst du bzw. wurdest du gut vorbereitet?"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_helpful, label: "Ja, die Schulung war sehr hilfreich und ich fühlte mich bestens vorbereitet." }
|
||||
- { key: mostly_helpful, label: "Ja, größtenteils – einige Themen hätte man noch vertiefen können." }
|
||||
- { key: partly_helpful, label: "Es ging – die Schulung war teilweise hilfreich, aber nicht ausreichend für alle Aufgaben." }
|
||||
- { key: rather_not_helpful, label: "Eher nein – ich fühlte mich in vielen Bereichen unvorbereitet." }
|
||||
- { key: not_helpful, label: "Nein, die Schulung war kaum hilfreich und hat mich nicht auf den Arbeitsalltag vorbereitet." }
|
||||
- { key: not_attended, label: "Ich habe nicht teilgenommen." }
|
||||
|
||||
- key: onsite_organisation_rating
|
||||
label: "Wie gut lief die Reiseorganisation vor Ort?"
|
||||
type: rating
|
||||
required: true
|
||||
|
||||
- key: had_organisational_problems
|
||||
label: "Gab es organisatorische Probleme?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: organisational_problems
|
||||
label: "Wenn ja, welche?"
|
||||
type: text
|
||||
show_if: { question: had_organisational_problems, equals: "yes" }
|
||||
|
||||
- key: communication
|
||||
title: "Kommunikation & Zusammenarbeit"
|
||||
questions:
|
||||
- key: teamer_collaboration_rating
|
||||
label: "Wie lief die Kommunikation & Zusammenarbeit mit den E&P Teamer:innen vor Ort während der Reise?"
|
||||
type: rating
|
||||
required: true
|
||||
|
||||
- key: support_wished
|
||||
label: "Welche Unterstützung hättest du dir zusätzlich gewünscht?"
|
||||
type: textarea
|
||||
|
||||
- key: house_manager_problem_handling
|
||||
label: "Wie ist deine Hausleitung mit Problemen vor Ort umgegangen?"
|
||||
type: choice
|
||||
widget: dropdown
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut – Probleme wurden schnell, fair und lösungsorientiert behandelt." }
|
||||
- { key: good, label: "Gut – die meisten Anliegen wurden ernst genommen und bearbeitet." }
|
||||
- { key: mixed, label: "Teils gut/teils schlecht – manche Probleme wurden gelöst, andere nicht." }
|
||||
- { key: rather_poor, label: "Eher schlecht – es wurde wenig unternommen oder nicht auf Augenhöhe kommuniziert." }
|
||||
- { key: very_poor, label: "Sehr schlecht – Probleme wurden ignoriert oder verschlimmert." }
|
||||
|
||||
- key: felt_overwhelmed
|
||||
label: "Gab es Momente, in denen du dich überfordert gefühlt hast?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: overwhelmed_reason
|
||||
label: "Wenn ja, wodurch?"
|
||||
type: textarea
|
||||
show_if: { question: felt_overwhelmed, equals: "yes" }
|
||||
|
||||
- key: improvement
|
||||
title: "Verbesserung"
|
||||
questions:
|
||||
- key: wished_differently
|
||||
label: "Was hättest du dir anders oder besser gewünscht?"
|
||||
type: textarea
|
||||
|
||||
- key: would_work_again
|
||||
label: "Würdest du wieder als Reiseleitung für E&P arbeiten?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: would_not_work_again_reason
|
||||
label: "Wenn nein, warum nicht?"
|
||||
type: textarea
|
||||
show_if: { question: would_work_again, equals: "no" }
|
||||
|
||||
- key: other
|
||||
title: "Sonstiges"
|
||||
questions:
|
||||
- key: anything_else
|
||||
label: |-
|
||||
Du hast nun schon vieles beantwortet,
|
||||
aber nirgendwo konntest du folgende Aspekte loswerden:
|
||||
type: textarea
|
||||
+180
-4
@@ -35,11 +35,63 @@ parameters:
|
||||
1461: 'SHM'
|
||||
1462: 'AGR'
|
||||
|
||||
# Shared key the sibling websites authenticate their form submissions with. Empty here on
|
||||
# purpose - the real value belongs in the environment of each stage, and an empty key
|
||||
# rejects every request instead of letting them through.
|
||||
application_form_api_key: '%env(string:APPLICATION_FORM_API_KEY)%'
|
||||
|
||||
# Shared key n8n reads the assignments feed with (GET /api/assignments). Same rules as above.
|
||||
n8n_api_key: '%env(string:N8N_API_KEY)%'
|
||||
|
||||
# What the n8n assignments feed covers: assignments in these houses (matched against the first
|
||||
# three characters of destination.hotelCode) for these job profiles - guide, course and
|
||||
# service-team work. Job profile ids are database ids, so check them against production.
|
||||
assignment_export.house_codes: [ DPW, DGS, DWW, DKI, DKS ]
|
||||
assignment_export.job_profile_ids:
|
||||
- 5 # Reiseleitung
|
||||
- 9 # Reiseleitung Ski
|
||||
- 10 # Reiseleitung Snowboard
|
||||
- 3 # Kursleitung Ski Anfänger:innen
|
||||
- 14 # Kursleitung Ski Fortgeschrittene
|
||||
- 18 # Kursleitung Ski Anfänger:innen + Fortgeschrittene
|
||||
- 36 # Kursleitung Ski Kinder Anfänger:innen & Kinderbetreuung
|
||||
- 37 # Kursleitung Ski Kinder Fortgeschrittene
|
||||
- 38 # Kursleitung Ski Kinder
|
||||
- 4 # Kursleitung Snowboard Anfänger:innen
|
||||
- 15 # Kursleitung Snowboard Fortgeschrittene
|
||||
- 19 # Kursleitung Snowboard Anfänger:innen + Fortgeschrittene
|
||||
- 47 # Kursleitung Snowboard Kinder/Jugendliche Anfänger:innen
|
||||
- 20 # Reise- und Kursleitung Ski Anfänger:innen
|
||||
- 21 # Reise- und Kursleitung Ski Fortgeschrittene
|
||||
- 22 # Reise- und Kursleitung Ski Anfänger:innen + Fortgeschrittene
|
||||
- 23 # Reise- und Kursleitung Snowboard Anfänger:innen
|
||||
- 24 # Reise- und Kursleitung Snowboard Fortgeschrittene
|
||||
- 25 # Reise- und Kursleitung Snowboard Anfänger:innen + Fortgeschrittene
|
||||
- 6 # Serviceteam 40h (ST40)
|
||||
- 13 # Serviceteam 25h (ST25)
|
||||
- 16 # Serviceteam 10h (ST10)
|
||||
|
||||
# Booking link the application form receipt mail invites the applicant to. A deployment
|
||||
# value rather than part of the wording, so a new booking system does not need an edit in
|
||||
# every stage's mail text.
|
||||
application_form_booking_url: '%env(string:APPLICATION_FORM_BOOKING_URL)%'
|
||||
|
||||
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
|
||||
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
|
||||
|
||||
# Per deployment and not per environment: staging and production run the same compiled
|
||||
# environment and differ only in their .env.local. The csv processor turns an unset
|
||||
# variable into an empty list, which switches the filtering off - so production is
|
||||
# unaffected without anything having to be remembered at deploy time.
|
||||
mail_recipient_whitelist: '%env(csv:APP_MAIL_RECIPIENT_WHITELIST)%'
|
||||
|
||||
teamer_inactive_period: '-2 years'
|
||||
|
||||
# The stand-in PDF served instead of real uploads while the upload_replacement feature is
|
||||
# on. Relative to the project dir. Which file is a fixed fact of the repo; whether it is
|
||||
# used at all is the feature flag's job alone.
|
||||
upload_replacement_file: 'assets/pdf/chicken.pdf'
|
||||
|
||||
# The contract upload period, counted from the creation of the disposition. This is the
|
||||
# single definition: the reminder mail, the "Honorarvertrag fällig" flag on the teamer
|
||||
# dashboard, the admin list of overdue contracts and the wording of the mails all read it,
|
||||
@@ -69,6 +121,31 @@ parameters:
|
||||
season_start: '11-01'
|
||||
season_end: '04-01'
|
||||
|
||||
# The teamer questionnaires after a placement (config/questionnaires/teamer_feedback.yaml, plus
|
||||
# bus_feedback.yaml for a bus escort) are offered for placements that ended within this many
|
||||
# days. Without a window every past placement would show up as pending on the teamers' dashboards.
|
||||
questionnaire_window_days: 30
|
||||
|
||||
# How far ahead of the destination's first travel day a season assignment's checklist
|
||||
# entries fall due. A relative date expression, applied to destination.dateFrom, and the
|
||||
# single definition of both deadlines: the first is this offset, the second defaults to
|
||||
# the first. Per-entry overrides are stored on the task row; this is what they override.
|
||||
season_task_deadline_offset: '-1 month'
|
||||
|
||||
# What the staffing matrix generates (App\Service\Assignment\AssignmentPlanner), keyed by
|
||||
# App\Enum\StaffingRole / StaffingFee value. Ids and not names, so renaming a job profile
|
||||
# or fee in the admin cannot silently change what the drafts get. A missing or deleted id
|
||||
# makes App\Service\Assignment\StaffingResolver refuse rather than skip.
|
||||
auto_assignment_job_profiles:
|
||||
guide: 5 # Reiseleitung
|
||||
ski_beginner: 3 # Kursleitung Ski Anfänger:innen
|
||||
ski_advanced: 14 # Kursleitung Ski Fortgeschrittene
|
||||
guide_ski_beginner: 20 # Reise- und Kursleitung Ski Anfänger:innen
|
||||
guide_ski_advanced: 21 # Reise- und Kursleitung Ski Fortgeschrittene
|
||||
auto_assignment_fees:
|
||||
guiding: 3 # Skigebietsguiding (pro Einheit)
|
||||
course: 4 # Ski-/Snowboardkurs (pro Stunde)
|
||||
|
||||
# Houses, hotel code: name. The name is used both as a label and to match
|
||||
# destination.hotel, the code to match destination.hotelCode.
|
||||
houses:
|
||||
@@ -103,7 +180,6 @@ services:
|
||||
# it drops its records from that table.
|
||||
$logger: '@monolog.logger.audit'
|
||||
$houses: '%houses%'
|
||||
$xmlExport: '@xml_export.storage'
|
||||
$xmlDump: '@xml_dump.storage'
|
||||
$teamerInactivePeriod: '%teamer_inactive_period%'
|
||||
$contractUploadDeadlineDays: '%contract_upload_deadline_days%'
|
||||
@@ -111,9 +187,15 @@ services:
|
||||
# Only the path enters the container - the file itself is parsed on demand by
|
||||
# App\Config\EmailTextCatalog, since its contents matter on few requests.
|
||||
$emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml'
|
||||
# Likewise parsed on demand, by App\Config\QuestionnaireCatalog.
|
||||
$questionnaireDir: '%kernel.project_dir%/config/questionnaires'
|
||||
$questionnaireWindowDays: '%questionnaire_window_days%'
|
||||
$mailingBusTransport: '%mailing_bus_transport%'
|
||||
$seasonStart: '%season_start%'
|
||||
$seasonEnd: '%season_end%'
|
||||
$seasonTaskDeadlineOffset: '%season_task_deadline_offset%'
|
||||
$autoAssignmentJobProfiles: '%auto_assignment_job_profiles%'
|
||||
$autoAssignmentFees: '%auto_assignment_fees%'
|
||||
|
||||
# Tagged by interface rather than listed by hand: order between collectors is
|
||||
# irrelevant, so the only thing an explicit list could add here is the chance to
|
||||
@@ -137,7 +219,7 @@ services:
|
||||
- name: liip_imagine.cache.resolver
|
||||
resolver: format_extension
|
||||
|
||||
App\Command\BpnImportCommand:
|
||||
App\Command\MyEpImportCommand:
|
||||
arguments:
|
||||
$connection: '@doctrine.dbal.default_connection'
|
||||
|
||||
@@ -159,6 +241,13 @@ services:
|
||||
bpn_api_port: '%env(APP_BPN_PORT)%'
|
||||
debug: '%env(bool:APP_BPN_DEBUG)%'
|
||||
|
||||
App\Service\Teamer\TeamerProvisioner:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.bpn'
|
||||
# The same selection the login reads a teamer's role from - this is the one place that
|
||||
# writes it, so it must be the same id.
|
||||
$teamerSelectionId: '%bpn_crm_id_teamer%'
|
||||
|
||||
App\BusProNet\ResponseParser:
|
||||
arguments:
|
||||
$options:
|
||||
@@ -168,6 +257,45 @@ services:
|
||||
bpn_crm_house_manager_ids: '%bpn_crm_house_manager_ids%'
|
||||
bpn_default_hotel_code: '%bpn_default_hotel_code%'
|
||||
|
||||
App\Saas\ApiClient:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.saas'
|
||||
$options:
|
||||
base_url: '%env(APP_SAAS_BASE_URL)%'
|
||||
api_key: '%env(APP_SAAS_API_KEY)%'
|
||||
|
||||
App\Saas\DataProvider\EmployeeDataProvider:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.saas'
|
||||
# The roster is slow and barely changes; six hours means one fetch per run and a
|
||||
# new starter is picked up the same day.
|
||||
$ttl: 21600
|
||||
|
||||
App\MyEp\Api\AccessTokenProvider:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.myep'
|
||||
$cache: '@cache.app'
|
||||
$options:
|
||||
client_id: '%env(MYEP_API_CLIENT_ID)%'
|
||||
client_secret: '%env(MYEP_API_CLIENT_SECRET)%'
|
||||
|
||||
App\MyEp\Api\ApiClient:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.myep'
|
||||
|
||||
App\MyEp\Api\ResponseMapper:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.myep'
|
||||
|
||||
App\MyEp\Cache\StaleWhileErrorCache:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.myep'
|
||||
$cache: '@cache.app'
|
||||
|
||||
App\MyEp\Import\TravelExportClient:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.myep'
|
||||
|
||||
App\Twig\AppRuntime:
|
||||
arguments:
|
||||
$intlExtension: '@twig.extension.intl'
|
||||
@@ -179,6 +307,15 @@ services:
|
||||
tags:
|
||||
- name: monolog.processor
|
||||
|
||||
# Every SaaS url carries the api key as a path segment, so the request lines Symfony's
|
||||
# http client writes - and the exception messages that repeat those urls - are redacted
|
||||
# on the way into the log rather than suppressed.
|
||||
App\Logging\RedactSaasCredentialsProcessor:
|
||||
arguments:
|
||||
$apiKey: '%env(APP_SAAS_API_KEY)%'
|
||||
tags:
|
||||
- name: monolog.processor
|
||||
|
||||
App\Logging\RequestInfoProcessor:
|
||||
arguments:
|
||||
- '@request_stack'
|
||||
@@ -238,8 +375,7 @@ services:
|
||||
arguments:
|
||||
$orphanageManager: '@oneup_uploader.orphanage_manager'
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$environment: '%kernel.environment%'
|
||||
$uploadReplacementFile: '%env(default::UPLOAD_REPLACEMENT_FILE)%'
|
||||
$uploadReplacementFile: '%upload_replacement_file%'
|
||||
|
||||
App\Service\Pdf\Sanitizer:
|
||||
arguments:
|
||||
@@ -312,12 +448,50 @@ services:
|
||||
$options:
|
||||
project_dir: '%kernel.project_dir%'
|
||||
|
||||
App\Service\Pdf\WorkMonthRenderer:
|
||||
arguments:
|
||||
$options:
|
||||
project_dir: '%kernel.project_dir%'
|
||||
|
||||
App\Email\Mailer:
|
||||
arguments:
|
||||
$defaults:
|
||||
from: '%default_email_from%'
|
||||
to: '%default_email_to%'
|
||||
|
||||
# Its own log channel, not the audit one bound by default: the audit channel writes a row
|
||||
# to the log table through App\Logging\DatabaseHandler, which records what staff did here.
|
||||
App\Email\EmailTextRenderer:
|
||||
arguments:
|
||||
$bookingUrl: '%application_form_booking_url%'
|
||||
|
||||
App\Security\AccessToken\ApiKeyTokenHandler:
|
||||
arguments:
|
||||
# user identifier in security.yaml's api_user_provider => key
|
||||
$apiKeys:
|
||||
api: '%application_form_api_key%'
|
||||
n8n: '%n8n_api_key%'
|
||||
$logger: '@monolog.logger.api'
|
||||
|
||||
App\Controller\Api\ApplicationFormController:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.api'
|
||||
tags: [ 'controller.service_arguments' ]
|
||||
|
||||
App\Controller\Api\Assignment\ListController:
|
||||
arguments:
|
||||
$houseCodes: '%assignment_export.house_codes%'
|
||||
$jobProfileIds: '%assignment_export.job_profile_ids%'
|
||||
$logger: '@monolog.logger.api'
|
||||
tags: [ 'controller.service_arguments' ]
|
||||
|
||||
App\EventListener\EmailRecipientWhitelistSubscriber:
|
||||
arguments:
|
||||
$allowedRecipients: '%mail_recipient_whitelist%'
|
||||
# Not the audit channel this argument is bound to by default: a mail dropped by a
|
||||
# staging guard is a technical event and has no place in the application log table.
|
||||
$logger: '@logger'
|
||||
|
||||
App\Security\OAuth2\MyEpClient:
|
||||
arguments:
|
||||
$options:
|
||||
@@ -338,4 +512,6 @@ when@dev:
|
||||
|
||||
when@test:
|
||||
parameters:
|
||||
# a job profile id ListControllerTest inserts itself - db_test has no job profiles
|
||||
assignment_export.job_profile_ids: [ 900001 ]
|
||||
mailing_bus_transport: 'sync'
|
||||
+3
-1
@@ -38,6 +38,7 @@ $rsyncOptions = [
|
||||
'.env.test.local',
|
||||
'.gitignore',
|
||||
'.nvmrc',
|
||||
'.browserslistrc',
|
||||
'.php-cs-fixer.cache',
|
||||
'.php-cs-fixer.dist.php',
|
||||
'.phpunit.result.cache',
|
||||
@@ -46,11 +47,12 @@ $rsyncOptions = [
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'*.http',
|
||||
'http-client*.json',
|
||||
'postcss.config.js',
|
||||
'phpunit.xml.dist',
|
||||
'public/.htaccess',
|
||||
'tailwind.config.js',
|
||||
'webpack.config.js',
|
||||
'webpack.config.mjs',
|
||||
'rapidapi.paw',
|
||||
'bin/.phpunit',
|
||||
'temp/*',
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
# API Consumer Guide
|
||||
|
||||
This document describes the public JSON API exposed under `/api`, written for developers implementing a **client** in another application. It is self-contained: no access to this codebase is required to use it.
|
||||
|
||||
## Overview
|
||||
|
||||
A Symfony application exposing a read-mostly JSON API under the `/api` prefix. All `/api/*` requests are stateless and authenticated via OAuth2 Bearer tokens (League OAuth2 Server, JWT access tokens). Responses are `application/json`. HTTPS is enforced on all routes.
|
||||
|
||||
## Authentication
|
||||
|
||||
- Token endpoint: `POST /token` (unauthenticated).
|
||||
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
|
||||
- Access token TTL: **10 minutes** — clients must refresh/re-request frequently.
|
||||
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
|
||||
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
|
||||
- Send the token as `Authorization: Bearer <access_token>`.
|
||||
|
||||
Scope required per endpoint:
|
||||
|
||||
| Scope needed | Endpoints |
|
||||
|---|---|
|
||||
| `api` | everything except the two below |
|
||||
| `profile` | `GET /api/crm-attributes` |
|
||||
| `email` | `GET /api/userinfo` (optional extra scopes `id`, `profile`, `roles` widen the response) |
|
||||
|
||||
`/api/userinfo` and `/api/crm-attributes` act on the **authenticated end user**, so they require an authorization_code token issued for a user. All other endpoints are machine-to-machine and work with client_credentials.
|
||||
|
||||
### Machine-to-machine example
|
||||
|
||||
```
|
||||
POST /token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=client_credentials&client_id=...&client_secret=...&scope=api
|
||||
```
|
||||
|
||||
```
|
||||
GET /api/hotels
|
||||
Authorization: Bearer eyJ0eXAi...
|
||||
```
|
||||
|
||||
## Error conventions
|
||||
|
||||
There is no single error envelope; three shapes occur:
|
||||
|
||||
- `{"message": "..."}` — commonly with 404 / 400.
|
||||
- `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code.
|
||||
- `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter).
|
||||
|
||||
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
|
||||
|
||||
The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Reference data (BusProNet master data)
|
||||
|
||||
#### `GET /api/hotels` — scope `api`
|
||||
|
||||
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`.
|
||||
|
||||
#### `GET /api/hotels/{id}` — scope `api`
|
||||
|
||||
Single hotel by numeric id, same field set as the list. `404 {"message":"Not found"}` if unknown.
|
||||
|
||||
#### `GET /api/countries` — scope `api`
|
||||
|
||||
Array of `{id, name, token, nationality}`.
|
||||
|
||||
#### `GET /api/products` — scope `api`
|
||||
|
||||
Product master data from the upstream BusProNet system, cached server-side for 1 hour. On upstream failure returns `{"error": "...", "type": "api_error"}` **with status 200**.
|
||||
|
||||
#### `GET /api/last-update` — scope `api`
|
||||
|
||||
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}`. Note: if no export marker file exists the current implementation errors (500) rather than returning null — treat a non-JSON/500 response as "unknown".
|
||||
|
||||
---
|
||||
|
||||
### Pickups (bus boarding points)
|
||||
|
||||
#### `GET /api/pickups` — scope `api`
|
||||
|
||||
Array of `{id, code, city, postalCode, street}`.
|
||||
|
||||
#### `GET /api/pickups/{id}` — scope `api`
|
||||
|
||||
Single pickup by numeric id, same fields as the list.
|
||||
|
||||
#### `GET /api/pickups-planning/{travelCode}` — scope `api`
|
||||
|
||||
Planned outbound (HIN) boarding stops for one travel code. Returns an array, chronologically sorted:
|
||||
|
||||
```json
|
||||
[{"city": "...", "location": "...", "datetime": "YYYY-MM-DD HH:MM", "busNumber": "..."}]
|
||||
```
|
||||
|
||||
`404 {"error":"Planning data not available"}` if no planning has been uploaded yet, `404 {"error":"Travel code not found"}` for an unknown code, `500 {"error":"Failed to read planning data"}` on storage errors.
|
||||
|
||||
#### `POST /api/pickups-planning` — scope `api`
|
||||
|
||||
Bulk-replaces the planning dataset. Body: a JSON **array** of raw planning rows using the upstream German keys:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"PlanungsArt": "HIN",
|
||||
"Reisecode": "ABC123",
|
||||
"Zustieg/Ausstieg Ort": "Köln",
|
||||
"Zustieg/Ausstieg Strasse": "Hauptbahnhof",
|
||||
"Zeit": "24.12.2026 07:30",
|
||||
"BusNummer": "1"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Rows without `PlanungsArt === "HIN"` or without `Reisecode` are dropped; duplicates (same city + location + datetime + bus) are de-duplicated; results are grouped by `Reisecode` and sorted by time. Response `{"status":"ok"}` (200). `400 {"error":"Invalid JSON payload"}`, `500 {"error":"Failed to write pickups planning data"}`.
|
||||
|
||||
This is a full overwrite, not a merge.
|
||||
|
||||
---
|
||||
|
||||
### Travels
|
||||
|
||||
#### `GET /api/travels` — scope `api`
|
||||
|
||||
Index/mapping of all known travel dates. Object keyed by numeric travel id, each value `{id, code, label, dateFrom, dateTo, hotels: [...]}`.
|
||||
|
||||
#### `GET /api/travels/{dateId}/{hotelId}` — scope `api`
|
||||
|
||||
Single travel by **numeric** ids (`hotelId` optional). Route matches only when both segments are digits.
|
||||
|
||||
#### `GET /api/travels/{dateCode}/{hotelCode}` — scope `api`
|
||||
|
||||
Same, addressed by **string** codes (`hotelCode` optional). The date code is sanitized (separators stripped) before lookup. `404 {"message":"Not found"}` if the date code is unknown, `404 {"message":"Travel not found"}` if no data exists.
|
||||
|
||||
Both variants accept optional query parameters:
|
||||
|
||||
- `source=local` — force the local XML/snapshot data.
|
||||
- `source=remote` — force a live upstream call.
|
||||
- `prefer_remote=1` — used only when `source` is absent; prefers upstream with local fallback.
|
||||
|
||||
Response fields: `id`, `code`, `productCode`, `productId`, `label`, `hotelId`, `hotel` (hotel object), `dateFrom`, `dateTo`, `type`, `status`, `allowedBookingStatus[]`, `priceFrom`, `selectionGroups[]`, `additionalServices[]`, `transportationServices[]`, `pickups[]`, `dropOffs[]`, `rooms[]`, `guide`, `insurances[]`.
|
||||
|
||||
#### `GET /api/travels/{dateId}/{hotelId}/{dateTo}/availability` — scope `api`
|
||||
|
||||
Live hotel availability from the upstream system. `dateTo` must be `Y-m-d`. On upstream failure returns `{"error":"...","type":"api_error"}` **with status 200**.
|
||||
|
||||
---
|
||||
|
||||
### Contingents & accommodation prices
|
||||
|
||||
#### `GET /api/contingents/prices?hotelCode=…&year=…` — scope `api`
|
||||
|
||||
Price timeline for one accommodation and calendar year, collapsed into contiguous segments (adjacent days with the same effective price are merged into one row). The timeline never starts before today; if the requested year is entirely in the past the response is `[]`.
|
||||
|
||||
- `hotelCode`: required, max 16 chars, `[A-Za-z0-9_-]+` (matches the accommodation's calendar code).
|
||||
- `year`: required int, 1000–9999.
|
||||
|
||||
Each row:
|
||||
|
||||
```json
|
||||
{
|
||||
"dateFrom": "2026-06-01",
|
||||
"dateTo": "2026-06-14",
|
||||
"season": "high|low|...|null",
|
||||
"includedPax": 2,
|
||||
"pricePerNight": 89.5,
|
||||
"priceAdditionalPerson": 25.0,
|
||||
"defaultPricePerNight": 99.0,
|
||||
"defaultPriceAdditionalPerson": 30.0,
|
||||
"currency": "EUR",
|
||||
"type": "discount|null"
|
||||
}
|
||||
```
|
||||
|
||||
Prices are **decimal major units** (converted from integer minor units server-side). `default*` fields are non-null only when the winning price is a discount, and then carry the undiscounted price. `400 {"error":"Hotel not found for hotelCode."}` for an unknown code; `400` with violations for invalid parameters.
|
||||
|
||||
#### `GET /api/contingents/calendar?hotelCode=…&dateFrom=…&dateTo=…` — scope `api`
|
||||
|
||||
Per-day availability enriched with prices. Both dates are `Y-m-d` and **inclusive** (`dateTo` is the last night, not the checkout day); the range may not exceed 366 days and `dateTo` must not precede `dateFrom`. Upstream contingent data is cached 1 hour per (hotelCode, dateFrom, dateTo).
|
||||
|
||||
Each entry:
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"status": "Free|Blocked|...",
|
||||
"type": "discount|null",
|
||||
"pricePerNight": 89.5,
|
||||
"defaultPricePerNight": 99.0,
|
||||
"priceAdditionalPerson": 25.0,
|
||||
"defaultPriceAdditionalPerson": 30.0,
|
||||
"currency": "EUR",
|
||||
"includedPax": 2,
|
||||
"minNights": 2
|
||||
}
|
||||
```
|
||||
|
||||
Important rule: **a day with no maintained price is forced to `Blocked`** regardless of what the upstream contingent says. Price fields may be `null` in that case.
|
||||
|
||||
Errors: `400 {"error":"Hotel not found for hotelCode."}`, `502 {"error":"Failed to fetch contingent data."}` when the upstream contingent service fails, `400` with violations for invalid parameters.
|
||||
|
||||
---
|
||||
|
||||
### Accommodation bookings (group bookings)
|
||||
|
||||
#### `GET /api/accommodation-bookings/{uuid}` — scope `api`
|
||||
|
||||
Single booking by UUID. `404 {"message":"Not found"}` if unknown.
|
||||
|
||||
```json
|
||||
{
|
||||
"uuid": "...",
|
||||
"status": "...",
|
||||
"type": "...",
|
||||
"dateFrom": "2026-06-01",
|
||||
"dateTo": "2026-06-08",
|
||||
"nights": 7,
|
||||
"paxCount": 40,
|
||||
"minorsCount": 12,
|
||||
"childrenCount": 3,
|
||||
"groupName": "...",
|
||||
"acceptedAt": "2026-05-01T10:00:00+00:00",
|
||||
"personalData": {},
|
||||
"accommodation": {},
|
||||
"boardService": {},
|
||||
"additionalServices": [],
|
||||
"accommodationDiscount": null,
|
||||
"boardServiceDiscount": null,
|
||||
"additionalServicesDiscount": null,
|
||||
"totalPrice": 123456,
|
||||
"pricingCurrency": "EUR",
|
||||
"pricingVersion": 3,
|
||||
"priceBreakdown": {}
|
||||
}
|
||||
```
|
||||
|
||||
`totalPrice` and the `*Discount` fields are integer **minor units** (cents) — unlike the contingent endpoints. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure.
|
||||
|
||||
#### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api`
|
||||
|
||||
Marks the booking as accepted (server-side side effects: status change, `acceptedAt`, notifications). No request body. Returns the same booking representation as the GET. `404` if unknown. Not idempotent in terms of side effects — call once.
|
||||
|
||||
---
|
||||
|
||||
### Contact form
|
||||
|
||||
#### `POST /api/contactform` — scope `api`
|
||||
|
||||
Creates or matches an address record in the upstream CRM.
|
||||
|
||||
Request body (all fields optional strings; the upstream system decides what is required):
|
||||
|
||||
```json
|
||||
{
|
||||
"lastName": "...", "firstName": "...", "gender": "...",
|
||||
"street": "...", "zipCode": "...", "city": "...",
|
||||
"email": "...", "phone": "..."
|
||||
}
|
||||
```
|
||||
|
||||
- `201` — `{"success": true, "addressId": 123, "personId": 456, "isNewRecord": true}`
|
||||
- `409` — `{"success": false, "message": "<upstream notification>"}` (upstream rejected/conflicted)
|
||||
- `400` — `{"success": false, "message": "<error>"}` (upstream call failed)
|
||||
- `422` — malformed payload
|
||||
|
||||
---
|
||||
|
||||
### Newsletter
|
||||
|
||||
#### `GET /api/newsletters` — scope `api`
|
||||
|
||||
The configured newsletter lists as an array of `{id, name, ...}` mapping objects. Use the returned `id` values as `listIds` below.
|
||||
|
||||
#### `POST /api/newsletter-subscriptions` — scope `api`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"firstName": "Ada",
|
||||
"lastName": "Lovelace",
|
||||
"listIds": [1, 2]
|
||||
}
|
||||
```
|
||||
|
||||
- `email`: required, strict email validation.
|
||||
- `firstName` / `lastName`: optional, max 255 chars.
|
||||
- `listIds`: required, at least one positive integer; every id must be one of the configured lists.
|
||||
|
||||
Responses:
|
||||
|
||||
- `200` — subscription completed, `state: "subscribed"`.
|
||||
- `202` — a double-opt-in confirmation mail was requested, `state: "confirmation_requested"`.
|
||||
- Body in both cases:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"email": "[email protected]",
|
||||
"lists": [{"id": 1, "name": "...", "state": "success|pending|already_registered"}],
|
||||
"state": "subscribed|pending_confirmation|confirmation_requested",
|
||||
"confirmationRequested": true
|
||||
}
|
||||
```
|
||||
|
||||
- `400` — unknown or disallowed list ids: `{"success": false, "message": "...", "listIds": [...], "unknownListIds": [...]}`, or validation violations.
|
||||
- `503` — `{"success": false, "email": "...", "listIds": [...], "message": "Newsletter subscription request could not be processed."}` when the newsletter provider is unavailable. Safe to retry.
|
||||
|
||||
---
|
||||
|
||||
### End-user endpoints (require a user-bound token)
|
||||
|
||||
#### `GET /api/userinfo` — scope `email` (+ optional `id`, `profile`, `roles`)
|
||||
|
||||
OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes:
|
||||
|
||||
- always: `email`
|
||||
- scope `id`: `person_id`, `address_id`
|
||||
- scope `roles`: `roles` (array; the implicit baseline role is stripped, only explicitly assigned roles are exported)
|
||||
- scope `profile`: `profile` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"first_name": "...", "last_name": "...", "title": "...", "salutation": "...",
|
||||
"gender": "...", "date_of_birth": "1990-01-01", "nationality": "...",
|
||||
"address": {"street": "...", "postcode": "...", "city": "...", "district": "...", "country": "..."},
|
||||
"communication": {"email": "...", "mobile": "...", "phone": "...", "newsletter": true},
|
||||
"hotel_codes": ["..."], "height": null, "weight": null, "shoe_size": null, "remarks": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Note: `profile.communication.email` comes from the CRM address record and is **not necessarily** the login email — a single address can hold several contacts. Use the top-level `email` claim for identity.
|
||||
|
||||
`400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials.
|
||||
|
||||
#### `GET /api/crm-attributes` — scope `profile`
|
||||
|
||||
CRM selection attributes/segments for the authenticated user. `400 {"message": "...", "code": "..."}` on upstream failure.
|
||||
|
||||
---
|
||||
|
||||
## Client implementation notes
|
||||
|
||||
1. **Token lifetime is 10 minutes** — implement refresh/re-fetch with a safety margin and retry once on 401.
|
||||
2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint.
|
||||
3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary.
|
||||
4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries.
|
||||
5. **Server-side caching** (1 h on products and the contingent calendar) means upstream changes may not be visible immediately.
|
||||
6. **Route ambiguity**: `/api/travels/{a}/{b}` resolves to the id-based route when both segments are numeric, otherwise to the code-based route. Non-numeric codes are safe; purely numeric *codes* will hit the id route.
|
||||
+1
-1
@@ -131,7 +131,7 @@ Three failure modes worth knowing because they are quiet rather than loud:
|
||||
## Scheduled tasks are a different mechanism
|
||||
|
||||
`config/packages/zenstruck_schedule.yaml` holds the daily jobs (`app:cron`,
|
||||
`app:bpn-import`, `app:teamer-status`, …), driven by a separate `schedule:run` cron entry.
|
||||
`app:myep-import`, `app:teamer-status`, …), driven by a separate `schedule:run` cron entry.
|
||||
|
||||
**Do not put `messenger:consume` in there.** The bundle runs tasks in-process and
|
||||
sequentially, so a consume task holds the entire schedule for its duration — every later
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"prod": {
|
||||
"saas_base_url": "https://desktop.saas.de/rest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260824124850 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE teamer ADD all_season TINYINT(1) NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE teamer DROP all_season');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Adds the season_assignment table: the placement of an all-season teamer at a destination.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff - the diff against this
|
||||
* database also carries a large amount of pre-existing drift (datetime and JSON column
|
||||
* declarations across nearly every table) that has nothing to do with this feature.
|
||||
*/
|
||||
final class Version20260909124625 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add season_assignment table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE season_assignment (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, remarks LONGTEXT DEFAULT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, deleted_at DATETIME DEFAULT NULL, teamer_id INT DEFAULT NULL, destination_id INT DEFAULT NULL, job_profile_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_FB891DD17F50A6 (uuid), INDEX IDX_FB891D4302FF75 (teamer_id), INDEX IDX_FB891D816C6140 (destination_id), INDEX IDX_FB891DA68D82A5 (job_profile_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('ALTER TABLE season_assignment ADD CONSTRAINT FK_FB891D4302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)');
|
||||
$this->addSql('ALTER TABLE season_assignment ADD CONSTRAINT FK_FB891D816C6140 FOREIGN KEY (destination_id) REFERENCES destination (id)');
|
||||
$this->addSql('ALTER TABLE season_assignment ADD CONSTRAINT FK_FB891DA68D82A5 FOREIGN KEY (job_profile_id) REFERENCES job_profile (id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE season_assignment DROP FOREIGN KEY FK_FB891D4302FF75');
|
||||
$this->addSql('ALTER TABLE season_assignment DROP FOREIGN KEY FK_FB891D816C6140');
|
||||
$this->addSql('ALTER TABLE season_assignment DROP FOREIGN KEY FK_FB891DA68D82A5');
|
||||
$this->addSql('DROP TABLE season_assignment');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Adds the season assignment checklist: one row per checklist entry per assignment, the binding
|
||||
* of an entry to a document from the global library, and the two links a season assignment needs
|
||||
* to carry its own files.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, like Version20260909124625 -
|
||||
* the diff against this database also carries the pre-existing drift in datetime and JSON column
|
||||
* declarations, which has nothing to do with this feature.
|
||||
*/
|
||||
final class Version20260915103000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add season assignment tasks, task document mapping and season uploads';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE season_assignment_task (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, task_key VARCHAR(64) NOT NULL, done TINYINT(1) NOT NULL, done_at DATETIME DEFAULT NULL, done_by VARCHAR(255) DEFAULT NULL, uploaded TINYINT(1) NOT NULL, deadline_override1 DATE DEFAULT NULL, deadline_override2 DATE DEFAULT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, season_assignment_id INT NOT NULL, UNIQUE INDEX UNIQ_BEEB9D8DD17F50A6 (uuid), UNIQUE INDEX season_assignment_task_unique (season_assignment_id, task_key), INDEX IDX_BEEB9D8DB291FD85 (season_assignment_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('CREATE TABLE season_task_document (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, task_key VARCHAR(64) NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, document_id INT NOT NULL, UNIQUE INDEX UNIQ_FBAB9F47D17F50A6 (uuid), UNIQUE INDEX UNIQ_FBAB9F47E512532E (task_key), INDEX IDX_FBAB9F47C33F7837 (document_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('ALTER TABLE season_assignment_task ADD CONSTRAINT FK_BEEB9D8DB291FD85 FOREIGN KEY (season_assignment_id) REFERENCES season_assignment (id)');
|
||||
$this->addSql('ALTER TABLE season_task_document ADD CONSTRAINT FK_FBAB9F47C33F7837 FOREIGN KEY (document_id) REFERENCES upload (id)');
|
||||
$this->addSql('ALTER TABLE season_assignment ADD done_teamer TINYINT(1) NOT NULL');
|
||||
$this->addSql('ALTER TABLE upload ADD season_assignment_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE upload ADD CONSTRAINT FK_17BDE61FB291FD85 FOREIGN KEY (season_assignment_id) REFERENCES season_assignment (id)');
|
||||
$this->addSql('CREATE INDEX IDX_17BDE61FB291FD85 ON upload (season_assignment_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE upload DROP FOREIGN KEY FK_17BDE61FB291FD85');
|
||||
$this->addSql('DROP INDEX IDX_17BDE61FB291FD85 ON upload');
|
||||
$this->addSql('ALTER TABLE upload DROP season_assignment_id');
|
||||
$this->addSql('ALTER TABLE season_assignment DROP done_teamer');
|
||||
$this->addSql('ALTER TABLE season_assignment_task DROP FOREIGN KEY FK_BEEB9D8DB291FD85');
|
||||
$this->addSql('ALTER TABLE season_task_document DROP FOREIGN KEY FK_FBAB9F47C33F7837');
|
||||
$this->addSql('DROP TABLE season_assignment_task');
|
||||
$this->addSql('DROP TABLE season_task_document');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Replaces the season assignment's Destination relation with its own period and house.
|
||||
*
|
||||
* A Destination is one travel with one departure window; an all-season placement runs a whole
|
||||
* season at one house, so the relation only ever meant "borrow three scalars from an arbitrary
|
||||
* travel row". The house is now a key of the configured "houses" map, which is keyed by the
|
||||
* normalized hotel code - hence the SER stripping in the backfill, mirroring
|
||||
* App\Entity\Destination::getHotelCodeNormalized().
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, like Version20260909124625 -
|
||||
* the diff against this database also carries the pre-existing drift in datetime and JSON column
|
||||
* declarations, which has nothing to do with this feature.
|
||||
*/
|
||||
final class Version20260916160000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Move season assignment period and house onto the assignment itself';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// nullable first, so the existing rows can be backfilled before the columns are tightened
|
||||
$this->addSql('ALTER TABLE season_assignment ADD date_from DATE DEFAULT NULL, ADD date_to DATE DEFAULT NULL, ADD hotel_code VARCHAR(32) DEFAULT NULL');
|
||||
|
||||
$this->addSql('UPDATE season_assignment sa JOIN destination d ON d.id = sa.destination_id SET sa.date_from = d.date_from, sa.date_to = d.date_to, sa.hotel_code = CASE WHEN d.hotel_code LIKE \'SER%\' THEN SUBSTRING(d.hotel_code, 4, 3) ELSE SUBSTRING(d.hotel_code, 1, 3) END');
|
||||
|
||||
$this->addSql('ALTER TABLE season_assignment CHANGE date_from date_from DATE NOT NULL, CHANGE date_to date_to DATE NOT NULL, CHANGE hotel_code hotel_code VARCHAR(32) NOT NULL');
|
||||
|
||||
$this->addSql('ALTER TABLE season_assignment DROP FOREIGN KEY FK_FB891D816C6140');
|
||||
$this->addSql('DROP INDEX IDX_FB891D816C6140 ON season_assignment');
|
||||
$this->addSql('ALTER TABLE season_assignment DROP destination_id');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE season_assignment ADD destination_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE season_assignment ADD CONSTRAINT FK_FB891D816C6140 FOREIGN KEY (destination_id) REFERENCES destination (id)');
|
||||
$this->addSql('CREATE INDEX IDX_FB891D816C6140 ON season_assignment (destination_id)');
|
||||
|
||||
// The destination a placement was cut from cannot be recovered from a period and a house
|
||||
// code, so the relation comes back empty.
|
||||
$this->addSql('ALTER TABLE season_assignment DROP date_from, DROP date_to, DROP hotel_code');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Drops the season assignment's done_teamer flag.
|
||||
*
|
||||
* It was a note the office set by hand and it gated nothing - neither the weekly teamer digest
|
||||
* nor the office deadline summary ever read it. Both stages it was meant to express follow from
|
||||
* the checklist rows, so App\Service\Season\SeasonChecklistBuilder derives them instead.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, like Version20260916160000:
|
||||
* the diff against this database also carries the pre-existing drift in datetime and JSON column
|
||||
* declarations, which has nothing to do with this change.
|
||||
*/
|
||||
final class Version20260917120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop the informative done_teamer flag from season assignments';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE season_assignment DROP done_teamer');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// The hand-set notes themselves cannot be recovered, so the column comes back unset.
|
||||
$this->addSql('ALTER TABLE season_assignment ADD done_teamer TINYINT(1) NOT NULL');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Renames the checklist entry's uploaded flag to office_done.
|
||||
*
|
||||
* The flag is the office's own step on an entry, and that step differs per entry - filing a
|
||||
* returned contract is not the same act as registering someone with the municipality. Named
|
||||
* after a file arriving, it read as a second kind of "done" everywhere else, next to the done
|
||||
* flag that actually closes the entry.
|
||||
*
|
||||
* A rename, not a new column: the ticks themselves stay.
|
||||
*/
|
||||
final class Version20260917123000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Rename season_assignment_task.uploaded to office_done';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE season_assignment_task CHANGE uploaded office_done TINYINT(1) NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE season_assignment_task CHANGE office_done uploaded TINYINT(1) NOT NULL');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Copies the library document onto the checklist row.
|
||||
*
|
||||
* The library holds what the next assignments get and is replaced from season to season, so
|
||||
* resolving it on read rewrote what an assignment that already exists - a finished one included -
|
||||
* hands out. The row carries its own reference from now on.
|
||||
*
|
||||
* The backfill maps every existing row to what it resolves to today, so nothing changes for the
|
||||
* rows that are already out there. ON DELETE SET NULL keeps a row readable when the library file
|
||||
* itself is deleted: "kein Dokument hinterlegt" is a state every view already handles.
|
||||
*/
|
||||
final class Version20260917140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Give each season checklist row its own document reference';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE season_assignment_task ADD document_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE season_assignment_task ADD CONSTRAINT FK_BEEB9D8DC33F7837 FOREIGN KEY (document_id) REFERENCES upload (id) ON DELETE SET NULL');
|
||||
$this->addSql('CREATE INDEX IDX_BEEB9D8DC33F7837 ON season_assignment_task (document_id)');
|
||||
|
||||
$this->addSql('UPDATE season_assignment_task t JOIN season_task_document d ON d.task_key = t.task_key SET t.document_id = d.document_id');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE season_assignment_task DROP FOREIGN KEY FK_BEEB9D8DC33F7837');
|
||||
$this->addSql('DROP INDEX IDX_BEEB9D8DC33F7837 ON season_assignment_task');
|
||||
$this->addSql('ALTER TABLE season_assignment_task DROP document_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Work months imported from the SaaS time tracking, and the signed Arbeitszeitnachweis that
|
||||
* belongs to each of them.
|
||||
*
|
||||
* Hand-trimmed: doctrine:migrations:diff also produced a long tail of datetime, JSON and
|
||||
* TINYINT redeclarations across almost every table. That is the long-standing drift between
|
||||
* the dev database and the mapping, not anything this feature changed, so none of it is here.
|
||||
*/
|
||||
final class Version20260921122743 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds teamer_work_month, the SaaS employee id on teamer and the work month relation on upload';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE teamer_work_month (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, year SMALLINT NOT NULL, month SMALLINT NOT NULL, days JSON NOT NULL, total_minutes INT NOT NULL, worked_days INT NOT NULL, absence_days INT NOT NULL, source_ist_minutes INT NOT NULL, status VARCHAR(255) NOT NULL, submitted_at DATETIME DEFAULT NULL, reviewed_at DATETIME DEFAULT NULL, reviewed_by VARCHAR(8) DEFAULT NULL, review_comment LONGTEXT DEFAULT NULL, synced_at DATETIME NOT NULL, warnings JSON DEFAULT NULL, mismatch_detected_at DATETIME DEFAULT NULL, mismatch_total_minutes INT DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, teamer_id INT NOT NULL, UNIQUE INDEX UNIQ_28857572D17F50A6 (uuid), UNIQUE INDEX teamer_work_month_unique (teamer_id, year, month), INDEX IDX_288575724302FF75 (teamer_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE teamer_work_month ADD CONSTRAINT FK_288575724302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)');
|
||||
|
||||
$this->addSql('ALTER TABLE teamer ADD saas_employee_uuid VARCHAR(36) DEFAULT NULL');
|
||||
|
||||
$this->addSql('ALTER TABLE upload ADD teamer_work_month_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE upload ADD CONSTRAINT FK_17BDE61FFEAE833A FOREIGN KEY (teamer_work_month_id) REFERENCES teamer_work_month (id)');
|
||||
$this->addSql('CREATE INDEX IDX_17BDE61FFEAE833A ON upload (teamer_work_month_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE upload DROP FOREIGN KEY FK_17BDE61FFEAE833A');
|
||||
$this->addSql('DROP INDEX IDX_17BDE61FFEAE833A ON upload');
|
||||
$this->addSql('ALTER TABLE upload DROP teamer_work_month_id');
|
||||
|
||||
$this->addSql('ALTER TABLE teamer DROP saas_employee_uuid');
|
||||
|
||||
$this->addSql('ALTER TABLE teamer_work_month DROP FOREIGN KEY FK_288575724302FF75');
|
||||
$this->addSql('DROP TABLE teamer_work_month');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* The monthly target and the month's total break time, both shown in the head of the
|
||||
* Arbeitszeitnachweis next to the hours actually worked.
|
||||
*
|
||||
* Hand-trimmed, like Version20260921122743: doctrine:migrations:diff sweeps in the
|
||||
* long-standing datetime and JSON drift of the dev database, none of which belongs here.
|
||||
*
|
||||
* The per-day start, end and break times that go with these live in the existing "days" JSON
|
||||
* column, so they need no schema change. Rows imported before this migration simply carry
|
||||
* neither, and are refreshed by the next import of a month that is not yet signed.
|
||||
*/
|
||||
final class Version20260921160000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds the monthly target and total break time to teamer_work_month';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE teamer_work_month ADD target_minutes INT DEFAULT 0 NOT NULL, ADD break_minutes INT DEFAULT 0 NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE teamer_work_month DROP target_minutes, DROP break_minutes');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* The employee's department, shown in the head of the Arbeitszeitnachweis.
|
||||
*
|
||||
* Hand-trimmed like its two predecessors: doctrine:migrations:diff sweeps in the long-standing
|
||||
* datetime and JSON drift of the dev database, none of which belongs here.
|
||||
*
|
||||
* Nullable, and not only because existing rows have none: a month made up entirely of holiday
|
||||
* carries no booking to read a department from.
|
||||
*/
|
||||
final class Version20260921170000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds the department to teamer_work_month';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE teamer_work_month ADD department VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE teamer_work_month DROP department');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Form submissions posted by the sibling websites (TYPO3 ApplicationApiFinisher).
|
||||
*
|
||||
* Generated with doctrine:migrations:diff and reduced to these two statements: the diff also
|
||||
* swept in the long-standing dev drift in the datetime and json column declarations, plus
|
||||
* tables that only exist on another branch.
|
||||
*/
|
||||
final class Version20260923062331 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add application_form_submission for form submissions posted by the websites';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE application_form_submission (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, form VARCHAR(64) NOT NULL, form_label VARCHAR(255) NOT NULL, submitted_at DATETIME NOT NULL, page_uid INT DEFAULT NULL, page_url VARCHAR(2048) DEFAULT NULL, first_name VARCHAR(255) DEFAULT NULL, last_name VARCHAR(255) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, phone VARCHAR(64) DEFAULT NULL, mobile VARCHAR(64) DEFAULT NULL, birth_date VARCHAR(64) DEFAULT NULL, street VARCHAR(255) DEFAULT NULL, zip_code VARCHAR(32) DEFAULT NULL, city VARCHAR(255) DEFAULT NULL, details JSON NOT NULL, payload JSON NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_F47CE995D17F50A6 (uuid), INDEX IDX_F47CE995B5265E72 (form, submitted_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE application_form_submission');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* The follow-up a reviewer works through on a received form submission: two mails and two calls.
|
||||
*
|
||||
* Hand-written rather than generated: doctrine:migrations:diff also sweeps in the long-standing
|
||||
* dev drift in the datetime and json column declarations and tables from other branches. These
|
||||
* are the only statements this change needs.
|
||||
*/
|
||||
final class Version20260924090000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add the follow-up mail and call dates to application_form_submission';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE application_form_submission ADD first_mail_sent_at DATETIME DEFAULT NULL, ADD second_mail_sent_at DATETIME DEFAULT NULL, ADD first_call_held_at DATE DEFAULT NULL, ADD second_call_held_at DATE DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE application_form_submission DROP first_mail_sent_at, DROP second_mail_sent_at, DROP first_call_held_at, DROP second_call_held_at');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* The one payload field a rule depends on: whether the form reported an EU (incl. Switzerland)
|
||||
* nationality. Nullable with three meanings - true, false, and "this form does not ask" - because
|
||||
* only true earns the automatic receipt mail.
|
||||
*
|
||||
* Hand-written rather than generated, like the other two: doctrine:migrations:diff also sweeps in
|
||||
* the long-standing dev drift and tables from other branches.
|
||||
*/
|
||||
final class Version20260924120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add the eu eligibility flag to application_form_submission';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE application_form_submission ADD eu TINYINT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE application_form_submission DROP eu');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* A slot marker on the document library, so a feature can look a file up by what it is for
|
||||
* instead of by id (App\Config\UploadToken). Unique: one document per slot.
|
||||
*
|
||||
* Reduced to these two statements by hand - the generated diff also wanted to drop another
|
||||
* branch's upload columns and restate the long-standing datetime drift on this table.
|
||||
*/
|
||||
final class Version20260924140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add the token slot marker to upload';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE upload ADD token VARCHAR(64) DEFAULT NULL');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_17BDE61F5F37A13B ON upload (token)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX UNIQ_17BDE61F5F37A13B ON upload');
|
||||
$this->addSql('ALTER TABLE upload DROP token');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* What it takes to provision a teamer from a received application: the applicant's gender, and the
|
||||
* outcome of each of the three provisioning writes.
|
||||
*
|
||||
* Emptying the table first is deliberate. gender is NOT NULL because BusPro refuses an address
|
||||
* without one and Teamer::$gender is not nullable either, and the existing rows predate the
|
||||
* tightened endpoint contract, so none of them carries a value. They are test submissions in dev -
|
||||
* the feature has never shipped, and nothing in production is affected. Doing it here rather than
|
||||
* by hand keeps it reviewable and keeps the migration replayable.
|
||||
*
|
||||
* Reduced to these statements by hand: the generated diff also wanted to restate the long-standing
|
||||
* datetime comment drift and another branch's tables.
|
||||
*/
|
||||
final class Version20260925120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add gender and the provisioning state to application_form_submission';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DELETE FROM application_form_submission');
|
||||
$this->addSql('ALTER TABLE application_form_submission ADD gender VARCHAR(1) NOT NULL, ADD bus_pro_address_id INT DEFAULT NULL, ADD bus_pro_person_id INT DEFAULT NULL, ADD teamer_created_at DATETIME DEFAULT NULL, ADD user_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE application_form_submission ADD CONSTRAINT FK_F47CE995A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE SET NULL');
|
||||
$this->addSql('CREATE INDEX IDX_F47CE995A76ED395 ON application_form_submission (user_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE application_form_submission DROP FOREIGN KEY FK_F47CE995A76ED395');
|
||||
$this->addSql('DROP INDEX IDX_F47CE995A76ED395 ON application_form_submission');
|
||||
$this->addSql('ALTER TABLE application_form_submission DROP gender, DROP bus_pro_address_id, DROP bus_pro_person_id, DROP teamer_created_at, DROP user_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Moves destination.country from the raw BusProNet abbreviations to lowercase ISO-2.
|
||||
*
|
||||
* The column used to hold whatever BusProNet reported as hotel/land, lowercased: a,
|
||||
* ch, d, f, i, nl. That set is not a standard - the same country appears as A, AT and
|
||||
* AUT upstream - so the import now stores the ISO code MyE&P resolves instead, and the
|
||||
* existing rows are converted to match. The flag icons and the country filters use the
|
||||
* same vocabulary and are switched with it.
|
||||
*/
|
||||
final class Version20260927090000 extends AbstractMigration
|
||||
{
|
||||
private const TO_ISO = [
|
||||
'a' => 'at',
|
||||
'd' => 'de',
|
||||
'f' => 'fr',
|
||||
'i' => 'it',
|
||||
];
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Convert destination.country to lowercase ISO-3166-1 alpha-2';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
foreach (self::TO_ISO as $legacy => $iso) {
|
||||
$this->addSql('UPDATE destination SET country = ? WHERE country = ?', [$iso, $legacy]);
|
||||
}
|
||||
|
||||
// ch and nl are already ISO; empty strings were never a country
|
||||
$this->addSql("UPDATE destination SET country = NULL WHERE country = ''");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
foreach (self::TO_ISO as $legacy => $iso) {
|
||||
$this->addSql('UPDATE destination SET country = ? WHERE country = ?', [$legacy, $iso]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Adds the per-placement feedback waiver.
|
||||
*
|
||||
* The overdue-feedback lists are a live query without any stored state, so there was no way to
|
||||
* write off a feedback nobody will provide any more. The flag takes a placement out of the
|
||||
* dashboards' overdue lists, the house-manager list and the reminder mail.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
|
||||
* pre-existing drift of the dev database.
|
||||
*/
|
||||
final class Version20261013120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add the feedback_waived flag to dispositions';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE disposition ADD feedback_waived TINYINT DEFAULT 0 NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE disposition DROP feedback_waived');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Adds the staffing matrix and what the assignment generator needs from the travel import.
|
||||
*
|
||||
* destination.product_code is the travel code MyE&P reports per date; destination.imported_at
|
||||
* tells the dates the latest import reported from ones cancelled upstream, which the import
|
||||
* never deletes. Both stay NULL until the next app:myep-import.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
|
||||
* pre-existing drift of the dev database.
|
||||
*/
|
||||
final class Version20261015154754 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add the staffing_rule table and destination.product_code / imported_at';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE staffing_rule (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, product_code VARCHAR(32) NOT NULL, guides INT NOT NULL, ski_beginner TINYINT NOT NULL, ski_advanced TINYINT NOT NULL, split TINYINT NOT NULL, guiding TINYINT NOT NULL, bus_escort TINYINT NOT NULL, enabled TINYINT NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, deleted_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_91DFB1A2D17F50A6 (uuid), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE destination ADD product_code VARCHAR(32) DEFAULT NULL, ADD imported_at DATETIME DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE staffing_rule');
|
||||
$this->addSql('ALTER TABLE destination DROP product_code, DROP imported_at');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Drops the staffing matrix's Busbegleitung flag.
|
||||
*
|
||||
* Whether a date gets a bus escort follows from the date itself: one with boarding points has
|
||||
* bus travel, and App\Service\Assignment\AssignmentPlanner pre-selects the first of them. A flag
|
||||
* per product could only repeat that, and less precisely, since bus travel varies between the
|
||||
* dates of one product.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
|
||||
* pre-existing drift of the dev database.
|
||||
*/
|
||||
final class Version20261016090000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop staffing_rule.bus_escort';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE staffing_rule DROP bus_escort');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE staffing_rule ADD bus_escort TINYINT NOT NULL');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Adds questionnaire_response: a respondent's answers to one questionnaire from
|
||||
* config/questionnaires/, for now the teamer questionnaire after a placement.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
|
||||
* pre-existing drift of the dev database.
|
||||
*/
|
||||
final class Version20261027140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add questionnaire_response';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE questionnaire_response (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, questionnaire VARCHAR(64) NOT NULL, version INT NOT NULL, status VARCHAR(16) NOT NULL, submitted_at DATETIME DEFAULT NULL, answers JSON NOT NULL, context JSON NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, disposition_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_A0400276D17F50A6 (uuid), UNIQUE INDEX questionnaire_disposition (questionnaire, disposition_id), INDEX IDX_A0400276287B65ED (disposition_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`');
|
||||
$this->addSql('ALTER TABLE questionnaire_response ADD CONSTRAINT FK_A0400276287B65ED FOREIGN KEY (disposition_id) REFERENCES disposition (id) ON DELETE CASCADE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE questionnaire_response DROP FOREIGN KEY FK_A0400276287B65ED');
|
||||
$this->addSql('DROP TABLE questionnaire_response');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Replaces the staffing matrix's split flag with one flag per ski level.
|
||||
*
|
||||
* A split row takes over every level it has, so it keeps planning the same assignments. The way
|
||||
* back folds the two flags into one, which loses a row that takes over one level of two.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
|
||||
* pre-existing drift of the dev database.
|
||||
*/
|
||||
final class Version20261105100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Replace staffing_rule.split with split_beginner / split_advanced';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE staffing_rule ADD split_beginner TINYINT NOT NULL, ADD split_advanced TINYINT NOT NULL');
|
||||
$this->addSql('UPDATE staffing_rule SET split_beginner = split AND ski_beginner, split_advanced = split AND ski_advanced');
|
||||
$this->addSql('ALTER TABLE staffing_rule DROP split');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE staffing_rule ADD split TINYINT NOT NULL');
|
||||
$this->addSql('UPDATE staffing_rule SET split = split_beginner OR split_advanced');
|
||||
$this->addSql('ALTER TABLE staffing_rule DROP split_beginner, DROP split_advanced');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Numbers the places of an assignment, for the n8n placements feed ("platz_id").
|
||||
*
|
||||
* Existing active placements are numbered 1..n per assignment in creation order; called-off ones
|
||||
* stay without a number, they hold no place. n8n has not seen a place number yet, so the initial
|
||||
* order is free to choose.
|
||||
*
|
||||
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
|
||||
* pre-existing drift of the dev database.
|
||||
*/
|
||||
final class Version20261106100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add disposition.slot and number the active placements per assignment';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE disposition ADD slot SMALLINT DEFAULT NULL');
|
||||
$this->addSql(<<<'SQL'
|
||||
UPDATE disposition
|
||||
INNER JOIN (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY assignment_id ORDER BY id) AS slot
|
||||
FROM disposition
|
||||
WHERE status <> 'called_off' AND assignment_id IS NOT NULL
|
||||
) AS numbered ON numbered.id = disposition.id
|
||||
SET disposition.slot = numbered.slot
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE disposition DROP slot');
|
||||
}
|
||||
}
|
||||
Generated
+1676
-5443
File diff suppressed because it is too large.
Load diff
+12
-14
@@ -1,39 +1,37 @@
|
||||
{
|
||||
"name": "myep-team",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.17.0",
|
||||
"@babel/preset-env": "^7.16.0",
|
||||
"@babel/core": "^8.0.0",
|
||||
"@babel/preset-env": "^8.0.0",
|
||||
"@hotwired/stimulus": "^3.0.0",
|
||||
"@symfony/stimulus-bridge": "^3.2.0",
|
||||
"@symfony/webpack-encore": "^4.0.0",
|
||||
"@symfony/webpack-encore": "^7.2.0",
|
||||
"@tailwindcss/forms": "^0.5.6",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@trevoreyre/autocomplete-js": "^2.4.1",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"babel-plugin-polyfill-corejs3": "^1.0.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"core-js": "^3.23.0",
|
||||
"core-js": "^3.50.0",
|
||||
"dropzone": "^6.0.0-beta.2",
|
||||
"file-loader": "^6.2.0",
|
||||
"flatpickr": "^4.6.13",
|
||||
"htmx.org": "^1.9.10",
|
||||
"htmx.org": "^2.0.11",
|
||||
"lightningcss": "^1.32.0",
|
||||
"mjml": "^4.15.3",
|
||||
"postcss": "^8.4.29",
|
||||
"postcss-import": "^15.1.0",
|
||||
"postcss-loader": "^7.3.3",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"regenerator-runtime": "^0.13.9",
|
||||
"sortablejs": "^1.15.0",
|
||||
"stimulus-use": "^0.52.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tippy.js": "^6.3.7",
|
||||
"toastify-js": "^1.12.0",
|
||||
"webpack": "^5.74.0",
|
||||
"webpack-cli": "^4.10.0"
|
||||
"webpack": "^5.101.0",
|
||||
"webpack-cli": "^7.0.0"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev-server": "encore dev-server",
|
||||
"dev": "encore dev",
|
||||
"watch": "encore dev --watch",
|
||||
"build": "encore production --progress",
|
||||
|
||||
+18
-10
@@ -1,20 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
|
||||
<!-- https://docs.phpunit.de/en/12.5/configuration.html -->
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
backupGlobals="false"
|
||||
colors="true"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
convertDeprecationsToExceptions="false"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
failOnDeprecation="true"
|
||||
failOnNotice="true"
|
||||
failOnWarning="true"
|
||||
displayDetailsOnTestsThatTriggerDeprecations="true"
|
||||
displayDetailsOnTestsThatTriggerNotices="true"
|
||||
displayDetailsOnTestsThatTriggerWarnings="true"
|
||||
displayDetailsOnPhpunitDeprecations="true"
|
||||
displayDetailsOnPhpunitNotices="true"
|
||||
>
|
||||
<php>
|
||||
<ini name="display_errors" value="1" />
|
||||
<ini name="error_reporting" value="-1" />
|
||||
<server name="APP_ENV" value="test" force="true" />
|
||||
<server name="SHELL_VERBOSITY" value="-1" />
|
||||
<server name="SYMFONY_PHPUNIT_REMOVE" value="" />
|
||||
<server name="SYMFONY_PHPUNIT_VERSION" value="9.5" />
|
||||
</php>
|
||||
|
||||
<testsuites>
|
||||
@@ -23,13 +29,15 @@
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<coverage processUncoveredFiles="true">
|
||||
<!-- Symfony triggers its deprecations with @trigger_error(); without
|
||||
ignoreSuppressionOfDeprecations PHPUnit would swallow them all. -->
|
||||
<source ignoreSuppressionOfDeprecations="true">
|
||||
<include>
|
||||
<directory suffix=".php">src</directory>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
</coverage>
|
||||
</source>
|
||||
|
||||
<listeners>
|
||||
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
|
||||
</listeners>
|
||||
<extensions>
|
||||
<bootstrap class="Symfony\Bridge\PhpUnit\SymfonyExtension" />
|
||||
</extensions>
|
||||
</phpunit>
|
||||
+1
-3
@@ -1,7 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
### GET all employees
|
||||
GET {{saas_base_url}}/api/v2/employees/{{saas_api_key}}
|
||||
|
||||
### GET all departments
|
||||
GET {{saas_base_url}}/api/v2/employee/departments/{{saas_api_key}}
|
||||
|
||||
### GET single time account
|
||||
GET {{saas_base_url}}/api/v2/time/account/{{saas_api_key}}/{{saas_employee_email}}/2026/2
|
||||
|
||||
+135
-28
@@ -2,11 +2,12 @@
|
||||
|
||||
namespace App\BusProNet;
|
||||
|
||||
use App\BusProNet\Model\BaseDataResponse;
|
||||
use App\BusProNet\Model\CrmAttributesResponse;
|
||||
use App\BusProNet\Model\NotificationResponse;
|
||||
use App\BusProNet\Model\ProfileResponse;
|
||||
use App\BusProNet\Model\ProfileUpdateResponse;
|
||||
use App\BusProNet\Model\RegistrationResponse;
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Entity\User;
|
||||
use League\Flysystem\FilesystemException;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
@@ -15,15 +16,24 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* The socket protocol against BusProNet: length prefixed German XML over fsockopen.
|
||||
*
|
||||
* This is the **write** path and the user facing reads that go with it - profile lookup and
|
||||
* update, registration, password reset, the CRM selections behind the leads roles and hotel
|
||||
* codes. All of it is per user and authenticated as that user, which is why it stays here.
|
||||
*
|
||||
* It is not legacy. The base data reads that used to sit alongside it - STAMMLAENDER,
|
||||
* STAMMZUSTIEGE, STAMMHOTELS - are gone: hotels, pickups and countries now come from MyE&P's
|
||||
* REST api (App\BusProNet\DataProvider\*, App\MyEp\Api\ApiClient), which is the one place
|
||||
* that understands BusProNet's XML.
|
||||
*/
|
||||
class ApiClient
|
||||
{
|
||||
use ApiClientTrait;
|
||||
|
||||
public const TYPE_NOTIFICATION = 'HINWEIS';
|
||||
public const TYPE_CUSTOMER_DATA = 'KUNDENKONTO';
|
||||
public const TYPE_BASE_DATA_COUNTRIES = 'STAMMLAENDER';
|
||||
public const TYPE_BASE_DATA_PICKUPS = 'STAMMZUSTIEGE';
|
||||
public const TYPE_BASE_DATA_HOTELS = 'STAMMHOTELS';
|
||||
|
||||
private array $config;
|
||||
|
||||
@@ -58,15 +68,28 @@ class ApiClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the teamer's profile to their BusPro address without their password.
|
||||
*
|
||||
* "login" switches the password check off, so the two ids are the whole authorisation of
|
||||
* this write: an address can hold several persons, and only "idadresseperson" names the
|
||||
* person, while "email" is ignored. The ids must therefore only ever come from a verified
|
||||
* source - MyE&P userinfo or a BusPro answer - never from a form or a request parameter.
|
||||
* Verified on the BusPro test system (2026-10-16): BusPro accepts "login" on KUNDENKONTO
|
||||
* requests made with our API credentials.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
* @throws ResponseParserException
|
||||
*/
|
||||
public function updateProfile(User $user, string $password): NotificationResponse|ProfileUpdateResponse
|
||||
public function updateProfile(User $user): NotificationResponse|ProfileUpdateResponse
|
||||
{
|
||||
if (null === $teamer = $user->getTeamer()) {
|
||||
throw new ApiClientException('Invalid argument');
|
||||
}
|
||||
|
||||
if (null === $user->getBusProAddressId() || null === $user->getBusProPersonId()) {
|
||||
throw new ApiClientException('User has no BusPro address and person id');
|
||||
}
|
||||
|
||||
$data = [
|
||||
'anfrage' => [
|
||||
'user' => $this->config['bpn_username'],
|
||||
@@ -74,8 +97,9 @@ class ApiClient
|
||||
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
|
||||
'art' => 'Adressdaten_Ändern',
|
||||
'email' => $user->getEmail(),
|
||||
'passwort' => $password,
|
||||
'login' => 'J',
|
||||
'idadresse' => $user->getBusProAddressId(),
|
||||
'idadresseperson' => $user->getBusProPersonId(),
|
||||
'adressdaten' => $teamer->toPayload(),
|
||||
],
|
||||
];
|
||||
@@ -83,6 +107,87 @@ class ApiClient
|
||||
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the applicant's address in the CRM, which is what makes them a person BusPro knows.
|
||||
*
|
||||
* BusPro answers with the ids of an address it already held when it recognises the person
|
||||
* ("neuanlage" False), so this never produces a duplicate and needs no pre-flight lookup -
|
||||
* reading an address would require the customer's password, which provisioning deliberately
|
||||
* does not have.
|
||||
*
|
||||
* "ohnemailversand" suppresses BusPro's own mail about the new record: the applicant is asked
|
||||
* to set a password by resetPassword() at the end of provisioning instead, so that a failure
|
||||
* in between never mails somebody who has not been provisioned.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
* @throws ResponseParserException
|
||||
*/
|
||||
public function createAddress(ApplicationFormSubmission $submission): NotificationResponse|RegistrationResponse
|
||||
{
|
||||
$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' => 'Adresse_Neu',
|
||||
'adressdaten' => $submission->toPayload(),
|
||||
'ohnemailversand' => 'True',
|
||||
],
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or clears one CRM selection on a person - the one write to the CRM this application
|
||||
* makes. Everything else about selections is read-only here: roles and hotel codes are synced
|
||||
* from BusPro on every login and an admin may approve a claim, never grant one.
|
||||
*
|
||||
* "login" switches the password check off, which only works when the request identifies the
|
||||
* person by id: both ids are required, and the person id is "idadresseperson" on the way in
|
||||
* although the response calls it "idperson". The element order and the single-selection
|
||||
* payload are what BusPro accepted when verified against production (2026-09-25, selection
|
||||
* 1070 toggled off and back on) - the art is undocumented, so re-verify before changing the
|
||||
* shape.
|
||||
*
|
||||
* Idempotent: setting a selection that is already set answers änderung=True and changes
|
||||
* nothing, so a caller may write unconditionally and take isUpdated() as the success check.
|
||||
*
|
||||
* The write only goes through where the selection is marked changeable in the BusPro
|
||||
* settings, and that flag is per environment - 1070 is writable in production only.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
* @throws ResponseParserException
|
||||
*/
|
||||
public function updateCrmAttribute(
|
||||
string $email,
|
||||
int $addressId,
|
||||
int $personId,
|
||||
int $selectionId,
|
||||
bool $selected,
|
||||
): NotificationResponse|ProfileUpdateResponse {
|
||||
$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_Ändern',
|
||||
'login' => 'J',
|
||||
'email' => $email,
|
||||
'idadresse' => $addressId,
|
||||
'idadresseperson' => $personId,
|
||||
'selektionsmerkmale' => [
|
||||
'selektion' => [
|
||||
// Capitalised as in the read response, which is what the write accepts.
|
||||
['@id' => $selectionId, '@auswahl' => true === $selected ? 'True' : 'False'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
* @throws ResponseParserException
|
||||
@@ -122,23 +227,6 @@ class ApiClient
|
||||
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
* @throws ResponseParserException
|
||||
*/
|
||||
public function getBaseData(string $type): NotificationResponse|BaseDataResponse
|
||||
{
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
* @throws ResponseParserException
|
||||
@@ -162,17 +250,36 @@ class ApiClient
|
||||
$this->config['max_retries']
|
||||
);
|
||||
$this->send($socket, $body);
|
||||
$response = $this->receive($socket);
|
||||
$this->disconnect($socket);
|
||||
|
||||
// message length (10 bytes) is prepended to actual message
|
||||
$xml = substr($response, 10);
|
||||
try {
|
||||
// the length header is consumed by receive(), this is the payload
|
||||
$xml = $this->receive($socket);
|
||||
} finally {
|
||||
$this->disconnect($socket);
|
||||
}
|
||||
|
||||
if (true === $this->config['debug']) {
|
||||
$this->dumpXmlToFile('response', $requestId, $xml);
|
||||
}
|
||||
|
||||
return $this->responseParser->parseXmlString($type, $xml);
|
||||
try {
|
||||
return $this->responseParser->parseXmlString($type, $xml);
|
||||
} catch (ResponseParserException $e) {
|
||||
// Keep the evidence even outside debug mode: without the raw response a parse
|
||||
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
|
||||
if (true !== $this->config['debug']) {
|
||||
$this->dumpXmlToFile('response', $requestId, $xml);
|
||||
}
|
||||
|
||||
$this->logger->error('Unable to parse BusProNet response', [
|
||||
'request_id' => $requestId,
|
||||
'type' => $type,
|
||||
'response_length' => strlen($xml),
|
||||
'error_message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function dumpXmlToFile(string $type, string $requestId, string $body): void
|
||||
|
||||
@@ -58,15 +58,58 @@ trait ApiClientTrait
|
||||
fwrite($socket, $send);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a single response message. The protocol prepends the payload length as a
|
||||
* 10 byte header, so read exactly that many bytes rather than guessing at EOF:
|
||||
* a peer that closes mid-stream would otherwise yield a silently truncated body.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
private function receive($socket): string
|
||||
{
|
||||
$response = '';
|
||||
$header = $this->readBytes($socket, 10);
|
||||
|
||||
while (false === feof($socket)) {
|
||||
$response .= fread($socket, 4096);
|
||||
if (10 !== strlen($header)) {
|
||||
throw new ApiClientException(sprintf('Incomplete response header, got %d of 10 bytes', strlen($header)));
|
||||
}
|
||||
|
||||
return $response;
|
||||
$expectedLength = (int) trim($header);
|
||||
|
||||
if (1 > $expectedLength) {
|
||||
throw new ApiClientException(sprintf('Response announced an empty body (header "%s")', trim($header)));
|
||||
}
|
||||
|
||||
$body = $this->readBytes($socket, $expectedLength);
|
||||
|
||||
if (strlen($body) !== $expectedLength) {
|
||||
throw new ApiClientException(sprintf('Truncated response, got %d of %d announced bytes', strlen($body), $expectedLength));
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
private function readBytes($socket, int $length): string
|
||||
{
|
||||
$buffer = '';
|
||||
|
||||
while (strlen($buffer) < $length && false === feof($socket)) {
|
||||
$chunk = fread($socket, min(4096, $length - strlen($buffer)));
|
||||
|
||||
if (false === $chunk || '' === $chunk) {
|
||||
if (true === (stream_get_meta_data($socket)['timed_out'] ?? false)) {
|
||||
throw new ApiClientException(sprintf('Timed out reading response after %d of %d bytes', strlen($buffer), $length));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$buffer .= $chunk;
|
||||
}
|
||||
|
||||
return $buffer;
|
||||
}
|
||||
|
||||
private function disconnect($socket): void
|
||||
|
||||
@@ -2,44 +2,39 @@
|
||||
|
||||
namespace App\BusProNet\DataProvider;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\ApiClientException;
|
||||
use App\BusProNet\Model\Country;
|
||||
use App\BusProNet\Model\NotificationResponse;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use App\MyEp\Api\ApiClient;
|
||||
use App\MyEp\Api\ResponseMapper;
|
||||
use App\MyEp\Cache\StaleWhileErrorCache;
|
||||
|
||||
/**
|
||||
* Country base data, read from MyE&P rather than from BusProNet directly.
|
||||
*
|
||||
* Keyed by the raw BusProNet token, not by an ISO code: the token is what a hotel's
|
||||
* country and a teamer's nationality join against, and that vocabulary stays raw.
|
||||
*/
|
||||
class CountryDataProvider
|
||||
{
|
||||
private const CACHE_KEY = 'myep_countries';
|
||||
private const TTL = 3600;
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly ResponseMapper $responseMapper,
|
||||
private readonly StaleWhileErrorCache $cache,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Country> keyed by token
|
||||
*/
|
||||
public function getAll(): array
|
||||
{
|
||||
try {
|
||||
$countries = $this->cache->get('bpn_countries', function (ItemInterface $item) {
|
||||
$item->expiresAfter(3600);
|
||||
|
||||
$response = $this->apiClient->getBaseData(ApiClient::TYPE_BASE_DATA_COUNTRIES);
|
||||
|
||||
if ($response instanceof NotificationResponse) {
|
||||
$this->logger->error('Unable to fetch country base data from BusProNet. Code: '.$response->getCode().', Message: '.$response->getMessage());
|
||||
|
||||
throw new ApiClientException('Unable to fetch country base data from BusProNet');
|
||||
}
|
||||
|
||||
return $response->getItems();
|
||||
});
|
||||
} catch (ApiClientException $e) {
|
||||
$countries = [];
|
||||
}
|
||||
|
||||
return $countries;
|
||||
return $this->cache->get(
|
||||
self::CACHE_KEY,
|
||||
self::TTL,
|
||||
fn (): array => $this->responseMapper->mapCountries($this->apiClient->get('api/countries')),
|
||||
);
|
||||
}
|
||||
|
||||
public function get(?string $token): ?Country
|
||||
|
||||
@@ -2,45 +2,40 @@
|
||||
|
||||
namespace App\BusProNet\DataProvider;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\ApiClientException;
|
||||
use App\BusProNet\Model\Hotel;
|
||||
use App\BusProNet\Model\NotificationResponse;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use App\MyEp\Api\ApiClient;
|
||||
use App\MyEp\Api\ResponseMapper;
|
||||
use App\MyEp\Cache\StaleWhileErrorCache;
|
||||
|
||||
/**
|
||||
* Hotel base data, read from MyE&P rather than from BusProNet directly.
|
||||
*
|
||||
* The models and the public interface are unchanged from when this spoke the BusProNet
|
||||
* socket protocol: every caller keys on the BusProNet idbuspro, which is exactly what
|
||||
* MyE&P reports as the hotel id.
|
||||
*/
|
||||
class HotelDataProvider
|
||||
{
|
||||
private const CACHE_KEY = 'myep_hotels';
|
||||
private const TTL = 3600;
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly ResponseMapper $responseMapper,
|
||||
private readonly StaleWhileErrorCache $cache,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Hotel> keyed by busProId
|
||||
*/
|
||||
public function getAll(): array
|
||||
{
|
||||
try {
|
||||
$hotels = $this->cache->get('bpn_hotels', function (ItemInterface $item) {
|
||||
$item->expiresAfter(3600);
|
||||
|
||||
$response = $this->apiClient->getBaseData(ApiClient::TYPE_BASE_DATA_HOTELS);
|
||||
|
||||
if ($response instanceof NotificationResponse) {
|
||||
$this->logger->error('Unable to fetch hotel base data from BusProNet. Code: '.$response->getCode().', Message: '.$response->getMessage());
|
||||
|
||||
throw new ApiClientException('Unable to fetch hotel base data from BusProNet');
|
||||
}
|
||||
|
||||
return $response->getItems();
|
||||
});
|
||||
} catch (ApiClientException|InvalidArgumentException $e) {
|
||||
$hotels = [];
|
||||
}
|
||||
|
||||
return $hotels;
|
||||
return $this->cache->get(
|
||||
self::CACHE_KEY,
|
||||
self::TTL,
|
||||
fn (): array => $this->responseMapper->mapHotels($this->apiClient->get('api/hotels')),
|
||||
);
|
||||
}
|
||||
|
||||
public function get(int $busProId): ?Hotel
|
||||
@@ -48,12 +43,21 @@ class HotelDataProvider
|
||||
return $this->getAll()[$busProId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Hotel>
|
||||
*/
|
||||
public function findByCode(string $code): array
|
||||
{
|
||||
$hotels = [];
|
||||
|
||||
foreach ($this->getAll() as $hotel) {
|
||||
if (str_starts_with($hotel->getCode(), $code) || str_ends_with($hotel->getCode(), $code)) {
|
||||
$hotelCode = $hotel->getCode();
|
||||
|
||||
if (null === $hotelCode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($hotelCode, $code) || str_ends_with($hotelCode, $code)) {
|
||||
$hotels[] = $hotel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,44 +2,39 @@
|
||||
|
||||
namespace App\BusProNet\DataProvider;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\ApiClientException;
|
||||
use App\BusProNet\Model\NotificationResponse;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use App\MyEp\Api\ApiClient;
|
||||
use App\MyEp\Api\ResponseMapper;
|
||||
use App\MyEp\Cache\StaleWhileErrorCache;
|
||||
|
||||
/**
|
||||
* Boarding point base data, read from MyE&P rather than from BusProNet directly.
|
||||
*
|
||||
* These are the stops themselves, without a departure time: a time belongs to a stop on
|
||||
* a given travel date and is carried by the destination's pickups instead.
|
||||
*/
|
||||
class PickupDataProvider
|
||||
{
|
||||
private const CACHE_KEY = 'myep_pickups';
|
||||
private const TTL = 3600;
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly ResponseMapper $responseMapper,
|
||||
private readonly StaleWhileErrorCache $cache,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Pickup> keyed by busProId
|
||||
*/
|
||||
public function getAll(): array
|
||||
{
|
||||
try {
|
||||
$pickups = $this->cache->get('bpn_pickups', function (ItemInterface $item) {
|
||||
$item->expiresAfter(3600);
|
||||
|
||||
$response = $this->apiClient->getBaseData(ApiClient::TYPE_BASE_DATA_PICKUPS);
|
||||
|
||||
if ($response instanceof NotificationResponse) {
|
||||
$this->logger->error('Unable to fetch pickup base data from BusProNet. Code: '.$response->getCode().', Message: '.$response->getMessage());
|
||||
|
||||
throw new ApiClientException('Unable to fetch pickup base data from BusProNet');
|
||||
}
|
||||
|
||||
return $response->getItems();
|
||||
});
|
||||
} catch (ApiClientException $e) {
|
||||
$pickups = [];
|
||||
}
|
||||
|
||||
return $pickups;
|
||||
return $this->cache->get(
|
||||
self::CACHE_KEY,
|
||||
self::TTL,
|
||||
fn (): array => $this->responseMapper->mapPickups($this->apiClient->get('api/pickups')),
|
||||
);
|
||||
}
|
||||
|
||||
public function get(int $busProId): ?Pickup
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
class BaseDataResponse
|
||||
{
|
||||
public function __construct(private readonly array $items)
|
||||
{
|
||||
}
|
||||
|
||||
public function getItems(): array
|
||||
{
|
||||
return $this->items;
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,9 @@ class Communication
|
||||
|
||||
public function setEmail(?string $email): static
|
||||
{
|
||||
$this->email = $email;
|
||||
// BusPro compares addresses case-insensitively; normalize on the way in so the DTO
|
||||
// carries the same shape the entities store.
|
||||
$this->email = null === $email ? null : mb_strtolower(trim($email));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -4,23 +4,10 @@ namespace App\BusProNet\Model;
|
||||
|
||||
class Country
|
||||
{
|
||||
private ?int $id = null;
|
||||
private ?string $name = null;
|
||||
private ?string $token = null;
|
||||
private ?string $nationality = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(?int $id): static
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
|
||||
@@ -90,15 +90,20 @@ class CrmAttributesResponse
|
||||
// a response the CRM sent no attributes in still has to flatten to an empty set
|
||||
foreach ($this->getAttributeGroups() ?? [] as $group) {
|
||||
/** @var CrmAttributeGroup $group */
|
||||
if (false === isset($crmSelections[$group->getLabel()])) {
|
||||
$crmSelections[$group->getLabel()] = [];
|
||||
// The CRM may report a group without a label. PHP used to coerce that null
|
||||
// to an empty string on its own; since 8.5 it deprecates it, so the same
|
||||
// key is now formed explicitly and nothing about the result changes.
|
||||
$label = $group->getLabel() ?? '';
|
||||
|
||||
if (false === isset($crmSelections[$label])) {
|
||||
$crmSelections[$label] = [];
|
||||
}
|
||||
foreach ($group->getAttributes() ?? [] as $attribute) {
|
||||
/** @var CrmAttribute $attribute */
|
||||
if (false === $attribute->isSelected()) {
|
||||
continue;
|
||||
}
|
||||
$crmSelections[$group->getLabel()][] = $attribute->getLabel();
|
||||
$crmSelections[$label][] = $attribute->getLabel();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,29 +4,22 @@ namespace App\BusProNet\Model;
|
||||
|
||||
class Hotel
|
||||
{
|
||||
private ?int $id = null;
|
||||
private ?int $busProId = null;
|
||||
private ?string $code = null;
|
||||
private ?string $name = null;
|
||||
private ?string $city = null;
|
||||
private ?string $street = null;
|
||||
private ?string $phone = null;
|
||||
private ?string $type = null;
|
||||
/**
|
||||
* The raw BusProNet abbreviation, which is not a standard: the same country turns
|
||||
* up as D, DEU, A, AT or AUT. It joins against the country base data by token.
|
||||
*/
|
||||
private ?string $country = null;
|
||||
|
||||
/**
|
||||
* The same country as lowercase ISO-3166-1 alpha-2, resolved by MyE&P. Null when
|
||||
* the source carries no country or none can be determined.
|
||||
*/
|
||||
private ?string $countryCode = null;
|
||||
private ?string $costUnit = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(?int $id): static
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBusProId(): ?int
|
||||
{
|
||||
return $this->busProId;
|
||||
@@ -63,54 +56,6 @@ class Hotel
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCity(): ?string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function setCity(?string $city): static
|
||||
{
|
||||
$this->city = $city;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreet(): ?string
|
||||
{
|
||||
return $this->street;
|
||||
}
|
||||
|
||||
public function setStreet(?string $street): static
|
||||
{
|
||||
$this->street = $street;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhone(): ?string
|
||||
{
|
||||
return $this->phone;
|
||||
}
|
||||
|
||||
public function setPhone(?string $phone): static
|
||||
{
|
||||
$this->phone = $phone;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): ?string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(?string $type): static
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCountry(): ?string
|
||||
{
|
||||
return $this->country;
|
||||
@@ -123,6 +68,18 @@ class Hotel
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCountryCode(): ?string
|
||||
{
|
||||
return $this->countryCode;
|
||||
}
|
||||
|
||||
public function setCountryCode(?string $countryCode): static
|
||||
{
|
||||
$this->countryCode = $countryCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCostUnit(): ?string
|
||||
{
|
||||
return $this->costUnit;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
/**
|
||||
* The answer to "Adresse_Neu": the ids of the address the CRM now holds for this person, and
|
||||
* whether it had to create it.
|
||||
*
|
||||
* isNewRecord() is BusPro's own duplicate check ("neuanlage"): False means it matched an address
|
||||
* that already existed and answered with that one's ids, so a caller never has to look for
|
||||
* duplicates itself - which it could not anyway, as reading an address needs the customer's
|
||||
* password. It says nothing about which CRM selections that address carries.
|
||||
*/
|
||||
class RegistrationResponse
|
||||
{
|
||||
public function __construct(
|
||||
private readonly int $addressId,
|
||||
private readonly int $personId,
|
||||
private readonly bool $isNewRecord,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getAddressId(): int
|
||||
{
|
||||
return $this->addressId;
|
||||
}
|
||||
|
||||
public function getPersonId(): int
|
||||
{
|
||||
return $this->personId;
|
||||
}
|
||||
|
||||
public function isNewRecord(): bool
|
||||
{
|
||||
return $this->isNewRecord;
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,14 @@
|
||||
namespace App\BusProNet;
|
||||
|
||||
use App\BusProNet\Model\Address;
|
||||
use App\BusProNet\Model\BaseDataResponse;
|
||||
use App\BusProNet\Model\Communication;
|
||||
use App\BusProNet\Model\Country;
|
||||
use App\BusProNet\Model\CrmAttribute;
|
||||
use App\BusProNet\Model\CrmAttributeGroup;
|
||||
use App\BusProNet\Model\CrmAttributesResponse;
|
||||
use App\BusProNet\Model\Hotel;
|
||||
use App\BusProNet\Model\NotificationResponse;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\BusProNet\Model\ProfileResponse;
|
||||
use App\BusProNet\Model\ProfileUpdateResponse;
|
||||
use App\BusProNet\Model\RegistrationResponse;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class ResponseParser
|
||||
@@ -30,10 +27,7 @@ class ResponseParser
|
||||
*/
|
||||
public function parseXmlString(string $type, string $content): mixed
|
||||
{
|
||||
$xml = simplexml_load_string($content);
|
||||
if (false === $xml) {
|
||||
throw new ResponseParserException('Unable to parse XML response');
|
||||
}
|
||||
$xml = $this->loadXml($content);
|
||||
|
||||
// Override type when present in XML to catch error responses
|
||||
$responseType = $type;
|
||||
@@ -53,19 +47,22 @@ class ResponseParser
|
||||
case 'Adressdaten_Ändern':
|
||||
return $this->createProfileUpdateResponse($xml);
|
||||
case 'SelektionCRM':
|
||||
case 'SelektionCRM_Ändern':
|
||||
return $this->createCrmAttributesResponse($xml);
|
||||
case 'SelektionCRM_Ändern':
|
||||
// Not a CRM attribute tree: the write answers idadresse + idperson +
|
||||
// änderung, exactly the shape createProfileUpdateResponse() reads. It used
|
||||
// to be routed to createCrmAttributesResponse(), which walks a
|
||||
// "selektionsmerkmale" node that is absent here and would have returned an
|
||||
// empty response with every role flag false - dead code until something
|
||||
// sent the art.
|
||||
return $this->createProfileUpdateResponse($xml);
|
||||
case 'Adresse_Neu':
|
||||
return $this->createRegistrationResponse($xml);
|
||||
}
|
||||
break;
|
||||
case ApiClient::TYPE_BASE_DATA_COUNTRIES:
|
||||
return $this->createCountriesResponse($xml);
|
||||
case ApiClient::TYPE_BASE_DATA_PICKUPS:
|
||||
return $this->createPickupsResponse($xml);
|
||||
case ApiClient::TYPE_BASE_DATA_HOTELS:
|
||||
return $this->createHotelsResponse($xml);
|
||||
}
|
||||
|
||||
throw new ResponseParserException('Unable to parse XML response');
|
||||
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType));
|
||||
}
|
||||
|
||||
public function createNotificationResponse(\SimpleXMLElement $xml): NotificationResponse
|
||||
@@ -169,6 +166,34 @@ class ResponseParser
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ResponseParserException
|
||||
*/
|
||||
public function createRegistrationResponse(\SimpleXMLElement $xml): RegistrationResponse
|
||||
{
|
||||
$addressIdXml = $xml->xpath('idadresse') ?: [];
|
||||
$personIdXml = $xml->xpath('idperson') ?: [];
|
||||
|
||||
// Without both ids there is nothing to carry on with: the CRM selection is set by id, and
|
||||
// a local account without them cannot be matched again on the next login. Better a parse
|
||||
// error naming the response than a half-provisioned applicant.
|
||||
if (0 === count($addressIdXml) || 0 === count($personIdXml)) {
|
||||
throw new ResponseParserException('Missing idadresse or idperson in the Adresse_Neu response');
|
||||
}
|
||||
|
||||
$isNewRecord = false;
|
||||
$isNewRecordXml = $xml->xpath('neuanlage') ?: [];
|
||||
if (0 < count($isNewRecordXml)) {
|
||||
$isNewRecord = 'true' === strtolower((string) $isNewRecordXml[0]);
|
||||
}
|
||||
|
||||
return new RegistrationResponse(
|
||||
(int) $addressIdXml[0],
|
||||
(int) $personIdXml[0],
|
||||
$isNewRecord,
|
||||
);
|
||||
}
|
||||
|
||||
public function createCrmAttributesResponse(\SimpleXMLElement $xml): CrmAttributesResponse
|
||||
{
|
||||
$groups = [];
|
||||
@@ -233,73 +258,48 @@ class ResponseParser
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function createCountriesResponse(\SimpleXMLElement $xml): BaseDataResponse
|
||||
/**
|
||||
* The BusProNet endpoint can answer with an empty or truncated body. Keep the libxml
|
||||
* reason instead of collapsing every shape of broken response into one message.
|
||||
*
|
||||
* @throws ResponseParserException
|
||||
*/
|
||||
private function loadXml(string $content): \SimpleXMLElement
|
||||
{
|
||||
$countries = [];
|
||||
|
||||
foreach ($xml->laender->land as $item) {
|
||||
$itemAttributes = $item->attributes();
|
||||
$token = (string) $itemAttributes['kuerzel'];
|
||||
$country = new Country();
|
||||
$country
|
||||
->setId((int) $itemAttributes['id'])
|
||||
->setName((string) $itemAttributes['bezeichnung'])
|
||||
->setToken((string) $itemAttributes['kuerzel'])
|
||||
->setNationality((string) $itemAttributes['nationalitaet'])
|
||||
;
|
||||
$countries[$token] = $country;
|
||||
if ('' === trim($content)) {
|
||||
throw new ResponseParserException(sprintf('Unable to parse XML response (%d bytes): empty response', strlen($content)));
|
||||
}
|
||||
|
||||
return new BaseDataResponse($countries);
|
||||
$previousUseErrors = libxml_use_internal_errors(true);
|
||||
libxml_clear_errors();
|
||||
|
||||
try {
|
||||
$xml = simplexml_load_string($content);
|
||||
|
||||
if (false === $xml) {
|
||||
throw new ResponseParserException(sprintf('Unable to parse XML response (%d bytes): %s', strlen($content), $this->describeLibxmlErrors()));
|
||||
}
|
||||
|
||||
return $xml;
|
||||
} finally {
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previousUseErrors);
|
||||
}
|
||||
}
|
||||
|
||||
public function createPickupsResponse(\SimpleXMLElement $xml): BaseDataResponse
|
||||
private function describeLibxmlErrors(): string
|
||||
{
|
||||
$pickups = [];
|
||||
$messages = [];
|
||||
|
||||
foreach ($xml->zustieg as $item) {
|
||||
$itemAttributes = $item->attributes();
|
||||
$id = (int) $itemAttributes['id'];
|
||||
$busProId = (int) $itemAttributes['idbuspro'];
|
||||
$pickup = new Pickup();
|
||||
$pickup
|
||||
->setId($id)
|
||||
->setBusProId($busProId)
|
||||
->setCode((string) $itemAttributes['code'])
|
||||
->setCity((string) $item->ort)
|
||||
->setStreet((string) $item->strasse)
|
||||
;
|
||||
$pickups[$busProId] = $pickup;
|
||||
foreach (libxml_get_errors() as $error) {
|
||||
$messages[] = sprintf('%s (line %d, column %d)', trim($error->message), $error->line, $error->column);
|
||||
}
|
||||
|
||||
return new BaseDataResponse($pickups);
|
||||
}
|
||||
|
||||
public function createHotelsResponse(\SimpleXMLElement $xml): BaseDataResponse
|
||||
{
|
||||
$hotels = [];
|
||||
|
||||
foreach ($xml->hotel as $item) {
|
||||
$itemAttributes = $item->attributes();
|
||||
$id = (int) $itemAttributes['id'];
|
||||
$busProId = (int) $itemAttributes['idbuspro'];
|
||||
$hotel = new Hotel();
|
||||
$hotel
|
||||
->setId($id)
|
||||
->setBusProId($busProId)
|
||||
->setCode((string) $itemAttributes['code'])
|
||||
->setName((string) $item->name)
|
||||
->setCountry(strtolower((string) $item->land))
|
||||
->setCity((string) $item->ort)
|
||||
->setStreet((string) $item->strasse)
|
||||
->setPhone((string) $item->telefon)
|
||||
->setType((string) $item->art)
|
||||
->setCostUnit($item->kostenstelle)
|
||||
;
|
||||
$hotels[$busProId] = $hotel;
|
||||
if ([] === $messages) {
|
||||
return 'unknown XML error';
|
||||
}
|
||||
|
||||
return new BaseDataResponse($hotels);
|
||||
return implode('; ', array_unique($messages));
|
||||
}
|
||||
|
||||
private function resolveOptions(array $options): array
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Model\PlannedAssignment;
|
||||
use App\Model\StaffingCandidate;
|
||||
use App\Service\Assignment\AssignmentGenerator;
|
||||
use App\Service\Assignment\StaffingConfigurationException;
|
||||
use App\Service\Assignment\StaffingResolver;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Creates draft assignments for the future dates of every enabled staffing matrix row.
|
||||
*
|
||||
* Safe to repeat: a date that has ever had an assignment is skipped, so drafts the office
|
||||
* edited or deleted are never recreated. Run it after app:myep-import - it only considers
|
||||
* the dates the latest import reported.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:assignment-generate',
|
||||
description: 'Creates draft assignments from the staffing matrix',
|
||||
)]
|
||||
class AssignmentGenerateCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AssignmentGenerator $generator,
|
||||
private readonly StaffingResolver $resolver,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Only list what would be created');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = true === $input->getOption('dry-run');
|
||||
|
||||
try {
|
||||
$this->resolver->assertConfigured();
|
||||
} catch (StaffingConfigurationException $e) {
|
||||
$io->error('Jobprofil oder Honorar der Besetzungsmatrix nicht gefunden – abgebrochen: '.$e->getMessage());
|
||||
$this->logger->error('Assignment generation aborted', ['reason' => $e->getMessage()]);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$candidates = $this->generator->getCandidates(enabledOnly: true);
|
||||
$open = array_values(array_filter($candidates, static fn (StaffingCandidate $c): bool => false === $c->skipped));
|
||||
|
||||
$io->info(sprintf(
|
||||
'%d Reisetermine aus der Besetzungsmatrix, davon %d schon mit Einsätzen (übersprungen)',
|
||||
count($candidates),
|
||||
count($candidates) - count($open),
|
||||
));
|
||||
|
||||
if ([] === $open) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->table(
|
||||
['Termin', 'Produkt', 'Beginn', 'Einsätze'],
|
||||
array_map($this->describe(...), $open),
|
||||
);
|
||||
|
||||
if (true === $dryRun) {
|
||||
$io->note('Probelauf – es wurde nichts angelegt.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
|
||||
foreach ($open as $candidate) {
|
||||
$created += count($this->generator->generate($candidate));
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d Einsätze für %d Reisetermine als Entwurf angelegt', $created, count($open)));
|
||||
$this->logger->info('Assignment generation finished', [
|
||||
'destinations' => count($open),
|
||||
'assignments' => $created,
|
||||
'skipped' => count($candidates) - count($open),
|
||||
]);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function describe(StaffingCandidate $candidate): array
|
||||
{
|
||||
return [
|
||||
$candidate->destination->getCode(),
|
||||
$candidate->rule->getProductCode(),
|
||||
$candidate->destination->getDateFrom()->format('d.m.Y'),
|
||||
implode("\n", array_map(
|
||||
fn (PlannedAssignment $planned): string => sprintf(
|
||||
'%d× %s%s',
|
||||
$planned->places,
|
||||
$this->resolver->jobProfile($planned->role)->getName(),
|
||||
null === $planned->pickup ? '' : ' (Busbegleitung)',
|
||||
),
|
||||
$candidate->planned,
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\BusProNet\DataProvider\HotelDataProvider;
|
||||
use App\BusProNet\DataProvider\PickupDataProvider;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use League\Flysystem\FilesystemException;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use League\Flysystem\StorageAttributes;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:bpn-import',
|
||||
description: 'Imports dates, products and hotels from BusProNet',
|
||||
)]
|
||||
class BpnImportCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly HotelDataProvider $hotelDataProvider,
|
||||
private readonly PickupDataProvider $pickupDataProvider,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly FilesystemOperator $xmlExport,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
// Get all xml files
|
||||
try {
|
||||
$xmlFiles = $this
|
||||
->xmlExport
|
||||
->listContents('')
|
||||
->filter(fn (StorageAttributes $attributes) => $attributes->isFile() && str_starts_with($attributes->path(), 'Ziel_'))
|
||||
->toArray();
|
||||
} catch (FilesystemException $e) {
|
||||
$io->error('Unable to list XML files');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$totalCount = count($xmlFiles);
|
||||
|
||||
if (0 === $totalCount) {
|
||||
$io->error('No XML files available or found');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->info('Found '.$totalCount.' XML files');
|
||||
$addedCount = 0;
|
||||
$updatedCount = 0;
|
||||
$warnings = [];
|
||||
|
||||
$progressBar = $io->createProgressBar($totalCount);
|
||||
$progressBar->setFormat(" %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%\n %message%");
|
||||
$progressBar->setMessage('Starting');
|
||||
$progressBar->start();
|
||||
|
||||
// Iterate over xml files
|
||||
foreach ($xmlFiles as $xmlFile) {
|
||||
$progressBar->setMessage($xmlFile->path());
|
||||
|
||||
// Load xml file into simplexml object
|
||||
try {
|
||||
$xml = simplexml_load_string($this->xmlExport->read($xmlFile->path()));
|
||||
} catch (FilesystemException $e) {
|
||||
$warnings[] = 'Unable to read XML file '.$xmlFile->path();
|
||||
$progressBar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Iterate over all destinations
|
||||
foreach ($xml->xpath('reise/termin') as $destinationXml) {
|
||||
$product = (string) $destinationXml->xpath('text')[0];
|
||||
$busProId = (int) $destinationXml->attributes()['idbuspro'];
|
||||
$code = (string) $destinationXml->attributes()['code'];
|
||||
$dateFrom = \DateTimeImmutable::createFromFormat('d.m.Y', (string) $destinationXml->attributes()['termin']);
|
||||
$dateTo = \DateTimeImmutable::createFromFormat('d.m.Y', (string) $destinationXml->attributes()['bis']);
|
||||
|
||||
// Find pickups
|
||||
$datePickups = [];
|
||||
foreach ($destinationXml->xpath('zustiege/zustieg') as $pickupXml) {
|
||||
$pickupBusProId = (int) $pickupXml->attributes()['idbuspro'];
|
||||
if ($pickupBusProId) {
|
||||
if (null === $pickup = $this->pickupDataProvider->get($pickupBusProId)) {
|
||||
$warnings[] = 'Pickup with busProId '.$pickupBusProId.' not found';
|
||||
continue;
|
||||
}
|
||||
|
||||
$datePickups[] = [
|
||||
'busProId' => $pickupBusProId,
|
||||
'city' => $pickup->getCity(),
|
||||
'street' => $pickup->getStreet(),
|
||||
'time' => (string) $pickupXml->attributes()['zeit'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate over all hotel entries
|
||||
foreach ($destinationXml->xpath('hotel') as $hotelXml) {
|
||||
$hotelBusProId = (int) $hotelXml->attributes()['idbuspro'];
|
||||
if (null === $hotel = $this->hotelDataProvider->get($hotelBusProId)) {
|
||||
$warnings[] = 'Hotel with busProId '.$hotelBusProId.' not found';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Finally update or store collected data
|
||||
if ($id = $this->connection->fetchOne('SELECT id FROM destination WHERE bus_pro_id=? AND hotel_bus_pro_id=?', [$busProId, $hotelBusProId])) {
|
||||
$this
|
||||
->connection
|
||||
->update('destination', [
|
||||
'code' => $code,
|
||||
'date_from' => $dateFrom->format('Y-m-d'),
|
||||
'date_to' => $dateTo->format('Y-m-d'),
|
||||
'product' => $product,
|
||||
'pickups' => json_encode($datePickups),
|
||||
'cost_unit' => $hotel->getCostUnit(),
|
||||
'country' => $hotel->getCountry(),
|
||||
'updated_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
|
||||
], [
|
||||
'id' => $id,
|
||||
'hotel_bus_pro_id' => $hotelBusProId,
|
||||
])
|
||||
;
|
||||
++$updatedCount;
|
||||
} else {
|
||||
$this
|
||||
->connection
|
||||
->insert('destination', [
|
||||
'code' => $code,
|
||||
'bus_pro_id' => $busProId,
|
||||
'date_from' => $dateFrom->format('Y-m-d'),
|
||||
'date_to' => $dateTo->format('Y-m-d'),
|
||||
'product' => $product,
|
||||
'hotel' => $hotel->getName(),
|
||||
'hotel_code' => $hotel->getCode(),
|
||||
'hotel_bus_pro_id' => $hotelBusProId,
|
||||
'pickups' => json_encode($datePickups),
|
||||
'cost_unit' => $hotel->getCostUnit(),
|
||||
'country' => $hotel->getCountry(),
|
||||
'created_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
|
||||
])
|
||||
;
|
||||
++$addedCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->setMessage('Done');
|
||||
$progressBar->finish();
|
||||
$io->newLine(2);
|
||||
|
||||
if ($warnings) {
|
||||
$io->warning(array_unique($warnings));
|
||||
}
|
||||
|
||||
$logMessage = 'Added '.$addedCount.' and updated '.$updatedCount.' destinations';
|
||||
$io->info($logMessage);
|
||||
$this->logger->info($logMessage);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ use App\Service\Cron\DispositionReminderService;
|
||||
use App\Service\Cron\DispositionStatusService;
|
||||
use App\Service\Cron\FeedbackReminderService;
|
||||
use App\Service\Cron\FlushLogsService;
|
||||
use App\Service\Cron\SeasonTaskReminderService;
|
||||
use App\Service\Cron\UploadReminderService;
|
||||
use App\Service\Cron\WorkMonthReminderService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -25,6 +27,8 @@ class CronCommand extends Command
|
||||
private readonly FeedbackReminderService $feedbackReminderService,
|
||||
private readonly DispositionStatusService $dispositionStatusService,
|
||||
private readonly DispositionReminderService $dispositionReminderService,
|
||||
private readonly SeasonTaskReminderService $seasonTaskReminderService,
|
||||
private readonly WorkMonthReminderService $workMonthReminderService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -55,6 +59,15 @@ class CronCommand extends Command
|
||||
$message = $this->dispositionReminderService->sendDispositionReminders();
|
||||
$io->info($message);
|
||||
|
||||
$message = $this->seasonTaskReminderService->sendDeadlineSummary();
|
||||
$io->info($message);
|
||||
|
||||
$message = $this->seasonTaskReminderService->sendTeamerDigest();
|
||||
$io->info($message);
|
||||
|
||||
$message = $this->workMonthReminderService->sendReminders();
|
||||
$io->info($message);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\BusProNet\DataProvider\HotelDataProvider;
|
||||
use App\BusProNet\DataProvider\PickupDataProvider;
|
||||
use App\MyEp\Api\ApiClient;
|
||||
use App\MyEp\Import\DestinationRow;
|
||||
use App\MyEp\Import\InvalidRowException;
|
||||
use App\Service\Assignment\PickupDepartureOrder;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Read-only probe of the MyE&P API: does it answer, and does it carry what the import needs.
|
||||
*
|
||||
* Writes nothing and touches no database. It exists because the interesting failures in this
|
||||
* integration are silent ones - a field named `id` where the import reads `busProId`, a time
|
||||
* in a shape the departure ordering cannot parse - and they are only visible against a real
|
||||
* response. `app:myep-import` cannot serve as the probe: its own guard passes or fails on the
|
||||
* hotel payload, and once past it the command writes.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:myep-check',
|
||||
description: 'Checks that the MyE&P API answers and carries the fields the import needs',
|
||||
)]
|
||||
class MyEpCheckCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly HotelDataProvider $hotelDataProvider,
|
||||
private readonly PickupDataProvider $pickupDataProvider,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->section('Base data');
|
||||
|
||||
$hotels = $this->hotelDataProvider->getAll();
|
||||
$pickups = $this->pickupDataProvider->getAll();
|
||||
|
||||
if ([] === $hotels) {
|
||||
$io->error('No hotels returned - the import would refuse to run.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$withCostUnit = count(array_filter($hotels, fn ($h) => null !== $h->getCostUnit()));
|
||||
$withCountry = count(array_filter($hotels, fn ($h) => null !== $h->getCountryCode()));
|
||||
|
||||
$io->definitionList(
|
||||
['hotels' => (string) count($hotels)],
|
||||
['with costUnit' => sprintf('%d%s', $withCostUnit, 0 === $withCostUnit ? ' <- import would refuse' : '')],
|
||||
['with countryCode' => sprintf('%d%s', $withCountry, 0 === $withCountry ? ' <- import would refuse' : '')],
|
||||
['pickups' => (string) count($pickups)],
|
||||
);
|
||||
|
||||
$io->section('Travel export');
|
||||
|
||||
// A whole page rather than one row: the first row often has no boarding points, and an
|
||||
// unread pickups array is exactly the failure this probe exists to catch.
|
||||
$payload = $this->apiClient->get('api/exports/travels', ['page' => 1, 'perPage' => 500]);
|
||||
$meta = $payload['meta'] ?? [];
|
||||
|
||||
$io->definitionList(...array_map(
|
||||
fn (string $key) => [$key => var_export($meta[$key] ?? null, true)],
|
||||
['total', 'totalPages', 'perPage', 'skipped', 'pickupsWithoutMasterData', 'generatedAt'],
|
||||
));
|
||||
|
||||
$rows = array_values(array_filter((array) ($payload['data'] ?? []), 'is_array'));
|
||||
|
||||
if ([] === $rows) {
|
||||
$io->error('The export returned no rows.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$withPickups = array_values(array_filter($rows, fn (array $r) => [] !== ($r['pickups'] ?? [])));
|
||||
|
||||
$io->definitionList(
|
||||
['rows on this page' => (string) count($rows)],
|
||||
['of them with pickups' => (string) count($withPickups)],
|
||||
);
|
||||
|
||||
$row = $withPickups[0] ?? $rows[0];
|
||||
|
||||
// Does `datetime` actually resolve the overnight leg? A coach leaves in the evening and
|
||||
// keeps boarding through the night, so a 00:30 stop belongs to the day after a 23:30 one.
|
||||
// If every entry on such a row carries the same calendar date, the field is unresolved and
|
||||
// ordering by it inverts the route.
|
||||
$straddling = 0;
|
||||
$unresolved = [];
|
||||
|
||||
foreach ($withPickups as $candidate) {
|
||||
$times = array_filter(array_column($candidate['pickups'], 'time'));
|
||||
$dates = array_unique(array_map(
|
||||
fn (?string $d) => null === $d ? null : substr($d, 0, 10),
|
||||
array_column($candidate['pickups'], 'datetime'),
|
||||
));
|
||||
|
||||
$late = array_filter($times, fn (string $t) => $t >= '18:00');
|
||||
$early = array_filter($times, fn (string $t) => $t <= '06:00');
|
||||
|
||||
if ([] === $late || [] === $early) {
|
||||
continue;
|
||||
}
|
||||
|
||||
++$straddling;
|
||||
|
||||
if (1 === count($dates)) {
|
||||
$unresolved[] = $candidate['busProId'];
|
||||
}
|
||||
}
|
||||
|
||||
$io->definitionList(
|
||||
['rows straddling midnight' => (string) $straddling],
|
||||
['of them with one date only' => sprintf('%d%s', count($unresolved), [] === $unresolved ? '' : ' <- datetime unresolved')],
|
||||
);
|
||||
|
||||
// The decisive question is not whether a date looks odd but whether adopting `datetime`
|
||||
// would reorder a route. Compared against our own anchor-based ordering, which reads the
|
||||
// bare clock times and is what runs today.
|
||||
$disagree = [];
|
||||
|
||||
foreach ($withPickups as $candidate) {
|
||||
$pickups = $candidate['pickups'];
|
||||
|
||||
if (2 > count($pickups)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$legacy = array_column(
|
||||
PickupDepartureOrder::sort(array_map(
|
||||
function (array $p): array {
|
||||
unset($p['datetime']);
|
||||
|
||||
return $p;
|
||||
},
|
||||
$pickups,
|
||||
)),
|
||||
'busProId',
|
||||
);
|
||||
|
||||
$dated = $pickups;
|
||||
usort($dated, fn (array $a, array $b) => ($a['datetime'] ?? '') <=> ($b['datetime'] ?? ''));
|
||||
|
||||
if ($legacy !== array_column($dated, 'busProId')) {
|
||||
$disagree[] = $candidate['busProId'];
|
||||
}
|
||||
}
|
||||
|
||||
$io->definitionList(
|
||||
['rows where datetime order differs' => sprintf(
|
||||
'%d of %d%s',
|
||||
count($disagree),
|
||||
count($withPickups),
|
||||
[] === $disagree ? '' : ' <- adopting datetime would reorder these',
|
||||
)],
|
||||
);
|
||||
|
||||
if ([] !== $unresolved) {
|
||||
$io->warning(sprintf(
|
||||
'datetime does not roll past midnight on %d of %d straddling rows (e.g. busProId %s). '
|
||||
.'Ordering by it would put the early-morning stops before the evening departure.',
|
||||
count($unresolved),
|
||||
$straddling,
|
||||
implode(', ', array_slice($unresolved, 0, 5)),
|
||||
));
|
||||
}
|
||||
|
||||
$resolvedStraddlers = array_values(array_filter(
|
||||
$withPickups,
|
||||
function (array $r) use ($unresolved): bool {
|
||||
$times = array_filter(array_column($r['pickups'], 'time'));
|
||||
$late = array_filter($times, fn (string $t) => $t >= '18:00');
|
||||
$early = array_filter($times, fn (string $t) => $t <= '06:00');
|
||||
|
||||
return [] !== $late && [] !== $early && false === in_array($r['busProId'], $unresolved, true);
|
||||
},
|
||||
));
|
||||
|
||||
if ([] !== $resolvedStraddlers) {
|
||||
$sample = $resolvedStraddlers[0];
|
||||
$io->writeln(sprintf('<comment>A straddling row where datetime DID roll (busProId %d, dateFrom %s)</comment>', $sample['busProId'], $sample['dateFrom']));
|
||||
foreach ($sample['pickups'] as $pickup) {
|
||||
$io->writeln(sprintf(' %-14s %-6s %s', $pickup['city'] ?? '?', $pickup['time'] ?? '-', $pickup['datetime'] ?? '-'));
|
||||
}
|
||||
$io->newLine();
|
||||
}
|
||||
|
||||
$io->writeln('<comment>Raw first row</comment>');
|
||||
$io->writeln(json_encode($row, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
$io->section('As the import reads it');
|
||||
|
||||
try {
|
||||
$destinationRow = DestinationRow::fromArray($row);
|
||||
} catch (InvalidRowException $e) {
|
||||
$io->error(sprintf('The import would skip this row: %s', $e->getMessage()));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->definitionList(
|
||||
['busProId' => (string) $destinationRow->busProId],
|
||||
['code' => var_export($destinationRow->code, true)],
|
||||
['product' => var_export($destinationRow->product, true)],
|
||||
['dateFrom' => $destinationRow->dateFrom->format('Y-m-d')],
|
||||
['dateTo' => $destinationRow->dateTo->format('Y-m-d')],
|
||||
['hotelBusProId' => (string) $destinationRow->hotelBusProId],
|
||||
['hotelCode' => var_export($destinationRow->hotelCode, true)],
|
||||
['hotelName' => var_export($destinationRow->hotelName, true)],
|
||||
['costUnit' => var_export($destinationRow->costUnit, true)],
|
||||
['country' => var_export($destinationRow->country, true)],
|
||||
['pickups' => json_encode($destinationRow->pickups, JSON_UNESCAPED_UNICODE)],
|
||||
);
|
||||
|
||||
if ([] !== $row['pickups'] && [] === $destinationRow->pickups) {
|
||||
$io->error('The export carries pickups but the import read none - check the id field name.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success('MyE&P answers and the import can read the row.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\MyEp\Api\ApiException;
|
||||
use App\MyEp\Import\DestinationRow;
|
||||
use App\MyEp\Import\TravelExportClient;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Fills the destination table from MyE&P's travel export.
|
||||
*
|
||||
* Replaces the Ziel_*.xml parsing this command used to do. MyE&P now owns every part of that:
|
||||
* it reads BusProNet's German XML, flattens it to one row per travel date x hotel - the pair
|
||||
* this table is keyed on - joins the hotel and pickup base data in, and resolves the boarding
|
||||
* times. What is left here is the upsert.
|
||||
*
|
||||
* Still raw DBAL rather than the ORM: the rows carry no behaviour, nothing listens for their
|
||||
* changes, and a Destination is soft deletable, so hydrating 400 entities to write scalars
|
||||
* onto them would buy nothing.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:myep-import',
|
||||
description: 'Imports travel dates, products and hotels from the MyE&P API',
|
||||
)]
|
||||
class MyEpImportCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly TravelExportClient $travelExportClient,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
try {
|
||||
$export = $this->travelExportClient->fetchAll();
|
||||
} catch (ApiException $e) {
|
||||
$io->error('Der Reise-Export konnte nicht von MyE&P geladen werden – Import abgebrochen.');
|
||||
$this->logger->error('Import aborted: the MyE&P travel export could not be read', [
|
||||
'reason' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// An empty export would otherwise be a run that touches nothing and reports success,
|
||||
// which reads as "nothing changed upstream" rather than as a failure to read it.
|
||||
if ([] === $export->rows) {
|
||||
$io->error('Der Reise-Export von MyE&P enthält keine Reisen – Import abgebrochen.');
|
||||
$this->logger->error('Import aborted: the MyE&P travel export was empty');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Every destination this import touches gets its cost unit and its country written from
|
||||
// the export, on update as well as on insert. If MyE&P stops reporting those two fields,
|
||||
// a run would quietly blank them on rows that have them: the cost unit then drops off
|
||||
// the invoices (App\Service\Pdf\InvoiceApprover) and a missing country code blanks the
|
||||
// flag icons and takes the row out of the country filters. Refusing to run is the
|
||||
// recoverable failure; overwriting good data is not.
|
||||
if ([] !== $missingFields = $this->missingHotelFields($export->rows)) {
|
||||
$io->error(sprintf(
|
||||
'Die MyE&P-API liefert keine %s – Import abgebrochen, damit bestehende Werte nicht überschrieben werden.',
|
||||
implode(' und ', $missingFields),
|
||||
));
|
||||
$this->logger->error('Import aborted: the MyE&P travel export carries no '.implode(', ', $missingFields));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->info(sprintf('%d Reisen von MyE&P geladen', count($export->rows)));
|
||||
|
||||
// Both are losses MyE&P made silently on its side: a skipped pair is a travel the
|
||||
// disposition will never offer, and a boarding point without master data reaches the
|
||||
// form with no town. Logged every run so they get chased instead of settling in.
|
||||
$this->logger->info('MyE&P travel export read', [
|
||||
'rows' => count($export->rows),
|
||||
'skipped' => $export->skipped,
|
||||
'pickups_without_master_data' => $export->pickupsWithoutMasterData,
|
||||
'generated_at' => $export->generatedAt,
|
||||
]);
|
||||
|
||||
if (0 < $export->skipped || 0 < $export->pickupsWithoutMasterData) {
|
||||
$io->warning(sprintf(
|
||||
'MyE&P hat %d Reise-Hotel-Kombinationen verworfen und %d Zustiege ohne Stammdaten gemeldet.',
|
||||
$export->skipped,
|
||||
$export->pickupsWithoutMasterData,
|
||||
));
|
||||
}
|
||||
|
||||
$now = new \DateTimeImmutable();
|
||||
$addedCount = 0;
|
||||
$updatedCount = 0;
|
||||
|
||||
$progressBar = $io->createProgressBar(count($export->rows));
|
||||
$progressBar->setFormat(" %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%\n %message%");
|
||||
$progressBar->setMessage('Starting');
|
||||
$progressBar->start();
|
||||
|
||||
foreach ($export->rows as $row) {
|
||||
$progressBar->setMessage(sprintf('%s %s', $row->dateFrom->format('d.m.Y'), $row->product));
|
||||
|
||||
$id = $this->connection->fetchOne(
|
||||
'SELECT id FROM destination WHERE bus_pro_id=? AND hotel_bus_pro_id=?',
|
||||
[$row->busProId, $row->hotelBusProId],
|
||||
);
|
||||
|
||||
if ($id) {
|
||||
$this->connection->update('destination', $row->toUpdatePayload($now), ['id' => $id]);
|
||||
++$updatedCount;
|
||||
} else {
|
||||
$this->connection->insert('destination', $row->toInsertPayload($now));
|
||||
++$addedCount;
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->setMessage('Done');
|
||||
$progressBar->finish();
|
||||
$io->newLine(2);
|
||||
|
||||
$io->success(sprintf('%d Destinationen angelegt, %d aktualisiert', $addedCount, $updatedCount));
|
||||
$this->logger->info('MyE&P destination import finished', [
|
||||
'added' => $addedCount,
|
||||
'updated' => $updatedCount,
|
||||
]);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the hotel fields MyE&P is not reporting, German labels for the operator.
|
||||
*
|
||||
* Asserted across the whole export rather than per row, because a row legitimately has
|
||||
* neither: only a minority of hotels carry a kostenstelle upstream, and some carry no
|
||||
* country BusProNet can resolve. An export where not one row has the field is a field
|
||||
* missing from the response, not a gap in the data.
|
||||
*
|
||||
* @param DestinationRow[] $rows
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function missingHotelFields(array $rows): array
|
||||
{
|
||||
$required = [
|
||||
'Kostenstellen' => static fn (DestinationRow $row): bool => '' !== (string) $row->costUnit,
|
||||
'Länderkennungen' => static fn (DestinationRow $row): bool => '' !== (string) $row->country,
|
||||
];
|
||||
|
||||
$missing = [];
|
||||
|
||||
foreach ($required as $label => $isReported) {
|
||||
foreach ($rows as $row) {
|
||||
if ($isReported($row)) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
|
||||
$missing[] = $label;
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Enum\WorkHoursSyncOutcome;
|
||||
use App\Model\WorkHoursSyncResult;
|
||||
use App\Service\Saas\WorkHoursSynchronizer;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:saas:work-hours',
|
||||
description: "Imports season teamers' working hours of one month from the SaaS",
|
||||
)]
|
||||
class SaasWorkHoursCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WorkHoursSynchronizer $synchronizer,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument(
|
||||
'month',
|
||||
InputArgument::OPTIONAL,
|
||||
'The month to import as YYYY-MM. Defaults to the previous month, which is what the schedule wants.',
|
||||
)
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report what would be written without writing it')
|
||||
->addOption(
|
||||
'teamer',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Limit the run to one teamer, by uuid or email address. Use this to try the API out.',
|
||||
)
|
||||
->addOption(
|
||||
'limit',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Stop after this many teamers, in the order the list comes back',
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled for the 10th of each month, importing the month before.
|
||||
*
|
||||
* A teamer that cannot be imported does not stop the others: only genuine failures
|
||||
* (the request or the payload) make the command fail, because the scheduler mails on a
|
||||
* non-zero exit and an incomplete profile would otherwise mail every month forever.
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
try {
|
||||
[$year, $month] = $this->resolveMonth($input->getArgument('month'));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$io->title(sprintf('Arbeitszeiten %04d-%02d', $year, $month));
|
||||
|
||||
if ($dryRun) {
|
||||
$io->note('Dry run - nothing is written.');
|
||||
}
|
||||
|
||||
$teamers = $this->synchronizer->findEligibleTeamers($year, $month);
|
||||
|
||||
if (null !== $only = $input->getOption('teamer')) {
|
||||
$teamers = $this->onlyThisTeamer($teamers, (string) $only);
|
||||
|
||||
if ([] === $teamers) {
|
||||
$io->error(sprintf('"%s" matches no season teamer placed in this month.', $only));
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $limit = $input->getOption('limit')) {
|
||||
$teamers = array_slice($teamers, 0, max(1, (int) $limit));
|
||||
}
|
||||
|
||||
if ([] === $teamers) {
|
||||
$io->warning('No season teamer is placed in this month.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->writeln(sprintf('%d teamer(s) to fetch, one request each.', count($teamers)));
|
||||
|
||||
$io->progressStart(count($teamers));
|
||||
|
||||
/** @var WorkHoursSyncResult[] $results */
|
||||
$results = [];
|
||||
|
||||
foreach ($teamers as $teamer) {
|
||||
$results[] = $this->synchronizer->syncTeamer($teamer, $year, $month, $dryRun);
|
||||
$io->progressAdvance();
|
||||
}
|
||||
|
||||
$io->progressFinish();
|
||||
|
||||
$this->report($io, $results);
|
||||
|
||||
return $this->hasFailures($results) ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param WorkHoursSyncResult[] $results
|
||||
*/
|
||||
private function report(SymfonyStyle $io, array $results): void
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($results as $result) {
|
||||
if (WorkHoursSyncOutcome::IMPORTED === $result->getOutcome() && [] === $result->getWarnings()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
$result->getTeamer()->getFullName(),
|
||||
$result->getOutcome()->value,
|
||||
$result->getMessage() ?? implode(' | ', $result->getWarnings()),
|
||||
];
|
||||
}
|
||||
|
||||
if ([] !== $rows) {
|
||||
$io->section('Needs a look');
|
||||
$io->table(['Teamer', 'Outcome', 'Detail'], $rows);
|
||||
}
|
||||
|
||||
$counts = array_count_values(array_map(
|
||||
static fn (WorkHoursSyncResult $result) => $result->getOutcome()->value,
|
||||
$results,
|
||||
));
|
||||
|
||||
$summary = [];
|
||||
|
||||
foreach (WorkHoursSyncOutcome::cases() as $outcome) {
|
||||
$summary[] = sprintf('%s: %d', $outcome->value, $counts[$outcome->value] ?? 0);
|
||||
}
|
||||
|
||||
$minutes = array_sum(array_map(
|
||||
static fn (WorkHoursSyncResult $result) => WorkHoursSyncOutcome::IMPORTED === $result->getOutcome()
|
||||
? (int) $result->getTotalMinutes()
|
||||
: 0,
|
||||
$results,
|
||||
));
|
||||
|
||||
$io->writeln(implode(' ', $summary));
|
||||
$io->writeln(sprintf('Imported %d minutes in total.', $minutes));
|
||||
|
||||
if ($this->hasFailures($results)) {
|
||||
$io->error('At least one teamer could not be imported.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d teamer(s) processed.', count($results)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Teamer[] $teamers
|
||||
*
|
||||
* @return Teamer[]
|
||||
*/
|
||||
private function onlyThisTeamer(array $teamers, string $needle): array
|
||||
{
|
||||
$needle = strtolower(trim($needle));
|
||||
|
||||
return array_values(array_filter($teamers, static function (Teamer $teamer) use ($needle) {
|
||||
return strtolower($teamer->getUuid()) === $needle
|
||||
|| strtolower((string) $teamer->getCommunication()?->getEmail()) === $needle;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param WorkHoursSyncResult[] $results
|
||||
*/
|
||||
private function hasFailures(array $results): bool
|
||||
{
|
||||
foreach ($results as $result) {
|
||||
if ($result->isFailure()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{int, int}
|
||||
*/
|
||||
private function resolveMonth(?string $argument): array
|
||||
{
|
||||
if (null === $argument) {
|
||||
$previous = (new \DateTimeImmutable('first day of this month'))->modify('-1 month');
|
||||
|
||||
return [(int) $previous->format('Y'), (int) $previous->format('n')];
|
||||
}
|
||||
|
||||
if (1 !== preg_match('/^(\d{4})-(\d{2})$/', $argument, $matches)) {
|
||||
throw new \InvalidArgumentException(sprintf('"%s" is not a month; expected YYYY-MM.', $argument));
|
||||
}
|
||||
|
||||
$month = (int) $matches[2];
|
||||
|
||||
if ($month < 1 || $month > 12) {
|
||||
throw new \InvalidArgumentException(sprintf('"%s" is not a month; expected YYYY-MM.', $argument));
|
||||
}
|
||||
|
||||
return [(int) $matches[1], $month];
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ enum EmailTextKey: string
|
||||
{
|
||||
case APPLICATION_ACCEPTED = 'application_accepted';
|
||||
case APPLICATION_ACCEPTED_NO_CONTRACT = 'application_accepted_no_contract';
|
||||
case APPLICATION_FORM_FOLLOW_UP = 'application_form_follow_up';
|
||||
case APPLICATION_FORM_RECEIVED = 'application_form_received';
|
||||
case APPLICATION_REJECTED = 'application_rejected';
|
||||
case ASSIGNMENT_CALLED_OFF = 'assignment_called_off';
|
||||
case DISPOSITION_CALLED_OFF = 'disposition_called_off';
|
||||
@@ -20,4 +22,7 @@ enum EmailTextKey: string
|
||||
case REMINDER_INVOICE_UPLOAD = 'reminder_invoice_upload';
|
||||
case REMINDER_INVOICE_UPLOAD_FINAL = 'reminder_invoice_upload_final';
|
||||
case REMINDER_DISPOSITION = 'reminder_disposition';
|
||||
case REMINDER_SEASON_TASKS = 'reminder_season_tasks';
|
||||
case REMINDER_WORK_MONTH_UPLOAD = 'reminder_work_month_upload';
|
||||
case WORK_MONTH_REJECTED = 'work_month_rejected';
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
use App\Enum\ChoiceWidget;
|
||||
use App\Enum\ConditionOperator;
|
||||
use App\Enum\QuestionType;
|
||||
use App\Model\Questionnaire\Condition;
|
||||
use App\Model\Questionnaire\Definition;
|
||||
use App\Model\Questionnaire\Option;
|
||||
use App\Model\Questionnaire\Question;
|
||||
use App\Model\Questionnaire\Section;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
* Reads questionnaire definitions from config/questionnaires/.
|
||||
*
|
||||
* `<key>.yaml` is the current version of a questionnaire; `<key>.v<n>.yaml` keeps an older one that
|
||||
* responses were submitted under, so they can still be rendered with the labels they were given.
|
||||
*
|
||||
* Files are parsed lazily and memoised, like EmailTextCatalog. A definition is checked strictly
|
||||
* while it is built - unknown fields, unknown types, duplicate keys and show_if conditions that
|
||||
* point at a later question or a non-existent option all throw - because a typo in the YAML should
|
||||
* fail in QuestionnaireCatalogTest, not in front of a teamer.
|
||||
*/
|
||||
class QuestionnaireCatalog
|
||||
{
|
||||
/**
|
||||
* The questionnaire every teamer fills in after a placement; see DispositionVoter::QUESTIONNAIRE.
|
||||
*/
|
||||
public const string TEAMER_FEEDBACK = 'teamer_feedback';
|
||||
|
||||
/**
|
||||
* The one a teamer who escorted the bus fills in as well, on the same page.
|
||||
*/
|
||||
public const string BUS_FEEDBACK = 'bus_feedback';
|
||||
|
||||
private const array ROOT_FIELDS = ['key', 'version', 'title', 'description', 'sections'];
|
||||
private const array SECTION_FIELDS = ['key', 'title', 'questions'];
|
||||
private const array QUESTION_FIELDS = ['key', 'label', 'type', 'required', 'help', 'widget', 'options', 'show_if'];
|
||||
private const array OPTION_FIELDS = ['key', 'label'];
|
||||
|
||||
// A question key becomes a form field name and a JSON key in stored answers.
|
||||
private const string KEY_PATTERN = '/^[a-z][a-z0-9_]*$/';
|
||||
|
||||
private const int RATING_MIN = 1;
|
||||
private const int RATING_MAX = 5;
|
||||
|
||||
/**
|
||||
* @var array<string, Definition> keyed by file path
|
||||
*/
|
||||
private array $definitions = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly string $questionnaireDir,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The first end date the teamer questionnaire is still offered for: the services.yaml
|
||||
* parameter questionnaire_window_days, in days before today. Placements that ended earlier are neither
|
||||
* pending nor open, so a deploy does not offer it for every past placement at once.
|
||||
*/
|
||||
public static function windowStart(int $windowDays): \DateTimeImmutable
|
||||
{
|
||||
if ($windowDays < 0) {
|
||||
throw new \InvalidArgumentException(sprintf('questionnaire_window_days must not be negative, got %d', $windowDays));
|
||||
}
|
||||
|
||||
return new \DateTimeImmutable(sprintf('today -%d days', $windowDays));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|null $version null for the current version
|
||||
*/
|
||||
public function get(string $key, ?int $version = null): Definition
|
||||
{
|
||||
$current = $this->load($this->questionnaireDir.'/'.$key.'.yaml');
|
||||
|
||||
if (null === $version || $version === $current->version) {
|
||||
return $current;
|
||||
}
|
||||
|
||||
return $this->load(sprintf('%s/%s.v%d.yaml', $this->questionnaireDir, $key, $version));
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys of the questionnaires there are, i.e. of their current files.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function keys(): array
|
||||
{
|
||||
$keys = [];
|
||||
|
||||
foreach (glob($this->questionnaireDir.'/*.yaml') ?: [] as $file) {
|
||||
if (1 === preg_match('/^([a-z0-9_]+)\.yaml$/', basename($file), $match)) {
|
||||
$keys[] = $match[1];
|
||||
}
|
||||
}
|
||||
|
||||
sort($keys);
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every definition in the directory, archived versions included.
|
||||
*
|
||||
* @return list<Definition>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
$files = glob($this->questionnaireDir.'/*.yaml') ?: [];
|
||||
sort($files);
|
||||
|
||||
return array_map($this->load(...), $files);
|
||||
}
|
||||
|
||||
private function load(string $file): Definition
|
||||
{
|
||||
if (!is_file($file)) {
|
||||
throw new \InvalidArgumentException(sprintf('Questionnaire definition "%s" does not exist', $file));
|
||||
}
|
||||
|
||||
return $this->definitions[$file] ??= $this->build(Yaml::parseFile($file), basename($file));
|
||||
}
|
||||
|
||||
private function build(mixed $data, string $file): Definition
|
||||
{
|
||||
$this->assertFields($data, self::ROOT_FIELDS, ['key', 'version', 'title', 'sections'], $file);
|
||||
|
||||
if (1 !== preg_match('/^(?<key>[a-z0-9_]+)(\.v(?<version>\d+))?\.yaml$/', $file, $name)) {
|
||||
throw $this->error($file, 'file name must be <key>.yaml or <key>.v<version>.yaml');
|
||||
}
|
||||
|
||||
if ($data['key'] !== $name['key']) {
|
||||
throw $this->error($file, sprintf('key "%s" does not match the file name', $data['key']));
|
||||
}
|
||||
|
||||
if (!is_int($data['version']) || $data['version'] < 1) {
|
||||
throw $this->error($file, 'version must be a positive integer');
|
||||
}
|
||||
|
||||
if (isset($name['version']) && (int) $name['version'] !== $data['version']) {
|
||||
throw $this->error($file, sprintf('version %d does not match the file name', $data['version']));
|
||||
}
|
||||
|
||||
if (!is_array($data['sections']) || [] === $data['sections'] || !array_is_list($data['sections'])) {
|
||||
throw $this->error($file, 'sections must be a non-empty list');
|
||||
}
|
||||
|
||||
$sections = [];
|
||||
$sectionKeys = [];
|
||||
/** @var array<string, Question> $questions every question parsed so far, for show_if */
|
||||
$questions = [];
|
||||
|
||||
foreach ($data['sections'] as $index => $sectionData) {
|
||||
$context = sprintf('%s: section #%d', $file, $index + 1);
|
||||
$this->assertFields($sectionData, self::SECTION_FIELDS, ['key', 'questions'], $context);
|
||||
$key = $this->key($sectionData['key'], $context);
|
||||
|
||||
if (in_array($key, $sectionKeys, true)) {
|
||||
throw $this->error($context, sprintf('duplicate section key "%s"', $key));
|
||||
}
|
||||
|
||||
$sectionKeys[] = $key;
|
||||
$context = sprintf('%s: section "%s"', $file, $key);
|
||||
|
||||
if (!is_array($sectionData['questions']) || [] === $sectionData['questions'] || !array_is_list($sectionData['questions'])) {
|
||||
throw $this->error($context, 'questions must be a non-empty list');
|
||||
}
|
||||
|
||||
$sectionQuestions = [];
|
||||
|
||||
foreach ($sectionData['questions'] as $questionData) {
|
||||
$question = $this->buildQuestion($questionData, $questions, $context);
|
||||
|
||||
$questions[$question->key] = $question;
|
||||
$sectionQuestions[] = $question;
|
||||
}
|
||||
|
||||
$sections[] = new Section($key, $this->optionalString($sectionData['title'] ?? null, 'title', $context), $sectionQuestions);
|
||||
}
|
||||
|
||||
return new Definition(
|
||||
$data['key'],
|
||||
$data['version'],
|
||||
$this->string($data['title'], 'title', $file),
|
||||
$this->optionalString($data['description'] ?? null, 'description', $file),
|
||||
$sections,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, Question> $earlier
|
||||
*/
|
||||
private function buildQuestion(mixed $data, array $earlier, string $context): Question
|
||||
{
|
||||
$this->assertFields($data, self::QUESTION_FIELDS, ['key', 'label', 'type'], $context.': question');
|
||||
$key = $this->key($data['key'], $context.': question');
|
||||
$context = sprintf('%s: question "%s"', $context, $key);
|
||||
|
||||
if (isset($earlier[$key])) {
|
||||
throw $this->error($context, 'duplicate question key');
|
||||
}
|
||||
|
||||
$type = QuestionType::tryFrom((string) $data['type'])
|
||||
?? throw $this->error($context, sprintf('unknown type "%s"', $data['type']));
|
||||
|
||||
$required = $data['required'] ?? false;
|
||||
|
||||
if (!is_bool($required)) {
|
||||
throw $this->error($context, 'required must be true or false');
|
||||
}
|
||||
|
||||
$widget = null;
|
||||
|
||||
if (array_key_exists('widget', $data)) {
|
||||
if (QuestionType::CHOICE !== $type) {
|
||||
throw $this->error($context, 'widget is only allowed on a choice question');
|
||||
}
|
||||
|
||||
$widget = ChoiceWidget::tryFrom((string) $data['widget'])
|
||||
?? throw $this->error($context, sprintf('unknown widget "%s"', $data['widget']));
|
||||
} elseif (QuestionType::CHOICE === $type) {
|
||||
$widget = ChoiceWidget::RADIO;
|
||||
}
|
||||
|
||||
$options = [];
|
||||
|
||||
if ($type->hasOptions()) {
|
||||
$options = $this->buildOptions($data['options'] ?? null, $context);
|
||||
} elseif (array_key_exists('options', $data)) {
|
||||
throw $this->error($context, sprintf('options are not allowed on a %s question', $type->value));
|
||||
}
|
||||
|
||||
$showIf = array_key_exists('show_if', $data)
|
||||
? $this->buildCondition($data['show_if'], $earlier, $context)
|
||||
: null;
|
||||
|
||||
return new Question(
|
||||
$key,
|
||||
$this->string($data['label'], 'label', $context),
|
||||
$type,
|
||||
$required,
|
||||
$this->optionalString($data['help'] ?? null, 'help', $context),
|
||||
$widget,
|
||||
$options,
|
||||
$showIf,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Option>
|
||||
*/
|
||||
private function buildOptions(mixed $data, string $context): array
|
||||
{
|
||||
if (!is_array($data) || [] === $data || !array_is_list($data)) {
|
||||
throw $this->error($context, 'options must be a non-empty list');
|
||||
}
|
||||
|
||||
$options = [];
|
||||
$keys = [];
|
||||
|
||||
foreach ($data as $optionData) {
|
||||
$this->assertFields($optionData, self::OPTION_FIELDS, self::OPTION_FIELDS, $context.': option');
|
||||
// An option key is stored as the answer; YAML would turn an unquoted 1 into an int.
|
||||
$key = $this->key((string) $optionData['key'], $context.': option');
|
||||
|
||||
if (in_array($key, $keys, true)) {
|
||||
throw $this->error($context, sprintf('duplicate option key "%s"', $key));
|
||||
}
|
||||
|
||||
$keys[] = $key;
|
||||
$options[] = new Option($key, $this->string($optionData['label'], 'option label', $context));
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, Question> $earlier
|
||||
*/
|
||||
private function buildCondition(mixed $data, array $earlier, string $context): Condition
|
||||
{
|
||||
$context .= ': show_if';
|
||||
$operators = array_map(static fn (ConditionOperator $operator): string => $operator->value, ConditionOperator::cases());
|
||||
$this->assertFields($data, ['question', ...$operators], ['question'], $context);
|
||||
|
||||
$given = array_values(array_intersect($operators, array_keys($data)));
|
||||
|
||||
if (1 !== count($given)) {
|
||||
throw $this->error($context, sprintf('needs exactly one of %s', implode(', ', $operators)));
|
||||
}
|
||||
|
||||
$operator = ConditionOperator::from($given[0]);
|
||||
$value = $data[$given[0]];
|
||||
|
||||
// Only an earlier question can be answered by the time this one is reached, and it rules
|
||||
// out cycles without having to look for them.
|
||||
$controlling = $earlier[$data['question']]
|
||||
?? throw $this->error($context, sprintf('question "%s" is not an earlier question', $data['question']));
|
||||
|
||||
match ($operator) {
|
||||
ConditionOperator::EQUALS, ConditionOperator::IN => $this->assertOptionValue($controlling, $operator, $value, $context),
|
||||
ConditionOperator::LTE => $this->assertRatingValue($controlling, $value, $context),
|
||||
};
|
||||
|
||||
return new Condition($controlling->key, $operator, $value);
|
||||
}
|
||||
|
||||
private function assertOptionValue(Question $controlling, ConditionOperator $operator, mixed $value, string $context): void
|
||||
{
|
||||
if (QuestionType::CHOICE !== $controlling->type) {
|
||||
throw $this->error($context, sprintf('%s needs a choice question, "%s" is %s', $operator->value, $controlling->key, $controlling->type->value));
|
||||
}
|
||||
|
||||
$values = ConditionOperator::IN === $operator ? $value : [$value];
|
||||
|
||||
if (!is_array($values) || [] === $values || !array_is_list($values)) {
|
||||
throw $this->error($context, 'in needs a non-empty list of option keys');
|
||||
}
|
||||
|
||||
foreach ($values as $optionKey) {
|
||||
if (!is_string($optionKey) || null === $controlling->getOption($optionKey)) {
|
||||
throw $this->error($context, sprintf('"%s" is not an option of "%s"', is_string($optionKey) ? $optionKey : json_encode($optionKey), $controlling->key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function assertRatingValue(Question $controlling, mixed $value, string $context): void
|
||||
{
|
||||
if (QuestionType::RATING !== $controlling->type) {
|
||||
throw $this->error($context, sprintf('lte needs a rating question, "%s" is %s', $controlling->key, $controlling->type->value));
|
||||
}
|
||||
|
||||
if (!is_int($value) || $value < self::RATING_MIN || $value > self::RATING_MAX) {
|
||||
throw $this->error($context, sprintf('lte needs a rating from %d to %d', self::RATING_MIN, self::RATING_MAX));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $allowed
|
||||
* @param list<string> $required
|
||||
*/
|
||||
private function assertFields(mixed $data, array $allowed, array $required, string $context): void
|
||||
{
|
||||
if (!is_array($data) || array_is_list($data)) {
|
||||
throw $this->error($context, 'must be a mapping');
|
||||
}
|
||||
|
||||
if ([] !== $unknown = array_diff(array_keys($data), $allowed)) {
|
||||
throw $this->error($context, sprintf('unknown field(s) %s', implode(', ', $unknown)));
|
||||
}
|
||||
|
||||
if ([] !== $missing = array_diff($required, array_keys($data))) {
|
||||
throw $this->error($context, sprintf('missing field(s) %s', implode(', ', $missing)));
|
||||
}
|
||||
}
|
||||
|
||||
private function key(mixed $value, string $context): string
|
||||
{
|
||||
if (!is_string($value) || 1 !== preg_match(self::KEY_PATTERN, $value)) {
|
||||
throw $this->error($context, sprintf('key %s must be lower snake_case', json_encode($value)));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function string(mixed $value, string $field, string $context): string
|
||||
{
|
||||
if (!is_string($value) || '' === trim($value)) {
|
||||
throw $this->error($context, sprintf('%s must be a non-empty string', $field));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function optionalString(mixed $value, string $field, string $context): ?string
|
||||
{
|
||||
return null === $value ? null : $this->string($value, $field, $context);
|
||||
}
|
||||
|
||||
private function error(string $context, string $message): \UnexpectedValueException
|
||||
{
|
||||
return new \UnexpectedValueException(sprintf('%s: %s', $context, $message));
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,64 @@ final class SeasonCalendar
|
||||
return $day >= $start && $day <= $end ? $year : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The season that is running today, or - when today lies outside any window - the one that
|
||||
* starts next.
|
||||
*
|
||||
* The fall-forward is what keeps the teamer's season page reachable all year: without it the
|
||||
* page would disappear between 02.04. and 31.10. for the default window, which covers the
|
||||
* default first deadline of 01.10. for a season starting 01.11. - teamers would be reminded
|
||||
* about documents with nowhere to upload them.
|
||||
*/
|
||||
public function currentOrUpcomingSeasonYear(?\DateTimeInterface $date = null): int
|
||||
{
|
||||
$day = CarbonImmutable::instance($date ?? new \DateTimeImmutable())->startOfDay();
|
||||
|
||||
if (null !== $seasonYear = $this->seasonYearFor($day)) {
|
||||
return $seasonYear;
|
||||
}
|
||||
|
||||
$year = (int) $day->format('Y');
|
||||
|
||||
// Off-season, so the next window is the one whose start still lies ahead. The candidates
|
||||
// bracket the turn of the year, which is all a MM-DD window can ever straddle.
|
||||
foreach ([$year - 1, $year, $year + 1] as $candidate) {
|
||||
if ($this->boundaryFor($this->seasonStart, $candidate) > $day) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \LogicException(sprintf('No season starts after %s.', $day->format('Y-m-d')));
|
||||
}
|
||||
|
||||
/**
|
||||
* The inclusive first and last day of a season, for queries that cannot call seasonYearFor()
|
||||
* per row - which is every query, since DQL knows nothing about this class.
|
||||
*
|
||||
* @return array{CarbonImmutable, CarbonImmutable}
|
||||
*/
|
||||
public function windowFor(int $seasonYear): array
|
||||
{
|
||||
$start = $this->boundaryFor($this->seasonStart, $seasonYear);
|
||||
$end = $this->boundaryFor($this->seasonEnd, $seasonYear);
|
||||
|
||||
// Wrapping window: the season ends in the following calendar year.
|
||||
if ($end < $start) {
|
||||
$end = $this->boundaryFor($this->seasonEnd, $seasonYear + 1);
|
||||
}
|
||||
|
||||
return [$start, $end];
|
||||
}
|
||||
|
||||
/**
|
||||
* 2026 -> "26/27". The short form the mails to applicants use, where "Saison 2026/27" would
|
||||
* read like a heading in the middle of a sentence.
|
||||
*/
|
||||
public function shortLabel(int $seasonYear): string
|
||||
{
|
||||
return sprintf('%02d/%02d', $seasonYear % 100, ($seasonYear + 1) % 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* null -> "außerhalb der Saison"; 2024 -> "Saison 2024/25".
|
||||
*/
|
||||
@@ -68,6 +126,15 @@ final class SeasonCalendar
|
||||
return sprintf('Saison %d/%02d', $seasonYear, ($seasonYear + 1) % 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* The season label for a date, for the common case of "which season does this belong to" -
|
||||
* "?" when the date is not known yet, which a half-filled create form can be in.
|
||||
*/
|
||||
public function labelFor(?\DateTimeInterface $date): string
|
||||
{
|
||||
return null === $date ? '?' : $this->label($this->seasonYearFor($date));
|
||||
}
|
||||
|
||||
private function boundaryFor(string $monthDay, int $year): CarbonImmutable
|
||||
{
|
||||
$boundary = CarbonImmutable::createFromFormat('Y-m-d', sprintf('%d-%s', $year, $monthDay));
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
/**
|
||||
* How far the office has worked through one season assignment's checklist.
|
||||
*
|
||||
* Derived from the rows by App\Service\Season\SeasonChecklistBuilder, never stored: the two
|
||||
* completed stages follow from the entries themselves, so a column would only be a second,
|
||||
* hand-maintained copy of what the rows already say.
|
||||
*/
|
||||
enum SeasonChecklistStatus: string
|
||||
{
|
||||
case OPEN = 'open';
|
||||
|
||||
/**
|
||||
* Everything the teamer has to send back has arrived and been ticked off, so the weekly
|
||||
* digest has nothing left to chase. Office-internal entries can still be open.
|
||||
*/
|
||||
case RETURNS_COMPLETE = 'returns_complete';
|
||||
|
||||
case COMPLETE = 'complete';
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
/**
|
||||
* The formalities the office works through for every season assignment.
|
||||
*
|
||||
* This enum is the single source of truth: the entries themselves, their German labels, their
|
||||
* order and both of their properties live here and nowhere else. Rows in season_assignment_task
|
||||
* only carry state, and App\Service\Season\SeasonTaskSynchronizer creates the ones an assignment
|
||||
* is missing - so adding a case here is all it takes to add an entry everywhere, including on
|
||||
* assignments that already exist.
|
||||
*
|
||||
* Removing a case leaves its rows behind on purpose. Every read resolves a stored key through
|
||||
* tryFrom() and skips what no longer exists, which keeps the history recoverable instead of
|
||||
* deleting it.
|
||||
*/
|
||||
enum SeasonTask: string
|
||||
{
|
||||
case MASTER_DATA = 'master_data';
|
||||
case VEHICLE = 'vehicle';
|
||||
case CONTRACT = 'contract';
|
||||
case ADDITIONAL_CONTRACT = 'additional_contract';
|
||||
case SAAS_REGISTERED = 'saas_registered';
|
||||
case MUNICIPALITY_REGISTERED = 'municipality_registered';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::MASTER_DATA => 'Stammdaten',
|
||||
self::VEHICLE => 'KfZ',
|
||||
self::CONTRACT => 'Vertrag',
|
||||
self::ADDITIONAL_CONTRACT => 'Zusatzvertrag',
|
||||
self::SAAS_REGISTERED => 'SaaS angemeldet',
|
||||
self::MUNICIPALITY_REGISTERED => 'Gemeinde angemeldet',
|
||||
};
|
||||
}
|
||||
|
||||
public function provides(): SeasonTaskProvision
|
||||
{
|
||||
return match ($this) {
|
||||
self::MASTER_DATA, self::VEHICLE => SeasonTaskProvision::STANDARD,
|
||||
self::CONTRACT, self::ADDITIONAL_CONTRACT => SeasonTaskProvision::INDIVIDUAL,
|
||||
self::SAAS_REGISTERED, self::MUNICIPALITY_REGISTERED => SeasonTaskProvision::NONE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the teamer has to send something back. This is what puts a task on the teamer's
|
||||
* own page and into their weekly reminder; everything else is office-internal.
|
||||
*/
|
||||
public function requiresReturn(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::MASTER_DATA, self::VEHICLE, self::CONTRACT, self::ADDITIONAL_CONTRACT => true,
|
||||
self::SAAS_REGISTERED, self::MUNICIPALITY_REGISTERED => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return self[]
|
||||
*/
|
||||
public static function requiringReturn(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
self::cases(),
|
||||
static fn (self $task): bool => true === $task->requiresReturn()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
/**
|
||||
* What the office hands out for a season task.
|
||||
*
|
||||
* NONE nothing is issued, the task is either a pure return or an internal check
|
||||
* INDIVIDUAL a document produced for this one teamer and uploaded onto their season assignment
|
||||
* STANDARD the same document for everyone, taken from the global document library
|
||||
*/
|
||||
enum SeasonTaskProvision: string
|
||||
{
|
||||
case NONE = 'none';
|
||||
case INDIVIDUAL = 'individual';
|
||||
case STANDARD = 'standard';
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
/**
|
||||
* The slots in the staff-wide document library (Upload::TYPE_DOCUMENT) that code looks a file up
|
||||
* by, rather than by id or name.
|
||||
*
|
||||
* A fixed list and not free text on purpose: something in the application has to consume a slot
|
||||
* for it to mean anything, so inventing one without a release would only produce a token nothing
|
||||
* reads - and a typo would look exactly the same as a document nobody has assigned yet, while
|
||||
* silently dropping an attachment.
|
||||
*
|
||||
* A case's value is what ends up in upload.token, so renaming one orphans the assignment and the
|
||||
* mail quietly loses its attachment. The column is unique: one document per slot.
|
||||
*/
|
||||
enum UploadToken: string
|
||||
{
|
||||
case APPLICATION_FORM_HANDOUT = 'application_form_handout';
|
||||
|
||||
/**
|
||||
* What the document library offers in its dropdown.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::APPLICATION_FORM_HANDOUT => 'Handout für die 2. Mail an Bewerber:innen',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\ApplicationFormSubmission;
|
||||
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Form\ApplicationFormSubmissionCallsType;
|
||||
use App\Model\ApplicationFormSubmissionCallsDto;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Saves the two "call held" checkboxes of the detail view. Rendered by DetailController, which
|
||||
* builds the same form - this one only takes the submission.
|
||||
*/
|
||||
class CallsController extends AbstractController
|
||||
{
|
||||
use ReturnUrlTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/application-form-submission/calls/{uuid}', name: 'app_admin_application_form_submission_calls', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(
|
||||
#[MapEntity(mapping: ['uuid' => 'uuid'])]
|
||||
ApplicationFormSubmission $submission,
|
||||
Request $request,
|
||||
): Response {
|
||||
$callsDto = new ApplicationFormSubmissionCallsDto($submission);
|
||||
$form = $this->createForm(ApplicationFormSubmissionCallsType::class, $callsDto);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$callsDto->applyToSubmission();
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->logger->info('Update application form submission calls', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'firstCallHeldAt' => $submission->getFirstCallHeldAt()?->format('Y-m-d'),
|
||||
'secondCallHeldAt' => $submission->getSecondCallHeldAt()?->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$this->addFlash('success', 'Die Calls wurden gespeichert');
|
||||
}
|
||||
|
||||
return $this->redirect($this->detailUrl($submission, $request));
|
||||
}
|
||||
|
||||
private function detailUrl(ApplicationFormSubmission $submission, Request $request): string
|
||||
{
|
||||
return $this->generateUrl('app_admin_application_form_submission_detail', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'r' => $request->query->get('r'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\ApplicationFormSubmission;
|
||||
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Model\TeamerProvisioningResult;
|
||||
use App\Service\Teamer\TeamerProvisioner;
|
||||
use App\Service\Teamer\TeamerProvisioningException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Makes a teamer out of an applicant who passed the interview: one click, because the submission
|
||||
* carries everything the CRM needs and there is nothing left for the reviewer to type.
|
||||
*
|
||||
* The click writes to the CRM for real - three writes, none of them undoable from here - so what
|
||||
* goes to BusPro is shown in the confirmation before it goes, and TeamerProvisioner refuses a
|
||||
* submission it cannot provision before sending anything.
|
||||
*/
|
||||
class CreateTeamerController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TeamerProvisioner $provisioner,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/application-form-submission/create-teamer/{uuid}', name: 'app_admin_application_form_submission_create_teamer')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(
|
||||
#[MapEntity(mapping: ['uuid' => 'uuid'])]
|
||||
ApplicationFormSubmission $submission,
|
||||
Request $request,
|
||||
): Response {
|
||||
if (false === $request->isMethod(Request::METHOD_POST)) {
|
||||
return $this->render('admin/application_form_submission/modal_create_teamer.html.twig', [
|
||||
'submission' => $submission,
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->provisioner->provision($submission);
|
||||
$this->addFlash('success', $this->describe($result));
|
||||
$this->log($submission, $result);
|
||||
} catch (TeamerProvisioningException $e) {
|
||||
// Deliberately the exception's own wording: it usually carries BusPro's complaint, and
|
||||
// a reviewer can act on a named mandatory field but not on "something went wrong".
|
||||
$this->addFlash('error', $e->getMessage());
|
||||
$this->logger->error('Unable to provision a teamer from an application form submission', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'error_message' => $e->getMessage(),
|
||||
...$e->getContext(),
|
||||
]);
|
||||
}
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_application_form_submission_detail', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'r' => $request->query->get('r'),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Names what happened rather than "erledigt": whether BusPro recognised the applicant, whether
|
||||
* an account already existed and - the one thing that needs a human afterwards - whether the
|
||||
* password mail went out.
|
||||
*/
|
||||
private function describe(TeamerProvisioningResult $result): string
|
||||
{
|
||||
if (true === $result->isAlreadyProvisioned()) {
|
||||
return 'Diese Bewerbung wurde bereits übernommen, es wurde nichts erneut angelegt';
|
||||
}
|
||||
|
||||
$message = true === $result->isUserCreated()
|
||||
? sprintf('%s wurde als Teamer:in angelegt', (string) $result->getUser()->getTeamer())
|
||||
: sprintf('%s hat schon ein Konto und ist jetzt Teamer:in', $result->getUser()->getFullName());
|
||||
|
||||
if (false === $result->isAddressCreated()) {
|
||||
$message .= '. In BusPro gab es die Adresse schon, sie wurde übernommen';
|
||||
}
|
||||
|
||||
$message .= true === $result->isPasswordMailSent()
|
||||
? '. BusPro hat die Passwort-Mail versendet'
|
||||
: '. Die Passwort-Mail von BusPro ist nicht rausgegangen - bitte erneut anstoßen';
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
private function log(ApplicationFormSubmission $submission, TeamerProvisioningResult $result): void
|
||||
{
|
||||
$this->logger->info('Provision a teamer from an application form submission', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'user_id' => $result->getUser()->getId(),
|
||||
'user_email' => $result->getUser()->getEmail(),
|
||||
'bus_pro_address_id' => $submission->getBusProAddressId(),
|
||||
'bus_pro_person_id' => $submission->getBusProPersonId(),
|
||||
'address_created' => $result->isAddressCreated(),
|
||||
'user_created' => $result->isUserCreated(),
|
||||
'already_teamer' => $result->isAlreadyTeamer(),
|
||||
'already_provisioned' => $result->isAlreadyProvisioned(),
|
||||
'password_mail_sent' => $result->isPasswordMailSent(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\ApplicationFormSubmission;
|
||||
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* A real DELETE, not setDeleted(): the retention rule for job applications asks for the row to
|
||||
* be gone, and "details" and "payload" go with it. The audit log keeps the uuid and who deleted
|
||||
* it, deliberately without the applicant's data.
|
||||
*/
|
||||
class DeleteController extends AbstractController
|
||||
{
|
||||
use ReturnUrlTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/application-form-submission/delete/{uuid}', name: 'app_admin_application_form_submission_delete')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(
|
||||
#[MapEntity(mapping: ['uuid' => 'uuid'])]
|
||||
ApplicationFormSubmission $submission,
|
||||
Request $request,
|
||||
): Response {
|
||||
if (true === $request->isMethod(Request::METHOD_POST)) {
|
||||
$uuid = $submission->getUuid();
|
||||
|
||||
$this->entityManager->remove($submission);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->logger->info('Delete application form submission', [
|
||||
'uuid' => $uuid,
|
||||
'form' => $submission->getForm(),
|
||||
'submittedAt' => $submission->getSubmittedAt()->format(\DateTimeInterface::ATOM),
|
||||
]);
|
||||
|
||||
$this->addFlash('success', 'Das eingegangene Formular wurde gelöscht');
|
||||
|
||||
return new HxRedirectResponse(
|
||||
$this->getReturnUrl($request, 'app_admin_application_form_submission_index')
|
||||
);
|
||||
}
|
||||
|
||||
return $this->render('admin/application_form_submission/modal_delete.html.twig', [
|
||||
'submission' => $submission,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\ApplicationFormSubmission;
|
||||
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Form\ApplicationFormSubmissionCallsType;
|
||||
use App\Model\ApplicationFormSubmissionCallsDto;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class DetailController extends AbstractController
|
||||
{
|
||||
use ReturnUrlTrait;
|
||||
|
||||
#[Route('/admin/application-form-submission/detail/{uuid}', name: 'app_admin_application_form_submission_detail')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(
|
||||
#[MapEntity(mapping: ['uuid' => 'uuid'])]
|
||||
ApplicationFormSubmission $submission,
|
||||
Request $request,
|
||||
): Response {
|
||||
// Rendered here, taken by CallsController - the form has no validation to fail, so it
|
||||
// never has to come back to this page with errors.
|
||||
$callsForm = $this->createForm(
|
||||
ApplicationFormSubmissionCallsType::class,
|
||||
new ApplicationFormSubmissionCallsDto($submission),
|
||||
['action' => $this->generateUrl('app_admin_application_form_submission_calls', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'r' => $request->query->get('r'),
|
||||
])]
|
||||
);
|
||||
|
||||
return $this->render('admin/application_form_submission/detail.html.twig', [
|
||||
'submission' => $submission,
|
||||
'callsForm' => $callsForm->createView(),
|
||||
'returnUrl' => $this->getReturnUrl($request, 'app_admin_application_form_submission_index'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\ApplicationFormSubmission;
|
||||
|
||||
use App\Repository\ApplicationFormSubmissionRepository;
|
||||
use App\Service\Common\ApplicationFormSubmissionFilterHandler;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The forms the public websites posted here, newest first. Read-only apart from the follow-up
|
||||
* dates and the deletion the retention rule asks for - nothing in this application creates a
|
||||
* submission.
|
||||
*/
|
||||
class IndexController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ApplicationFormSubmissionRepository $submissionRepository,
|
||||
private readonly PaginatorInterface $paginator,
|
||||
private readonly ApplicationFormSubmissionFilterHandler $filterHandler,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/application-form-submission', name: 'app_admin_application_form_submission_index')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$filterDto = $this->filterHandler->getFilterSettings();
|
||||
|
||||
$pagination = $this->paginator->paginate(
|
||||
$this->submissionRepository->getListQuery($filterDto),
|
||||
$request->query->getInt('page', 1),
|
||||
$request->query->getInt('limit', 50),
|
||||
[
|
||||
'defaultSortFieldName' => 'submission.submittedAt',
|
||||
'defaultSortDirection' => 'desc',
|
||||
]
|
||||
);
|
||||
|
||||
return $this->render('admin/application_form_submission/index.html.twig', [
|
||||
'pagination' => $pagination,
|
||||
'filterDto' => $filterDto,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\ApplicationFormSubmission;
|
||||
|
||||
use App\Email\ApplicationFormSubmissionMailer;
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Sends the follow-up mail. Never automatic: a reviewer decides when a submission has waited long
|
||||
* enough, and may send it again after a bounce or a wording fix - the stamp is overwritten.
|
||||
*
|
||||
* Unlike the receipt this one is allowed to fail loudly: somebody is watching the response.
|
||||
*/
|
||||
class SendFollowUpMailController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly ApplicationFormSubmissionMailer $mailer)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/application-form-submission/send-follow-up/{uuid}', name: 'app_admin_application_form_submission_send_follow_up')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(
|
||||
#[MapEntity(mapping: ['uuid' => 'uuid'])]
|
||||
ApplicationFormSubmission $submission,
|
||||
Request $request,
|
||||
): Response {
|
||||
if (true === $request->isMethod(Request::METHOD_POST)) {
|
||||
$this->mailer->sendFollowUp($submission);
|
||||
|
||||
$this->addFlash('success', 'Die zweite E-Mail wurde versendet');
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_application_form_submission_detail', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'r' => $request->query->get('r'),
|
||||
]));
|
||||
}
|
||||
|
||||
return $this->render('admin/application_form_submission/modal_send_follow_up.html.twig', [
|
||||
'submission' => $submission,
|
||||
// Named in the confirmation so a reviewer sees whether the handout the wording
|
||||
// promises is actually going out.
|
||||
'handout' => $this->mailer->findHandout(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\ApplicationFormSubmission;
|
||||
|
||||
use App\Email\ApplicationFormSubmissionMailer;
|
||||
use App\Entity\ApplicationFormSubmission;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Sends the receipt again by hand. The endpoint sends it by itself when a submission arrives, so
|
||||
* this is for the cases where that did not work: an address that was missing or wrong, or wording
|
||||
* that was fixed afterwards.
|
||||
*/
|
||||
class SendReceiptMailController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly ApplicationFormSubmissionMailer $mailer)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/application-form-submission/send-receipt/{uuid}', name: 'app_admin_application_form_submission_send_receipt')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function index(
|
||||
#[MapEntity(mapping: ['uuid' => 'uuid'])]
|
||||
ApplicationFormSubmission $submission,
|
||||
Request $request,
|
||||
): Response {
|
||||
if (true === $request->isMethod(Request::METHOD_POST)) {
|
||||
$this->mailer->sendReceipt($submission);
|
||||
|
||||
$this->addFlash('success', 'Die Eingangsbestätigung wurde erneut versendet');
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_application_form_submission_detail', [
|
||||
'uuid' => $submission->getUuid(),
|
||||
'r' => $request->query->get('r'),
|
||||
]));
|
||||
}
|
||||
|
||||
return $this->render('admin/application_form_submission/modal_send_receipt.html.twig', [
|
||||
'submission' => $submission,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -30,4 +30,22 @@ class TeamerController extends AbstractController
|
||||
|
||||
return $this->json($dates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Season assignments are only ever made for all-season teamers, so their form must not be
|
||||
* able to offer anybody else.
|
||||
*/
|
||||
#[Route('/admin/autocomplete/season-teamer', name: 'app_admin_autocomplete_season_teamer')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function season(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$query = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||
$queryString = $query['search'];
|
||||
} catch (\JsonException $e) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
return $this->json($this->teamerRepository->getAutocompletionData($queryString, true));
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ namespace App\Controller\Admin\Teamer;
|
||||
use App\Entity\Teamer;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Repository\ApplicationRepository;
|
||||
use App\Repository\DispositionRepository;
|
||||
use App\Service\Teamer\AccountDeletionHandler;
|
||||
use App\Service\Teamer\UpcomingPlacementCollector;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -18,7 +18,7 @@ class DeleteAccountController extends AbstractController
|
||||
public function __construct(
|
||||
private readonly AccountDeletionHandler $accountDeletionHandler,
|
||||
private readonly ApplicationRepository $applicationRepository,
|
||||
private readonly DispositionRepository $dispositionRepository,
|
||||
private readonly UpcomingPlacementCollector $upcomingPlacementCollector,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ class DeleteAccountController extends AbstractController
|
||||
return $this->render('admin/teamer/modal_delete_account.html.twig', [
|
||||
'teamer' => $teamer,
|
||||
'pendingApplicationCount' => count($this->applicationRepository->getPendingForTeamer($teamer, 100)),
|
||||
'upcomingDispositionCount' => count($this->dispositionRepository->getUpcomingDispositionsByTeamer($teamer, 100)),
|
||||
// season assignments included: an active season placement is an open commitment as well
|
||||
'upcomingPlacementCount' => count($this->upcomingPlacementCollector->collect($teamer)),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ class MailingController extends AbstractController
|
||||
));
|
||||
}
|
||||
|
||||
$filterDto = $this->filterHandler->getFilterSettings();
|
||||
$filterDto = $this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings();
|
||||
|
||||
// No redirect after the preview mail, the composed mail has to survive it. The
|
||||
// preview pane is rendered here so the page arrives complete; from then on the
|
||||
@@ -69,7 +69,8 @@ class MailingController extends AbstractController
|
||||
'recipients' => $this->mailingService->resolveRecipients($filterDto),
|
||||
'placeholders' => TeamerMailingService::PLACEHOLDERS,
|
||||
'hasDraft' => $this->draftHandler->hasDraft(),
|
||||
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_teamer_index'),
|
||||
'returnUrl' => $this->getReturnUrl($request, $this->getListRoute($request)),
|
||||
'scope' => $request->query->get('scope'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -94,11 +95,13 @@ class MailingController extends AbstractController
|
||||
|
||||
#[Route('/admin/teamer/mailing/discard', name: 'app_admin_teamer_mailing_discard')]
|
||||
#[IsGranted('ROLE_TEAM_ADMIN')]
|
||||
public function discard(): Response
|
||||
public function discard(Request $request): Response
|
||||
{
|
||||
$this->draftHandler->resetDraft();
|
||||
|
||||
return $this->redirectToRoute('app_admin_teamer_mailing');
|
||||
return $this->redirectToRoute('app_admin_teamer_mailing', [
|
||||
'scope' => $request->query->get('scope'),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/teamer/mailing/confirm', name: 'app_admin_teamer_mailing_confirm', methods: ['POST'])]
|
||||
@@ -106,7 +109,7 @@ class MailingController extends AbstractController
|
||||
public function confirm(Request $request): Response
|
||||
{
|
||||
$form = $this->createMailingForm($request);
|
||||
$filterDto = $this->filterHandler->getFilterSettings();
|
||||
$filterDto = $this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings();
|
||||
|
||||
// the composed mail is carried on in hidden fields so no half written mailing has
|
||||
// to be parked in the session between the confirmation and the send
|
||||
@@ -115,6 +118,7 @@ class MailingController extends AbstractController
|
||||
'mailingDto' => $form->getData(),
|
||||
'valid' => $form->isSubmitted() && $form->isValid(),
|
||||
'recipients' => $this->mailingService->resolveRecipients($filterDto),
|
||||
'scope' => $request->query->get('scope'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -127,10 +131,14 @@ class MailingController extends AbstractController
|
||||
if (false === $form->isSubmitted() || false === $form->isValid()) {
|
||||
$this->addFlash('error', 'Die Mail konnte nicht gesendet werden, Betreff oder Nachricht fehlen');
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_teamer_mailing'));
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_teamer_mailing', [
|
||||
'scope' => $request->query->get('scope'),
|
||||
]));
|
||||
}
|
||||
|
||||
$recipients = $this->mailingService->resolveRecipients($this->filterHandler->getFilterSettings());
|
||||
$recipients = $this->mailingService->resolveRecipients(
|
||||
$this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings()
|
||||
);
|
||||
$mailingDto = $form->getData();
|
||||
|
||||
// the recipients travel with the message, so the mailing reaches exactly the
|
||||
@@ -151,7 +159,18 @@ class MailingController extends AbstractController
|
||||
$recipients->getEligibleCount()
|
||||
));
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
|
||||
return new HxRedirectResponse($this->generateUrl($this->getListRoute($request)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The list this mailing was started from, so a send returns to it and an unscoped
|
||||
* mailing never falls back to the wrong one.
|
||||
*/
|
||||
private function getListRoute(Request $request): string
|
||||
{
|
||||
return TeamerFilterHandler::SCOPE_ALL_SEASON === $request->query->get('scope')
|
||||
? 'app_administrative_season_teamer_index'
|
||||
: 'app_administrative_teamer_index';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loaded 100 of 536 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user