Compare commits

...
103 Commits
Author SHA1 Message Date
fromme 3c34cfa76a feat: offer the questionnaire for placements ended within a rolling window 2026-11-10 09:23:23 +01:00
fromme 9d0cdd7f52 chore: update inline documentation of feedback config files 2026-11-08 14:41:38 +01:00
fromme 9583612fdc refactor: use english naming for the n8n assignments api 2026-11-07 09:33:54 +01:00
fromme 0bc77b7463 feat: expose winter placements to n8n over an api endpoint 2026-11-06 10:09:56 +01:00
fromme 4ff963d1e2 feat: let managers edit the staffing matrix 2026-11-05 17:18:26 +01:00
fromme f9d2ff084d feat: decide the staffing split per ski level 2026-11-05 17:04:24 +01:00
fromme 7aefe88610 chore: replace contract template pdf 2026-11-05 10:57:18 +01:00
fromme 09eea86183 chore: update project dependencies 2026-11-04 16:29:04 +01:00
fromme ddfaa4b440 chore: streamlined naming of feedback menu items 2026-10-28 11:23:21 +01:00
fromme d3b9b0611b chore: update project dependencies 2026-10-28 09:19:05 +01:00
fromme fe0c854f1d refactor: offer the questionnaire test from the assignment overview 2026-10-27 17:37:47 +01:00
fromme 0b736b51fb feat: calmer questionnaire form with selects and clickable answer rows 2026-10-27 17:32:41 +01:00
fromme af97468b2d feat: put the placement in one line above the questionnaire 2026-10-27 17:27:40 +01:00
fromme a0170f5952 refactor: split the questionnaire into teamer and bus feedback 2026-10-27 17:22:22 +01:00
fromme 2f4f9800dd fix: offer the questionnaire after every placement, the bus part to escorts 2026-10-27 17:12:24 +01:00
fromme 9fedc5b5d3 feat: let the office test the questionnaire on any assignment 2026-10-27 17:07:17 +01:00
fromme a35f91ac63 feat: list and show submitted questionnaires to the office 2026-10-27 17:02:51 +01:00
fromme 890e04f8a6 feat: remind teamers of open questionnaires on the dashboard 2026-10-27 16:59:44 +01:00
fromme 5e4e574076 feat: let teamers fill in and read their questionnaire 2026-10-27 16:56:49 +01:00
fromme 2a041d25a0 feat: let the teamer fill in the questionnaire after a bus escort 2026-10-27 16:53:23 +01:00
fromme df6734c939 feat: store questionnaire responses per disposition 2026-10-27 16:51:56 +01:00
fromme 8070fda64d feat: build the questionnaire form from its definition 2026-10-27 16:47:38 +01:00
fromme d06b647dac feat: decide when a conditional questionnaire question is shown 2026-10-27 16:43:14 +01:00
fromme a84275b40e feat: add questionnaire definitions and their catalog 2026-10-27 16:41:46 +01:00
fromme 2ec2a4c93f chore: fix typo in menu item 2026-10-20 17:14:41 +02:00
fromme de4aedd26e refactor: name the per-teamer disposition lists correctly 2026-10-20 17:14:13 +02:00
frommeandClaude Opus 5.5 5090f72fab fix: log a called-off disposition under its own id key
The disposition call-off logged the disposition's id as "assignment_id" and
set the status from the Assignment constant; both now use the disposition's
own name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 17:13:53 +02:00
fromme c00dd86eb4 feat: list upcoming season assignments among a teamer's dispositions 2026-10-20 17:13:45 +02:00
fromme 300bb2058f feat: apply season task deadlines to all open checklist entries 2026-10-20 16:52:45 +02:00
fromme 3fe510e64e fix: render form help texts smaller and muted 2026-10-20 16:52:32 +02:00
fromme 48691227cb chore: move menu entry to better suiting submenu 2026-10-20 16:34:12 +02:00
fromme 34ff630b75 chore: update project dependencies 2026-10-20 14:21:22 +02:00
fromme 3f806669c6 fix: show scrollbars only when an inner area overflows 2026-10-20 14:17:58 +02:00
fromme e31f607f66 chore: declare browser targets in .browserslistrc 2026-10-20 14:05:05 +02:00
fromme 8c53676133 feat: let the remaining textareas grow with their content 2026-10-20 14:04:55 +02:00
fromme 2de985b009 chore: upgrade webpack encore from 4.7 to 7.2 2026-10-20 14:04:43 +02:00
fromme 1cda9d378a chore: upgrade tailwind css from 3.4 to 4.3 2026-10-20 14:04:24 +02:00
fromme 9fcba26942 chore: bump node to 24 in ddev 2026-10-16 10:46:40 +02:00
fromme 13e5b6c3af feat: boost pagination and sorting links with htmx 2026-10-16 10:39:47 +02:00
fromme 493a43d952 fix: give the empty-field test case its own data provider 2026-10-16 10:12:30 +02:00
fromme 7493afde45 chore: update project dependencies 2026-10-16 10:06:22 +02:00
fromme d159cb212c feat: paginate the staffing matrix preview 2026-10-16 10:05:42 +02:00
fromme c7e0ad67e6 fix: put the guiding fee on one guide place only 2026-10-16 09:56:04 +02:00
fromme f0ad5374e9 docs: remove the myep api consumer guide 2026-10-16 09:15:04 +02:00
fromme 96d49fc488 docs: drop code references to local plan files 2026-10-16 09:14:44 +02:00
fromme 2a554bb7bf feat: stop keeping buspro password hash in the session 2026-10-16 09:00:00 +02:00
fromme c16e4bf53f feat: update profile in buspro without the teamer's password 2026-10-16 08:59:39 +02:00
fromme 18fb3382c6 feat: redirect to the deeplink after the form login 2026-10-16 08:38:05 +02:00
fromme 0e2021e96d refactor: derive the bus escort from the travel date 2026-10-16 08:36:54 +02:00
fromme 2fc9806c69 feat: generate draft assignments from a staffing matrix 2026-10-15 18:11:51 +02:00
fromme 3116d23199 fix: refuse deleting a document assigned to a season task 2026-10-15 13:02:13 +02:00
fromme 4aa2d7a5e6 fix: keep upload files until their deletion is committed 2026-10-15 12:49:48 +02:00
frommeandClaude Opus 5.5 7e182e3d51 feat: per-disposition feedback waiver
The overdue-feedback lists are a live query without stored state, so a
feedback nobody will provide any more could not be written off. The new
disposition.feedback_waived flag takes a placement out of the admin and
manager dashboards, the house-manager list and the reminder mail.

Statistics still count a waived placement as a missing feedback. The
flag has no UI yet and is set by SQL.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 13:53:25 +02:00
fromme c04d9e0faf chore: update project dependencies 2026-10-13 12:53:44 +02:00
fromme 3188f5f3da feat: additional 'add' button on top of list view 2026-10-13 12:52:28 +02:00
fromme 57781ba347 fix: teamer dashboard crashing on isContractDue() without deadline 2026-10-06 15:04:17 +02:00
fromme 849da8d471 chore: upgrade phpunit to 12.5 2026-10-01 10:07:05 +02:00
fromme da039d0527 fix: resolve the remaining deprecations reported by phpunit 2026-10-01 10:06:59 +02:00
fromme 1446211d22 chore: bump project to php 8.5 2026-10-01 09:55:34 +02:00
fromme 838b8944a2 feat: rearranged admin menu, proper gendering 2026-10-01 08:23:06 +02:00
fromme a4206f51eb chore: update project dependencies 2026-09-29 15:43:21 +02:00
fromme 2329fe17a0 fix: report an unresolvable hotel instead of failing on it 2026-09-29 15:27:44 +02:00
fromme 72be779038 chore: name the current import command in the probe's docblock 2026-09-29 15:27:34 +02:00
fromme d8ff148f86 fix: adopt changed export api url 2026-09-29 14:45:51 +02:00
fromme db529d2dc9 refactor: slim the hotel and country base-data models 2026-09-29 13:06:20 +02:00
fromme aa66c50979 refactor: delete the BusProNet base-data read path 2026-09-29 13:06:12 +02:00
fromme 0f76c5a7b6 feat: import destinations from the MyE&P travel export 2026-09-29 12:57:54 +02:00
fromme 5c7e702eab fix: accept the ISO-2 country codes when authoring a destination 2026-09-29 12:47:25 +02:00
fromme 169a17e19c chore: verify the ISO-2 country move and renumber its migration 2026-09-29 12:47:25 +02:00
fromme 6c0a512c05 fix: refuse the import while MyE&P reports no cost unit or country code 2026-09-29 11:08:04 +02:00
Björn Fromme 2e9328fc51 feat: read BusProNet base data from the MyE&P API 2026-09-29 11:08:04 +02:00
fromme 1505f9b50b chore: update project dependencies 2026-09-27 18:10:15 +02:00
fromme 392e67448e feat: order and format the pickup fields 2026-09-27 18:05:12 +02:00
fromme 8da9af46f5 chore: update project dependencies 2026-09-26 12:13:59 +02:00
fromme ca4a73eb4f chore: cleanup ddev config 2026-09-26 12:13:59 +02:00
fromme 1a7c272414 feat: import work hours from SaaS and exchange signed time sheet 2026-09-26 12:13:59 +02:00
fromme 7cf39850e6 feat: email recipient whitelist 2026-09-26 12:13:37 +02:00
fromme 4cab57d136 feat: additional functionality and layout adjustments for season teamers 2026-09-26 12:13:16 +02:00
fromme 80c6016da3 fix: replace only missing uploads with dummy document 2026-09-26 12:13:16 +02:00
fromme 69b8e549ee feat: gate the upload replacement file behind a feature flag 2026-09-26 12:13:16 +02:00
fromme 77986776c2 feat: send season deadline summary to the office address 2026-09-26 12:13:16 +02:00
Björn Fromme cf876ed224 feat: all-season assignments 2026-09-26 12:13:16 +02:00
fromme 675cec1b69 chore: rename menu entry and view title 2026-09-26 10:32:42 +02:00
fromme e460b18661 fix: accept the German W as a gender on the application endpoint 2026-09-26 10:25:48 +02:00
fromme 5141673bcd chore: drop the teamer provisioning feature flag 2026-09-25 14:51:39 +02:00
fromme b530679658 feat: provision a teamer from a received application 2026-09-25 14:36:24 +02:00
fromme d5f4b12f0a chore: rename local project to align with prod 2026-09-25 09:36:59 +02:00
fromme aa728ddc99 feat: answer received form submissions by mail 2026-09-24 16:35:31 +02:00
fromme 19364df2db feat: mark a library document for a specific purpose 2026-09-24 16:35:24 +02:00
fromme cb2d64f2cd feat: review received form submissions in the admin ui 2026-09-24 16:35:18 +02:00
fromme f073a2014d fix: keep the log processors working on a stateless firewall 2026-09-24 16:35:12 +02:00
fromme 30ad5e8488 feat: accept form submissions from the websites over an API endpoint 2026-09-24 16:35:06 +02:00
fromme f48e53fdd0 fix: verify the oauth2 state before the error parameter 2026-09-23 17:48:24 +02:00
fromme 3f4586ce06 feat: harden the myep oauth2 client 2026-09-23 16:06:25 +02:00
frommeandClaude Opus 5 2a8649fc6d chore: run MariaDB 11.8 in ddev to match production
Development ran MySQL 8.4 while production runs MariaDB, so nothing engine-specific
was ever exercised on the engine it ships on.

DATABASE_URL carries serverVersion=mariadb-* now, which is what makes DBAL pick its
MariaDB platform. This matters most for the test environment: it reads DATABASE_URL
from the committed .env and not from .env.dev.local, so a MySQL version there had the
suite running on MySQL84Platform against a MariaDB server - and schema:validate
reporting every nullable and every json column as drift. With the platform right, a
db_test built from the mapping is in sync.

The post-start hooks create db_test and grant the db user, which ddev does not do on
its own, and sync the schema from the entity mapping. Without them the first test run
after a fresh start fails on the missing database.

The remaining changes in config.yaml are ddev's own regeneration of the commented
reference section.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 09:02:56 +02:00
fromme 161cfb9e62 chore: exclude secrets file from repository 2026-09-23 08:06:45 +02:00
fromme eac125517f fix: use a SortDirection instance for the availabilities ordering 2026-09-21 12:20:55 +02:00
fromme af46c57bd1 feat: normalize email addresses to lowercase on write 2026-09-21 12:20:55 +02:00
fromme d6327f033e fix: stagger cron triggered tasks between prod and staging 2026-09-20 13:32:48 +02:00
fromme 752db081b3 chore: update project dependencies 2026-09-20 13:32:23 +02:00
fromme 518f3a46d0 test: adopt the ROLE_TEAM_ADMIN label rename 2026-09-19 11:36:23 +02:00
fromme a05143bfbe fix: make BusProNet response failures diagnosable and non-fatal 2026-09-19 11:36:14 +02:00
fromme 29cedd5916 chore: disable unused ddev ssh-agent 2026-09-17 08:31:29 +02:00
536 changed files with 42515 additions and 8489 deletions

No files matched your search

+4
View File
@@ -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
View File
@@ -1,24 +1,26 @@
type: php type: symfony
docroot: public docroot: public
php_version: "8.4" php_version: "8.5"
webserver_type: apache-fpm webserver_type: apache-fpm
xdebug_enabled: false xdebug_enabled: false
additional_hostnames: [] additional_hostnames: []
additional_fqdns: [] additional_fqdns: []
database: database:
type: mysql type: mariadb
version: "8.4" 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 use_dns_when_possible: true
timezone: Europe/Berlin timezone: Europe/Berlin
composer_version: "2" composer_version: "2"
web_environment: [] web_environment: []
nodejs_version: "18" nodejs_version: "24"
corepack_enable: false corepack_enable: false
disable_upload_dirs_warning: true disable_upload_dirs_warning: true
xhgui_https_port: "8142"
xhgui_http_port: "8143"
# Key features of DDEV's config.yaml: # Key features of DDEV's config.yaml:
# name: <projectname> # Name of the project, automatically provides # 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, # If the name is omitted, the project will take the name of the enclosing directory,
# which is useful if you want to have a copy of the project side by side with this one. # which is useful if you want to have a copy of the project side by side with this one.
# type: <projecttype> # backdrop, cakephp, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, generic, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress # type: <projecttype> # asterios, backdrop, cakephp, codeigniter, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, drupal12, generic, joomla, laravel, magento, magento2, maho, modx, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock
# See https://docs.ddev.com/en/stable/users/quickstart/ for more # See https://docs.ddev.com/en/stable/users/quickstart/ for more
# information on the different project types # information on the different project types
# docroot: <relative_path> # Relative path to the directory containing index.php. # docroot: <relative_path> # Relative path to the directory containing index.php.
# php_version: "8.3" # PHP version to use, "5.6" through "8.5" # php_version: "8.4" # PHP version to use, "5.6" through "8.5"
# You can explicitly specify the webimage but this # You can explicitly specify the webimage but this
# is not recommended, as the images are often closely tied to DDEV's' behavior, # is not recommended, as the images are often closely tied to DDEV's behavior,
# so this can break upgrades. # so this can break upgrades.
# webimage: <docker_image> # webimage: <docker_image>
@@ -45,10 +47,18 @@ xhgui_http_port: "8143"
# database: # database:
# type: <dbtype> # mysql, mariadb, postgres # type: <dbtype> # mysql, mariadb, postgres
# version: <version> # database version, like "10.11" or "8.0" # version: <version> # database version, like "10.11" or "8.0"
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8 # MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8, 12.3
# MySQL versions can be 5.5-8.0, 8.4 # MySQL versions can be 5.5-8.0, 8.4, 9.7
# PostgreSQL versions can be 9-18 # PostgreSQL versions can be 9-18
# You can explicitly specify the dbimage but this
# is not recommended, as the images are often closely tied to DDEV's behavior,
# so this can break upgrades.
# dbimage: <docker_image>
# It’s unusual to change this option, and we don’t recommend it without Docker experience and a good reason.
# Typically, this means additions to the existing db image using a .ddev/db-build/Dockerfile.*
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80) # router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443) # router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
@@ -68,8 +78,7 @@ xhgui_http_port: "8143"
# bind_all_ports is used (normally with router disabled) # bind_all_ports is used (normally with router disabled)
# xhprof_mode: [prepend|xhgui|global] # xhprof_mode: [prepend|xhgui|global]
# Set to "xhgui" to enable XHGui features # Default is "xhgui"
# "xhgui" will become default in a future major release
# webserver_type: nginx-fpm, apache-fpm, generic # webserver_type: nginx-fpm, apache-fpm, generic
@@ -87,22 +96,24 @@ xhgui_http_port: "8143"
# commands are executed. # commands are executed.
# composer_version: "2" # composer_version: "2"
# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1 # You can set it to "" or "2" (default) for Composer v2
# to use the latest major version available at the time your container is built. # to use the latest major version available at the time your container is built.
# It is also possible to use each other Composer version channel. This includes: # It is also possible to use any other Composer version channel. This includes:
# - 2.2 (latest Composer LTS version) # - 2.2 (latest Composer LTS version)
# - stable # - stable
# - preview # - preview
# - snapshot # - snapshot
# Alternatively, an explicit Composer version may be specified, for example "2.2.18". # Alternatively, an explicit Composer version may be specified, for example "2.2.18".
# To reinstall Composer after the image was built, run "ddev debug rebuild". # To reinstall Composer after the image was built, run "ddev utility rebuild".
# nodejs_version: "22" # nodejs_root: <relative_path>
# Relative path to the directory containing the Node.js version file from the
# project root. Only used with "nodejs_version: auto" or "nodejs_version: engine".
# nodejs_version: "24"
# change from the default system Node.js version to any other version. # change from the default system Node.js version to any other version.
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information # See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation, # and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation.
# Note that using of 'ddev nvm' is discouraged because "nodejs_version" is much easier to use,
# can specify any version, and is more robust than using 'nvm'.
# corepack_enable: false # corepack_enable: false
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm # Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
@@ -162,9 +173,7 @@ xhgui_http_port: "8143"
# - "global": uses the value from the global config. # - "global": uses the value from the global config.
# - "none": disables performance optimization for this project. # - "none": disables performance optimization for this project.
# - "mutagen": enables Mutagen for this project. # - "mutagen": enables Mutagen for this project.
# - "nfs": enables NFS for this project.
# #
# See https://docs.ddev.com/en/stable/users/install/performance/#nfs
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen # See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
# fail_on_hook_fail: False # fail_on_hook_fail: False
@@ -194,10 +203,10 @@ xhgui_http_port: "8143"
# The mailpit port is not normally bound on the host at all, instead being routed # The mailpit port is not normally bound on the host at all, instead being routed
# through ddev-router, but it can be bound directly to localhost if specified here. # through ddev-router, but it can be bound directly to localhost if specified here.
# webimage_extra_packages: [php7.4-tidy, php-bcmath] # webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
# Extra Debian packages that are needed in the webimage can be added here # Extra Debian packages that are needed in the webimage can be added here
# dbimage_extra_packages: [telnet,netcat] # dbimage_extra_packages: [netcat, telnet, sudo]
# Extra Debian packages that are needed in the dbimage can be added here # Extra Debian packages that are needed in the dbimage can be added here
# use_dns_when_possible: true # use_dns_when_possible: true
@@ -209,12 +218,15 @@ xhgui_http_port: "8143"
# project_tld: ddev.site # project_tld: ddev.site
# The top-level domain used for project URLs # The top-level domain used for project URLs
# The default "ddev.site" allows DNS lookup via a wildcard # The default "ddev.site" allows DNS lookup via a wildcard
# If you prefer you can change this to "ddev.local" to preserve
# pre-v1.9 behavior.
# ngrok_args: --basic-auth username:pass1234 # share_default_provider: ngrok
# Provide extra flags to the "ngrok http" command, see # The default share provider to use for "ddev share"
# https://ngrok.com/docs/agent/config/v3/#agent-configuration or run "ngrok http -h" # Defaults to global configuration, usually "ngrok"
# Can be "ngrok" or "cloudflared" or the name of a custom provider from .ddev/share-providers/
# share_provider_args: --basic-auth username:pass1234
# Provide extra flags to the share provider script
# See https://docs.ddev.com/en/stable/users/configuration/config/#share_provider_args
# disable_settings_management: false # disable_settings_management: false
# If true, DDEV will not create CMS-specific settings files like # If true, DDEV will not create CMS-specific settings files like
@@ -276,7 +288,7 @@ xhgui_http_port: "8143"
# override_config: false # override_config: false
# By default, config.*.yaml files are *merged* into the configuration # By default, config.*.yaml files are *merged* into the configuration
# But this means that some things can't be overridden # But this means that some things can't be overridden
# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge # For example, if you have 'use_dns_when_possible: true' you can't override it with a merge
# and you can't erase existing hooks or all environment variables. # and you can't erase existing hooks or all environment variables.
# However, with "override_config: true" in a particular config.*.yaml file, # However, with "override_config: true" in a particular config.*.yaml file,
# 'use_dns_when_possible: false' can override the existing values, and # 'use_dns_when_possible: false' can override the existing values, and
@@ -286,7 +298,7 @@ xhgui_http_port: "8143"
# web_environment: [] # web_environment: []
# or # or
# additional_hostnames: [] # additional_hostnames: []
# can have their intended affect. 'override_config' affects only behavior of the # can have their intended effect. 'override_config' affects only behavior of the
# config.*.yaml file it exists in. # config.*.yaml file it exists in.
# Many DDEV commands can be extended to run tasks before or after the # Many DDEV commands can be extended to run tasks before or after the
@@ -296,3 +308,6 @@ xhgui_http_port: "8143"
# information on the commands that can be extended and the tasks you can define # information on the commands that can be extended and the tasks you can define
# for them. Example: # for them. Example:
#hooks: #hooks:
## Un-comment to consume async message.
# post-start:
# - exec: symfony run --daemon --watch=config,src,templates,vendor bin/console messenger:consume async -vv
+40 -9
View File
@@ -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=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=10.11.2-MariaDB&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="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 ### ###< doctrine/doctrine-bundle ###
###> symfony/messenger ### ###> symfony/messenger ###
@@ -44,7 +47,12 @@ MAILER_DSN=null://null
MAILING_MAILER_DSN=null://null MAILING_MAILER_DSN=null://null
###< symfony/mailer ### ###< 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_USER=
APP_BPN_PASSWORD= APP_BPN_PASSWORD=
@@ -62,11 +70,21 @@ APP_BPN_CRM_ID_TEAMER=1070
APP_DEFAULT_EMAIL_FROM=[email protected] APP_DEFAULT_EMAIL_FROM=[email protected]
APP_DEFAULT_EMAIL_TO=[email protected] APP_DEFAULT_EMAIL_TO=[email protected]
SFTP_XML_EXPORT_HOST= # Regular expressions of the only recipients a mail may actually reach. Everything else is
SFTP_XML_EXPORT_PORT=22 # stripped from the envelope and a mail left without a recipient is not sent at all, see
SFTP_XML_EXPORT_USER= # App\EventListener\EmailRecipientWhitelistSubscriber. Empty means no filtering, which is what
SFTP_XML_EXPORT_PASSWORD= # production runs with. Set this on staging: it runs the real transports against real teamer
SFTP_XML_EXPORT_ROOT= # 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_ID=
MYEP_OAUTH2_CLIENT_SECRET= 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_ACCESS_TOKEN=https://my.ep-reisen.de/token
MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS=https://my.ep-reisen.de/api/userinfo MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS=https://my.ep-reisen.de/api/userinfo
MYEP_OAUTH2_SCOPES=email,id,roles,profile 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 ### ###> symfony/mailjet-mailer ###
# Bulk teamer mailings only, everything else goes out over MAILER_DSN. Set the real # 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_STAMP_INVOICES=false
FEATURE_DATEV_EMAIL=false FEATURE_DATEV_EMAIL=false
FEATURE_MYEP_LOGIN=false FEATURE_MYEP_LOGIN=false
FEATURE_UPLOAD_REPLACEMENT=false
UPLOAD_REPLACEMENT_FILE=assets/pdf/chicken.pdf
MAINTENANCE_MODE_ENABLED=false MAINTENANCE_MODE_ENABLED=false
MAINTENANCE_MODE_IP_WHITELIST=188.245.62.21 MAINTENANCE_MODE_IP_WHITELIST=188.245.62.21
+11 -1
View File
@@ -1,6 +1,16 @@
# define your env variables for the test env here # define your env variables for the test env here
KERNEL_CLASS='App\Kernel' KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st' APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999 SYMFONY_DEPRECATIONS_HELPER=max[self]=0
PANTHER_APP_ENV=panther PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots 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
View File
@@ -12,11 +12,11 @@
###> phpunit/phpunit ### ###> phpunit/phpunit ###
/phpunit.xml /phpunit.xml
.phpunit.result.cache /.phpunit.cache/
###< phpunit/phpunit ### ###< phpunit/phpunit ###
###> symfony/phpunit-bridge ### ###> symfony/phpunit-bridge ###
.phpunit.result.cache /.phpunit.cache/
/phpunit.xml /phpunit.xml
###< symfony/phpunit-bridge ### ###< symfony/phpunit-bridge ###
@@ -35,3 +35,5 @@ yarn-error.log
/.php-cs-fixer.php /.php-cs-fixer.php
/.php-cs-fixer.cache /.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ### ###< friendsofphp/php-cs-fixer ###
/http-client.private.env.json
+1 -1
View File
@@ -1 +1 @@
18 24
+57
View File
@@ -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": {}
}
+9
View File
@@ -3,6 +3,15 @@ htmx.config.includeIndicatorStyles = false
htmx.config.historyCacheSize = 0 htmx.config.historyCacheSize = 0
htmx.config.allowScriptTags = false 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'; import './bootstrap.js';
/* /*
* Welcome to your app's main JavaScript file! * 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
}
}
+8 -4
View File
@@ -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"/> <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>
<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-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-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-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-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-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-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-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> <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 --> <!-- 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"> <symbol id="icon-spinner" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">
@@ -241,4 +241,8 @@
</circle> </circle>
</g> </g>
</symbol> </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> </svg>

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.
+9 -9
View File
@@ -1,9 +1,9 @@
@import "components/badge.css"; @import "./components/badge.css";
@import "components/autocomplete.css"; @import "./components/autocomplete.css";
@import "components/button.css"; @import "./components/button.css";
@import "components/datatable.css"; @import "./components/datatable.css";
@import "components/datepicker.css"; @import "./components/datepicker.css";
@import "components/toast.css"; @import "./components/toast.css";
@import "components/tooltip.css"; @import "./components/tooltip.css";
@import "components/timeline.css"; @import "./components/timeline.css";
@import "components/mail-body.css"; @import "./components/mail-body.css";
+44 -5
View File
@@ -1,7 +1,46 @@
@import "tailwindcss/base"; @import "tailwindcss";
@import "_base.css"; @import "./_base.css" layer(base);
@import "./_components.css" layer(components);
@import "tailwindcss/components"; @plugin "@tailwindcss/forms";
@import "_components.css";
@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 -3
View File
@@ -3,11 +3,11 @@
.flatpickr-day.selected, .flatpickr-day.selected,
.flatpickr-day.selected:hover, .flatpickr-day.selected:hover,
.flatpickr-day.selected:focus { .flatpickr-day.selected:focus {
background-color: theme('colors.secondary'); background-color: var(--color-secondary);
border-color: theme('colors.secondary'); border-color: var(--color-secondary);
} }
.flatpickr-monthDropdown-months, .flatpickr-monthDropdown-months,
.flatpickr-current-month .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 -1
View File
@@ -1,4 +1,4 @@
.scroll-container { .scroll-container {
@apply overflow-scroll; @apply overflow-auto;
max-height: calc(100vh - 320px); max-height: calc(100vh - 320px);
} }
+4 -4
View File
@@ -1,20 +1,20 @@
@import "toastify-js/src/toastify.css"; @import "toastify-js/src/toastify.css";
.toastify { .toastify {
@apply shadow-lg rounded; @apply shadow-lg rounded-sm;
} }
.toastify--success { .toastify--success {
@apply text-gray-100; @apply text-gray-100;
background: theme('colors.emerald.600'); background: var(--color-emerald-600);
} }
.toastify--warning { .toastify--warning {
@apply text-gray-100; @apply text-gray-100;
background: theme('colors.red.600'); background: var(--color-red-600);
} }
.toastify--info { .toastify--info {
@apply text-gray-100; @apply text-gray-100;
background: theme('colors.blue.600'); background: var(--color-blue-600);
} }
+5 -5
View File
@@ -1,18 +1,18 @@
@import "tippy.js/dist/tippy.css"; @import "tippy.js/dist/tippy.css";
.tippy-box { .tippy-box {
background-color: theme('colors.primary'); background-color: var(--color-primary);
} }
.tippy-box[data-placement^='top'] > .tippy-arrow::before { .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 { .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 { .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 { .tippy-box[data-placement^='right'] > .tippy-arrow::before {
border-right-color: theme('colors.primary'); border-right-color: var(--color-primary);
} }
+3 -9
View File
@@ -5,15 +5,9 @@ if (!ini_get('date.timezone')) {
ini_set('date.timezone', 'UTC'); ini_set('date.timezone', 'UTC');
} }
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) { if (!is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php'); echo "Unable to find PHPUnit in `vendor/phpunit/phpunit/`. Run `composer install`.\n";
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); exit(1);
} }
require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php'; require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
}
+3 -2
View File
@@ -19,6 +19,7 @@
"doctrine/doctrine-bundle": "^2.15", "doctrine/doctrine-bundle": "^2.15",
"doctrine/doctrine-migrations-bundle": "^3.2", "doctrine/doctrine-migrations-bundle": "^3.2",
"doctrine/orm": "^3.6", "doctrine/orm": "^3.6",
"dompdf/dompdf": "^3.1",
"flagception/flagception-bundle": "^6.0", "flagception/flagception-bundle": "^6.0",
"knplabs/knp-menu-bundle": "^3.2", "knplabs/knp-menu-bundle": "^3.2",
"knplabs/knp-paginator-bundle": "^6.2", "knplabs/knp-paginator-bundle": "^6.2",
@@ -88,7 +89,7 @@
}, },
"sort-packages": true, "sort-packages": true,
"platform": { "platform": {
"php": "8.4.24" "php": "8.5.9"
} }
}, },
"autoload": { "autoload": {
@@ -137,7 +138,7 @@
"fakerphp/faker": "*", "fakerphp/faker": "*",
"friendsofphp/php-cs-fixer": "^3.84", "friendsofphp/php-cs-fixer": "^3.84",
"marcocesarato/php-conventional-changelog": "^1.17", "marcocesarato/php-conventional-changelog": "^1.17",
"phpunit/phpunit": "^9.5", "phpunit/phpunit": "^12.5",
"rector/rector": "^2.6", "rector/rector": "^2.6",
"symfony/browser-kit": "7.4.*", "symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*", "symfony/css-selector": "7.4.*",
Generated
+1492 -1160
View File
File diff suppressed because it is too large. Load diff
+115
View File
@@ -51,6 +51,74 @@ texts:
Bitte wende dich bei Rückfragen an [email protected]. 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: application_rejected:
label: 'Bewerbung abgelehnt' label: 'Bewerbung abgelehnt'
placeholders: ['destination'] placeholders: ['destination']
@@ -108,6 +176,20 @@ texts:
**{destination}** **{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: reminder_invoice_upload:
label: 'Erinnerung: Honorarnote hochladen' label: 'Erinnerung: Honorarnote hochladen'
placeholders: ['destination', 'invoiceUploadDeadlineDays'] placeholders: ['destination', 'invoiceUploadDeadlineDays']
@@ -146,7 +228,38 @@ texts:
# Descriptions shown next to the form fields. They describe the placeholder itself rather # 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. # 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: 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"' destination: 'Einsatz, z. B. "12.01.25 - 19.01.25 Skireise"'
specialAgreements: 'Zusätzliche Absprachen zum Einsatz' specialAgreements: 'Zusätzliche Absprachen zum Einsatz'
contractUploadDeadlineDays: 'Frist zum Hochladen des Honorarvertrags in Tagen' contractUploadDeadlineDays: 'Frist zum Hochladen des Honorarvertrags in Tagen'
@@ -159,3 +272,5 @@ placeholders:
dateFrom: 'Beginn des Einsatzes' dateFrom: 'Beginn des Einsatzes'
dateTo: 'Ende des Einsatzes' dateTo: 'Ende des Einsatzes'
diffInDays: 'Tage bis zum Beginn 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"'
+7
View File
@@ -37,6 +37,13 @@ when@test:
dbal: dbal:
# "TEST_TOKEN" is typically set by ParaTest # "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%' 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: when@prod:
doctrine: doctrine:
+3
View File
@@ -13,3 +13,6 @@ flagception:
myep_login: myep_login:
default: false default: false
env: FEATURE_MYEP_LOGIN env: FEATURE_MYEP_LOGIN
upload_replacement:
default: false
env: FEATURE_UPLOAD_REPLACEMENT
-7
View File
@@ -1,13 +1,6 @@
# Read the documentation at https://github.com/thephpleague/flysystem-bundle/blob/master/docs/1-getting-started.md # Read the documentation at https://github.com/thephpleague/flysystem-bundle/blob/master/docs/1-getting-started.md
flysystem: flysystem:
storages: 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: xml_dump.storage:
local: local:
directory: '%kernel.project_dir%/var/bpn' directory: '%kernel.project_dir%/var/bpn'
+21
View File
@@ -5,6 +5,11 @@ framework:
http_method_override: false http_method_override: false
handle_all_throwables: true 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. # 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. # Remove or comment this section to explicitly disable session support.
session: session:
@@ -23,6 +28,22 @@ framework:
router: router:
default_uri: '%env(APP_BASE_URI)%' 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: when@test:
framework: framework:
test: true test: true
+7 -1
View File
@@ -17,7 +17,13 @@ liip_imagine:
format: jpeg format: jpeg
jpeg_quality: 75 jpeg_quality: 75
filters: 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: profile:
format: jpeg format: jpeg
jpeg_quality: 75 jpeg_quality: 75
+45 -4
View File
@@ -2,7 +2,10 @@ monolog:
channels: channels:
- deprecation - deprecation
- bpn - bpn
- saas
- myep
- audit - audit
- api
handlers: handlers:
main: main:
@@ -11,7 +14,7 @@ monolog:
handler: exceptions handler: exceptions
excluded_http_codes: [404, 405] excluded_http_codes: [404, 405]
buffer_size: 50 buffer_size: 50
channels: ["!bpn", "!audit"] channels: ["!bpn", "!saas", "!myep", "!audit", "!api"]
exceptions: exceptions:
type: rotating_file type: rotating_file
path: "%kernel.logs_dir%/framework.%kernel.environment%.log" path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
@@ -23,12 +26,32 @@ monolog:
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log' path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
max_files: 5 max_files: 5
level: info 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: audit:
channels: [ "audit" ] channels: [ "audit" ]
type: rotating_file type: rotating_file
path: '%kernel.logs_dir%/audit.%kernel.environment%.log' path: '%kernel.logs_dir%/audit.%kernel.environment%.log'
max_files: 5 max_files: 5
level: info 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: database:
channels: ["audit"] channels: ["audit"]
type: service type: service
@@ -36,7 +59,7 @@ monolog:
console: console:
type: console type: console
process_psr_3_messages: false process_psr_3_messages: false
channels: ["!event", "!bpn", "!doctrine", "!console"] channels: ["!event", "!bpn", "!saas", "!myep", "!api", "!doctrine", "!console"]
when@dev: when@dev:
monolog: monolog:
@@ -73,7 +96,7 @@ when@prod:
handler: exceptions handler: exceptions
excluded_http_codes: [404, 405] excluded_http_codes: [404, 405]
buffer_size: 50 buffer_size: 50
channels: ["!bpn", "!audit"] channels: ["!bpn", "!saas", "!myep", "!audit", "!api"]
exceptions: exceptions:
type: rotating_file type: rotating_file
path: "%kernel.logs_dir%/framework.%kernel.environment%.log" path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
@@ -85,12 +108,30 @@ when@prod:
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log' path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
max_files: 5 max_files: 5
level: info 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: audit:
channels: [ "audit" ] channels: [ "audit" ]
type: rotating_file type: rotating_file
path: '%kernel.logs_dir%/audit.%kernel.environment%.log' path: '%kernel.logs_dir%/audit.%kernel.environment%.log'
max_files: 5 max_files: 5
level: info level: info
api:
channels: [ "api" ]
type: rotating_file
path: '%kernel.logs_dir%/api.%kernel.environment%.log'
max_files: 30
level: info
database: database:
channels: ["audit"] channels: ["audit"]
type: service type: service
@@ -98,4 +139,4 @@ when@prod:
console: console:
type: console type: console
process_psr_3_messages: false process_psr_3_messages: false
channels: ["!event", "!doctrine", "!bpn", "!audit"] channels: ["!event", "!doctrine", "!bpn", "!saas", "!myep", "!audit", "!api"]
+23
View File
@@ -45,6 +45,29 @@ oneup_uploader:
namer: app.upload_namer namer: app.upload_namer
storage: storage:
directory: '%kernel.project_dir%/uploads/receipt' 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: chunks:
maxage: 86400 maxage: 86400
storage: storage:
+20
View File
@@ -7,6 +7,13 @@ security:
entity: entity:
class: App\Entity\User class: App\Entity\User
property: email 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_hierarchy:
ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ] ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ]
@@ -16,6 +23,17 @@ security:
dev: dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/ pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false 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: main:
lazy: true lazy: true
provider: bpn_user_provider provider: bpn_user_provider
@@ -29,6 +47,8 @@ security:
path: app_security_logout path: app_security_logout
target: app_security_login target: app_security_login
access_control: 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: '^/_uploader', roles: ROLE_USER, requires_channel: https }
- { path: '^/', roles: PUBLIC_ACCESS, requires_channel: https } - { path: '^/', roles: PUBLIC_ACCESS, requires_channel: https }
+7
View File
@@ -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: ~
+42 -4
View File
@@ -12,10 +12,14 @@ zenstruck_schedule:
enabled: true enabled: true
to: [email protected] to: [email protected]
when@prod:
zenstruck_schedule:
tasks: tasks:
- task: app:bpn-import # 03:20 rather than 03:00: MyE&P mirrors from BusProNet hourly overnight, on the
frequency: '0 3 * * *' # hour, so an import at 03:00 can read a half written export.
description: "Imports dates, products and hotels from BusProNet" - task: app:myep-import
frequency: '20 3 * * *'
description: "Imports travel dates, products and hotels from the MyE&P API"
- task: app:cron - task: app:cron
frequency: '0 1 * * *' frequency: '0 1 * * *'
@@ -25,10 +29,44 @@ zenstruck_schedule:
frequency: '30 1 * * *' frequency: '30 1 * * *'
description: "Updates status of teamers depending on their disposition" 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 - task: oneup:uploader:clear-orphans
frequency: '30 * * * *' frequency: '30 * * * *'
description: "Removes orphaned uploads" description: "Removes orphaned uploads"
- task: app:cleanup:xml-dumps - task: app:cleanup:xml-dumps
frequency: "0 2 * * *" 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" description: "Removes outdated XML dumps of requests/responses to BPN API for debugging"
+271
View File
@@ -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
+137
View File
@@ -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
View File
@@ -35,11 +35,63 @@ parameters:
1461: 'SHM' 1461: 'SHM'
1462: 'AGR' 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_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%' 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' 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 # 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 # 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, # 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_start: '11-01'
season_end: '04-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 # Houses, hotel code: name. The name is used both as a label and to match
# destination.hotel, the code to match destination.hotelCode. # destination.hotel, the code to match destination.hotelCode.
houses: houses:
@@ -103,7 +180,6 @@ services:
# it drops its records from that table. # it drops its records from that table.
$logger: '@monolog.logger.audit' $logger: '@monolog.logger.audit'
$houses: '%houses%' $houses: '%houses%'
$xmlExport: '@xml_export.storage'
$xmlDump: '@xml_dump.storage' $xmlDump: '@xml_dump.storage'
$teamerInactivePeriod: '%teamer_inactive_period%' $teamerInactivePeriod: '%teamer_inactive_period%'
$contractUploadDeadlineDays: '%contract_upload_deadline_days%' $contractUploadDeadlineDays: '%contract_upload_deadline_days%'
@@ -111,9 +187,15 @@ services:
# Only the path enters the container - the file itself is parsed on demand by # Only the path enters the container - the file itself is parsed on demand by
# App\Config\EmailTextCatalog, since its contents matter on few requests. # App\Config\EmailTextCatalog, since its contents matter on few requests.
$emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml' $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%' $mailingBusTransport: '%mailing_bus_transport%'
$seasonStart: '%season_start%' $seasonStart: '%season_start%'
$seasonEnd: '%season_end%' $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 # 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 # 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 - name: liip_imagine.cache.resolver
resolver: format_extension resolver: format_extension
App\Command\BpnImportCommand: App\Command\MyEpImportCommand:
arguments: arguments:
$connection: '@doctrine.dbal.default_connection' $connection: '@doctrine.dbal.default_connection'
@@ -159,6 +241,13 @@ services:
bpn_api_port: '%env(APP_BPN_PORT)%' bpn_api_port: '%env(APP_BPN_PORT)%'
debug: '%env(bool:APP_BPN_DEBUG)%' 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: App\BusProNet\ResponseParser:
arguments: arguments:
$options: $options:
@@ -168,6 +257,45 @@ services:
bpn_crm_house_manager_ids: '%bpn_crm_house_manager_ids%' bpn_crm_house_manager_ids: '%bpn_crm_house_manager_ids%'
bpn_default_hotel_code: '%bpn_default_hotel_code%' 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: App\Twig\AppRuntime:
arguments: arguments:
$intlExtension: '@twig.extension.intl' $intlExtension: '@twig.extension.intl'
@@ -179,6 +307,15 @@ services:
tags: tags:
- name: monolog.processor - 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: App\Logging\RequestInfoProcessor:
arguments: arguments:
- '@request_stack' - '@request_stack'
@@ -238,8 +375,7 @@ services:
arguments: arguments:
$orphanageManager: '@oneup_uploader.orphanage_manager' $orphanageManager: '@oneup_uploader.orphanage_manager'
$projectDir: '%kernel.project_dir%' $projectDir: '%kernel.project_dir%'
$environment: '%kernel.environment%' $uploadReplacementFile: '%upload_replacement_file%'
$uploadReplacementFile: '%env(default::UPLOAD_REPLACEMENT_FILE)%'
App\Service\Pdf\Sanitizer: App\Service\Pdf\Sanitizer:
arguments: arguments:
@@ -312,12 +448,50 @@ services:
$options: $options:
project_dir: '%kernel.project_dir%' project_dir: '%kernel.project_dir%'
App\Service\Pdf\WorkMonthRenderer:
arguments:
$options:
project_dir: '%kernel.project_dir%'
App\Email\Mailer: App\Email\Mailer:
arguments: arguments:
$defaults: $defaults:
from: '%default_email_from%' from: '%default_email_from%'
to: '%default_email_to%' 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: App\Security\OAuth2\MyEpClient:
arguments: arguments:
$options: $options:
@@ -338,4 +512,6 @@ when@dev:
when@test: when@test:
parameters: parameters:
# a job profile id ListControllerTest inserts itself - db_test has no job profiles
assignment_export.job_profile_ids: [ 900001 ]
mailing_bus_transport: 'sync' mailing_bus_transport: 'sync'
+3 -1
View File
@@ -38,6 +38,7 @@ $rsyncOptions = [
'.env.test.local', '.env.test.local',
'.gitignore', '.gitignore',
'.nvmrc', '.nvmrc',
'.browserslistrc',
'.php-cs-fixer.cache', '.php-cs-fixer.cache',
'.php-cs-fixer.dist.php', '.php-cs-fixer.dist.php',
'.phpunit.result.cache', '.phpunit.result.cache',
@@ -46,11 +47,12 @@ $rsyncOptions = [
'package.json', 'package.json',
'package-lock.json', 'package-lock.json',
'*.http', '*.http',
'http-client*.json',
'postcss.config.js', 'postcss.config.js',
'phpunit.xml.dist', 'phpunit.xml.dist',
'public/.htaccess', 'public/.htaccess',
'tailwind.config.js', 'tailwind.config.js',
'webpack.config.js', 'webpack.config.mjs',
'rapidapi.paw', 'rapidapi.paw',
'bin/.phpunit', 'bin/.phpunit',
'temp/*', 'temp/*',
-354
View File
@@ -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
View File
@@ -131,7 +131,7 @@ Three failure modes worth knowing because they are quiet rather than loud:
## Scheduled tasks are a different mechanism ## Scheduled tasks are a different mechanism
`config/packages/zenstruck_schedule.yaml` holds the daily jobs (`app:cron`, `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 **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 sequentially, so a consume task holds the entire schedule for its duration — every later
+5
View File
@@ -0,0 +1,5 @@
{
"prod": {
"saas_base_url": "https://desktop.saas.de/rest"
}
}
+31
View File
@@ -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');
}
}
+39
View File
@@ -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');
}
}
+49
View File
@@ -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');
}
}
+54
View File
@@ -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');
}
}
+38
View File
@@ -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');
}
}
+36
View File
@@ -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');
}
}
+43
View File
@@ -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');
}
}
+48
View File
@@ -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');
}
}
+37
View File
@@ -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');
}
}
+35
View File
@@ -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');
}
}
+33
View File
@@ -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');
}
}
+33
View File
@@ -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');
}
}
+34
View File
@@ -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');
}
}
+35
View File
@@ -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');
}
}
+44
View File
@@ -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');
}
}
+49
View File
@@ -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]);
}
}
}
+36
View File
@@ -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');
}
}
+38
View File
@@ -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');
}
}
+37
View File
@@ -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');
}
}
+35
View File
@@ -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');
}
}
+39
View File
@@ -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');
}
}
+45
View File
@@ -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');
}
}
+1675 -5442
View File
File diff suppressed because it is too large. Load diff
+12 -14
View File
@@ -1,39 +1,37 @@
{ {
"name": "myep-team", "name": "myep-team",
"dependencies": { "dependencies": {
"@babel/core": "^7.17.0", "@babel/core": "^8.0.0",
"@babel/preset-env": "^7.16.0", "@babel/preset-env": "^8.0.0",
"@hotwired/stimulus": "^3.0.0", "@hotwired/stimulus": "^3.0.0",
"@symfony/stimulus-bridge": "^3.2.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/forms": "^0.5.6",
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/postcss": "^4.3.3",
"@trevoreyre/autocomplete-js": "^2.4.1", "@trevoreyre/autocomplete-js": "^2.4.1",
"autoprefixer": "^10.4.15", "babel-plugin-polyfill-corejs3": "^1.0.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"core-js": "^3.23.0", "core-js": "^3.50.0",
"dropzone": "^6.0.0-beta.2", "dropzone": "^6.0.0-beta.2",
"file-loader": "^6.2.0",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"htmx.org": "^1.9.10", "htmx.org": "^2.0.11",
"lightningcss": "^1.32.0",
"mjml": "^4.15.3", "mjml": "^4.15.3",
"postcss": "^8.4.29", "postcss": "^8.4.29",
"postcss-import": "^15.1.0", "postcss-loader": "^8.1.1",
"postcss-loader": "^7.3.3",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^6.1.1",
"regenerator-runtime": "^0.13.9", "regenerator-runtime": "^0.13.9",
"sortablejs": "^1.15.0", "sortablejs": "^1.15.0",
"stimulus-use": "^0.52.2", "stimulus-use": "^0.52.2",
"tailwindcss": "^3.3.3", "tailwindcss": "^4.3.3",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"toastify-js": "^1.12.0", "toastify-js": "^1.12.0",
"webpack": "^5.74.0", "webpack": "^5.101.0",
"webpack-cli": "^4.10.0" "webpack-cli": "^7.0.0"
}, },
"license": "UNLICENSED", "license": "UNLICENSED",
"private": true, "private": true,
"scripts": { "scripts": {
"dev-server": "encore dev-server",
"dev": "encore dev", "dev": "encore dev",
"watch": "encore dev --watch", "watch": "encore dev --watch",
"build": "encore production --progress", "build": "encore production --progress",
+18 -10
View File
@@ -1,20 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html --> <!-- https://docs.phpunit.de/en/12.5/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
backupGlobals="false" backupGlobals="false"
colors="true" colors="true"
bootstrap="tests/bootstrap.php" bootstrap="tests/bootstrap.php"
convertDeprecationsToExceptions="false" cacheDirectory=".phpunit.cache"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true"
displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerWarnings="true"
displayDetailsOnPhpunitDeprecations="true"
displayDetailsOnPhpunitNotices="true"
> >
<php> <php>
<ini name="display_errors" value="1" /> <ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" /> <ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" /> <server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" /> <server name="SHELL_VERBOSITY" value="-1" />
<server name="SYMFONY_PHPUNIT_REMOVE" value="" />
<server name="SYMFONY_PHPUNIT_VERSION" value="9.5" />
</php> </php>
<testsuites> <testsuites>
@@ -23,13 +29,15 @@
</testsuite> </testsuite>
</testsuites> </testsuites>
<coverage processUncoveredFiles="true"> <!-- Symfony triggers its deprecations with @trigger_error(); without
ignoreSuppressionOfDeprecations PHPUnit would swallow them all. -->
<source ignoreSuppressionOfDeprecations="true">
<include> <include>
<directory suffix=".php">src</directory> <directory>src</directory>
</include> </include>
</coverage> </source>
<listeners> <extensions>
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" /> <bootstrap class="Symfony\Bridge\PhpUnit\SymfonyExtension" />
</listeners> </extensions>
</phpunit> </phpunit>
+1 -3
View File
@@ -1,7 +1,5 @@
module.exports = { module.exports = {
plugins: { plugins: {
'postcss-import': {}, '@tailwindcss/postcss': {},
tailwindcss: {},
autoprefixer: {},
} }
} }
+9
View File
@@ -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
+134 -27
View File
@@ -2,11 +2,12 @@
namespace App\BusProNet; namespace App\BusProNet;
use App\BusProNet\Model\BaseDataResponse;
use App\BusProNet\Model\CrmAttributesResponse; use App\BusProNet\Model\CrmAttributesResponse;
use App\BusProNet\Model\NotificationResponse; use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\Model\ProfileResponse; use App\BusProNet\Model\ProfileResponse;
use App\BusProNet\Model\ProfileUpdateResponse; use App\BusProNet\Model\ProfileUpdateResponse;
use App\BusProNet\Model\RegistrationResponse;
use App\Entity\ApplicationFormSubmission;
use App\Entity\User; use App\Entity\User;
use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
@@ -15,15 +16,24 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Uid\Uuid; 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 class ApiClient
{ {
use ApiClientTrait; use ApiClientTrait;
public const TYPE_NOTIFICATION = 'HINWEIS'; public const TYPE_NOTIFICATION = 'HINWEIS';
public const TYPE_CUSTOMER_DATA = 'KUNDENKONTO'; 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; 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 ApiClientException
* @throws ResponseParserException * @throws ResponseParserException
*/ */
public function updateProfile(User $user, string $password): NotificationResponse|ProfileUpdateResponse public function updateProfile(User $user): NotificationResponse|ProfileUpdateResponse
{ {
if (null === $teamer = $user->getTeamer()) { if (null === $teamer = $user->getTeamer()) {
throw new ApiClientException('Invalid argument'); throw new ApiClientException('Invalid argument');
} }
if (null === $user->getBusProAddressId() || null === $user->getBusProPersonId()) {
throw new ApiClientException('User has no BusPro address and person id');
}
$data = [ $data = [
'anfrage' => [ 'anfrage' => [
'user' => $this->config['bpn_username'], 'user' => $this->config['bpn_username'],
@@ -74,8 +97,9 @@ class ApiClient
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Adressdaten_Ändern', 'art' => 'Adressdaten_Ändern',
'email' => $user->getEmail(), 'email' => $user->getEmail(),
'passwort' => $password, 'login' => 'J',
'idadresse' => $user->getBusProAddressId(), 'idadresse' => $user->getBusProAddressId(),
'idadresseperson' => $user->getBusProPersonId(),
'adressdaten' => $teamer->toPayload(), 'adressdaten' => $teamer->toPayload(),
], ],
]; ];
@@ -83,6 +107,87 @@ class ApiClient
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); 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 ApiClientException
* @throws ResponseParserException * @throws ResponseParserException
@@ -122,23 +227,6 @@ class ApiClient
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); 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 ApiClientException
* @throws ResponseParserException * @throws ResponseParserException
@@ -162,17 +250,36 @@ class ApiClient
$this->config['max_retries'] $this->config['max_retries']
); );
$this->send($socket, $body); $this->send($socket, $body);
$response = $this->receive($socket);
$this->disconnect($socket);
// message length (10 bytes) is prepended to actual message try {
$xml = substr($response, 10); // the length header is consumed by receive(), this is the payload
$xml = $this->receive($socket);
} finally {
$this->disconnect($socket);
}
if (true === $this->config['debug']) { if (true === $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $xml); $this->dumpXmlToFile('response', $requestId, $xml);
} }
try {
return $this->responseParser->parseXmlString($type, $xml); 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 private function dumpXmlToFile(string $type, string $requestId, string $body): void
+47 -4
View File
@@ -58,15 +58,58 @@ trait ApiClientTrait
fwrite($socket, $send); 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 private function receive($socket): string
{ {
$response = ''; $header = $this->readBytes($socket, 10);
while (false === feof($socket)) { if (10 !== strlen($header)) {
$response .= fread($socket, 4096); 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 private function disconnect($socket): void
@@ -2,44 +2,39 @@
namespace App\BusProNet\DataProvider; namespace App\BusProNet\DataProvider;
use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\BusProNet\Model\Country; use App\BusProNet\Model\Country;
use App\BusProNet\Model\NotificationResponse; use App\MyEp\Api\ApiClient;
use Psr\Log\LoggerInterface; use App\MyEp\Api\ResponseMapper;
use Symfony\Contracts\Cache\CacheInterface; use App\MyEp\Cache\StaleWhileErrorCache;
use Symfony\Contracts\Cache\ItemInterface;
/**
* 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 class CountryDataProvider
{ {
private const CACHE_KEY = 'myep_countries';
private const TTL = 3600;
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly CacheInterface $cache, private readonly ResponseMapper $responseMapper,
private readonly LoggerInterface $logger, private readonly StaleWhileErrorCache $cache,
) { ) {
} }
/**
* @return array<string, Country> keyed by token
*/
public function getAll(): array public function getAll(): array
{ {
try { return $this->cache->get(
$countries = $this->cache->get('bpn_countries', function (ItemInterface $item) { self::CACHE_KEY,
$item->expiresAfter(3600); self::TTL,
fn (): array => $this->responseMapper->mapCountries($this->apiClient->get('api/countries')),
$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;
} }
public function get(?string $token): ?Country public function get(?string $token): ?Country
@@ -2,45 +2,40 @@
namespace App\BusProNet\DataProvider; namespace App\BusProNet\DataProvider;
use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\BusProNet\Model\Hotel; use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\NotificationResponse; use App\MyEp\Api\ApiClient;
use Psr\Cache\InvalidArgumentException; use App\MyEp\Api\ResponseMapper;
use Psr\Log\LoggerInterface; use App\MyEp\Cache\StaleWhileErrorCache;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* 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 class HotelDataProvider
{ {
private const CACHE_KEY = 'myep_hotels';
private const TTL = 3600;
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly CacheInterface $cache, private readonly ResponseMapper $responseMapper,
private readonly LoggerInterface $logger, private readonly StaleWhileErrorCache $cache,
) { ) {
} }
/**
* @return array<int, Hotel> keyed by busProId
*/
public function getAll(): array public function getAll(): array
{ {
try { return $this->cache->get(
$hotels = $this->cache->get('bpn_hotels', function (ItemInterface $item) { self::CACHE_KEY,
$item->expiresAfter(3600); self::TTL,
fn (): array => $this->responseMapper->mapHotels($this->apiClient->get('api/hotels')),
$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;
} }
public function get(int $busProId): ?Hotel public function get(int $busProId): ?Hotel
@@ -48,12 +43,21 @@ class HotelDataProvider
return $this->getAll()[$busProId] ?? null; return $this->getAll()[$busProId] ?? null;
} }
/**
* @return list<Hotel>
*/
public function findByCode(string $code): array public function findByCode(string $code): array
{ {
$hotels = []; $hotels = [];
foreach ($this->getAll() as $hotel) { 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; $hotels[] = $hotel;
} }
} }
@@ -2,44 +2,39 @@
namespace App\BusProNet\DataProvider; namespace App\BusProNet\DataProvider;
use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\Model\Pickup; use App\BusProNet\Model\Pickup;
use Psr\Log\LoggerInterface; use App\MyEp\Api\ApiClient;
use Symfony\Contracts\Cache\CacheInterface; use App\MyEp\Api\ResponseMapper;
use Symfony\Contracts\Cache\ItemInterface; 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 class PickupDataProvider
{ {
private const CACHE_KEY = 'myep_pickups';
private const TTL = 3600;
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly CacheInterface $cache, private readonly ResponseMapper $responseMapper,
private readonly LoggerInterface $logger, private readonly StaleWhileErrorCache $cache,
) { ) {
} }
/**
* @return array<int, Pickup> keyed by busProId
*/
public function getAll(): array public function getAll(): array
{ {
try { return $this->cache->get(
$pickups = $this->cache->get('bpn_pickups', function (ItemInterface $item) { self::CACHE_KEY,
$item->expiresAfter(3600); self::TTL,
fn (): array => $this->responseMapper->mapPickups($this->apiClient->get('api/pickups')),
$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;
} }
public function get(int $busProId): ?Pickup public function get(int $busProId): ?Pickup
-15
View File
@@ -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;
}
}
+3 -1
View File
@@ -39,7 +39,9 @@ class Communication
public function setEmail(?string $email): static 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; return $this;
} }
-13
View File
@@ -4,23 +4,10 @@ namespace App\BusProNet\Model;
class Country class Country
{ {
private ?int $id = null;
private ?string $name = null; private ?string $name = null;
private ?string $token = null; private ?string $token = null;
private ?string $nationality = 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 public function getName(): ?string
{ {
return $this->name; 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 // a response the CRM sent no attributes in still has to flatten to an empty set
foreach ($this->getAttributeGroups() ?? [] as $group) { foreach ($this->getAttributeGroups() ?? [] as $group) {
/** @var CrmAttributeGroup $group */ /** @var CrmAttributeGroup $group */
if (false === isset($crmSelections[$group->getLabel()])) { // The CRM may report a group without a label. PHP used to coerce that null
$crmSelections[$group->getLabel()] = []; // 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) { foreach ($group->getAttributes() ?? [] as $attribute) {
/** @var CrmAttribute $attribute */ /** @var CrmAttribute $attribute */
if (false === $attribute->isSelected()) { if (false === $attribute->isSelected()) {
continue; continue;
} }
$crmSelections[$group->getLabel()][] = $attribute->getLabel(); $crmSelections[$label][] = $attribute->getLabel();
} }
} }
+22 -65
View File
@@ -4,29 +4,22 @@ namespace App\BusProNet\Model;
class Hotel class Hotel
{ {
private ?int $id = null;
private ?int $busProId = null; private ?int $busProId = null;
private ?string $code = null; private ?string $code = null;
private ?string $name = null; private ?string $name = null;
private ?string $city = null; /**
private ?string $street = null; * The raw BusProNet abbreviation, which is not a standard: the same country turns
private ?string $phone = null; * up as D, DEU, A, AT or AUT. It joins against the country base data by token.
private ?string $type = null; */
private ?string $country = null; 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; 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 public function getBusProId(): ?int
{ {
return $this->busProId; return $this->busProId;
@@ -63,54 +56,6 @@ class Hotel
return $this; 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 public function getCountry(): ?string
{ {
return $this->country; return $this->country;
@@ -123,6 +68,18 @@ class Hotel
return $this; return $this;
} }
public function getCountryCode(): ?string
{
return $this->countryCode;
}
public function setCountryCode(?string $countryCode): static
{
$this->countryCode = $countryCode;
return $this;
}
public function getCostUnit(): ?string public function getCostUnit(): ?string
{ {
return $this->costUnit; 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;
}
}
+72 -72
View File
@@ -3,17 +3,14 @@
namespace App\BusProNet; namespace App\BusProNet;
use App\BusProNet\Model\Address; use App\BusProNet\Model\Address;
use App\BusProNet\Model\BaseDataResponse;
use App\BusProNet\Model\Communication; use App\BusProNet\Model\Communication;
use App\BusProNet\Model\Country;
use App\BusProNet\Model\CrmAttribute; use App\BusProNet\Model\CrmAttribute;
use App\BusProNet\Model\CrmAttributeGroup; use App\BusProNet\Model\CrmAttributeGroup;
use App\BusProNet\Model\CrmAttributesResponse; use App\BusProNet\Model\CrmAttributesResponse;
use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\NotificationResponse; use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\ProfileResponse; use App\BusProNet\Model\ProfileResponse;
use App\BusProNet\Model\ProfileUpdateResponse; use App\BusProNet\Model\ProfileUpdateResponse;
use App\BusProNet\Model\RegistrationResponse;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
class ResponseParser class ResponseParser
@@ -30,10 +27,7 @@ class ResponseParser
*/ */
public function parseXmlString(string $type, string $content): mixed public function parseXmlString(string $type, string $content): mixed
{ {
$xml = simplexml_load_string($content); $xml = $this->loadXml($content);
if (false === $xml) {
throw new ResponseParserException('Unable to parse XML response');
}
// Override type when present in XML to catch error responses // Override type when present in XML to catch error responses
$responseType = $type; $responseType = $type;
@@ -53,19 +47,22 @@ class ResponseParser
case 'Adressdaten_Ändern': case 'Adressdaten_Ändern':
return $this->createProfileUpdateResponse($xml); return $this->createProfileUpdateResponse($xml);
case 'SelektionCRM': case 'SelektionCRM':
case 'SelektionCRM_Ändern':
return $this->createCrmAttributesResponse($xml); 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; 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 public function createNotificationResponse(\SimpleXMLElement $xml): NotificationResponse
@@ -169,6 +166,34 @@ class ResponseParser
return $response; 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 public function createCrmAttributesResponse(\SimpleXMLElement $xml): CrmAttributesResponse
{ {
$groups = []; $groups = [];
@@ -233,73 +258,48 @@ class ResponseParser
return $response; 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 = []; if ('' === trim($content)) {
throw new ResponseParserException(sprintf('Unable to parse XML response (%d bytes): empty response', strlen($content)));
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;
} }
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()));
} }
public function createPickupsResponse(\SimpleXMLElement $xml): BaseDataResponse return $xml;
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previousUseErrors);
}
}
private function describeLibxmlErrors(): string
{ {
$pickups = []; $messages = [];
foreach ($xml->zustieg as $item) { foreach (libxml_get_errors() as $error) {
$itemAttributes = $item->attributes(); $messages[] = sprintf('%s (line %d, column %d)', trim($error->message), $error->line, $error->column);
$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;
} }
return new BaseDataResponse($pickups); if ([] === $messages) {
return 'unknown XML error';
} }
public function createHotelsResponse(\SimpleXMLElement $xml): BaseDataResponse return implode('; ', array_unique($messages));
{
$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;
}
return new BaseDataResponse($hotels);
} }
private function resolveOptions(array $options): array private function resolveOptions(array $options): array
+115
View File
@@ -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,
)),
];
}
}
-176
View File
@@ -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;
}
}
+13
View File
@@ -6,7 +6,9 @@ use App\Service\Cron\DispositionReminderService;
use App\Service\Cron\DispositionStatusService; use App\Service\Cron\DispositionStatusService;
use App\Service\Cron\FeedbackReminderService; use App\Service\Cron\FeedbackReminderService;
use App\Service\Cron\FlushLogsService; use App\Service\Cron\FlushLogsService;
use App\Service\Cron\SeasonTaskReminderService;
use App\Service\Cron\UploadReminderService; use App\Service\Cron\UploadReminderService;
use App\Service\Cron\WorkMonthReminderService;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@@ -25,6 +27,8 @@ class CronCommand extends Command
private readonly FeedbackReminderService $feedbackReminderService, private readonly FeedbackReminderService $feedbackReminderService,
private readonly DispositionStatusService $dispositionStatusService, private readonly DispositionStatusService $dispositionStatusService,
private readonly DispositionReminderService $dispositionReminderService, private readonly DispositionReminderService $dispositionReminderService,
private readonly SeasonTaskReminderService $seasonTaskReminderService,
private readonly WorkMonthReminderService $workMonthReminderService,
) { ) {
parent::__construct(); parent::__construct();
} }
@@ -55,6 +59,15 @@ class CronCommand extends Command
$message = $this->dispositionReminderService->sendDispositionReminders(); $message = $this->dispositionReminderService->sendDispositionReminders();
$io->info($message); $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; return Command::SUCCESS;
} }
} }
+235
View File
@@ -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;
}
}
+176
View File
@@ -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;
}
}
+228
View File
@@ -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];
}
}
+5
View File
@@ -12,6 +12,8 @@ enum EmailTextKey: string
{ {
case APPLICATION_ACCEPTED = 'application_accepted'; case APPLICATION_ACCEPTED = 'application_accepted';
case APPLICATION_ACCEPTED_NO_CONTRACT = 'application_accepted_no_contract'; 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 APPLICATION_REJECTED = 'application_rejected';
case ASSIGNMENT_CALLED_OFF = 'assignment_called_off'; case ASSIGNMENT_CALLED_OFF = 'assignment_called_off';
case DISPOSITION_CALLED_OFF = 'disposition_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 = 'reminder_invoice_upload';
case REMINDER_INVOICE_UPLOAD_FINAL = 'reminder_invoice_upload_final'; case REMINDER_INVOICE_UPLOAD_FINAL = 'reminder_invoice_upload_final';
case REMINDER_DISPOSITION = 'reminder_disposition'; 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';
} }
+389
View File
@@ -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));
}
}
+67
View File
@@ -56,6 +56,64 @@ final class SeasonCalendar
return $day >= $start && $day <= $end ? $year : null; 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". * 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); 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 private function boundaryFor(string $monthDay, int $year): CarbonImmutable
{ {
$boundary = CarbonImmutable::createFromFormat('Y-m-d', sprintf('%d-%s', $year, $monthDay)); $boundary = CarbonImmutable::createFromFormat('Y-m-d', sprintf('%d-%s', $year, $monthDay));
+23
View File
@@ -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';
}
+70
View File
@@ -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()
));
}
}
+17
View File
@@ -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';
}
+30
View File
@@ -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); 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\Entity\Teamer;
use App\Htmx\HxRedirectResponse; use App\Htmx\HxRedirectResponse;
use App\Repository\ApplicationRepository; use App\Repository\ApplicationRepository;
use App\Repository\DispositionRepository;
use App\Service\Teamer\AccountDeletionHandler; use App\Service\Teamer\AccountDeletionHandler;
use App\Service\Teamer\UpcomingPlacementCollector;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -18,7 +18,7 @@ class DeleteAccountController extends AbstractController
public function __construct( public function __construct(
private readonly AccountDeletionHandler $accountDeletionHandler, private readonly AccountDeletionHandler $accountDeletionHandler,
private readonly ApplicationRepository $applicationRepository, 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', [ return $this->render('admin/teamer/modal_delete_account.html.twig', [
'teamer' => $teamer, 'teamer' => $teamer,
'pendingApplicationCount' => count($this->applicationRepository->getPendingForTeamer($teamer, 100)), '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 // 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 // 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), 'recipients' => $this->mailingService->resolveRecipients($filterDto),
'placeholders' => TeamerMailingService::PLACEHOLDERS, 'placeholders' => TeamerMailingService::PLACEHOLDERS,
'hasDraft' => $this->draftHandler->hasDraft(), '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')] #[Route('/admin/teamer/mailing/discard', name: 'app_admin_teamer_mailing_discard')]
#[IsGranted('ROLE_TEAM_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function discard(): Response public function discard(Request $request): Response
{ {
$this->draftHandler->resetDraft(); $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'])] #[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 public function confirm(Request $request): Response
{ {
$form = $this->createMailingForm($request); $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 // 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 // to be parked in the session between the confirmation and the send
@@ -115,6 +118,7 @@ class MailingController extends AbstractController
'mailingDto' => $form->getData(), 'mailingDto' => $form->getData(),
'valid' => $form->isSubmitted() && $form->isValid(), 'valid' => $form->isSubmitted() && $form->isValid(),
'recipients' => $this->mailingService->resolveRecipients($filterDto), '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()) { if (false === $form->isSubmitted() || false === $form->isValid()) {
$this->addFlash('error', 'Die Mail konnte nicht gesendet werden, Betreff oder Nachricht fehlen'); $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(); $mailingDto = $form->getData();
// the recipients travel with the message, so the mailing reaches exactly the // the recipients travel with the message, so the mailing reaches exactly the
@@ -151,7 +159,18 @@ class MailingController extends AbstractController
$recipients->getEligibleCount() $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