Compare commits

...
9 Commits
194 changed files with 17909 additions and 432 deletions

No files matched your search

+4 -10
View File
@@ -1,4 +1,4 @@
type: php
type: symfony
docroot: public
php_version: "8.4"
webserver_type: apache-fpm
@@ -9,15 +9,8 @@ database:
type: mariadb
version: "11.8"
hooks:
# The agent hooks in config.codex-claude.yaml are merged with these, not replaced by them.
post-start:
# ddev creates "db" alone, so the test database has to be created and granted here.
# Without it the first test run after a fresh start fails with "Access denied for user
# 'db'@'%' to database 'db_test'".
- exec: mysql -uroot -proot -hdb -e "CREATE DATABASE IF NOT EXISTS db_test CHARACTER SET utf8mb4; GRANT ALL ON db_test.* TO 'db'@'%';"
# Schema from the entity mapping, never from the migrations: the squashed history is not
# replayable from an empty database (Version20260824124850 alters a table that
# Version20260901090029 only creates later). No fixtures - a test persists its own rows.
- exec: php bin/console doctrine:schema:update --force --env=test --no-interaction
omit_containers: [ddev-ssh-agent]
use_dns_when_possible: true
@@ -27,8 +20,6 @@ web_environment: []
nodejs_version: "18"
corepack_enable: false
disable_upload_dirs_warning: true
xhgui_https_port: "8142"
xhgui_http_port: "8143"
# Key features of DDEV's config.yaml:
@@ -317,3 +308,6 @@ xhgui_http_port: "8143"
# information on the commands that can be extended and the tasks you can define
# for them. Example:
#hooks:
## Un-comment to consume async message.
# post-start:
# - exec: symfony run --daemon --watch=config,src,templates,vendor bin/console messenger:consume async -vv
+15 -2
View File
@@ -70,6 +70,14 @@ APP_BPN_CRM_ID_TEAMER=1070
APP_DEFAULT_EMAIL_FROM=[email protected]
APP_DEFAULT_EMAIL_TO=[email protected]
# Regular expressions of the only recipients a mail may actually reach. Everything else is
# stripped from the envelope and a mail left without a recipient is not sent at all, see
# App\EventListener\EmailRecipientWhitelistSubscriber. Empty means no filtering, which is what
# production runs with. Set this on staging: it runs the real transports against real teamer
# addresses. The patterns have to match the full address.
# Example: APP_MAIL_RECIPIENT_WHITELIST='.*@ep-reisen\.de,qa@example\.com'
APP_MAIL_RECIPIENT_WHITELIST=
SFTP_XML_EXPORT_HOST=
SFTP_XML_EXPORT_PORT=22
SFTP_XML_EXPORT_USER=
@@ -89,6 +97,12 @@ APPLICATION_FORM_API_KEY=
# an invitation with a dead booking link is worse than none.
APPLICATION_FORM_BOOKING_URL=
# SaaS time tracking. Season teamers clock their hours here; app:saas:work-hours imports them.
# The API key travels in the URL path, so it never belongs in the committed file - set both
# values in .env.local / the server environment.
APP_SAAS_BASE_URL=
APP_SAAS_API_KEY=
###> symfony/mailjet-mailer ###
# Bulk teamer mailings only, everything else goes out over MAILER_DSN. Set the real
# credentials in .env.local - the sending domain has to be verified in Mailjet (SPF,
@@ -114,8 +128,7 @@ FEATURE_SANITIZE_UPLOADS=false
FEATURE_STAMP_INVOICES=false
FEATURE_DATEV_EMAIL=false
FEATURE_MYEP_LOGIN=false
UPLOAD_REPLACEMENT_FILE=assets/pdf/chicken.pdf
FEATURE_UPLOAD_REPLACEMENT=false
MAINTENANCE_MODE_ENABLED=false
MAINTENANCE_MODE_IP_WHITELIST=188.245.62.21
+1
View File
@@ -19,6 +19,7 @@
"doctrine/doctrine-bundle": "^2.15",
"doctrine/doctrine-migrations-bundle": "^3.2",
"doctrine/orm": "^3.6",
"dompdf/dompdf": "^3.1",
"flagception/flagception-bundle": "^6.0",
"knplabs/knp-menu-bundle": "^3.2",
"knplabs/knp-paginator-bundle": "^6.2",
Generated
+338 -107
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "e4ae4ad86c4c01e26d770c2ab3e0fda4",
"content-hash": "3c22c1470cd1883d7c10f2e9800431e0",
"packages": [
{
"name": "beberlei/doctrineextensions",
@@ -1074,27 +1074,25 @@
},
{
"name": "doctrine/lexer",
"version": "3.0.1",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/doctrine/lexer.git",
"reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd"
"reference": "e96fe45e92a54233726014a7cc7340abf29bb14c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd",
"reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd",
"url": "https://api.github.com/repos/doctrine/lexer/zipball/e96fe45e92a54233726014a7cc7340abf29bb14c",
"reference": "e96fe45e92a54233726014a7cc7340abf29bb14c",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"doctrine/coding-standard": "^12",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.5",
"psalm/plugin-phpunit": "^0.18.3",
"vimeo/psalm": "^5.21"
"doctrine/coding-standard": "^14",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10.5.58 || ^12.5.4"
},
"type": "library",
"autoload": {
@@ -1131,7 +1129,7 @@
],
"support": {
"issues": "https://github.com/doctrine/lexer/issues",
"source": "https://github.com/doctrine/lexer/tree/3.0.1"
"source": "https://github.com/doctrine/lexer/tree/3.0.2"
},
"funding": [
{
@@ -1147,7 +1145,7 @@
"type": "tidelift"
}
],
"time": "2024-02-05T11:56:58+00:00"
"time": "2026-06-14T20:44:06+00:00"
},
{
"name": "doctrine/migrations",
@@ -1254,16 +1252,16 @@
},
{
"name": "doctrine/orm",
"version": "3.7.1",
"version": "3.7.2",
"source": {
"type": "git",
"url": "https://github.com/doctrine/orm.git",
"reference": "037807a39697890dcede8b766c5efe8a1b4855fe"
"reference": "6b47a02cd1d7119ef4d8f90ca6d49651accef1a3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/orm/zipball/037807a39697890dcede8b766c5efe8a1b4855fe",
"reference": "037807a39697890dcede8b766c5efe8a1b4855fe",
"url": "https://api.github.com/repos/doctrine/orm/zipball/6b47a02cd1d7119ef4d8f90ca6d49651accef1a3",
"reference": "6b47a02cd1d7119ef4d8f90ca6d49651accef1a3",
"shasum": ""
},
"require": {
@@ -1287,7 +1285,7 @@
"doctrine/coding-standard": "^14.0",
"phpbench/phpbench": "^1.0",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "2.1.23",
"phpstan/phpstan": "2.2.14",
"phpstan/phpstan-deprecation-rules": "^2",
"phpunit/phpunit": "^10.5.0 || ^11.5",
"psr/log": "^1 || ^2 || ^3",
@@ -1338,9 +1336,9 @@
],
"support": {
"issues": "https://github.com/doctrine/orm/issues",
"source": "https://github.com/doctrine/orm/tree/3.7.1"
"source": "https://github.com/doctrine/orm/tree/3.7.2"
},
"time": "2026-09-14T05:45:59+00:00"
"time": "2026-09-23T05:28:00+00:00"
},
{
"name": "doctrine/persistence",
@@ -1491,6 +1489,161 @@
},
"time": "2026-02-08T16:21:46+00:00"
},
{
"name": "dompdf/dompdf",
"version": "v3.1.6",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005",
"reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.6"
},
"time": "2026-07-20T12:29:38+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
},
"time": "2026-01-20T14:10:26+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4 || ^9.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
},
"time": "2026-01-02T16:01:13+00:00"
},
{
"name": "dragonmantank/cron-expression",
"version": "v3.6.0",
@@ -2454,16 +2607,16 @@
},
{
"name": "league/commonmark",
"version": "2.10.1",
"version": "2.10.3",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "9d489ab67a02960fd8ffe624d93f751daf95439e"
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9d489ab67a02960fd8ffe624d93f751daf95439e",
"reference": "9d489ab67a02960fd8ffe624d93f751daf95439e",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"shasum": ""
},
"require": {
@@ -2557,7 +2710,7 @@
"type": "tidelift"
}
],
"time": "2026-09-07T13:44:26+00:00"
"time": "2026-09-21T13:07:34+00:00"
},
{
"name": "league/config",
@@ -3307,6 +3460,73 @@
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "masterminds/html5",
"version": "2.11.0",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7",
"reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=7.4"
},
"require-dev": {
"phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "[email protected]"
},
{
"name": "Matt Farina",
"email": "[email protected]"
},
{
"name": "Asmir Mustafic",
"email": "[email protected]"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.11.0"
},
"time": "2026-08-18T06:18:41+00:00"
},
{
"name": "monolog/monolog",
"version": "3.12.0",
@@ -4920,6 +5140,84 @@
},
"time": "2021-10-29T13:26:27+00:00"
},
{
"name": "sabberworm/php-css-parser",
"version": "v9.5.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/f284e63b6e891e0c28631e54ba06c3ed102a9ef3",
"reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.33 || 2.2.9",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.18",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.12",
"phpunit/phpunit": "8.5.54",
"rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.6.2",
"rector/type-perfect": "1.0.0 || 2.1.4",
"squizlabs/php_codesniffer": "4.0.4"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.6.x-dev"
}
},
"autoload": {
"files": [
"src/Rule/Rule.php",
"src/RuleSet/RuleContainer.php"
],
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "[email protected]"
},
{
"name": "Jake Hotson",
"email": "[email protected]"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.5.0"
},
"time": "2026-09-20T15:02:00+00:00"
},
{
"name": "scienta/doctrine-json-functions",
"version": "6.5.0",
@@ -12708,16 +13006,16 @@
},
{
"name": "friendsofphp/php-cs-fixer",
"version": "v3.95.26",
"version": "v3.95.27",
"source": {
"type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
"reference": "11839dfaf25718e1617c522c3dde01ebf96c875f"
"reference": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/11839dfaf25718e1617c522c3dde01ebf96c875f",
"reference": "11839dfaf25718e1617c522c3dde01ebf96c875f",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/1d0c68ee5f37446d8ad5970b82384cd1bb0a3317",
"reference": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317",
"shasum": ""
},
"require": {
@@ -12800,7 +13098,7 @@
],
"support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.26"
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.27"
},
"funding": [
{
@@ -12808,7 +13106,7 @@
"type": "github"
}
],
"time": "2026-09-19T19:04:32+00:00"
"time": "2026-09-22T09:54:55+00:00"
},
{
"name": "marcocesarato/php-conventional-changelog",
@@ -12888,73 +13186,6 @@
},
"time": "2026-09-06T14:25:24+00:00"
},
{
"name": "masterminds/html5",
"version": "2.11.0",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7",
"reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=7.4"
},
"require-dev": {
"phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "[email protected]"
},
{
"name": "Matt Farina",
"email": "[email protected]"
},
{
"name": "Asmir Mustafic",
"email": "[email protected]"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.11.0"
},
"time": "2026-08-18T06:18:41+00:00"
},
{
"name": "myclabs/deep-copy",
"version": "1.14.0",
@@ -13192,11 +13423,11 @@
},
{
"name": "phpstan/phpstan",
"version": "2.2.14",
"version": "2.2.15",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac",
"reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/b158556ffd26825cf615a1c1f72fb5157a2301b7",
"reference": "b158556ffd26825cf615a1c1f72fb5157a2301b7",
"shasum": ""
},
"require": {
@@ -13252,7 +13483,7 @@
"type": "github"
}
],
"time": "2026-09-12T21:39:33+00:00"
"time": "2026-09-23T12:23:07+00:00"
},
{
"name": "phpunit/php-code-coverage",
@@ -13575,16 +13806,16 @@
},
{
"name": "phpunit/phpunit",
"version": "9.6.36",
"version": "9.6.37",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9"
"reference": "aceaa623912caf8e80bffea22fc4103a0a01565f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/abab27ed286d3e1246fbbfe6b56bfd732d945ec9",
"reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/aceaa623912caf8e80bffea22fc4103a0a01565f",
"reference": "aceaa623912caf8e80bffea22fc4103a0a01565f",
"shasum": ""
},
"require": {
@@ -13658,7 +13889,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.36"
"source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.37"
},
"funding": [
{
@@ -13666,7 +13897,7 @@
"type": "other"
}
],
"time": "2026-08-11T06:25:15+00:00"
"time": "2026-09-23T09:59:59+00:00"
},
{
"name": "react/cache",
+42
View File
@@ -176,6 +176,20 @@ texts:
**{destination}**
# Weekly, for as long as an entry of the season checklist is not ticked off. It is not tied
# to a deadline, so it must not name one.
reminder_season_tasks:
label: 'Erinnerung: Saisonunterlagen'
placeholders: ['destination', 'tasks']
subject: 'Reminder: Fehlende Saisonunterlagen'
headline: 'Hallo aus Köln,'
body: |-
für deinen Saisoneinsatz **{destination}** fehlen uns noch folgende Unterlagen:
{tasks}
Im E&P-Team Portal findest du unter "Saisonunterlagen" die Vorlagen zum Herunterladen und kannst deine unterschriebenen Unterlagen direkt hochladen.
reminder_invoice_upload:
label: 'Erinnerung: Honorarnote hochladen'
placeholders: ['destination', 'invoiceUploadDeadlineDays']
@@ -214,6 +228,32 @@ texts:
# Descriptions shown next to the form fields. They describe the placeholder itself rather
# than any one mail, so they are shared: a name means the same thing wherever it appears.
# Daily, for as long as a work month has no signed Arbeitszeitnachweis on it. The month
# is named rather than a deadline: there is none, the office simply wants it back.
reminder_work_month_upload:
label: 'Erinnerung: Arbeitszeitnachweis hochladen'
placeholders: ['workMonth']
subject: 'Reminder: Fehlender Arbeitszeitnachweis'
headline: 'Hallo aus Köln,'
body: |-
bitte denke daran, deinen unterschriebenen Arbeitszeitnachweis hochzuladen. Ohne ihn können wir den Monat nicht abrechnen.
**{workMonth}**
Du findest ihn im Portal unter "Mein Profil - Arbeitszeiten": dort herunterladen, unterschreiben und wieder hochladen.
work_month_rejected:
label: 'Arbeitszeitnachweis abgelehnt'
placeholders: ['workMonth', 'comment']
subject: 'Dein Arbeitszeitnachweis wurde abgelehnt'
headline: 'Hallo aus Köln,'
body: |-
leider können wir deinen Arbeitszeitnachweis für **{workMonth}** so nicht annehmen.
Begründung: {comment}
Bitte lade ihn im Portal unter "Mein Profil - Arbeitszeiten" noch einmal hoch.
placeholders:
bookingUrl: 'Buchungslink für den Team-Infocall, kommt aus der Konfiguration (APPLICATION_FORM_BOOKING_URL)'
season: 'Kommende Saison in Kurzform, z. B. "26/27" - wird automatisch eingesetzt'
@@ -232,3 +272,5 @@ placeholders:
dateFrom: 'Beginn des Einsatzes'
dateTo: 'Ende des Einsatzes'
diffInDays: 'Tage bis zum Beginn des Einsatzes'
tasks: 'Noch offene Punkte der Saison-Checkliste, kommagetrennt'
workMonth: 'Monat der Arbeitszeiten, z. B. "März 2026"'
+7
View File
@@ -37,6 +37,13 @@ when@test:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
# schema:create otherwise emits MySQL 8's platform default (utf8mb4_0900_ai_ci),
# while the migrations that built dev and production declare utf8mb4_unicode_ci.
# Different collation means different string comparison and ORDER BY semantics,
# so tests would sort and match differently than production.
default_table_options:
charset: utf8mb4
collation: utf8mb4_unicode_ci
when@prod:
doctrine:
+3
View File
@@ -13,3 +13,6 @@ flagception:
myep_login:
default: false
env: FEATURE_MYEP_LOGIN
upload_replacement:
default: false
env: FEATURE_UPLOAD_REPLACEMENT
+7 -1
View File
@@ -17,7 +17,13 @@ liip_imagine:
format: jpeg
jpeg_quality: 75
filters:
thumbnail: { size: [ 128, 128 ], mode: outbound }
# Phone photos carry their orientation in EXIF rather than in the pixels, so
# without this the table renders them sideways while every other filter set,
# which has always had it, shows them upright.
auto_rotate: ~
# outbound crops to the box but never scales up, so a photo narrower than
# 128px came out oblong and the rounded-full avatar with it.
thumbnail: { size: [ 128, 128 ], mode: outbound, allow_upscale: true }
profile:
format: jpeg
jpeg_quality: 75
+17 -4
View File
@@ -2,6 +2,7 @@ monolog:
channels:
- deprecation
- bpn
- saas
- audit
- api
@@ -12,7 +13,7 @@ monolog:
handler: exceptions
excluded_http_codes: [404, 405]
buffer_size: 50
channels: ["!bpn", "!audit", "!api"]
channels: ["!bpn", "!saas", "!audit", "!api"]
exceptions:
type: rotating_file
path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
@@ -24,6 +25,12 @@ monolog:
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
max_files: 5
level: info
saas:
channels: ["saas"]
type: rotating_file
path: '%kernel.logs_dir%/saas.%kernel.environment%.log'
max_files: 5
level: info
audit:
channels: [ "audit" ]
type: rotating_file
@@ -45,7 +52,7 @@ monolog:
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!bpn", "!api", "!doctrine", "!console"]
channels: ["!event", "!bpn", "!saas", "!api", "!doctrine", "!console"]
when@dev:
monolog:
@@ -82,7 +89,7 @@ when@prod:
handler: exceptions
excluded_http_codes: [404, 405]
buffer_size: 50
channels: ["!bpn", "!audit", "!api"]
channels: ["!bpn", "!saas", "!audit", "!api"]
exceptions:
type: rotating_file
path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
@@ -94,6 +101,12 @@ when@prod:
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
max_files: 5
level: info
saas:
channels: ["saas"]
type: rotating_file
path: '%kernel.logs_dir%/saas.%kernel.environment%.log'
max_files: 5
level: info
audit:
channels: [ "audit" ]
type: rotating_file
@@ -113,4 +126,4 @@ when@prod:
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!bpn", "!audit", "!api"]
channels: ["!event", "!doctrine", "!bpn", "!saas", "!audit", "!api"]
+23
View File
@@ -45,6 +45,29 @@ oneup_uploader:
namer: app.upload_namer
storage:
directory: '%kernel.project_dir%/uploads/receipt'
# Saisonunterlagen. Like receipt above, both the mapping key and the directory have to
# be exactly the Upload::TYPE_* value - out for what the office issues to one teamer,
# in for what that teamer sends back signed.
season_document_out:
frontend: dropzone
use_orphanage: true
namer: app.upload_namer
storage:
directory: '%kernel.project_dir%/uploads/season_document_out'
season_document_in:
frontend: dropzone
use_orphanage: true
namer: app.upload_namer
storage:
directory: '%kernel.project_dir%/uploads/season_document_in'
# Signed Arbeitszeitnachweise coming back from a teamer. Same rule again: the key and
# the directory are exactly Upload::TYPE_WORK_MONTH.
work_month:
frontend: dropzone
use_orphanage: true
namer: app.upload_namer
storage:
directory: '%kernel.project_dir%/uploads/work_month'
chunks:
maxage: 86400
storage:
+8
View File
@@ -27,6 +27,10 @@ when@prod:
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"
@@ -53,6 +57,10 @@ when@staging:
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"
+54 -2
View File
@@ -48,8 +48,19 @@ parameters:
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
# Per deployment and not per environment: staging and production run the same compiled
# environment and differ only in their .env.local. The csv processor turns an unset
# variable into an empty list, which switches the filtering off - so production is
# unaffected without anything having to be remembered at deploy time.
mail_recipient_whitelist: '%env(csv:APP_MAIL_RECIPIENT_WHITELIST)%'
teamer_inactive_period: '-2 years'
# The stand-in PDF served instead of real uploads while the upload_replacement feature is
# on. Relative to the project dir. Which file is a fixed fact of the repo; whether it is
# used at all is the feature flag's job alone.
upload_replacement_file: 'assets/pdf/chicken.pdf'
# The contract upload period, counted from the creation of the disposition. This is the
# single definition: the reminder mail, the "Honorarvertrag fällig" flag on the teamer
# dashboard, the admin list of overdue contracts and the wording of the mails all read it,
@@ -79,6 +90,12 @@ parameters:
season_start: '11-01'
season_end: '04-01'
# 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'
# Houses, hotel code: name. The name is used both as a label and to match
# destination.hotel, the code to match destination.hotelCode.
houses:
@@ -124,6 +141,7 @@ services:
$mailingBusTransport: '%mailing_bus_transport%'
$seasonStart: '%season_start%'
$seasonEnd: '%season_end%'
$seasonTaskDeadlineOffset: '%season_task_deadline_offset%'
# 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
@@ -185,6 +203,20 @@ services:
bpn_crm_house_manager_ids: '%bpn_crm_house_manager_ids%'
bpn_default_hotel_code: '%bpn_default_hotel_code%'
App\Saas\ApiClient:
arguments:
$logger: '@monolog.logger.saas'
$options:
base_url: '%env(APP_SAAS_BASE_URL)%'
api_key: '%env(APP_SAAS_API_KEY)%'
App\Saas\DataProvider\EmployeeDataProvider:
arguments:
$logger: '@monolog.logger.saas'
# The roster is slow and barely changes; six hours means one fetch per run and a
# new starter is picked up the same day.
$ttl: 21600
App\Twig\AppRuntime:
arguments:
$intlExtension: '@twig.extension.intl'
@@ -196,6 +228,15 @@ services:
tags:
- name: monolog.processor
# Every SaaS url carries the api key as a path segment, so the request lines Symfony's
# http client writes - and the exception messages that repeat those urls - are redacted
# on the way into the log rather than suppressed.
App\Logging\RedactSaasCredentialsProcessor:
arguments:
$apiKey: '%env(APP_SAAS_API_KEY)%'
tags:
- name: monolog.processor
App\Logging\RequestInfoProcessor:
arguments:
- '@request_stack'
@@ -255,8 +296,7 @@ services:
arguments:
$orphanageManager: '@oneup_uploader.orphanage_manager'
$projectDir: '%kernel.project_dir%'
$environment: '%kernel.environment%'
$uploadReplacementFile: '%env(default::UPLOAD_REPLACEMENT_FILE)%'
$uploadReplacementFile: '%upload_replacement_file%'
App\Service\Pdf\Sanitizer:
arguments:
@@ -329,6 +369,11 @@ services:
$options:
project_dir: '%kernel.project_dir%'
App\Service\Pdf\WorkMonthRenderer:
arguments:
$options:
project_dir: '%kernel.project_dir%'
App\Email\Mailer:
arguments:
$defaults:
@@ -351,6 +396,13 @@ services:
$logger: '@monolog.logger.api'
tags: [ 'controller.service_arguments' ]
App\EventListener\EmailRecipientWhitelistSubscriber:
arguments:
$allowedRecipients: '%mail_recipient_whitelist%'
# Not the audit channel this argument is bound to by default: a mail dropped by a
# staging guard is a technical event and has no place in the application log table.
$logger: '@logger'
App\Security\OAuth2\MyEpClient:
arguments:
$options:
+1
View File
@@ -46,6 +46,7 @@ $rsyncOptions = [
'package.json',
'package-lock.json',
'*.http',
'http-client*.json',
'postcss.config.js',
'phpunit.xml.dist',
'public/.htaccess',
+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');
}
}
+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
+13
View File
@@ -6,7 +6,9 @@ use App\Service\Cron\DispositionReminderService;
use App\Service\Cron\DispositionStatusService;
use App\Service\Cron\FeedbackReminderService;
use App\Service\Cron\FlushLogsService;
use App\Service\Cron\SeasonTaskReminderService;
use App\Service\Cron\UploadReminderService;
use App\Service\Cron\WorkMonthReminderService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@@ -25,6 +27,8 @@ class CronCommand extends Command
private readonly FeedbackReminderService $feedbackReminderService,
private readonly DispositionStatusService $dispositionStatusService,
private readonly DispositionReminderService $dispositionReminderService,
private readonly SeasonTaskReminderService $seasonTaskReminderService,
private readonly WorkMonthReminderService $workMonthReminderService,
) {
parent::__construct();
}
@@ -55,6 +59,15 @@ class CronCommand extends Command
$message = $this->dispositionReminderService->sendDispositionReminders();
$io->info($message);
$message = $this->seasonTaskReminderService->sendDeadlineSummary();
$io->info($message);
$message = $this->seasonTaskReminderService->sendTeamerDigest();
$io->info($message);
$message = $this->workMonthReminderService->sendReminders();
$io->info($message);
return Command::SUCCESS;
}
}
+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];
}
}
+3
View File
@@ -22,4 +22,7 @@ enum EmailTextKey: string
case REMINDER_INVOICE_UPLOAD = 'reminder_invoice_upload';
case REMINDER_INVOICE_UPLOAD_FINAL = 'reminder_invoice_upload_final';
case REMINDER_DISPOSITION = 'reminder_disposition';
case REMINDER_SEASON_TASKS = 'reminder_season_tasks';
case REMINDER_WORK_MONTH_UPLOAD = 'reminder_work_month_upload';
case WORK_MONTH_REJECTED = 'work_month_rejected';
}
+48 -10
View File
@@ -57,23 +57,52 @@ final class SeasonCalendar
}
/**
* The season a mail written now would call "die kommende Saison": the one currently running,
* or - between two seasons - the next one to start. Never null, unlike seasonYearFor(): a
* recruiting mail sent in September is outside every season window but is unmistakably about
* the winter ahead.
* 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): int
public function currentOrUpcomingSeasonYear(?\DateTimeInterface $date = null): int
{
if (null !== $seasonYear = $this->seasonYearFor($date)) {
$day = CarbonImmutable::instance($date ?? new \DateTimeImmutable())->startOfDay();
if (null !== $seasonYear = $this->seasonYearFor($day)) {
return $seasonYear;
}
$day = CarbonImmutable::instance($date)->startOfDay();
$year = (int) $day->format('Y');
// Outside any window, so the next start is this year's unless it has already passed,
// which for a wrapping window can only mean the season already began - handled above.
return $day <= $this->boundaryFor($this->seasonStart, $year) ? $year : $year + 1;
// 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];
}
/**
@@ -97,6 +126,15 @@ final class SeasonCalendar
return sprintf('Saison %d/%02d', $seasonYear, ($seasonYear + 1) % 100);
}
/**
* The season label for a date, for the common case of "which season does this belong to" -
* "?" when the date is not known yet, which a half-filled create form can be in.
*/
public function labelFor(?\DateTimeInterface $date): string
{
return null === $date ? '?' : $this->label($this->seasonYearFor($date));
}
private function boundaryFor(string $monthDay, int $year): CarbonImmutable
{
$boundary = CarbonImmutable::createFromFormat('Y-m-d', sprintf('%d-%s', $year, $monthDay));
+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,4 +30,22 @@ class TeamerController extends AbstractController
return $this->json($dates);
}
/**
* Season assignments are only ever made for all-season teamers, so their form must not be
* able to offer anybody else.
*/
#[Route('/admin/autocomplete/season-teamer', name: 'app_admin_autocomplete_season_teamer')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function season(Request $request): JsonResponse
{
try {
$query = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$queryString = $query['search'];
} catch (\JsonException $e) {
throw $this->createNotFoundException();
}
return $this->json($this->teamerRepository->getAutocompletionData($queryString, true));
}
}
@@ -57,7 +57,7 @@ class MailingController extends AbstractController
));
}
$filterDto = $this->filterHandler->getFilterSettings();
$filterDto = $this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings();
// No redirect after the preview mail, the composed mail has to survive it. The
// preview pane is rendered here so the page arrives complete; from then on the
@@ -69,7 +69,8 @@ class MailingController extends AbstractController
'recipients' => $this->mailingService->resolveRecipients($filterDto),
'placeholders' => TeamerMailingService::PLACEHOLDERS,
'hasDraft' => $this->draftHandler->hasDraft(),
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_teamer_index'),
'returnUrl' => $this->getReturnUrl($request, $this->getListRoute($request)),
'scope' => $request->query->get('scope'),
]);
}
@@ -94,11 +95,13 @@ class MailingController extends AbstractController
#[Route('/admin/teamer/mailing/discard', name: 'app_admin_teamer_mailing_discard')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function discard(): Response
public function discard(Request $request): Response
{
$this->draftHandler->resetDraft();
return $this->redirectToRoute('app_admin_teamer_mailing');
return $this->redirectToRoute('app_admin_teamer_mailing', [
'scope' => $request->query->get('scope'),
]);
}
#[Route('/admin/teamer/mailing/confirm', name: 'app_admin_teamer_mailing_confirm', methods: ['POST'])]
@@ -106,7 +109,7 @@ class MailingController extends AbstractController
public function confirm(Request $request): Response
{
$form = $this->createMailingForm($request);
$filterDto = $this->filterHandler->getFilterSettings();
$filterDto = $this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings();
// the composed mail is carried on in hidden fields so no half written mailing has
// to be parked in the session between the confirmation and the send
@@ -115,6 +118,7 @@ class MailingController extends AbstractController
'mailingDto' => $form->getData(),
'valid' => $form->isSubmitted() && $form->isValid(),
'recipients' => $this->mailingService->resolveRecipients($filterDto),
'scope' => $request->query->get('scope'),
]);
}
@@ -127,10 +131,14 @@ class MailingController extends AbstractController
if (false === $form->isSubmitted() || false === $form->isValid()) {
$this->addFlash('error', 'Die Mail konnte nicht gesendet werden, Betreff oder Nachricht fehlen');
return new HxRedirectResponse($this->generateUrl('app_admin_teamer_mailing'));
return new HxRedirectResponse($this->generateUrl('app_admin_teamer_mailing', [
'scope' => $request->query->get('scope'),
]));
}
$recipients = $this->mailingService->resolveRecipients($this->filterHandler->getFilterSettings());
$recipients = $this->mailingService->resolveRecipients(
$this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings()
);
$mailingDto = $form->getData();
// the recipients travel with the message, so the mailing reaches exactly the
@@ -151,7 +159,18 @@ class MailingController extends AbstractController
$recipients->getEligibleCount()
));
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
return new HxRedirectResponse($this->generateUrl($this->getListRoute($request)));
}
/**
* The list this mailing was started from, so a send returns to it and an unscoped
* mailing never falls back to the wrong one.
*/
private function getListRoute(Request $request): string
{
return TeamerFilterHandler::SCOPE_ALL_SEASON === $request->query->get('scope')
? 'app_administrative_season_teamer_index'
: 'app_administrative_teamer_index';
}
/**
@@ -30,6 +30,8 @@ class SkillsController extends AbstractController
{
$trainings = $this->trainingRepository->getList();
$allSeasonStatus = $teamer->isAllSeason();
$form = $this->createStatusForm($teamer);
$form->handleRequest($request);
@@ -42,6 +44,15 @@ class SkillsController extends AbstractController
'teamer' => $teamer->getFullName(),
]);
if ($teamer->isAllSeason() !== $allSeasonStatus) {
$this->addFlash('info', 'Der Saisonteamer:innern-Status wurde '.($teamer->isAllSeason() ? 'hinzugefügt' : 'entfernt'));
$logMessage = $teamer->isAllSeason() ? 'Assign all-season status' : 'Remove all-season status';
$this->logger->info($logMessage, [
'teamer' => $teamer->getFullName(),
]);
}
return $this->redirectToRoute('app_admin_teamer_skills', [
'uuid' => $teamer->getUuid(),
'r' => $request->query->get('r'),
@@ -62,10 +73,14 @@ class SkillsController extends AbstractController
->add('status', ChoiceType::class, [
'label' => 'Status',
'choices' => [
'Neuteamer' => Teamer::STATUS_NEW,
'Bestandsteamer' => Teamer::STATUS_EXISTING,
'Neuteamer:in' => Teamer::STATUS_NEW,
'Bestandsteamer:in' => Teamer::STATUS_EXISTING,
],
])
->add('allSeason', CheckboxType::class, [
'label' => 'Saisonteamer:in',
'required' => false,
])
->add('allowOverlappingApplications', CheckboxType::class, [
'label' => 'Überlappende Bewerbungen erlauben',
'required' => false,
@@ -0,0 +1,47 @@
<?php
namespace App\Controller\Admin\WorkMonth;
use App\Repository\TeamerWorkMonthRepository;
use App\Service\Common\WorkMonthFilterHandler;
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;
/**
* Every imported work month with the state of its Arbeitszeitnachweis.
*
* Deliberately not the existing document list: UploadRepository::getUploadListQuery() inner
* joins a disposition, so a document hanging off a month can never appear there, and it is
* the month rather than the file that carries the status anyway.
*/
class IndexController extends AbstractController
{
public function __construct(
private readonly TeamerWorkMonthRepository $workMonthRepository,
private readonly WorkMonthFilterHandler $filterHandler,
private readonly PaginatorInterface $paginator,
) {
}
#[Route('/admin/work-month', name: 'app_admin_work_month_index')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$filterDto = $this->filterHandler->getFilterSettings();
$pagination = $this->paginator->paginate(
$this->workMonthRepository->getListQuery($filterDto),
$request->query->getInt('page', 1),
50,
);
return $this->render('admin/work_month/index.html.twig', [
'pagination' => $pagination,
'filterDto' => $filterDto,
]);
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Controller\Admin\WorkMonth;
use App\Entity\TeamerWorkMonth;
use App\Service\Pdf\WorkMonthRenderer;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The same sheet the teamer gets, for the office.
*
* This is what a month's hours look like in full: there is no separate day-by-day screen,
* because it would only restate the document that actually gets signed.
*/
class PdfController extends AbstractController
{
public function __construct(
private readonly WorkMonthRenderer $renderer,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/work-month/pdf/{uuid}', name: 'app_admin_work_month_pdf')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(TeamerWorkMonth $workMonth): Response
{
$this->logger->info('Download work month pdf', [
'work_month' => $workMonth->getUuid(),
'teamer' => $workMonth->getTeamer()?->getFullName(),
]);
$response = new Response($this->renderer->render($workMonth));
$response->headers->set('Content-Type', 'application/pdf');
$response->headers->set('Content-Disposition', $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
$this->renderer->filename($workMonth),
));
return $response;
}
}
@@ -0,0 +1,117 @@
<?php
namespace App\Controller\Admin\WorkMonth;
use App\Config\EmailTextKey;
use App\Controller\Traits\ReturnUrlTrait;
use App\Email\Mailer;
use App\Entity\TeamerWorkMonth;
use App\Entity\Upload;
use App\Entity\User;
use App\Enum\WorkMonthStatus;
use App\Form\WorkMonthReviewType;
use App\Htmx\HxRedirectResponse;
use App\Model\WorkMonthReviewDto;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The office's verdict on a signed Arbeitszeitnachweis.
*
* A rejection does not undo the upload: the file stays on the month and the teamer uploads a
* new one over it, which puts the month back into "eingereicht".
*/
class ReviewController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly Mailer $mailer,
) {
}
#[Route('/admin/work-month/review/{uuid}', name: 'app_admin_work_month_review')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(TeamerWorkMonth $workMonth, Request $request): Response
{
if (WorkMonthStatus::SUBMITTED !== $workMonth->getStatus()) {
throw $this->createNotFoundException('This work month is not waiting for a review');
}
$returnUrl = $this->getReturnUrl($request, 'app_admin_work_month_index');
$formData = new WorkMonthReviewDto($workMonth);
$form = $this->createForm(WorkMonthReviewType::class, $formData);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var User $user */
$user = $this->getUser();
$status = $formData->getStatus();
$workMonth
->setStatus($status)
->setReviewedAt(new \DateTimeImmutable())
->setReviewedBy($user->getInitials())
->setReviewComment($formData->getComment())
;
// Keep the file's own status in step, so the existing upload badge and the
// UploadVoter's CHECK rule stay meaningful for it.
$workMonth->getCurrentDocument()?->setStatus(
WorkMonthStatus::APPROVED === $status ? Upload::STATUS_CHECKED : Upload::STATUS_REJECTED
);
$this->entityManager->flush();
if (WorkMonthStatus::REJECTED === $status) {
$this->notifyRejection($workMonth, (string) $formData->getComment());
}
$this->addFlash(
'success',
WorkMonthStatus::APPROVED === $status
? 'Der Arbeitszeitnachweis wurde bestätigt'
: 'Der Arbeitszeitnachweis wurde abgelehnt'
);
$this->logger->info('Review work month', [
'work_month' => $workMonth->getUuid(),
'teamer' => $workMonth->getTeamer()?->getFullName(),
'period' => sprintf('%04d-%02d', $workMonth->getYear(), $workMonth->getMonth()),
'status' => $status->value,
'by' => $user->getInitials(),
]);
return new HxRedirectResponse($returnUrl);
}
return $this->render('admin/work_month/modal_review.html.twig', [
'workMonth' => $workMonth,
'form' => $form->createView(),
'returnUrl' => $returnUrl,
]);
}
private function notifyRejection(TeamerWorkMonth $workMonth, string $comment): void
{
$email = $workMonth->getTeamer()?->getCommunication()?->getEmail();
if (null === $email || '' === trim($email)) {
return;
}
$this->mailer->createAndSendText(EmailTextKey::WORK_MONTH_REJECTED, [
'workMonth' => $workMonth->getPeriodLabel(),
'comment' => $comment,
], [
'to' => $email,
]);
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Controller\Admin\WorkMonth;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\TeamerWorkMonth;
use App\Enum\WorkHoursSyncOutcome;
use App\Service\Saas\WorkHoursSynchronizer;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Fetching one month again by hand, for a correction the schedule has already passed by.
*
* A month the teamer has signed is frozen, so re-fetching it only records that the SaaS now
* disagrees. Releasing it is a separate, deliberate step: it throws away the signature and
* sends the teamer back to the start.
*/
class SyncController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly WorkHoursSynchronizer $synchronizer,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/work-month/sync/{uuid}', name: 'app_admin_work_month_sync', methods: ['POST'])]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function sync(TeamerWorkMonth $workMonth, Request $request): Response
{
if (null === $teamer = $workMonth->getTeamer()) {
throw $this->createNotFoundException('No teamer on this work month');
}
$result = $this->synchronizer->syncTeamer($teamer, $workMonth->getYear(), $workMonth->getMonth());
match ($result->getOutcome()) {
WorkHoursSyncOutcome::IMPORTED => $this->addFlash('success', 'Die Arbeitszeiten wurden aktualisiert'),
WorkHoursSyncOutcome::LOCKED => $this->addFlash('success', $result->getMessage() ?? 'Der Monat ist bereits eingereicht'),
WorkHoursSyncOutcome::SKIPPED => $this->addFlash('error', $result->getMessage() ?? 'Der Abruf war nicht möglich'),
WorkHoursSyncOutcome::FAILED => $this->addFlash('error', 'Der Abruf ist fehlgeschlagen: '.$result->getMessage()),
};
return $this->redirect($this->getReturnUrl($request, 'app_admin_work_month_index'));
}
#[Route('/admin/work-month/unlock/{uuid}', name: 'app_admin_work_month_unlock', methods: ['POST'])]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function unlock(TeamerWorkMonth $workMonth, Request $request): Response
{
$this->synchronizer->unlock($workMonth);
$this->entityManager->flush();
$this->addFlash('success', 'Der Monat wurde wieder geöffnet und muss neu unterschrieben werden');
$this->logger->info('Unlock work month', [
'work_month' => $workMonth->getUuid(),
'teamer' => $workMonth->getTeamer()?->getFullName(),
'period' => sprintf('%04d-%02d', $workMonth->getYear(), $workMonth->getMonth()),
]);
return $this->redirect($this->getReturnUrl($request, 'app_admin_work_month_index'));
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Config\SeasonCalendar;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\SeasonAssignment;
use App\Form\SeasonAssignmentType;
use App\Service\Season\SeasonAssignmentLabeler;
use App\Service\Season\SeasonTaskSynchronizer;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class CreateController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly SeasonTaskSynchronizer $taskSynchronizer,
private readonly SeasonAssignmentLabeler $labeler,
private readonly SeasonCalendar $seasonCalendar,
private readonly LoggerInterface $logger,
) {
}
#[Route('/administrative/season-assignment/create', name: 'app_administrative_season_assignment_create')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$seasonAssignment = new SeasonAssignment();
// Almost every assignment laid out runs the full season, so the form opens on the
// season that is running today - or, off-season, the one that starts next, the same
// window the teamer's season page already falls forward to.
[$windowStart, $windowEnd] = $this->seasonCalendar->windowFor($this->seasonCalendar->currentOrUpcomingSeasonYear());
$seasonAssignment
->setDateFrom($windowStart->toDateTimeImmutable())
->setDateTo($windowEnd->toDateTimeImmutable())
;
$returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_index');
$form = $this->createForm(SeasonAssignmentType::class, $seasonAssignment);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($seasonAssignment);
$this->entityManager->flush();
// the checklist is what the office works with from here on, so it exists from the
// moment the assignment does
$this->taskSynchronizer->sync($seasonAssignment);
$this->addFlash('success', 'Der Saisoneinsatz wurde angelegt');
$this->logger->info('Create season assignment', [
'season_assignment_id' => $seasonAssignment->getId(),
'teamer' => $seasonAssignment->getTeamer()?->getFullName(),
'season_assignment' => $this->labeler->label($seasonAssignment),
'job_profile' => $seasonAssignment->getJobProfile()->getName(),
]);
return $this->redirect($returnUrl);
}
return $this->render('administrative/season_assignment/create.html.twig', [
'form' => $form->createView(),
'returnUrl' => $returnUrl,
]);
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Entity\SeasonAssignment;
use App\Htmx\HxRedirectResponse;
use App\Service\Season\SeasonAssignmentLabeler;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class DeleteController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly SeasonAssignmentLabeler $labeler,
private readonly LoggerInterface $logger,
) {
}
#[Route('/administrative/season-assignment/delete/{uuid}', name: 'app_administrative_season_assignment_delete')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignment $seasonAssignment, Request $request): Response
{
if (true === $request->isMethod('POST')) {
$seasonAssignment->setDeleted();
$this->entityManager->flush();
$this->logger->info('Delete season assignment', [
'season_assignment_id' => $seasonAssignment->getId(),
'teamer' => $seasonAssignment->getTeamer()?->getFullName(),
'season_assignment' => $this->labeler->label($seasonAssignment),
'job_profile' => $seasonAssignment->getJobProfile()->getName(),
]);
$this->addFlash('success', 'Der Saisoneinsatz wurde gelöscht');
return new HxRedirectResponse($this->generateUrl('app_administrative_season_assignment_index'));
}
return $this->render('administrative/season_assignment/modal_delete.html.twig', [
'seasonAssignment' => $seasonAssignment,
]);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\SeasonAssignment;
use App\Service\Season\SeasonChecklistBuilder;
use App\Service\Season\SeasonTaskSynchronizer;
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;
public function __construct(
private readonly SeasonTaskSynchronizer $taskSynchronizer,
private readonly SeasonChecklistBuilder $checklistBuilder,
) {
}
#[Route('/administrative/season-assignment/detail/{uuid}', name: 'app_administrative_season_assignment_detail')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignment $seasonAssignment, Request $request): Response
{
// one of the two write paths the synchronizer runs on, so an entry added to the enum
// later shows up on assignments that already exist
$this->taskSynchronizer->sync($seasonAssignment);
return $this->render('administrative/season_assignment/detail.html.twig', [
'seasonAssignment' => $seasonAssignment,
'checklist' => $this->checklistBuilder->build($seasonAssignment),
'checklistStatus' => $this->checklistBuilder->status($seasonAssignment),
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_season_assignment_index'),
]);
}
}
@@ -0,0 +1,156 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Entity\SeasonAssignment;
use App\Entity\Upload;
use App\Entity\User;
use App\Htmx\HxRedirectResponse;
use App\Model\UploadDto;
use App\Service\Upload\UploadHandler;
use Doctrine\ORM\EntityManagerInterface;
use Flagception\Manager\FeatureManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The documents the office issues for one season assignment.
*
* Deliberately not Upload::TYPE_DOCUMENT: that type is the staff-wide library, which UploadVoter
* opens to every teamer - an individually issued contract in it would be readable by everyone.
* These carry TYPE_SEASON_DOCUMENT_OUT instead, and the voter lets exactly the teamer of this
* assignment download them.
*
* There is no relation between a file and a checklist entry on purpose: the office looks at the
* documents, decides which entry a file satisfies and ticks it off by hand.
*/
class DocumentController extends AbstractController
{
public function __construct(
private readonly UploadHandler $uploadHandler,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly FeatureManagerInterface $featureManager,
) {
}
#[Route(
path: '/administrative/season-assignment/{uuid}/document',
name: 'app_administrative_season_assignment_document_upload'
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function upload(SeasonAssignment $seasonAssignment, Request $request): Response
{
// Dummy form without fields, as elsewhere: the files travel in the upload session, not in
// the form itself.
$form = $this->createFormBuilder()->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$count = $this->processUpload($seasonAssignment);
$this->addFlash(
'success',
1 === $count ? 'Die Unterlage wurde hochgeladen' : 'Die Unterlagen wurden hochgeladen'
);
return new HxRedirectResponse($this->generateUrl('app_administrative_season_assignment_detail', [
'uuid' => $seasonAssignment->getUuid(),
'r' => $request->query->get('r'),
]));
}
return $this->render('administrative/season_assignment/modal_document_upload.html.twig', [
'seasonAssignment' => $seasonAssignment,
'form' => $form->createView(),
]);
}
#[Route(
path: '/administrative/season-assignment/document/{uuid}',
name: 'app_administrative_season_assignment_document_delete'
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function delete(Upload $document, Request $request): Response
{
if (null === $seasonAssignment = $document->getSeasonAssignment()) {
throw $this->createNotFoundException('Not a season assignment document');
}
if (true === $request->isMethod('POST')) {
// Doctrine's DeleteUploadListener removes the file from disk on preRemove.
$this->entityManager->remove($document);
$this->entityManager->flush();
$this->addFlash('success', 'Die Unterlage wurde gelöscht');
$this->logger->info('Delete season assignment document', [
'season_assignment_id' => $seasonAssignment->getId(),
'teamer' => $seasonAssignment->getTeamer()?->getFullName(),
'document_filename' => $document->getOriginalFilename(),
'document_type' => $document->getType(),
]);
return new HxRedirectResponse($this->generateUrl('app_administrative_season_assignment_detail', [
'uuid' => $seasonAssignment->getUuid(),
'r' => $request->query->get('r'),
]));
}
return $this->render('administrative/season_assignment/modal_document_delete.html.twig', [
'document' => $document,
]);
}
private function processUpload(SeasonAssignment $seasonAssignment): int
{
/** @var User $user */
$user = $this->getUser();
$uploadSession = $this->uploadHandler->getUploadSession();
// Only this dropzone's files: the page may hold a second one, so the session must not be
// destroyed wholesale.
$uploadDtos = $uploadSession->getUploadsByType(Upload::TYPE_SEASON_DOCUMENT_OUT);
$uploads = [];
/** @var UploadDto $uploadDto */
foreach ($uploadDtos as $uploadDto) {
$upload = Upload::fromUploadDto($uploadDto, $user, Upload::TYPE_SEASON_DOCUMENT_OUT);
// No status: these are not checked or approved, they are handed out. UploadVoter
// grants CHECK only for "new" and "pending", so a null status keeps them out of the
// checking process by construction.
$upload->setStatus(null);
$seasonAssignment->addDocument($upload);
$this->entityManager->persist($upload);
$uploads[] = $upload;
}
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_SEASON_DOCUMENT_OUT, $uploadSession);
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_SEASON_DOCUMENT_OUT);
if (true === $this->featureManager->isActive('sanitize_uploads')) {
foreach ($uploads as $upload) {
$this->uploadHandler->ensurePdf($upload);
}
}
$this->entityManager->flush();
$this->logger->info('Upload season assignment documents', [
'user' => $user->getUserIdentifier(),
'season_assignment_id' => $seasonAssignment->getId(),
'count' => count($uploads),
]);
return count($uploads);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\SeasonAssignment;
use App\Form\SeasonAssignmentType;
use App\Service\Season\SeasonAssignmentLabeler;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class EditController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly SeasonAssignmentLabeler $labeler,
private readonly LoggerInterface $logger,
) {
}
#[Route('/administrative/season-assignment/edit/{uuid}', name: 'app_administrative_season_assignment_edit')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignment $seasonAssignment, Request $request): Response
{
$returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_index');
$form = $this->createForm(SeasonAssignmentType::class, $seasonAssignment);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->flush();
$this->addFlash('success', 'Der Saisoneinsatz wurde gespeichert');
$this->logger->info('Edit season assignment', [
'season_assignment_id' => $seasonAssignment->getId(),
'teamer' => $seasonAssignment->getTeamer()?->getFullName(),
'season_assignment' => $this->labeler->label($seasonAssignment),
'job_profile' => $seasonAssignment->getJobProfile()->getName(),
]);
return $this->redirect($returnUrl);
}
return $this->render('administrative/season_assignment/edit.html.twig', [
'form' => $form->createView(),
'seasonAssignment' => $seasonAssignment,
'returnUrl' => $returnUrl,
]);
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Repository\SeasonAssignmentRepository;
use App\Service\Common\SeasonAssignmentFilterHandler;
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;
class IndexController extends AbstractController
{
public function __construct(
private readonly SeasonAssignmentFilterHandler $filterHandler,
private readonly SeasonAssignmentRepository $seasonAssignmentRepository,
private readonly PaginatorInterface $paginator,
) {
}
#[Route('/administrative/season-assignment', name: 'app_administrative_season_assignment_index')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$filterDto = $this->filterHandler->getFilterSettings();
$query = $this
->seasonAssignmentRepository
->getListQuery($filterDto)
;
$pagination = $this->paginator->paginate(
$query,
$request->query->getInt('page', 1),
$request->query->getInt('limit', 50)
);
return $this->render('administrative/season_assignment/index.html.twig', [
'pagination' => $pagination,
'filterDto' => $filterDto,
]);
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\SeasonAssignmentTask;
use App\Form\SeasonTaskDeadlineType;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class TaskDeadlineController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route(
path: '/administrative/season-assignment/task/{uuid}/deadline',
name: 'app_administrative_season_assignment_task_deadline'
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignmentTask $task, Request $request): Response
{
$seasonAssignment = $task->getSeasonAssignment();
$returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_detail', [
'uuid' => $seasonAssignment?->getUuid(),
]);
$form = $this->createForm(SeasonTaskDeadlineType::class, $task, [
'action' => $this->generateUrl('app_administrative_season_assignment_task_deadline', [
'uuid' => $task->getUuid(),
'r' => $returnUrl,
]),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->flush();
$this->logger->info('Change season assignment task deadlines', [
'season_assignment_id' => $seasonAssignment?->getId(),
'teamer' => $seasonAssignment?->getTeamer()?->getFullName(),
'task' => $task->getTaskKey(),
'deadline_override_1' => $task->getDeadlineOverride1()?->format('Y-m-d'),
'deadline_override_2' => $task->getDeadlineOverride2()?->format('Y-m-d'),
]);
$this->addFlash('success', 'Die Fristen wurden gespeichert');
return new HxRedirectResponse($returnUrl);
}
return $this->render('administrative/season_assignment/modal_task_deadline.html.twig', [
'form' => $form->createView(),
'task' => $task,
]);
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Controller\Administrative\SeasonAssignment;
use App\Entity\SeasonAssignmentTask;
use App\Service\Season\SeasonChecklistBuilder;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Ticks one checklist entry and re-renders the checklist.
*
* Deliberately one request per tick rather than one form for the whole checklist: the office
* works through the list one line at a time while reading a document, and a whole-form submit
* would let two people working the same assignment overwrite each other's ticks. The answer
* still carries the whole checklist, because the status box below the table is derived from
* every row at once - and a tick re-rendered from the database also picks up what a colleague
* has ticked in the meantime.
*/
class TaskToggleController extends AbstractController
{
private const FIELD_DONE = 'done';
private const FIELD_OFFICE_DONE = 'office_done';
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly SeasonChecklistBuilder $checklistBuilder,
private readonly LoggerInterface $logger,
) {
}
#[Route(
path: '/administrative/season-assignment/task/{uuid}/{field}',
name: 'app_administrative_season_assignment_task_toggle',
// pinned in the route as well as checked below, so this can never shadow the sibling
// .../task/{uuid}/deadline route whichever order the two are loaded in
requirements: ['field' => 'done|office_done'],
methods: ['POST']
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonAssignmentTask $task, string $field): Response
{
// matched against the two names rather than turned into a property path: the field comes
// out of the URL
if (false === in_array($field, [self::FIELD_DONE, self::FIELD_OFFICE_DONE], true)) {
throw $this->createNotFoundException(sprintf('Unknown checklist field "%s".', $field));
}
if (self::FIELD_DONE === $field) {
$done = false === $task->isDone();
$task
->setDone($done)
// explicit, because Blameable's updatedBy is overwritten by any later edit and
// the office wants to see who closed the entry
->setDoneAt(true === $done ? new \DateTimeImmutable() : null)
->setDoneBy(true === $done ? $this->getUser()?->getUserIdentifier() : null)
;
} else {
$task->setOfficeDone(false === $task->isOfficeDone());
}
$this->entityManager->flush();
$this->logger->info('Toggle season assignment task', [
'season_assignment_id' => $task->getSeasonAssignment()?->getId(),
'teamer' => $task->getSeasonAssignment()?->getTeamer()?->getFullName(),
'task' => $task->getTaskKey(),
'field' => $field,
'value' => self::FIELD_DONE === $field ? $task->isDone() : $task->isOfficeDone(),
]);
$seasonAssignment = $task->getSeasonAssignment();
if (null === $seasonAssignment) {
throw $this->createNotFoundException('The checklist entry has no season assignment.');
}
return $this->render('administrative/season_assignment/_checklist.html.twig', [
'checklist' => $this->checklistBuilder->build($seasonAssignment),
'checklistStatus' => $this->checklistBuilder->status($seasonAssignment),
]);
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Controller\Administrative\System\SeasonTaskDocument;
use App\Entity\SeasonTaskDocument;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Removes the mapping, not the document: the library file itself stays where it is and is
* managed under Dokumente.
*/
class DeleteController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route(
path: '/administrative/system/season-task-document/delete/{uuid}',
name: 'app_administrative_system_season_task_document_delete'
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonTaskDocument $seasonTaskDocument, Request $request): Response
{
if (true === $request->isMethod('POST')) {
$taskKey = $seasonTaskDocument->getTaskKey();
$this->entityManager->remove($seasonTaskDocument);
$this->entityManager->flush();
$this->logger->info('Remove season task document', ['task' => $taskKey]);
$this->addFlash('success', 'Die Zuordnung wurde entfernt');
return new HxRedirectResponse(
$this->generateUrl('app_administrative_system_season_task_document_index')
);
}
return $this->render('administrative/system/season_task_document/modal_delete.html.twig', [
'seasonTaskDocument' => $seasonTaskDocument,
]);
}
}
@@ -0,0 +1,145 @@
<?php
namespace App\Controller\Administrative\System\SeasonTaskDocument;
use App\Config\SeasonTask;
use App\Config\SeasonTaskProvision;
use App\Entity\SeasonTaskDocument;
use App\Entity\Upload;
use App\Entity\User;
use App\Form\SeasonTaskDocumentType;
use App\Htmx\HxRedirectResponse;
use App\Repository\SeasonTaskDocumentRepository;
use App\Service\Upload\UploadHandler;
use Doctrine\ORM\EntityManagerInterface;
use Flagception\Manager\FeatureManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Sets the library document a checklist entry hands out. Addressed by the entry rather than by a
* record: the mapping may not exist yet, and there is at most one per entry.
*
* The file can be picked from the library or uploaded here, in which case it joins the library as
* an ordinary Upload::TYPE_DOCUMENT - so next season it can simply be picked. An uploaded file
* wins over the pick: the picker already shows the mapping's current document, so a new file is
* always the more recent statement of intent.
*
* Changing the mapping changes what the *next* assignments get. It never reaches an assignment
* that already exists - that is what the propagate action is for.
*/
class EditController extends AbstractController
{
public function __construct(
private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository,
private readonly UploadHandler $uploadHandler,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly FeatureManagerInterface $featureManager,
) {
}
#[Route(
path: '/administrative/system/season-task-document/edit/{taskKey}',
name: 'app_administrative_system_season_task_document_edit'
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(string $taskKey, Request $request): Response
{
$task = SeasonTask::tryFrom($taskKey);
if (null === $task || SeasonTaskProvision::STANDARD !== $task->provides()) {
throw $this->createNotFoundException(sprintf('No standard document is handed out for "%s".', $taskKey));
}
$seasonTaskDocument = $this->seasonTaskDocumentRepository->findForTask($task)
?? (new SeasonTaskDocument())->setTaskKey($task->value);
$form = $this->createForm(SeasonTaskDocumentType::class, $seasonTaskDocument);
$form->handleRequest($request);
$uploaded = null;
if ($form->isSubmitted() && $form->isValid()) {
$dropped = $this->uploadHandler->getUploadSession()->getUploadsByType(Upload::TYPE_DOCUMENT);
// A dropped file wins over the pick. Editing an existing mapping shows its current
// document in the picker, so having to clear that first to replace it would be a
// detour through a field the user is not talking about.
if (false === $dropped->isEmpty()) {
$uploaded = $this->storeUploadedDocument($form);
$seasonTaskDocument->setDocument($uploaded);
} elseif (null === $seasonTaskDocument->getDocument()) {
$form->addError(new FormError('Bitte wähle ein Dokument aus oder lade eine Datei hoch'));
}
}
if ($form->isSubmitted() && $form->isValid()) {
if (null === $seasonTaskDocument->getId()) {
$this->entityManager->persist($seasonTaskDocument);
}
$this->entityManager->flush();
$this->logger->info('Change season task document', [
'task' => $task->value,
'document_id' => $seasonTaskDocument->getDocument()?->getId(),
'uploaded' => null !== $uploaded,
]);
$this->addFlash('success', 'Das Dokument wurde hinterlegt');
return new HxRedirectResponse(
$this->generateUrl('app_administrative_system_season_task_document_index')
);
}
return $this->render('administrative/system/season_task_document/modal_edit.html.twig', [
'form' => $form->createView(),
'task' => $task,
]);
}
/**
* The dropped file, turned into a library document. It carries Upload::TYPE_DOCUMENT like
* everything under Dokumente, so next season it is simply picked from the autocomplete.
*
* The dropzone takes one file, and a second one would be a user error rather than a case to
* model, so only the first is kept and the rest of the session entry is dropped with it.
*/
private function storeUploadedDocument(FormInterface $form): Upload
{
/** @var User $user */
$user = $this->getUser();
$uploadSession = $this->uploadHandler->getUploadSession();
$upload = Upload::fromUploadDto(
$uploadSession->getUploadsByType(Upload::TYPE_DOCUMENT)->first(),
$user,
Upload::TYPE_DOCUMENT
);
if (null !== $displayName = $form->get('displayName')->getData()) {
$upload->setDisplayName($displayName);
}
$this->entityManager->persist($upload);
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_DOCUMENT, $uploadSession);
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_DOCUMENT);
if (true === $this->featureManager->isActive('sanitize_uploads')) {
$this->uploadHandler->ensurePdf($upload);
}
return $upload;
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Controller\Administrative\System\SeasonTaskDocument;
use App\Config\SeasonTask;
use App\Config\SeasonTaskProvision;
use App\Repository\SeasonTaskDocumentRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Which document from the global library a checklist entry hands out.
*
* The rows are the enum's entries, not database records: only the entries that hand out a
* standard document can carry a mapping at all, and one without a mapping is a valid - if
* incomplete - state that the teamer's page has to show as "kein Dokument hinterlegt".
*/
class IndexController extends AbstractController
{
public function __construct(private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository)
{
}
#[Route('/administrative/system/season-task-document', name: 'app_administrative_system_season_task_document_index')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(): Response
{
$mapped = $this->seasonTaskDocumentRepository->findIndexedByTaskKey();
$rows = [];
foreach (SeasonTask::cases() as $case) {
if (SeasonTaskProvision::STANDARD !== $case->provides()) {
continue;
}
$rows[] = [
'task' => $case,
'document' => $mapped[$case->value] ?? null,
];
}
return $this->render('administrative/system/season_task_document/index.html.twig', [
'rows' => $rows,
]);
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Controller\Administrative\System\SeasonTaskDocument;
use App\Entity\SeasonTaskDocument;
use App\Htmx\HxRedirectResponse;
use App\Repository\SeasonAssignmentTaskRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Copies the mapped document onto the checklist rows of assignments that already exist.
*
* A checklist row carries the document it was given when it was created, so depositing a new file
* changes nothing for a season that was already laid out. That is deliberate for finished work
* and unhelpful for a season still being prepared - this action closes exactly that gap, by hand
* and with the count shown before it runs.
*
* Scoped to entries that are not ticked off, on assignments that have not ended: what an entry
* already handed out is history and is not rewritten.
*
* officeDone is left alone. It is the office's own note, an untick is deliberate, and a document
* changing underneath is not an office action.
*/
class PropagateController extends AbstractController
{
public function __construct(
private readonly SeasonAssignmentTaskRepository $seasonAssignmentTaskRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route(
path: '/administrative/system/season-task-document/propagate/{uuid}',
name: 'app_administrative_system_season_task_document_propagate'
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(SeasonTaskDocument $seasonTaskDocument, Request $request): Response
{
$tasks = $this->seasonAssignmentTaskRepository->findOpenForTask(
$seasonTaskDocument->getTaskKey(),
new \DateTimeImmutable('today')
);
if (true === $request->isMethod('POST')) {
foreach ($tasks as $task) {
$task->setDocument($seasonTaskDocument->getDocument());
}
$this->entityManager->flush();
$this->logger->info('Propagate season task document', [
'task' => $seasonTaskDocument->getTaskKey(),
'document_id' => $seasonTaskDocument->getDocument()?->getId(),
'count' => count($tasks),
]);
$this->addFlash(
'success',
1 === count($tasks)
? 'Das Dokument wurde auf einen offenen Einsatz übertragen'
: sprintf('Das Dokument wurde auf %d offene Einsätze übertragen', count($tasks))
);
return new HxRedirectResponse(
$this->generateUrl('app_administrative_system_season_task_document_index')
);
}
return $this->render('administrative/system/season_task_document/modal_propagate.html.twig', [
'seasonTaskDocument' => $seasonTaskDocument,
'tasks' => $tasks,
]);
}
}
@@ -17,7 +17,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController
{
public function __construct(
protected readonly TeamerFilterHandler $filterHandler,
private readonly TeamerFilterHandler $filterHandler,
private readonly TeamerRepository $teamerRepository,
private readonly PaginatorInterface $paginator,
private readonly EntityManagerInterface $entityManager,
@@ -28,7 +28,38 @@ class IndexController extends AbstractController
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$filterDto = $this->filterHandler->getFilterSettings();
return $this->renderList($request, 'administrative/teamer/index.html.twig', null);
}
#[Route('/administrative/season-teamer', name: 'app_administrative_season_teamer_index')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function season(Request $request): Response
{
return $this->renderList($request, 'administrative/teamer/season_index.html.twig', TeamerFilterHandler::SCOPE_ALL_SEASON);
}
#[Route('/administrative/teamer/viewed/{uuid}', name: 'app_administrative_teamer_viewed', methods: ['POST'])]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function viewed(Teamer $teamer): Response
{
$teamer->setViewed(true);
$this->entityManager->flush();
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
}
/**
* Both teamer lists differ only in the scope they run under, so they share one body.
* The scope is set explicitly on either path to keep the two independent of the order
* in which they are called within a request.
*/
private function renderList(Request $request, string $template, ?string $scope): Response
{
$filterDto = $this
->filterHandler
->setScope($scope)
->getFilterSettings()
;
$query = $this
->teamerRepository
@@ -41,19 +72,9 @@ class IndexController extends AbstractController
$request->query->getInt('limit', 50)
);
return $this->render('administrative/teamer/index.html.twig', [
return $this->render($template, [
'pagination' => $pagination,
'filterDto' => $filterDto,
]);
}
#[Route('/administrative/teamer/viewed/{uuid}', name: 'app_administrative_teamer_viewed', methods: ['POST'])]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function viewed(Teamer $teamer): Response
{
$teamer->setViewed(true);
$this->entityManager->flush();
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Controller\Common;
use App\Controller\Traits\ReturnUrlTrait;
use App\Form\SeasonAssignmentFilterType;
use App\Htmx\HxRedirectResponse;
use App\Service\Common\SeasonAssignmentFilterHandler;
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 SeasonAssignmentFilterController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(private readonly SeasonAssignmentFilterHandler $filterHandler)
{
}
#[Route('/common/season-assignment/filter', name: 'app_common_season_assignment_filter')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Request $request): Response
{
$formData = $this->filterHandler->getFilterSettings();
$form = $this->createForm(SeasonAssignmentFilterType::class, $formData);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->filterHandler->handleRequest($form);
$returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_index');
return new HxRedirectResponse($returnUrl);
}
return $this->render('common/modal_season_assignment_filter.html.twig', [
'filterForm' => $form->createView(),
'filterDto' => $form->getData(),
]);
}
#[Route('/common/season-assignment/filter/reset', name: 'app_common_season_assignment_filter_reset')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
$returnUrl = $this->getReturnUrl($request, 'app_administrative_season_assignment_index');
return $this->redirect($returnUrl);
}
}
@@ -24,13 +24,15 @@ class TeamerFilterController extends AbstractController
#[IsGranted('ROLE_USER')]
public function index(Request $request): Response
{
$this->applyScope($request);
$formData = $this->filterHandler->getFilterSettings();
$form = $this->createForm(TeamerFilterType::class, $formData);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->filterHandler->handleRequest($form);
$returnUrl = $this->getReturnUrl($request, 'app_administrative_teamer_index');
$returnUrl = $this->getReturnUrl($request, $this->getDefaultRoute($request));
return new HxRedirectResponse($returnUrl);
}
@@ -45,9 +47,26 @@ class TeamerFilterController extends AbstractController
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response
{
$this->applyScope($request);
$this->filterHandler->resetFilterSettings();
$returnUrl = $this->getReturnUrl($request, 'app_administrative_teamer_index');
$returnUrl = $this->getReturnUrl($request, $this->getDefaultRoute($request));
return $this->redirect($returnUrl);
}
/**
* The modal serves both teamer lists; the scope says whose filter is being edited.
*/
private function applyScope(Request $request): void
{
$this->filterHandler->setScope($request->query->get('scope'));
}
private function getDefaultRoute(Request $request): string
{
return TeamerFilterHandler::SCOPE_ALL_SEASON === $request->query->get('scope')
? 'app_administrative_season_teamer_index'
: 'app_administrative_teamer_index';
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Controller\Common;
use App\Controller\Traits\ReturnUrlTrait;
use App\Form\WorkMonthFilterType;
use App\Htmx\HxRedirectResponse;
use App\Service\Common\WorkMonthFilterHandler;
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 WorkMonthFilterController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(private readonly WorkMonthFilterHandler $filterHandler)
{
}
#[Route('/common/work-month/filter', name: 'app_common_work_month_filter')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$formData = $this->filterHandler->getFilterSettings();
$form = $this->createForm(WorkMonthFilterType::class, $formData);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->filterHandler->handleRequest($form);
$returnUrl = $this->getReturnUrl($request, 'app_admin_work_month_index');
return new HxRedirectResponse($returnUrl);
}
return $this->render('common/modal_work_month_filter.html.twig', [
'filterForm' => $form->createView(),
'filterDto' => $form->getData(),
]);
}
#[Route('/common/work-month/filter/reset', name: 'app_common_work_month_filter_reset')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
$returnUrl = $this->getReturnUrl($request, 'app_admin_work_month_index');
return $this->redirect($returnUrl);
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Controller\Teamer\Season;
use App\Entity\User;
use App\Service\Season\SeasonAssignmentResolver;
use App\Service\Season\SeasonChecklistBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* What an all-season teamer has to download, sign and return.
*
* Only the entries expecting something back are listed, whether or not the office issues a
* document for them first; the office-internal checks never appear here. Frist 2 does not either:
* it is an internal escalation.
*/
class IndexController extends AbstractController
{
public function __construct(
private readonly SeasonAssignmentResolver $seasonAssignmentResolver,
private readonly SeasonChecklistBuilder $checklistBuilder,
) {
}
#[Route('/teamer/season', name: 'app_teamer_season_index')]
#[IsGranted('ROLE_TEAMER')]
public function index(): Response
{
/** @var User $user */
$user = $this->getUser();
if (null === $teamer = $user->getTeamer()) {
throw $this->createNotFoundException('No teamer for this user');
}
if (null === $seasonAssignment = $this->seasonAssignmentResolver->resolveFor($teamer)) {
throw $this->createNotFoundException('No season assignment for this teamer');
}
$rows = array_filter(
$this->checklistBuilder->build($seasonAssignment),
static fn (array $row): bool => true === $row['definition']->requiresReturn()
);
return $this->render('teamer/season/index.html.twig', [
'seasonAssignment' => $seasonAssignment,
'rows' => $rows,
'documents' => $seasonAssignment->getDocuments(),
]);
}
}
@@ -0,0 +1,111 @@
<?php
namespace App\Controller\Teamer\Season;
use App\Entity\SeasonAssignment;
use App\Entity\Upload;
use App\Entity\User;
use App\Htmx\HxRedirectResponse;
use App\Model\UploadDto;
use App\Service\Upload\UploadHandler;
use Doctrine\ORM\EntityManagerInterface;
use Flagception\Manager\FeatureManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The signed documents a teamer returns for their season assignment.
*
* They accumulate, like Belege: there is no relation between a file and a checklist entry, and
* nothing here closes one - only an administrative tick on "erledigt" does that.
*/
class UploadController extends AbstractController
{
public function __construct(
private readonly UploadHandler $uploadHandler,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly FeatureManagerInterface $featureManager,
) {
}
#[Route('/teamer/season/upload/{uuid}', name: 'app_teamer_season_upload')]
#[IsGranted('ROLE_TEAMER')]
public function index(SeasonAssignment $seasonAssignment, Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
// the assignment is addressed by uuid, so this is what keeps one teamer out of another's
if ($user->getTeamer() !== $seasonAssignment->getTeamer()) {
throw $this->createNotFoundException('Not this teamer\'s season assignment');
}
// Dummy form without fields, as elsewhere: the files travel in the upload session.
$form = $this->createFormBuilder()->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$count = $this->processUpload($seasonAssignment, $user);
$this->addFlash(
'success',
1 === $count ? 'Das Dokument wurde hochgeladen' : 'Die Dokumente wurden hochgeladen'
);
return new HxRedirectResponse($this->generateUrl('app_teamer_season_index'));
}
return $this->render('teamer/season/modal_upload.html.twig', [
'seasonAssignment' => $seasonAssignment,
'form' => $form->createView(),
]);
}
private function processUpload(SeasonAssignment $seasonAssignment, User $user): int
{
$uploadSession = $this->uploadHandler->getUploadSession();
// Only this dropzone's files: the session is shared, so it must not be destroyed whole.
$uploadDtos = $uploadSession->getUploadsByType(Upload::TYPE_SEASON_DOCUMENT_IN);
$uploads = [];
/** @var UploadDto $uploadDto */
foreach ($uploadDtos as $uploadDto) {
$upload = Upload::fromUploadDto($uploadDto, $user, Upload::TYPE_SEASON_DOCUMENT_IN);
// No status: a returned document is not run through the checking process, the office
// ticks the checklist entry off by hand.
$upload->setStatus(null);
$seasonAssignment->addDocument($upload);
$this->entityManager->persist($upload);
$uploads[] = $upload;
}
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_SEASON_DOCUMENT_IN, $uploadSession);
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_SEASON_DOCUMENT_IN);
if (true === $this->featureManager->isActive('sanitize_uploads')) {
foreach ($uploads as $upload) {
$this->uploadHandler->ensurePdf($upload);
}
}
$this->entityManager->flush();
$this->logger->info('Upload season documents', [
'user' => $user->getUserIdentifier(),
'season_assignment_id' => $seasonAssignment->getId(),
'count' => count($uploads),
]);
return count($uploads);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Controller\Teamer\WorkMonth;
use App\Entity\User;
use App\Repository\TeamerWorkMonthRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The months a teamer has to sign off, newest first.
*
* Only months the import has actually produced appear here; the current month is not among
* them until the 10th of the month after it.
*/
class IndexController extends AbstractController
{
public function __construct(
private readonly TeamerWorkMonthRepository $workMonthRepository,
) {
}
#[Route('/teamer/arbeitszeiten', name: 'app_teamer_work_month_index')]
#[IsGranted('ROLE_TEAMER')]
public function index(): Response
{
/** @var User $user */
$user = $this->getUser();
if (null === $teamer = $user->getTeamer()) {
throw $this->createNotFoundException('No teamer for this user');
}
return $this->render('teamer/work_month/index.html.twig', [
'workMonths' => $this->workMonthRepository->findForTeamer($teamer),
]);
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Controller\Teamer\WorkMonth;
use App\Entity\TeamerWorkMonth;
use App\Entity\User;
use App\Service\Pdf\WorkMonthRenderer;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The Arbeitszeitnachweis to print and sign.
*
* Built on demand and never stored, exactly as the contract and the invoice are. What keeps
* a regenerated sheet identical to the signed one is the lock in WorkHoursSynchronizer: once
* a month is submitted, its hours stop moving.
*/
class PdfController extends AbstractController
{
public function __construct(
private readonly WorkMonthRenderer $renderer,
private readonly LoggerInterface $logger,
) {
}
#[Route('/teamer/arbeitszeiten/pdf/{uuid}', name: 'app_teamer_work_month_pdf')]
#[IsGranted('ROLE_TEAMER')]
public function index(TeamerWorkMonth $workMonth): Response
{
/** @var User $user */
$user = $this->getUser();
// the month is addressed by uuid, so this is what keeps one teamer out of another's
if ($user->getTeamer() !== $workMonth->getTeamer()) {
throw $this->createNotFoundException('Not this teamer\'s work month');
}
$this->logger->info('Download work month pdf', [
'user' => $user->getUserIdentifier(),
'work_month' => $workMonth->getUuid(),
]);
$response = new Response($this->renderer->render($workMonth));
$response->headers->set('Content-Type', 'application/pdf');
$response->headers->set('Content-Disposition', $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$this->renderer->filename($workMonth),
));
return $response;
}
}
@@ -0,0 +1,133 @@
<?php
namespace App\Controller\Teamer\WorkMonth;
use App\Entity\TeamerWorkMonth;
use App\Entity\Upload;
use App\Entity\User;
use App\Enum\WorkMonthStatus;
use App\Htmx\HxRedirectResponse;
use App\Model\UploadDto;
use App\Service\Upload\UploadHandler;
use Doctrine\ORM\EntityManagerInterface;
use Flagception\Manager\FeatureManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The signed Arbeitszeitnachweis coming back for one month.
*
* Uploads accumulate rather than replace - Upload is not soft-deletable, and a rejection and
* the re-upload answering it both belong on the record. TeamerWorkMonth::getCurrentDocument()
* is what decides which one counts.
*/
class UploadController extends AbstractController
{
public function __construct(
private readonly UploadHandler $uploadHandler,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly FeatureManagerInterface $featureManager,
) {
}
#[Route('/teamer/arbeitszeiten/upload/{uuid}', name: 'app_teamer_work_month_upload')]
#[IsGranted('ROLE_TEAMER')]
public function index(TeamerWorkMonth $workMonth, Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
// the month is addressed by uuid, so this is what keeps one teamer out of another's
if ($user->getTeamer() !== $workMonth->getTeamer()) {
throw $this->createNotFoundException('Not this teamer\'s work month');
}
if (WorkMonthStatus::APPROVED === $workMonth->getStatus()) {
throw $this->createNotFoundException('This work month is already approved');
}
// Dummy form without fields, as elsewhere: the files travel in the upload session.
$form = $this->createFormBuilder()->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$count = $this->processUpload($workMonth, $user);
if (0 === $count) {
$this->addFlash('error', 'Es wurde keine Datei hochgeladen');
} else {
$this->addFlash('success', 'Der Arbeitszeitnachweis wurde hochgeladen');
}
return new HxRedirectResponse($this->generateUrl('app_teamer_work_month_index'));
}
return $this->render('teamer/work_month/modal_upload.html.twig', [
'workMonth' => $workMonth,
'form' => $form->createView(),
]);
}
private function processUpload(TeamerWorkMonth $workMonth, User $user): int
{
$uploadSession = $this->uploadHandler->getUploadSession();
// Only this dropzone's files: the session is shared, so it must not be destroyed whole.
$uploadDtos = $uploadSession->getUploadsByType(Upload::TYPE_WORK_MONTH);
$uploads = [];
/** @var UploadDto $uploadDto */
foreach ($uploadDtos as $uploadDto) {
$upload = Upload::fromUploadDto($uploadDto, $user, Upload::TYPE_WORK_MONTH);
// Unlike a season document this one is reviewed, so it enters the checking states
// the UploadVoter lets an administrative user act on.
$upload->setStatus(Upload::STATUS_NEW);
$workMonth->addDocument($upload);
$this->entityManager->persist($upload);
$uploads[] = $upload;
}
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_WORK_MONTH, $uploadSession);
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_WORK_MONTH);
if ([] === $uploads) {
return 0;
}
if (true === $this->featureManager->isActive('sanitize_uploads')) {
// Teamers photograph signed sheets, so what arrives is not necessarily a PDF.
foreach ($uploads as $upload) {
$this->uploadHandler->ensurePdf($upload);
}
}
$workMonth
->setStatus(WorkMonthStatus::SUBMITTED)
->setSubmittedAt(new \DateTimeImmutable())
// A new submission answers an earlier rejection; the old verdict must not stand.
->setReviewedAt(null)
->setReviewedBy(null)
->setReviewComment(null)
;
$this->entityManager->flush();
$this->logger->info('Upload work month document', [
'user' => $user->getUserIdentifier(),
'work_month' => $workMonth->getUuid(),
'period' => sprintf('%04d-%02d', $workMonth->getYear(), $workMonth->getMonth()),
'count' => count($uploads),
]);
return count($uploads);
}
}
+240
View File
@@ -0,0 +1,240 @@
<?php
namespace App\Entity;
use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\SoftDeletableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Repository\SeasonAssignmentRepository;
use App\Validator\Constraints as AppAssert;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
/**
* The placement of an all-season teamer, assigned directly by an administrative user.
*
* It carries its own period and house rather than pointing at a Destination: a Destination is one
* travel with one departure window, while an all-season teamer sits at one house for the whole
* season, so the relation only ever meant "borrow three scalars from an arbitrary travel row".
*
* Deliberately not an Assignment plus a Disposition: those two model the short term process,
* where an assignment is published, teamers apply and an admin turns an application into a
* placement. All-season teamers never apply, so there is nothing to publish, nothing to staff
* and no contract workflow to run - Disposition cannot even be constructed without an
* Application. One record carries the whole placement instead.
*/
#[ORM\Entity(repositoryClass: SeasonAssignmentRepository::class)]
#[AppAssert\SeasonAssignment]
class SeasonAssignment implements BlameableEntityInterface, TimestampableEntityInterface, SoftDeletableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
use SoftDeletableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
/**
* Left open on purpose: administrative users lay the season's assignments out first and
* only place teamers on them later, so an unassigned assignment is a normal working state
* rather than an incomplete record.
*/
#[ORM\ManyToOne(inversedBy: 'seasonAssignments')]
private ?Teamer $teamer = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Assert\NotNull(message: 'Bitte gib das Startdatum an')]
private ?\DateTimeImmutable $dateFrom = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Assert\NotNull(message: 'Bitte gib das Enddatum an')]
#[Assert\GreaterThan(propertyPath: 'dateFrom', message: 'Das Enddatum muss nach dem Beginndatum liegen')]
private ?\DateTimeImmutable $dateTo = null;
/**
* A key of the configured "houses" map, which App\Config\HouseCatalog turns into a name for
* display. Only the code is stored: a house renamed in config must not leave old placements
* showing the old name. Deliberately no Assert\Choice - the map is curated and can lose an
* entry, and a record that named that house has to stay editable.
*/
#[ORM\Column(length: 32)]
#[Assert\NotBlank(message: 'Bitte gib das Haus an')]
private ?string $hotelCode = null;
#[ORM\ManyToOne]
#[Assert\NotNull(message: 'Bitte gib das Jobprofil an')]
private ?JobProfile $jobProfile = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $remarks = null;
/**
* @var Collection<int, SeasonAssignmentTask>
*/
#[ORM\OneToMany(mappedBy: 'seasonAssignment', targetEntity: SeasonAssignmentTask::class, cascade: ['persist'])]
private Collection $tasks;
/**
* @var Collection<int, Upload>
*/
#[ORM\OneToMany(mappedBy: 'seasonAssignment', targetEntity: Upload::class)]
private Collection $documents;
public function __construct()
{
$this->uuid = Uuid::v4();
$this->tasks = new ArrayCollection();
$this->documents = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getTeamer(): ?Teamer
{
return $this->teamer;
}
public function setTeamer(?Teamer $teamer): static
{
$this->teamer = $teamer;
return $this;
}
public function getDateFrom(): ?\DateTimeImmutable
{
return $this->dateFrom;
}
public function setDateFrom(?\DateTimeImmutable $dateFrom): static
{
$this->dateFrom = $dateFrom;
return $this;
}
public function getDateTo(): ?\DateTimeImmutable
{
return $this->dateTo;
}
public function setDateTo(?\DateTimeImmutable $dateTo): static
{
$this->dateTo = $dateTo;
return $this;
}
public function getHotelCode(): ?string
{
return $this->hotelCode;
}
public function setHotelCode(?string $hotelCode): static
{
$this->hotelCode = $hotelCode;
return $this;
}
public function getJobProfile(): ?JobProfile
{
return $this->jobProfile;
}
public function setJobProfile(?JobProfile $jobProfile): static
{
$this->jobProfile = $jobProfile;
return $this;
}
public function getRemarks(): ?string
{
return $this->remarks;
}
public function setRemarks(?string $remarks): static
{
$this->remarks = $remarks;
return $this;
}
/**
* @return Collection<int, SeasonAssignmentTask>
*/
public function getTasks(): Collection
{
return $this->tasks;
}
public function addTask(SeasonAssignmentTask $task): static
{
if (!$this->tasks->contains($task)) {
$this->tasks->add($task);
$task->setSeasonAssignment($this);
}
return $this;
}
public function removeTask(SeasonAssignmentTask $task): static
{
if ($this->tasks->removeElement($task)) {
// set the owning side to null (unless already changed)
if ($task->getSeasonAssignment() === $this) {
$task->setSeasonAssignment(null);
}
}
return $this;
}
/**
* @return Collection<int, Upload>
*/
public function getDocuments(): Collection
{
return $this->documents;
}
public function addDocument(Upload $document): static
{
if (!$this->documents->contains($document)) {
$this->documents->add($document);
$document->setSeasonAssignment($this);
}
return $this;
}
public function removeDocument(Upload $document): static
{
if ($this->documents->removeElement($document)) {
// set the owning side to null (unless already changed)
if ($document->getSeasonAssignment() === $this) {
$document->setSeasonAssignment(null);
}
}
return $this;
}
}
+237
View File
@@ -0,0 +1,237 @@
<?php
namespace App\Entity;
use App\Config\SeasonTask;
use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Repository\SeasonAssignmentTaskRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* The state of one checklist entry on one season assignment.
*
* The entries themselves are defined by App\Config\SeasonTask; this table only records what the
* office has done with them. State lives in a row rather than in a JSON column because the nightly
* reminder asks "every entry whose deadline is today and that is not done" across all assignments,
* which JSON would turn into a full scan filtered in PHP.
*
* Deliberately not soft-deletable, unlike almost everything else in this app: rows are derived
* from the enum and synced, never deleted by a user, and a soft-deleted row would silently drop
* out of the reminder queries. Upload sets the same precedent.
*/
#[ORM\Entity(repositoryClass: SeasonAssignmentTaskRepository::class)]
#[ORM\UniqueConstraint(name: 'season_assignment_task_unique', columns: ['season_assignment_id', 'task_key'])]
class SeasonAssignmentTask implements BlameableEntityInterface, TimestampableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(inversedBy: 'tasks')]
#[ORM\JoinColumn(nullable: false)]
private ?SeasonAssignment $seasonAssignment = null;
#[ORM\Column(length: 64)]
private string $taskKey;
/**
* The only thing that closes a task and the only thing that stops a reminder.
*/
#[ORM\Column]
private bool $done = false;
/**
* Explicit rather than leaning on Blameable's updatedBy, which any later edit overwrites:
* the office wants to see who ticked the entry off and when.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $doneAt = null;
#[ORM\Column(nullable: true)]
private ?string $doneBy = null;
/**
* The office's own step on this entry, ticked by an administrative user. Purely
* informational: it gates nothing at all, and only $done closes the entry.
*
* Named for who ticks it rather than for what is ticked, because the step differs per entry:
* filing a returned contract is not the same act as registering someone with the
* municipality, and a name taken from one of those reads as a second $done on the others.
*/
#[ORM\Column]
private bool $officeDone = false;
/**
* The document this entry hands out, copied from the library when the row was created.
*
* A snapshot rather than a live lookup: the library holds what the *next* assignments get,
* and it is replaced from season to season, so resolving it on read would rewrite what a
* finished assignment handed out years ago. An already laid out season that should still get
* a newly deposited file is served by the propagate action instead, which is deliberate and
* leaves completed work alone.
*
* SET NULL rather than a guard on the library: deleting the file leaves the row readable and
* showing "kein Dokument hinterlegt", which is a state the views already handle.
*/
#[ORM\ManyToOne]
#[ORM\JoinColumn(onDelete: 'SET NULL')]
private ?Upload $document = null;
/**
* Null means "not overridden" - the deadlines are computed on read by
* App\Service\Season\SeasonTaskDeadlineResolver. Storing the computed value would leave them
* stale as soon as the assignment's dates move.
*/
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $deadlineOverride1 = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $deadlineOverride2 = null;
public function __construct()
{
$this->uuid = Uuid::v4();
}
public function __toString(): string
{
return $this->getTask()?->label() ?? $this->taskKey;
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getSeasonAssignment(): ?SeasonAssignment
{
return $this->seasonAssignment;
}
public function setSeasonAssignment(?SeasonAssignment $seasonAssignment): static
{
$this->seasonAssignment = $seasonAssignment;
return $this;
}
public function getTaskKey(): string
{
return $this->taskKey;
}
public function setTaskKey(string $taskKey): static
{
$this->taskKey = $taskKey;
return $this;
}
/**
* Null when the stored key no longer resolves, which is what happens to the rows of a case
* that was removed from the enum. Callers skip those rows instead of failing on them.
*/
public function getTask(): ?SeasonTask
{
return SeasonTask::tryFrom($this->taskKey);
}
public function isDone(): bool
{
return $this->done;
}
public function setDone(bool $done): static
{
$this->done = $done;
return $this;
}
public function getDoneAt(): ?\DateTimeImmutable
{
return $this->doneAt;
}
public function setDoneAt(?\DateTimeImmutable $doneAt): static
{
$this->doneAt = $doneAt;
return $this;
}
public function getDoneBy(): ?string
{
return $this->doneBy;
}
public function setDoneBy(?string $doneBy): static
{
$this->doneBy = $doneBy;
return $this;
}
public function isOfficeDone(): bool
{
return $this->officeDone;
}
public function setOfficeDone(bool $officeDone): static
{
$this->officeDone = $officeDone;
return $this;
}
public function getDocument(): ?Upload
{
return $this->document;
}
public function setDocument(?Upload $document): static
{
$this->document = $document;
return $this;
}
public function getDeadlineOverride1(): ?\DateTimeImmutable
{
return $this->deadlineOverride1;
}
public function setDeadlineOverride1(?\DateTimeImmutable $deadlineOverride1): static
{
$this->deadlineOverride1 = $deadlineOverride1;
return $this;
}
public function getDeadlineOverride2(): ?\DateTimeImmutable
{
return $this->deadlineOverride2;
}
public function setDeadlineOverride2(?\DateTimeImmutable $deadlineOverride2): static
{
$this->deadlineOverride2 = $deadlineOverride2;
return $this;
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Entity;
use App\Config\SeasonTask;
use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Repository\SeasonTaskDocumentRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* Binds a checklist entry that hands out a standard document to the library upload that document
* actually is.
*
* One row per task key at most; no row means no document is deposited, which the views have to
* show as such rather than fail on. The mapping points at a library Upload instead of holding a
* file of its own, so replacing the library file through the existing ReplaceController keeps this
* reference intact.
*
* This is a task-type to configuration relation. It is unrelated to the files on a season
* assignment, between which and the checklist there is deliberately no relation at all.
*/
#[ORM\Entity(repositoryClass: SeasonTaskDocumentRepository::class)]
class SeasonTaskDocument implements BlameableEntityInterface, TimestampableEntityInterface
{
use BlameableEntity;
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(length: 64, unique: true)]
private string $taskKey;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Upload $document = null;
public function __construct()
{
$this->uuid = Uuid::v4();
}
public function __toString(): string
{
return $this->getTask()?->label() ?? $this->taskKey;
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getTaskKey(): string
{
return $this->taskKey;
}
public function setTaskKey(string $taskKey): static
{
$this->taskKey = $taskKey;
return $this;
}
public function getTask(): ?SeasonTask
{
return SeasonTask::tryFrom($this->taskKey);
}
public function getDocument(): ?Upload
{
return $this->document;
}
public function setDocument(?Upload $document): static
{
$this->document = $document;
return $this;
}
}
+85
View File
@@ -126,6 +126,9 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
#[ORM\OneToMany(mappedBy: 'teamer', targetEntity: Disposition::class, cascade: ['remove'])]
private Collection $dispositions;
#[ORM\OneToMany(mappedBy: 'teamer', targetEntity: SeasonAssignment::class, cascade: ['remove'])]
private Collection $seasonAssignments;
#[ORM\OneToOne(mappedBy: 'teamer', fetch: 'EXTRA_LAZY')]
private ?User $user = null;
@@ -176,6 +179,24 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
#[ORM\Column(length: 8, nullable: true)]
private ?string $driverLicenseReviewedBy = null;
#[ORM\Column]
private bool $allSeason = false;
/**
* The teamer's id in the SaaS time-tracking service, learned from the first successful
* import and kept because it is stable while the email address the import looks them up
* by is not. Written by the importer, never by hand.
*/
#[ORM\Column(length: 36, nullable: true)]
private ?string $saasEmployeeUuid = null;
/**
* @var Collection<int, TeamerWorkMonth>
*/
#[ORM\OneToMany(mappedBy: 'teamer', targetEntity: TeamerWorkMonth::class, cascade: ['remove'])]
#[ORM\OrderBy(['year' => \SortDirection::Descending, 'month' => \SortDirection::Descending])]
private Collection $workMonths;
public function __construct()
{
$this->uuid = Uuid::v4();
@@ -188,7 +209,9 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
$this->bookmarks = new ArrayCollection();
$this->applications = new ArrayCollection();
$this->dispositions = new ArrayCollection();
$this->seasonAssignments = new ArrayCollection();
$this->customAvailabilities = new ArrayCollection();
$this->workMonths = new ArrayCollection();
}
public function __toString()
@@ -791,6 +814,36 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
return $this;
}
/**
* @return Collection<int, SeasonAssignment>
*/
public function getSeasonAssignments(): Collection
{
return $this->seasonAssignments;
}
public function addSeasonAssignment(SeasonAssignment $seasonAssignment): static
{
if (!$this->seasonAssignments->contains($seasonAssignment)) {
$this->seasonAssignments->add($seasonAssignment);
$seasonAssignment->setTeamer($this);
}
return $this;
}
public function removeSeasonAssignment(SeasonAssignment $seasonAssignment): static
{
if ($this->seasonAssignments->removeElement($seasonAssignment)) {
// set the owning side to null (unless already changed)
if ($seasonAssignment->getTeamer() === $this) {
$seasonAssignment->setTeamer(null);
}
}
return $this;
}
public function getUser(): ?User
{
return $this->user;
@@ -987,4 +1040,36 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
return $this;
}
public function isAllSeason(): bool
{
return $this->allSeason;
}
public function setAllSeason(bool $allSeason): static
{
$this->allSeason = $allSeason;
return $this;
}
public function getSaasEmployeeUuid(): ?string
{
return $this->saasEmployeeUuid;
}
public function setSaasEmployeeUuid(?string $saasEmployeeUuid): static
{
$this->saasEmployeeUuid = $saasEmployeeUuid;
return $this;
}
/**
* @return Collection<int, TeamerWorkMonth>
*/
public function getWorkMonths(): Collection
{
return $this->workMonths;
}
}
+440
View File
@@ -0,0 +1,440 @@
<?php
namespace App\Entity;
use App\Entity\Traits\TimestampableEntity;
use App\Enum\WorkMonthStatus;
use App\Model\WorkMonthDays;
use App\Repository\TeamerWorkMonthRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* One teamer's working time for one calendar month, imported from the SaaS time-tracking
* service, and the state of the Arbeitszeitnachweis the teamer signs for it.
*
* The days live in a JSON column rather than in one row per day: every read is "one teamer,
* one month", which a single row answers directly, and the signed document, its status and
* the import's own metadata all need exactly one monthly row to hang off. The figures a list
* sorts or filters on are denormalised into columns so nothing has to decode the blob to
* answer a question. App\Model\WorkMonthDays is the only place that knows the blob's shape.
*
* Deliberately not soft-deletable, like SeasonAssignmentTask and Upload: rows are derived
* from an import, never deleted by a user, and a soft-deleted row would silently shadow a
* re-imported one against the unique constraint below. Not blameable either - the importer
* writes these, not a person.
*/
#[ORM\Entity(repositoryClass: TeamerWorkMonthRepository::class)]
#[ORM\UniqueConstraint(name: 'teamer_work_month_unique', columns: ['teamer_id', 'year', 'month'])]
class TeamerWorkMonth implements TimestampableEntityInterface
{
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(inversedBy: 'workMonths')]
#[ORM\JoinColumn(nullable: false)]
private ?Teamer $teamer = null;
#[ORM\Column(type: Types::SMALLINT)]
private int $year;
/**
* The calendar month, 1-12. The SaaS counts months from zero; that index never leaves
* App\Saas.
*/
#[ORM\Column(type: Types::SMALLINT)]
private int $month;
/**
* @var array<string, array{minutes: int, absence?: string}>
*/
#[ORM\Column(type: Types::JSON)]
private array $days = [];
#[ORM\Column]
private int $totalMinutes = 0;
#[ORM\Column]
private int $workedDays = 0;
#[ORM\Column]
private int $absenceDays = 0;
/**
* The month total as the SaaS itself reported it. Kept alongside our own sum so a later
* divergence is visible without fetching the month again.
*/
#[ORM\Column]
private int $sourceIstMinutes = 0;
/**
* The month's target. A monthly figure by nature: every booking carries soll 0 with
* sollType MONTH, so there is no daily target to break it down into.
*/
#[ORM\Column]
private int $targetMinutes = 0;
#[ORM\Column]
private int $breakMinutes = 0;
/**
* The employee's department in the SaaS, shown in the head of the sheet. Nullable because
* a month of nothing but holiday carries no booking to read it from.
*/
#[ORM\Column(length: 255, nullable: true)]
private ?string $department = null;
#[ORM\Column(enumType: WorkMonthStatus::class)]
private WorkMonthStatus $status = WorkMonthStatus::OPEN;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $submittedAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $reviewedAt = null;
#[ORM\Column(length: 8, nullable: true)]
private ?string $reviewedBy = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $reviewComment = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $syncedAt;
/**
* Anything the parser could not make sense of, kept so a past import stays explicable.
*
* @var string[]|null
*/
#[ORM\Column(nullable: true)]
private ?array $warnings = null;
/**
* Set when an import found different hours for a month whose figures are frozen because
* the teamer already signed it. The stored hours stay as signed; this records that the
* SaaS has since disagreed.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $mismatchDetectedAt = null;
#[ORM\Column(nullable: true)]
private ?int $mismatchTotalMinutes = null;
/**
* Every signed copy the teamer has uploaded for this month, oldest first. They
* accumulate rather than replace, as season documents do, so a rejection and the
* re-upload that answered it both stay on the record.
*
* @var Collection<int, Upload>
*/
#[ORM\OneToMany(mappedBy: 'teamerWorkMonth', targetEntity: Upload::class)]
#[ORM\OrderBy(['createdAt' => \SortDirection::Ascending])]
private Collection $documents;
public function __construct()
{
$this->uuid = Uuid::v4();
$this->syncedAt = new \DateTimeImmutable();
$this->documents = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getTeamer(): ?Teamer
{
return $this->teamer;
}
public function setTeamer(?Teamer $teamer): static
{
$this->teamer = $teamer;
return $this;
}
public function getYear(): int
{
return $this->year;
}
public function setYear(int $year): static
{
$this->year = $year;
return $this;
}
public function getMonth(): int
{
return $this->month;
}
public function setMonth(int $month): static
{
$this->month = $month;
return $this;
}
/**
* The first of the month, for formatting and sorting in templates.
*/
public function getPeriod(): \DateTimeImmutable
{
return (new \DateTimeImmutable())->setDate($this->year, $this->month, 1)->setTime(0, 0);
}
/**
* "März 2026" - the way the month is named in mails and on the sheet itself.
*/
public function getPeriodLabel(): string
{
return (string) \IntlDateFormatter::formatObject($this->getPeriod(), 'LLLL yyyy', 'de');
}
public function getDays(): WorkMonthDays
{
return WorkMonthDays::fromArray($this->days);
}
public function setDays(WorkMonthDays $days): static
{
$this->days = $days->toArray();
$this->totalMinutes = $days->getTotalMinutes();
$this->breakMinutes = $days->getTotalBreakMinutes();
$this->workedDays = $days->countWorkedDays();
$this->absenceDays = $days->countAbsenceDays();
return $this;
}
public function getTotalMinutes(): int
{
return $this->totalMinutes;
}
public function getWorkedDays(): int
{
return $this->workedDays;
}
public function getAbsenceDays(): int
{
return $this->absenceDays;
}
public function getSourceIstMinutes(): int
{
return $this->sourceIstMinutes;
}
public function setSourceIstMinutes(int $sourceIstMinutes): static
{
$this->sourceIstMinutes = $sourceIstMinutes;
return $this;
}
public function getTargetMinutes(): int
{
return $this->targetMinutes;
}
public function setTargetMinutes(int $targetMinutes): static
{
$this->targetMinutes = $targetMinutes;
return $this;
}
public function getBreakMinutes(): int
{
return $this->breakMinutes;
}
public function getDepartment(): ?string
{
return $this->department;
}
public function setDepartment(?string $department): static
{
$this->department = $department;
return $this;
}
/**
* Worked minus target: positive is time over the month's target, negative under it.
*/
public function getBalanceMinutes(): int
{
return $this->totalMinutes - $this->targetMinutes;
}
public function getStatus(): WorkMonthStatus
{
return $this->status;
}
public function setStatus(WorkMonthStatus $status): static
{
$this->status = $status;
return $this;
}
public function getSubmittedAt(): ?\DateTimeImmutable
{
return $this->submittedAt;
}
public function setSubmittedAt(?\DateTimeImmutable $submittedAt): static
{
$this->submittedAt = $submittedAt;
return $this;
}
public function getReviewedAt(): ?\DateTimeImmutable
{
return $this->reviewedAt;
}
public function setReviewedAt(?\DateTimeImmutable $reviewedAt): static
{
$this->reviewedAt = $reviewedAt;
return $this;
}
public function getReviewedBy(): ?string
{
return $this->reviewedBy;
}
public function setReviewedBy(?string $reviewedBy): static
{
$this->reviewedBy = $reviewedBy;
return $this;
}
public function getReviewComment(): ?string
{
return $this->reviewComment;
}
public function setReviewComment(?string $reviewComment): static
{
$this->reviewComment = $reviewComment;
return $this;
}
public function getSyncedAt(): \DateTimeImmutable
{
return $this->syncedAt;
}
public function setSyncedAt(\DateTimeImmutable $syncedAt): static
{
$this->syncedAt = $syncedAt;
return $this;
}
/**
* @return string[]
*/
public function getWarnings(): array
{
return $this->warnings ?? [];
}
/**
* @param string[] $warnings
*/
public function setWarnings(array $warnings): static
{
$this->warnings = [] !== $warnings ? array_values($warnings) : null;
return $this;
}
public function getMismatchDetectedAt(): ?\DateTimeImmutable
{
return $this->mismatchDetectedAt;
}
public function getMismatchTotalMinutes(): ?int
{
return $this->mismatchTotalMinutes;
}
public function hasMismatch(): bool
{
return null !== $this->mismatchDetectedAt;
}
public function recordMismatch(?int $totalMinutes): static
{
$this->mismatchDetectedAt = new \DateTimeImmutable();
$this->mismatchTotalMinutes = $totalMinutes;
return $this;
}
public function clearMismatch(): static
{
$this->mismatchDetectedAt = null;
$this->mismatchTotalMinutes = null;
return $this;
}
/**
* @return Collection<int, Upload>
*/
public function getDocuments(): Collection
{
return $this->documents;
}
public function addDocument(Upload $document): static
{
if (!$this->documents->contains($document)) {
$this->documents->add($document);
$document->setTeamerWorkMonth($this);
}
return $this;
}
/**
* The signed copy that counts: the most recent one the teamer uploaded.
*/
public function getCurrentDocument(): ?Upload
{
return $this->documents->last() ?: null;
}
}
+51
View File
@@ -25,6 +25,24 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
public const TYPE_DRIVER_LICENSE = 'driver_license';
public const TYPE_RECEIPT = 'receipt';
/**
* A document the office issues to one single teamer. Deliberately not TYPE_DOCUMENT: that type
* is the staff-wide library and UploadVoter grants every teamer access to all of it.
*/
public const TYPE_SEASON_DOCUMENT_OUT = 'season_document_out';
/**
* A signed document the teamer returns. These accumulate, like receipts.
*/
public const TYPE_SEASON_DOCUMENT_IN = 'season_document_in';
/**
* A signed Arbeitszeitnachweis a teamer returns for one work month. These accumulate too,
* so a rejection and the re-upload answering it both stay on the month. Unlike the season
* documents this one is reviewed, so it carries a status rather than null.
*/
public const TYPE_WORK_MONTH = 'work_month';
public const STATUS_NEW = 'new';
public const STATUS_PENDING = 'pending';
public const STATUS_CHECKED = 'checked';
@@ -74,6 +92,12 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
#[ORM\ManyToOne(inversedBy: 'documents')]
private ?Disposition $disposition = null;
#[ORM\ManyToOne(inversedBy: 'documents')]
private ?SeasonAssignment $seasonAssignment = null;
#[ORM\ManyToOne(inversedBy: 'documents')]
private ?TeamerWorkMonth $teamerWorkMonth = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $comment = null;
@@ -146,6 +170,9 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
self::TYPE_DOCUMENT => 'Info',
self::TYPE_DRIVER_LICENSE => 'Führerschein',
self::TYPE_RECEIPT => 'Beleg',
self::TYPE_SEASON_DOCUMENT_OUT => 'Saisonunterlage',
self::TYPE_SEASON_DOCUMENT_IN => 'Rückläufer Saison',
self::TYPE_WORK_MONTH => 'Arbeitszeitnachweis',
default => 'Dokument',
};
}
@@ -268,6 +295,30 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
return $this;
}
public function getSeasonAssignment(): ?SeasonAssignment
{
return $this->seasonAssignment;
}
public function setSeasonAssignment(?SeasonAssignment $seasonAssignment): static
{
$this->seasonAssignment = $seasonAssignment;
return $this;
}
public function getTeamerWorkMonth(): ?TeamerWorkMonth
{
return $this->teamerWorkMonth;
}
public function setTeamerWorkMonth(?TeamerWorkMonth $teamerWorkMonth): static
{
$this->teamerWorkMonth = $teamerWorkMonth;
return $this;
}
public function getComment(): ?string
{
return $this->comment;
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Enum;
enum WorkHoursSyncOutcome: string
{
/** Hours were fetched and written. */
case IMPORTED = 'imported';
/** The month is signed, so the hours were compared but left alone. */
case LOCKED = 'locked';
/** Nothing to do: no address on file, or nothing to import. */
case SKIPPED = 'skipped';
/** The request or the payload failed. Worth waking someone for. */
case FAILED = 'failed';
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Enum;
/**
* Where one work month stands in the Arbeitszeitnachweis exchange.
*
* Deliberately not a Symfony workflow: the existing document state machine is keyed to
* Disposition's own marking store and cannot carry a month. Four states and two
* transitions do not need one.
*/
enum WorkMonthStatus: string
{
/** Imported, waiting for the teamer to download, sign and upload it. */
case OPEN = 'open';
/** The teamer uploaded a signed copy; the office has not looked at it yet. */
case SUBMITTED = 'submitted';
/** The office accepted the signed copy. */
case APPROVED = 'approved';
/** The office rejected it. The teamer uploads again, which returns it to SUBMITTED. */
case REJECTED = 'rejected';
public function label(): string
{
return match ($this) {
self::OPEN => 'Offen',
self::SUBMITTED => 'Eingereicht',
self::APPROVED => 'Geprüft',
self::REJECTED => 'Abgelehnt',
};
}
public function badgeClass(): string
{
return match ($this) {
self::OPEN => 'upload-status-badge--new',
self::SUBMITTED => 'upload-status-badge--default',
self::APPROVED => 'upload-status-badge--checked',
self::REJECTED => 'upload-status-badge--error',
};
}
/**
* Whether the imported hours are frozen.
*
* Once a teamer has signed a month, the stored hours have to keep matching the paper
* they signed, so a later import may compare but not overwrite. A rejected month is
* not locked: a rejection usually means the hours themselves were wrong.
*/
public function locksHours(): bool
{
return match ($this) {
self::SUBMITTED, self::APPROVED => true,
self::OPEN, self::REJECTED => false,
};
}
}
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\EventListener;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Mailer\Event\MessageEvent;
use Symfony\Component\Mime\Address;
/**
* Keeps mail away from real people on deployments that are not production.
*
* Staging runs the real transports against a copy of the production data, so every recipient
* in that database is a real address. With a whitelist configured, an envelope is reduced to
* the recipients it allows and a mail left without any recipient is not sent at all.
*
* An empty whitelist means "do nothing", never "drop everything": production runs with the
* environment variable unset and is therefore unaffected by this class. This is a deployment
* guard - no business rule decides who may be mailed here.
*
* Symfony's own framework.mailer.envelope handling cannot do this: it redirects everything to
* a catch-all address, and its allowed_recipients only adds the real recipient back on top.
*/
class EmailRecipientWhitelistSubscriber implements EventSubscriberInterface
{
/**
* @var string[]
*/
private readonly array $allowedRecipients;
/**
* @param string[] $allowedRecipients regular expressions matched against the full address
*
* @throws \InvalidArgumentException on a pattern that is not a valid regular expression;
* the service is lazy, so this surfaces on the first mail
*/
public function __construct(
array $allowedRecipients,
private readonly LoggerInterface $logger,
) {
// A blank entry - a stray space in a .env.local, a deploy template emitting an empty
// value - would otherwise make the whitelist non-empty while matching no address at
// all, which silently drops every mail. Dropped here, so "empty" stays "do nothing".
$patterns = array_values(array_filter(array_map(trim(...), $allowedRecipients), static fn (string $pattern) => '' !== $pattern));
foreach ($patterns as $pattern) {
// Checked once, here, rather than per address: an invalid pattern makes preg_match()
// return false for every address, which looks exactly like "nobody is whitelisted"
// and eats the mail. Blowing up on the first send is the louder failure.
if (false === @preg_match($this->toRegex($pattern), '')) {
throw new \InvalidArgumentException(\sprintf('Invalid recipient whitelist pattern "%s": it is not a valid regular expression.', $pattern));
}
}
$this->allowedRecipients = $patterns;
}
public static function getSubscribedEvents(): array
{
return [
// After MessageListener, which applies the configured default headers - a Bcc
// added there has to be filtered too - and before EnvelopeListener and
// MessageLoggerListener at -255, so a dropped mail is not recorded as sent.
MessageEvent::class => ['onMessage', -250],
];
}
public function onMessage(MessageEvent $event): void
{
if (!$this->allowedRecipients) {
return;
}
$envelope = $event->getEnvelope();
$recipients = $envelope->getRecipients();
$kept = [];
$stripped = [];
foreach ($recipients as $recipient) {
if ($this->isAllowed($recipient)) {
$kept[] = $recipient;
} else {
$stripped[] = $recipient->getAddress();
}
}
if (!$stripped) {
return;
}
if (!$kept) {
$this->logger->info('Mail dropped: no whitelisted recipient', [
'recipients' => $stripped,
'subject' => $this->subjectOf($event),
]);
$event->reject();
return;
}
$envelope->setRecipients($kept);
$this->logger->info('Mail recipients stripped: not whitelisted', [
'stripped' => $stripped,
'kept' => array_map(static fn (Address $address) => $address->getAddress(), $kept),
'subject' => $this->subjectOf($event),
]);
}
private function isAllowed(Address $recipient): bool
{
foreach ($this->allowedRecipients as $pattern) {
if (preg_match($this->toRegex($pattern), $recipient->getAddress())) {
return true;
}
}
return false;
}
/**
* Anchored the way Symfony\Component\Mailer\EventListener\EnvelopeListener anchors its own
* patterns: a pattern always has to match the whole address.
*/
private function toRegex(string $pattern): string
{
return '{\A'.$pattern.'\z}i';
}
private function subjectOf(MessageEvent $event): ?string
{
$message = $event->getMessage();
return method_exists($message, 'getSubject') ? $message->getSubject() : null;
}
}
+1 -5
View File
@@ -122,13 +122,9 @@ class AssignmentType extends AbstractType
;
},
])
->add('remarks', TextareaType::class, [
->add('remarks', AutosizeTextareaType::class, [
'label' => 'Anmerkungen intern',
'required' => false,
'attr' => [
'data-controller' => 'textarea-autosize',
'data-action' => 'textarea-autosize#resize',
],
])
->add('pickup', ChoiceType::class, [
'label' => 'Busbegleitung ab',
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* A textarea that grows with its content, wiring up assets/controllers/textarea_autosize_controller.js.
*
* The controller overwrites the rows attribute on connect, so `rows` never had an effect on
* these fields - `min_rows` is the floor it actually reads.
*/
class AutosizeTextareaType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['min_rows' => 1]);
$resolver->setAllowedTypes('min_rows', 'int');
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
// Merged rather than set, and the caller's own attributes win: a field that needs a
// second Stimulus controller on the same element composes the data-controller list
// itself, which is the only case this can collide on.
$view->vars['attr'] = array_merge([
'data-controller' => 'textarea-autosize',
'data-action' => 'textarea-autosize#resize',
'data-textarea-autosize-min-rows-value' => $options['min_rows'],
], $view->vars['attr']);
}
public function getParent(): string
{
return TextareaType::class;
}
}
+2 -11
View File
@@ -4,7 +4,6 @@ namespace App\Form;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
@@ -14,23 +13,15 @@ class DisableUserType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('disabledReason', TextareaType::class, [
->add('disabledReason', AutosizeTextareaType::class, [
'label' => 'Begründung',
'attr' => [
'data-controller' => 'textarea-autosize',
'data-action' => 'textarea-autosize#resize',
],
'constraints' => [
new NotBlank(message: 'Bitte gib die Begründung ein'),
],
])
->add('disabledReasonInternal', TextareaType::class, [
->add('disabledReasonInternal', AutosizeTextareaType::class, [
'label' => 'Begründung intern',
'required' => false,
'attr' => [
'data-controller' => 'textarea-autosize',
'data-action' => 'textarea-autosize#resize',
],
])
;
}
+1 -6
View File
@@ -6,7 +6,6 @@ use App\Entity\Disposition;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
@@ -23,12 +22,8 @@ class DispositionCallOffType extends AbstractType
'E&P' => Disposition::CALLED_OFF_BY_OFFICE,
],
])
->add('calledOffReason', TextareaType::class, [
->add('calledOffReason', AutosizeTextareaType::class, [
'label' => 'Begründung',
'attr' => [
'data-controller' => 'textarea-autosize',
'data-action' => 'textarea-autosize#resize',
],
'constraints' => [
new NotBlank(message: 'Bitte gib die Begründung ein'),
],
+2 -7
View File
@@ -6,7 +6,6 @@ use App\Config\EmailTextDefinition;
use App\Entity\EmailText;
use App\Validator\KnownPlaceholders;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
@@ -31,13 +30,9 @@ class EmailTextType extends AbstractType
'required' => false,
'constraints' => [$placeholders],
])
->add('body', TextareaType::class, [
->add('body', AutosizeTextareaType::class, [
'label' => 'Text',
'attr' => [
'data-controller' => 'textarea-autosize',
'data-action' => 'textarea-autosize#resize',
'data-textarea-autosize-min-rows-value' => 12,
],
'min_rows' => 12,
'constraints' => [$placeholders],
])
;
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Form;
use App\Config\HouseCatalog;
use App\Entity\JobProfile;
use App\Model\SeasonAssignmentFilterDto;
use Doctrine\ORM\EntityRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class SeasonAssignmentFilterType extends AbstractType
{
public function __construct(private readonly HouseCatalog $houseCatalog)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('teamerName', TextType::class, [
'label' => 'Name',
'required' => false,
])
// By code, not by name as the destination filters do: a season assignment stores
// the code itself, so this is an exact match rather than a substring guess.
->add('hotelCodes', MultiselectType::class, [
'label' => 'Haus',
'required' => false,
'empty_label' => 'nicht filtern',
'choices' => $this->houseCatalog->getCodeChoices(),
])
->add('jobProfiles', MultiselectEntityType::class, [
'label' => 'Job-Profile',
'class' => JobProfile::class,
'required' => false,
'choice_label' => 'name',
'empty_label' => 'nicht filtern',
'query_builder' => function (EntityRepository $repository) {
return $repository->createQueryBuilder('job_profile')
->orderBy('job_profile.name', \SortDirection::Ascending);
},
])
->add('includeDeleted', CheckboxType::class, [
'label' => 'gelöschte Saisoneinsätze anzeigen',
'required' => false,
])
// AbstractFilterHandler::handleRequest() branches on exactly these two names
->add('apply', SubmitType::class, [
'label' => 'filtern',
])
->add('reset', SubmitType::class, [
'label' => 'reset',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setDefaults([
'data_class' => SeasonAssignmentFilterDto::class,
])
;
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Form;
use App\Config\HouseCatalog;
use App\Entity\JobProfile;
use App\Entity\SeasonAssignment;
use App\Entity\Teamer;
use Doctrine\ORM\EntityRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class SeasonAssignmentType extends AbstractType
{
public function __construct(private readonly HouseCatalog $houseCatalog)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
// Optional: assignments are laid out first and staffed later. The endpoint only
// ever answers with all-season teamers, so a short term teamer cannot be picked
// here even by typing their full name.
->add('teamer', AutocompleteEntityType::class, [
'label' => 'Teamer:in',
'class' => Teamer::class,
'required' => false,
'placeholder' => 'noch nicht zugewiesen',
'label_property' => 'fullName',
'label_function' => fn (Teamer $teamer) => (string) $teamer,
'endpoint_route' => 'app_admin_autocomplete_season_teamer',
])
->add('dateFrom', DatepickerType::class, [
'label' => 'Datum von',
])
->add('dateTo', DatepickerType::class, [
'label' => 'Datum bis',
])
// The houses map is the whole list of places an all-season teamer can be placed, so
// a plain select over it needs neither an autocomplete endpoint nor a BusPro call.
->add('hotelCode', ChoiceType::class, [
'label' => 'Haus',
'placeholder' => 'Bitte auswählen',
'choices' => $this->houseCatalog->getCodeChoices(),
])
->add('jobProfile', EntityType::class, [
'label' => 'Jobprofil',
'class' => JobProfile::class,
'choice_label' => 'name',
'placeholder' => 'Bitte auswählen',
'query_builder' => function (EntityRepository $repository) {
return $repository->createQueryBuilder('job_profile')
->where('job_profile.deletedAt IS NULL')
->orderBy('job_profile.name', \SortDirection::Ascending);
},
])
->add('remarks', AutosizeTextareaType::class, [
'label' => 'Anmerkungen intern',
'required' => false,
'min_rows' => 4,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setDefaults([
'data_class' => SeasonAssignment::class,
])
;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Form;
use App\Entity\SeasonAssignmentTask;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* The two deadline overrides of one checklist entry.
*
* Both are optional: an empty field means "not overridden", and the deadline falls back to what
* App\Service\Season\SeasonTaskDeadlineResolver computes from the destination's travel start.
*/
class SeasonTaskDeadlineType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('deadlineOverride1', DatepickerType::class, [
'label' => 'Frist 1',
'required' => false,
])
->add('deadlineOverride2', DatepickerType::class, [
'label' => 'Frist 2',
'required' => false,
'help' => 'Ohne Angabe gilt Frist 1.',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => SeasonAssignmentTask::class,
]);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Form;
use App\Entity\SeasonTaskDocument;
use App\Entity\Upload;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Either an existing library document or a newly uploaded one.
*
* The upload itself does not travel in the form - it goes through the dropzone into the upload
* session, like everywhere else in this app - so the controller decides which of the two the
* mapping ends up with: a dropped file wins over the pick, and a submit carrying neither is
* refused.
*/
class SeasonTaskDocumentType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('document', AutocompleteEntityType::class, [
'label' => 'Vorhandenes Dokument',
'required' => false,
'class' => Upload::class,
'label_property' => 'originalFilename',
'label_function' => fn (Upload $upload) => $upload->getDisplayName() ?? $upload->getOriginalFilename(),
'endpoint_route' => 'app_admin_autocomplete_document',
])
// Unmapped: it names the uploaded file, which is an Upload and not part of the
// mapping. Renaming a picked document would rename it for the whole library.
->add('displayName', TextType::class, [
'label' => 'angezeigter Name der neuen Datei',
'required' => false,
'mapped' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => SeasonTaskDocument::class,
]);
}
}
+1 -6
View File
@@ -4,7 +4,6 @@ namespace App\Form;
use App\Entity\Disposition;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -12,13 +11,9 @@ class SpecialAgreementsType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('specialAgreements', TextareaType::class, [
$builder->add('specialAgreements', AutosizeTextareaType::class, [
'label' => 'zusätzliche Absprache',
'required' => false,
'attr' => [
'data-controller' => 'textarea-autosize',
'data-action' => 'textarea-autosize#resize',
],
]);
}
+2 -7
View File
@@ -4,7 +4,6 @@ namespace App\Form;
use App\Model\TeamerMailingDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -17,13 +16,9 @@ class TeamerMailingType extends AbstractType
->add('subject', TextType::class, [
'label' => 'Betreff',
])
->add('message', TextareaType::class, [
->add('message', AutosizeTextareaType::class, [
'label' => 'Nachricht',
'attr' => [
'data-controller' => 'textarea-autosize',
'data-action' => 'textarea-autosize#resize',
'data-textarea-autosize-min-rows-value' => 12,
],
'min_rows' => 12,
])
;
}
+2 -11
View File
@@ -5,7 +5,6 @@ namespace App\Form;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
@@ -30,23 +29,15 @@ class UserType extends AbstractType
'required' => false,
'help' => 'Gesperrte Benutzer:innen können sich nicht anmelden.',
])
->add('disabledReason', TextareaType::class, [
->add('disabledReason', AutosizeTextareaType::class, [
'label' => 'Begründung',
'required' => false,
'help' => 'Wird bei der Anmeldung angezeigt.',
'attr' => [
'data-controller' => 'textarea-autosize',
'data-action' => 'textarea-autosize#resize',
],
])
->add('disabledReasonInternal', TextareaType::class, [
->add('disabledReasonInternal', AutosizeTextareaType::class, [
'label' => 'Begründung intern',
'required' => false,
'help' => 'Wird der Benutzer:in nicht angezeigt.',
'attr' => [
'data-controller' => 'textarea-autosize',
'data-action' => 'textarea-autosize#resize',
],
])
;
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Form;
use App\Enum\WorkMonthStatus;
use App\Model\WorkMonthFilterDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class WorkMonthFilterType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('teamerName', TextType::class, [
'label' => 'Name',
'required' => false,
])
// The day is irrelevant here; FirstDayOfMonthDateTransformer pins it to the 1st.
->add('period', AbbreviatedDateType::class, [
'label' => 'Monat',
'required' => false,
'years' => range((int) date('Y') + 1, (int) date('Y') - 5),
])
->add('statuses', MultiselectType::class, [
'label' => 'Status',
'required' => false,
'empty_label' => 'nicht filtern',
'choices' => WorkMonthStatus::cases(),
'choice_label' => static fn (WorkMonthStatus $status) => $status->label(),
'choice_value' => static fn (?WorkMonthStatus $status) => $status?->value,
])
->add('includeApproved', CheckboxType::class, [
'label' => 'geprüfte Monate anzeigen',
'required' => false,
])
->add('includeMismatchedOnly', CheckboxType::class, [
'label' => 'nur Monate mit abweichenden Stunden',
'required' => false,
])
// AbstractFilterHandler::handleRequest() branches on exactly these two names
->add('apply', SubmitType::class, [
'label' => 'filtern',
])
->add('reset', SubmitType::class, [
'label' => 'reset',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => WorkMonthFilterDto::class,
]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Form;
use App\Enum\WorkMonthStatus;
use App\Model\WorkMonthReviewDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class WorkMonthReviewType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('status', ChoiceType::class, [
'label' => 'Entscheidung',
'expanded' => true,
'placeholder' => false,
// Only the two outcomes a review produces: "offen" and "eingereicht" are
// states the teamer puts the month into, not the office.
'choices' => [
WorkMonthStatus::APPROVED->label() => WorkMonthStatus::APPROVED,
WorkMonthStatus::REJECTED->label() => WorkMonthStatus::REJECTED,
],
'choice_value' => static fn (?WorkMonthStatus $status) => $status?->value,
])
->add('comment', TextareaType::class, [
'label' => 'Kommentar/Begründung',
'required' => false,
'attr' => [
'rows' => 3,
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => WorkMonthReviewDto::class,
]);
}
}
@@ -0,0 +1,99 @@
<?php
namespace App\Logging;
use Monolog\LogRecord;
/**
* Keeps SaaS api keys out of the log while leaving the requests themselves in it.
*
* The SaaS takes its api key as a path segment rather than in a header, so the url of every
* call carries a credential. Symfony's http client logs a line per request, and an HTTP error
* repeats the url inside the exception message, which then travels into whatever channel
* catches it - so silencing one client would not have been enough.
*
* Registered without a channel, deliberately: a url can surface anywhere, and a processor
* that only guarded the saas channel would miss the exception that matters most.
*
* Two passes, because the two failure modes are different. The configured key is redacted
* wherever it appears, whole or url-encoded, which is exact. The path shapes are redacted by
* pattern, which also covers the per-employee keys the roster hands out and any future
* endpoint that follows the same convention.
*/
class RedactSaasCredentialsProcessor
{
private const PLACEHOLDER = '[redacted]';
/**
* Anything sitting where an api key sits: /api/v2/<something>/<key>[/...].
*/
private const PATH_PATTERNS = [
'#(/api/v[0-9]+/time/account/)[^/\s"\']+#i',
'#(/api/v[0-9]+/employees/)[^/\s"\']+#i',
'#(/api/v[0-9]+/employee/departments/)[^/\s"\']+#i',
];
/**
* @var string[]
*/
private readonly array $secrets;
public function __construct(string $apiKey)
{
$secrets = [];
// Short values would match far too much; a real key never is.
if (8 <= strlen($apiKey)) {
$secrets[$apiKey] = $apiKey;
$secrets[rawurlencode($apiKey)] = rawurlencode($apiKey);
}
$this->secrets = array_values($secrets);
}
public function __invoke(LogRecord $record): LogRecord
{
$message = $this->redact($record->message);
$context = $this->redactDeep($record->context);
$extra = $this->redactDeep($record->extra);
if ($message === $record->message && $context === $record->context && $extra === $record->extra) {
return $record;
}
// LogRecord only allows some of its fields to be assigned through array access;
// message is not one of them, so a redacted record is a new one.
return $record->with(message: $message, context: $context, extra: $extra);
}
private function redactDeep(array $values, int $depth = 0): array
{
// Contexts hold arbitrary payloads; stop rather than walk something pathological.
if (8 < $depth) {
return $values;
}
foreach ($values as $key => $value) {
if (is_string($value)) {
$values[$key] = $this->redact($value);
} elseif (is_array($value)) {
$values[$key] = $this->redactDeep($value, $depth + 1);
}
}
return $values;
}
private function redact(string $value): string
{
foreach ($this->secrets as $secret) {
$value = str_replace($secret, self::PLACEHOLDER, $value);
}
foreach (self::PATH_PATTERNS as $pattern) {
$value = (string) preg_replace($pattern, '$1'.self::PLACEHOLDER, $value);
}
return $value;
}
}
+47
View File
@@ -116,6 +116,42 @@ class AdminMenuBuilder extends AbstractMenuBuilder
],
],
]);
$menu->addChild('Saisonteamer', [
'route' => 'app_administrative_season_teamer_index',
'linkAttributes' => [
'title' => 'Saisonteamer',
],
'extras' => [
'icon' => 'users',
'routes' => [
['pattern' => '/^app_administrative_season_teamer_/'],
],
],
]);
$menu->addChild('Saisoneinsätze', [
'route' => 'app_administrative_season_assignment_index',
'linkAttributes' => [
'title' => 'Saisoneinsätze',
],
'extras' => [
'icon' => 'calendar',
'routes' => [
['pattern' => '/^app_administrative_season_assignment_/'],
],
],
]);
$menu->addChild('Arbeitszeiten', [
'route' => 'app_admin_work_month_index',
'linkAttributes' => [
'title' => 'Arbeitszeiten',
],
'extras' => [
'icon' => 'hourglass',
'routes' => [
['pattern' => '/^app_admin_work_month_/'],
],
],
]);
$menu->addChild('Verfügbarkeiten', [
'route' => 'app_administrative_availability_index',
'linkAttributes' => [
@@ -272,6 +308,17 @@ class AdminMenuBuilder extends AbstractMenuBuilder
],
],
]);
$settingsMenu->addChild('Saisonteam-Dokumente', [
'route' => 'app_administrative_system_season_task_document_index',
'linkAttributes' => [
'title' => 'Saisonteam-Dokumente',
],
'extras' => [
'routes' => [
['pattern' => '/^app_administrative_system_season_task_document_/'],
],
],
]);
$settingsMenu->addChild('Ansprechpersonen', [
'route' => 'app_admin_system_contact_index',
'linkAttributes' => [
+35
View File
@@ -82,6 +82,30 @@ class ManagerMenuBuilder extends AbstractMenuBuilder
],
],
]);
$menu->addChild('Saisonteamer', [
'route' => 'app_administrative_season_teamer_index',
'linkAttributes' => [
'title' => 'Saisonteamer',
],
'extras' => [
'icon' => 'users',
'routes' => [
['pattern' => '/^app_administrative_season_teamer_/'],
],
],
]);
$menu->addChild('Saisoneinsätze', [
'route' => 'app_administrative_season_assignment_index',
'linkAttributes' => [
'title' => 'Saisoneinsätze',
],
'extras' => [
'icon' => 'calendar',
'routes' => [
['pattern' => '/^app_administrative_season_assignment_/'],
],
],
]);
$menu->addChild('Feedbackübersicht', [
'route' => 'app_administrative_feedback_index',
'linkAttributes' => [
@@ -175,6 +199,17 @@ class ManagerMenuBuilder extends AbstractMenuBuilder
],
],
]);
$settingsMenu->addChild('Saisonunterlagen', [
'route' => 'app_administrative_system_season_task_document_index',
'linkAttributes' => [
'title' => 'Saisonunterlagen',
],
'extras' => [
'routes' => [
['pattern' => '/^app_administrative_system_season_task_document_/'],
],
],
]);
$this->addAdminItem($menu);
$this->addTeamerItem($menu);
+68
View File
@@ -2,10 +2,27 @@
namespace App\Menu;
use App\Entity\SeasonAssignment;
use App\Entity\User;
use App\Service\Season\SeasonAssignmentResolver;
use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Contracts\Translation\TranslatorInterface;
class TeamerMenuBuilder extends AbstractMenuBuilder
{
public function __construct(
FactoryInterface $factory,
Security $security,
RequestStack $requestStack,
TranslatorInterface $translator,
private readonly SeasonAssignmentResolver $seasonAssignmentResolver,
) {
parent::__construct($factory, $security, $requestStack, $translator);
}
public function createMainMenu(array $options): ItemInterface
{
$menu = $this->createRootElement();
@@ -36,6 +53,39 @@ class TeamerMenuBuilder extends AbstractMenuBuilder
'icon' => 'profile',
],
]);
// Only for an all-season teamer who actually has an assignment this season: the page
// would have nothing to show otherwise, and it throws a 404 in exactly that case.
if (null !== $this->resolveSeasonAssignment()) {
$profileMenu->addChild('Saison-Teamer:in', [
'route' => 'app_teamer_season_index',
'linkAttributes' => [
'title' => 'Saison-Teamer:in',
],
'extras' => [
'routes' => [
['pattern' => '/^app_teamer_season_/'],
],
],
]);
}
// Season teamers only, and on the flag rather than on a current assignment: a month
// still has to be reachable after the season it belongs to has ended.
if (true === $this->isSeasonTeamer()) {
$profileMenu->addChild('Arbeitszeiten', [
'route' => 'app_teamer_work_month_index',
'linkAttributes' => [
'title' => 'Arbeitszeiten',
],
'extras' => [
'routes' => [
['pattern' => '/^app_teamer_work_month_/'],
],
],
]);
}
$profileMenu->addChild('Stammdaten &amp; Foto', [
'route' => 'app_teamer_profile_index',
'linkAttributes' => [
@@ -120,4 +170,22 @@ class TeamerMenuBuilder extends AbstractMenuBuilder
return $menu;
}
private function isSeasonTeamer(): bool
{
$user = $this->security->getUser();
return $user instanceof User && true === $user->getTeamer()?->isAllSeason();
}
private function resolveSeasonAssignment(): ?SeasonAssignment
{
$user = $this->security->getUser();
if (false === $user instanceof User || null === $teamer = $user->getTeamer()) {
return null;
}
return $this->seasonAssignmentResolver->resolveFor($teamer);
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Model;
use App\Entity\JobProfile;
class SeasonAssignmentFilterDto extends AbstractFilterDto
{
protected ?string $teamerName = null;
/** @var string[] keys of the configured "houses" map */
protected array $hotelCodes = [];
protected array $jobProfiles = [];
protected bool $includeDeleted = false;
public function getTeamerName(): ?string
{
return $this->teamerName;
}
public function setTeamerName(?string $teamerName): static
{
$this->teamerName = $teamerName;
return $this;
}
/**
* @return string[]
*/
public function getHotelCodes(): array
{
return $this->hotelCodes;
}
/**
* @param string[] $hotelCodes
*/
public function setHotelCodes(array $hotelCodes): static
{
$this->hotelCodes = $hotelCodes;
return $this;
}
/**
* @return JobProfile[]
*/
public function getJobProfiles(): array
{
return $this->jobProfiles;
}
public function setJobProfiles(array $jobProfiles): static
{
$this->jobProfiles = $jobProfiles;
return $this;
}
public function isIncludeDeleted(): bool
{
return $this->includeDeleted;
}
public function setIncludeDeleted(bool $includeDeleted): static
{
$this->includeDeleted = $includeDeleted;
return $this;
}
}
+20
View File
@@ -15,6 +15,14 @@ class TeamerFilterDto extends AbstractFilterDto
protected bool $includeInactive = false;
protected bool $includeDeleted = false;
/**
* The scope of the list this filter belongs to, not a filter the user set.
*
* The leading underscore keeps it out of AbstractFilterDto::getActiveFiltersCount(),
* so the all-season list does not permanently claim to have one filter active.
*/
protected bool $_allSeason = false;
public function getName(): ?string
{
return $this->name;
@@ -132,4 +140,16 @@ class TeamerFilterDto extends AbstractFilterDto
return $this;
}
public function isAllSeason(): bool
{
return $this->_allSeason;
}
public function setAllSeason(bool $allSeason): static
{
$this->_allSeason = $allSeason;
return $this;
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace App\Model;
use App\Entity\Teamer;
use App\Enum\WorkHoursSyncOutcome;
/**
* What one teamer's import did, so the command and the admin action can report it without
* the synchronizer having to know about either.
*/
class WorkHoursSyncResult
{
/**
* @param string[] $warnings
*/
private function __construct(
private readonly Teamer $teamer,
private readonly WorkHoursSyncOutcome $outcome,
private readonly ?string $message = null,
private readonly ?int $totalMinutes = null,
private readonly array $warnings = [],
) {
}
/**
* @param string[] $warnings
*/
public static function imported(Teamer $teamer, int $totalMinutes, array $warnings = []): self
{
return new self($teamer, WorkHoursSyncOutcome::IMPORTED, null, $totalMinutes, $warnings);
}
public static function locked(Teamer $teamer, string $message, ?int $totalMinutes = null): self
{
return new self($teamer, WorkHoursSyncOutcome::LOCKED, $message, $totalMinutes);
}
public static function skipped(Teamer $teamer, string $message): self
{
return new self($teamer, WorkHoursSyncOutcome::SKIPPED, $message);
}
public static function failed(Teamer $teamer, string $message): self
{
return new self($teamer, WorkHoursSyncOutcome::FAILED, $message);
}
public function getTeamer(): Teamer
{
return $this->teamer;
}
public function getOutcome(): WorkHoursSyncOutcome
{
return $this->outcome;
}
public function getMessage(): ?string
{
return $this->message;
}
public function getTotalMinutes(): ?int
{
return $this->totalMinutes;
}
/**
* @return string[]
*/
public function getWarnings(): array
{
return $this->warnings;
}
public function isFailure(): bool
{
return WorkHoursSyncOutcome::FAILED === $this->outcome;
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App\Model;
/**
* One calendar day as a work-month view renders it.
*
* "No minutes" and "zero minutes" are different things here: null means the SaaS reported
* nothing at all for that day, 0 means it reported a day that happens to add up to zero -
* a rest day, say. The view shows the first as a dash and the second as 0:00.
*/
class WorkMonthDay
{
public function __construct(
private readonly \DateTimeImmutable $date,
private readonly ?int $minutes,
private readonly ?string $absence,
private readonly ?string $startTime = null,
private readonly ?string $endTime = null,
private readonly int $breakMinutes = 0,
) {
}
public function getDate(): \DateTimeImmutable
{
return $this->date;
}
public function getMinutes(): ?int
{
return $this->minutes;
}
public function getAbsence(): ?string
{
return $this->absence;
}
public function getStartTime(): ?string
{
return $this->startTime;
}
public function getEndTime(): ?string
{
return $this->endTime;
}
public function getBreakMinutes(): int
{
return $this->breakMinutes;
}
/**
* Whether the day has a span to print. A rest day is booked but never worked in.
*/
public function hasSpan(): bool
{
return null !== $this->startTime && null !== $this->endTime;
}
public function isAbsence(): bool
{
return null !== $this->absence;
}
public function isReported(): bool
{
return null !== $this->minutes;
}
public function isWeekend(): bool
{
return in_array((int) $this->date->format('N'), [6, 7], true);
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
namespace App\Model;
use App\Saas\Model\TimeAccount;
use App\Saas\Model\TimeAccountDay;
/**
* The days of one work month, and the only place that knows how they are stored.
*
* TeamerWorkMonth keeps its days in a JSON column rather than in rows: every read is
* "one teamer, one month", which one row answers directly. The price is that the shape is
* invisible to the schema, so it is kept small and passes through here alone - a change to
* it is a change to this file and nothing else.
*
* Shape, with everything optional but "minutes" left out when it has nothing to say:
* {"2026-03-01": {"minutes": 285, "break": 60, "start": "09:00", "end": "14:45"},
* "2026-03-05": {"minutes": 0, "absence": "Ruhetag 24/25"}}
* Days the SaaS did not report are simply absent; forCalendarMonth() fills the gaps.
*/
class WorkMonthDays
{
/**
* @param array<string, array{minutes: int, break?: int, start?: string, end?: string, absence?: string}> $days keyed by Y-m-d
*/
private function __construct(private readonly array $days)
{
}
public static function fromArray(array $days): self
{
$clean = [];
foreach ($days as $key => $value) {
if (!is_string($key) || !is_array($value) || 1 !== preg_match('/^\d{4}-\d{2}-\d{2}$/', $key)) {
continue;
}
$entry = ['minutes' => (int) ($value['minutes'] ?? 0)];
if (0 < $break = (int) ($value['break'] ?? 0)) {
$entry['break'] = $break;
}
$start = self::cleanTime($value['start'] ?? null);
$end = self::cleanTime($value['end'] ?? null);
// A span needs both ends; half of one says nothing.
if (null !== $start && null !== $end) {
$entry['start'] = $start;
$entry['end'] = $end;
}
$absence = $value['absence'] ?? null;
if (is_string($absence) && '' !== $absence) {
$entry['absence'] = $absence;
}
$clean[$key] = $entry;
}
ksort($clean);
return new self($clean);
}
public static function fromTimeAccount(TimeAccount $account): self
{
$days = [];
foreach ($account->getDays() as $key => $day) {
/* @var TimeAccountDay $day */
$days[$key] = ['minutes' => $day->getMinutes()];
if (0 < $day->getBreakMinutes()) {
$days[$key]['break'] = $day->getBreakMinutes();
}
if (null !== $day->getStartTime() && null !== $day->getEndTime()) {
$days[$key]['start'] = $day->getStartTime();
$days[$key]['end'] = $day->getEndTime();
}
if (null !== $day->getHolidayType()) {
$days[$key]['absence'] = $day->getHolidayType();
}
}
return new self($days);
}
private static function cleanTime(mixed $raw): ?string
{
return is_string($raw) && 1 === preg_match('/^\d{2}:\d{2}$/', $raw) ? $raw : null;
}
public function toArray(): array
{
return $this->days;
}
public function getTotalMinutes(): int
{
return array_sum(array_column($this->days, 'minutes'));
}
public function countWorkedDays(): int
{
return count(array_filter($this->days, static fn (array $day) => ($day['minutes'] ?? 0) > 0));
}
public function countAbsenceDays(): int
{
return count(array_filter($this->days, static fn (array $day) => isset($day['absence'])));
}
public function getTotalBreakMinutes(): int
{
return array_sum(array_map(static fn (array $day) => $day['break'] ?? 0, $this->days));
}
/**
* Every day of the month in order, reported or not, for tables and the PDF.
*
* @return list<WorkMonthDay>
*/
public function forCalendarMonth(int $year, int $month): array
{
$first = \DateTimeImmutable::createFromFormat('!Y-n-j', sprintf('%04d-%d-1', $year, $month));
if (false === $first) {
return [];
}
$days = [];
for ($day = 1, $last = (int) $first->format('t'); $day <= $last; ++$day) {
$date = $first->setDate($year, $month, $day);
$entry = $this->days[$date->format('Y-m-d')] ?? null;
$days[] = new WorkMonthDay(
$date,
null !== $entry ? (int) $entry['minutes'] : null,
$entry['absence'] ?? null,
$entry['start'] ?? null,
$entry['end'] ?? null,
(int) ($entry['break'] ?? 0),
);
}
return $days;
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace App\Model;
use App\Enum\WorkMonthStatus;
class WorkMonthFilterDto extends AbstractFilterDto
{
protected ?string $teamerName = null;
/**
* Normalised to the first of the month by FirstDayOfMonthDateTransformer.
*/
protected ?\DateTimeImmutable $period = null;
/** @var WorkMonthStatus[] */
protected array $statuses = [];
/**
* Approved months are done with, so the list is about what still needs attention.
*/
protected bool $includeApproved = false;
/**
* Months whose hours the SaaS has since disagreed with, which is a rare thing to go
* looking for and a noisy thing to see by default.
*/
protected bool $includeMismatchedOnly = false;
public function getTeamerName(): ?string
{
return $this->teamerName;
}
public function setTeamerName(?string $teamerName): static
{
$this->teamerName = $teamerName;
return $this;
}
public function getPeriod(): ?\DateTimeImmutable
{
return $this->period;
}
public function setPeriod(?\DateTimeImmutable $period): static
{
$this->period = $period;
return $this;
}
/**
* @return WorkMonthStatus[]
*/
public function getStatuses(): array
{
return $this->statuses;
}
/**
* @param WorkMonthStatus[] $statuses
*/
public function setStatuses(array $statuses): static
{
$this->statuses = $statuses;
return $this;
}
public function isIncludeApproved(): bool
{
return $this->includeApproved;
}
public function setIncludeApproved(bool $includeApproved): static
{
$this->includeApproved = $includeApproved;
return $this;
}
public function isIncludeMismatchedOnly(): bool
{
return $this->includeMismatchedOnly;
}
public function setIncludeMismatchedOnly(bool $includeMismatchedOnly): static
{
$this->includeMismatchedOnly = $includeMismatchedOnly;
return $this;
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Model;
use App\Entity\TeamerWorkMonth;
use App\Enum\WorkMonthStatus;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
class WorkMonthReviewDto
{
private TeamerWorkMonth $workMonth;
#[Assert\NotNull(message: 'Bitte eine Entscheidung treffen')]
private ?WorkMonthStatus $status;
private ?string $comment;
public function __construct(TeamerWorkMonth $workMonth)
{
$this->workMonth = $workMonth;
$this->status = null;
$this->comment = $workMonth->getReviewComment();
}
public function getWorkMonth(): TeamerWorkMonth
{
return $this->workMonth;
}
public function getStatus(): ?WorkMonthStatus
{
return $this->status;
}
public function setStatus(?WorkMonthStatus $status): static
{
$this->status = $status;
return $this;
}
public function getComment(): ?string
{
return $this->comment;
}
public function setComment(?string $comment): static
{
$this->comment = $comment;
return $this;
}
/**
* A rejection without a reason leaves the teamer guessing what to fix.
*/
#[Assert\Callback]
public function validateComment(ExecutionContextInterface $context): void
{
if (WorkMonthStatus::REJECTED === $this->status && '' === trim((string) $this->comment)) {
$context
->buildViolation('Bitte eine Begründung für die Ablehnung angeben')
->atPath('comment')
->addViolation()
;
}
}
}
@@ -0,0 +1,162 @@
<?php
namespace App\Repository;
use App\Entity\SeasonAssignment;
use App\Entity\Teamer;
use App\Model\SeasonAssignmentFilterDto;
use App\Repository\Traits\QueryHelperTrait;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<SeasonAssignment>
*
* @method SeasonAssignment|null find($id, $lockMode = null, $lockVersion = null)
* @method SeasonAssignment|null findOneBy(array $criteria, array $orderBy = null)
* @method SeasonAssignment[] findAll()
* @method SeasonAssignment[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class SeasonAssignmentRepository extends ServiceEntityRepository
{
use QueryHelperTrait;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SeasonAssignment::class);
}
public function getListQuery(SeasonAssignmentFilterDto $filterDto): Query
{
$qb = $this->createQueryBuilder('season_assignment');
$qb
->select('season_assignment', 'teamer', 'job_profile')
// an assignment waiting for a teamer has to stay in the list - that is exactly
// the state an administrative user is looking for when placing people
->leftJoin('season_assignment.teamer', 'teamer')
->innerJoin('season_assignment.jobProfile', 'job_profile')
;
$this->applyFilterSettings($filterDto, $qb);
return $qb
->addOrderBy('season_assignment.dateFrom', \SortDirection::Descending)
->addOrderBy('teamer.lastName', \SortDirection::Ascending)
->getQuery()
;
}
/**
* The assignments a teamer already has in one season, for the rule that allows only one.
*
* Bucketed by the assignment's own first day, which is exactly how
* App\Config\SeasonCalendar::seasonYearFor() decides what season something belongs to.
* Deliberately not SeasonPeriodFilter: that one also requires the period to end inside the
* window, and a placement running past the season's last day still belongs to it here.
*/
public function countInSeasonWindow(
Teamer $teamer,
\DateTimeImmutable $windowStart,
\DateTimeImmutable $windowEnd,
?int $excludeId = null,
): int {
return (int) $this
->createInSeasonWindowQueryBuilder($teamer, $windowStart, $windowEnd, $excludeId)
->getQuery()
->getSingleScalarResult()
;
}
/**
* A teamer's assignments in one season, earliest first - what the teamer's own season page
* is about. Bucketed by the assignment's own first day, like countInSeasonWindow().
*
* @return SeasonAssignment[]
*/
public function findForTeamerInSeason(
Teamer $teamer,
\DateTimeImmutable $windowStart,
\DateTimeImmutable $windowEnd,
): array {
$qb = $this->createInSeasonWindowQueryBuilder($teamer, $windowStart, $windowEnd);
return $qb
->select('season_assignment')
->addOrderBy('season_assignment.dateFrom', \SortDirection::Ascending)
->getQuery()
->getResult()
;
}
/**
* Extracted so the conditions can be pinned by a test compiling the DQL - the suite has no
* fixtures, so what is asserted is what the query says, not what it returns.
*
* The window is read off the assignment itself, so there is no second row that could be
* soft-deleted underneath a live placement and drop it out of the season it belongs to.
*/
public function createInSeasonWindowQueryBuilder(
Teamer $teamer,
\DateTimeImmutable $windowStart,
\DateTimeImmutable $windowEnd,
?int $excludeId = null,
): QueryBuilder {
$qb = $this->createQueryBuilder('season_assignment');
$qb
->select($qb->expr()->count('season_assignment'))
->where($qb->expr()->andX(
$qb->expr()->eq('season_assignment.teamer', ':teamer'),
$qb->expr()->isNull('season_assignment.deletedAt'),
$qb->expr()->gte('season_assignment.dateFrom', ':windowStart'),
$qb->expr()->lte('season_assignment.dateFrom', ':windowEnd')
))
->setParameter('teamer', $teamer)
->setParameter('windowStart', $windowStart)
->setParameter('windowEnd', $windowEnd)
;
if (null !== $excludeId) {
$qb
->andWhere($qb->expr()->neq('season_assignment.id', ':excludeId'))
->setParameter('excludeId', $excludeId)
;
}
return $qb;
}
private function applyFilterSettings(SeasonAssignmentFilterDto $filterDto, QueryBuilder $qb): void
{
if (null !== $filterDto->getTeamerName()) {
$qb
->andWhere($qb->expr()->orX(
$qb->expr()->like('teamer.firstName', ':teamerName'),
$qb->expr()->like('teamer.lastName', ':teamerName')
))
->setParameter('teamerName', '%'.$this->escapeLikeWildcards($filterDto->getTeamerName()).'%')
;
}
if (0 < count($filterDto->getHotelCodes())) {
$qb
->andWhere($qb->expr()->in('season_assignment.hotelCode', ':hotelCodes'))
->setParameter('hotelCodes', $filterDto->getHotelCodes())
;
}
if (0 < count($filterDto->getJobProfiles())) {
$qb
->andWhere($qb->expr()->in('season_assignment.jobProfile', ':jobProfiles'))
->setParameter('jobProfiles', $filterDto->getJobProfiles())
;
}
if (false === $filterDto->isIncludeDeleted()) {
$qb->andWhere($qb->expr()->isNull('season_assignment.deletedAt'));
}
}
}
@@ -0,0 +1,129 @@
<?php
namespace App\Repository;
use App\Entity\SeasonAssignmentTask;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<SeasonAssignmentTask>
*
* @method SeasonAssignmentTask|null find($id, $lockMode = null, $lockVersion = null)
* @method SeasonAssignmentTask|null findOneBy(array $criteria, array $orderBy = null)
* @method SeasonAssignmentTask[] findAll()
* @method SeasonAssignmentTask[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class SeasonAssignmentTaskRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SeasonAssignmentTask::class);
}
/**
* Every open entry that could still be reminded about: not ticked off, on a live assignment
* that has a teamer on it.
*
* The deadlines themselves are not part of this query. They are computed from the
* assignment's first day by a relative offset, and month arithmetic is not invertible
* day by day - "minus one month" from the 31st and back again lands somewhere else - so a
* query built by inverting the offset would silently skip entries. The reminder resolves
* every row through SeasonTaskDeadlineResolver instead; the set is small, one assignment per
* all-season teamer per season.
*
* @return SeasonAssignmentTask[]
*/
public function findOpen(?\DateTimeImmutable $windowStart = null, ?\DateTimeImmutable $windowEnd = null): array
{
return $this
->createOpenQueryBuilder($windowStart, $windowEnd)
->getQuery()
->getResult()
;
}
/**
* The rows a newly deposited library document may still be copied onto: the entry is not
* ticked off yet and its assignment has not ended.
*
* A finished entry keeps what it handed out - that is the whole point of the row carrying its
* own document - and so does an assignment that is over, whether or not every entry on it was
* ticked.
*
* @return SeasonAssignmentTask[]
*/
public function findOpenForTask(string $taskKey, \DateTimeImmutable $today): array
{
return $this
->createOpenForTaskQueryBuilder($taskKey, $today)
->getQuery()
->getResult()
;
}
/**
* Extracted like createOpenQueryBuilder() below, so a test can pin the conditions by
* compiling the DQL rather than by persisting rows.
*/
public function createOpenForTaskQueryBuilder(string $taskKey, \DateTimeImmutable $today): QueryBuilder
{
$qb = $this->createQueryBuilder('task');
return $qb
->innerJoin('task.seasonAssignment', 'season_assignment')
->where($qb->expr()->andX(
$qb->expr()->eq('task.taskKey', ':taskKey'),
$qb->expr()->eq('task.done', ':done'),
$qb->expr()->gte('season_assignment.dateTo', ':today'),
$qb->expr()->isNull('season_assignment.deletedAt')
))
->setParameter('taskKey', $taskKey)
->setParameter('done', false)
->setParameter('today', $today)
;
}
/**
* Extracted so the conditions can be pinned by a test compiling the DQL - the suite has no
* fixtures, so what is asserted is what the query says, not what it returns.
*/
public function createOpenQueryBuilder(
?\DateTimeImmutable $windowStart = null,
?\DateTimeImmutable $windowEnd = null,
): QueryBuilder {
$qb = $this->createQueryBuilder('task');
$qb
->select('task', 'season_assignment', 'teamer')
->innerJoin('task.seasonAssignment', 'season_assignment')
// no teamer means nobody to remind and nobody to be late
->innerJoin('season_assignment.teamer', 'teamer')
->where($qb->expr()->andX(
$qb->expr()->eq('task.done', ':done'),
$qb->expr()->isNull('season_assignment.deletedAt'),
$qb->expr()->isNull('teamer.deletedAt')
))
->setParameter('done', false)
->addOrderBy('teamer.lastName', \SortDirection::Ascending)
->addOrderBy('teamer.firstName', \SortDirection::Ascending)
;
if (null !== $windowStart) {
$qb
->andWhere($qb->expr()->gte('season_assignment.dateFrom', ':windowStart'))
->setParameter('windowStart', $windowStart)
;
}
if (null !== $windowEnd) {
$qb
->andWhere($qb->expr()->lte('season_assignment.dateFrom', ':windowEnd'))
->setParameter('windowEnd', $windowEnd)
;
}
return $qb;
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Repository;
use App\Config\SeasonTask;
use App\Entity\SeasonTaskDocument;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<SeasonTaskDocument>
*
* @method SeasonTaskDocument|null find($id, $lockMode = null, $lockVersion = null)
* @method SeasonTaskDocument|null findOneBy(array $criteria, array $orderBy = null)
* @method SeasonTaskDocument[] findAll()
* @method SeasonTaskDocument[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class SeasonTaskDocumentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SeasonTaskDocument::class);
}
/**
* The mapping keyed by task, for views that render the whole checklist at once and would
* otherwise query per row.
*
* @return array<string, SeasonTaskDocument>
*/
public function findIndexedByTaskKey(): array
{
$indexed = [];
foreach ($this->findAll() as $seasonTaskDocument) {
$indexed[$seasonTaskDocument->getTaskKey()] = $seasonTaskDocument;
}
return $indexed;
}
public function findForTask(SeasonTask $task): ?SeasonTaskDocument
{
return $this->findOneBy(['taskKey' => $task->value]);
}
}
+66 -2
View File
@@ -150,6 +150,13 @@ class TeamerRepository extends ServiceEntityRepository
;
}
if (true === $filterDto->isAllSeason()) {
$qb
->andWhere($qb->expr()->eq('teamer.allSeason', ':allSeason'))
->setParameter('allSeason', true)
;
}
if (false === $filterDto->isIncludeDeleted()) {
$qb->andWhere($qb->expr()->isNull('teamer.deletedAt'));
}
@@ -167,11 +174,52 @@ class TeamerRepository extends ServiceEntityRepository
return $qb;
}
public function getAutocompletionData(string $search): array
/**
* The season teamers whose working time the SaaS import covers for one month.
*
* The season flag alone is not enough: it stays set all year, and fetching a teamer for a
* month they were not placed in would create a sheet with nothing on it and then remind
* them to sign it. "Placed in this month" is computed rather than stored - there is no flag
* on SeasonAssignment - as an overlap of the assignment's own period with the month,
* exactly as SeasonAssignmentRepository does it. An address is required because the API
* looks an employee up by email.
*
* @return Teamer[]
*/
public function findSeasonTeamersForMonth(\DateTimeImmutable $firstDay, \DateTimeImmutable $lastDay): array
{
$qb = $this->createQueryBuilder('teamer');
$teamers = $qb
return $qb
->innerJoin('teamer.seasonAssignments', 'season_assignment')
->andWhere($qb->expr()->eq('teamer.allSeason', ':allSeason'))
->andWhere($qb->expr()->isNull('teamer.deletedAt'))
->andWhere($qb->expr()->isNull('season_assignment.deletedAt'))
->andWhere('season_assignment.dateFrom <= :lastDay')
->andWhere('season_assignment.dateTo >= :firstDay')
->andWhere($qb->expr()->isNotNull('teamer.communication.email'))
->andWhere($qb->expr()->neq('teamer.communication.email', ':empty'))
->setParameter('allSeason', true)
->setParameter('firstDay', $firstDay)
->setParameter('lastDay', $lastDay)
->setParameter('empty', '')
->groupBy('teamer.id')
->orderBy('teamer.lastName', \SortDirection::Ascending)
->addOrderBy('teamer.firstName', \SortDirection::Ascending)
->getQuery()
->getResult()
;
}
/**
* @param bool $allSeasonOnly restricts the suggestions to all-season teamers, so a season
* assignment cannot be created for someone who works short term
*/
private function createAutocompletionQueryBuilder(string $search, bool $allSeasonOnly): QueryBuilder
{
$qb = $this->createQueryBuilder('teamer');
$qb
->where($qb->expr()->orX(
$qb->expr()->like('teamer.lastName', ':search'),
$qb->expr()->like('teamer.firstName', ':search'),
@@ -180,6 +228,22 @@ class TeamerRepository extends ServiceEntityRepository
->orderBy('teamer.lastName', \SortDirection::Ascending)
->addOrderBy('teamer.firstName', \SortDirection::Ascending)
->setParameter('search', '%'.$this->escapeLikeWildcards($search).'%')
;
if (true === $allSeasonOnly) {
$qb
->andWhere($qb->expr()->eq('teamer.allSeason', ':allSeason'))
->setParameter('allSeason', true)
;
}
return $qb;
}
public function getAutocompletionData(string $search, bool $allSeasonOnly = false): array
{
$teamers = $this
->createAutocompletionQueryBuilder($search, $allSeasonOnly)
->getQuery()
->getResult()
;
@@ -0,0 +1,134 @@
<?php
namespace App\Repository;
use App\Entity\Teamer;
use App\Entity\TeamerWorkMonth;
use App\Enum\WorkMonthStatus;
use App\Model\WorkMonthFilterDto;
use App\Repository\Traits\QueryHelperTrait;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TeamerWorkMonth>
*
* @method TeamerWorkMonth|null find($id, $lockMode = null, $lockVersion = null)
* @method TeamerWorkMonth|null findOneBy(array $criteria, array $orderBy = null)
* @method TeamerWorkMonth[] findAll()
* @method TeamerWorkMonth[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class TeamerWorkMonthRepository extends ServiceEntityRepository
{
use QueryHelperTrait;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TeamerWorkMonth::class);
}
public function findOneForTeamerAndMonth(Teamer $teamer, int $year, int $month): ?TeamerWorkMonth
{
return $this->findOneBy(['teamer' => $teamer, 'year' => $year, 'month' => $month]);
}
/**
* @return TeamerWorkMonth[]
*/
public function findForTeamer(Teamer $teamer): array
{
return $this->findBy(
['teamer' => $teamer],
['year' => \SortDirection::Descending, 'month' => \SortDirection::Descending],
);
}
/**
* The admin list.
*
* Deliberately over TeamerWorkMonth rather than over Upload: UploadRepository's list
* queries inner-join a disposition, which structurally excludes every document hanging
* off anything else, and it is the month that carries the status anyway.
*/
public function getListQuery(WorkMonthFilterDto $filterDto): Query
{
$qb = $this->createQueryBuilder('work_month');
$qb
->select('work_month', 'teamer')
->innerJoin('work_month.teamer', 'teamer')
;
$this->applyFilterSettings($filterDto, $qb);
return $qb
->addOrderBy('work_month.year', \SortDirection::Descending)
->addOrderBy('work_month.month', \SortDirection::Descending)
->addOrderBy('teamer.lastName', \SortDirection::Ascending)
->getQuery()
;
}
/**
* Months still waiting for a signed copy, for the reminder.
*
* @return TeamerWorkMonth[]
*/
public function findAwaitingDocument(\DateTimeImmutable $importedBefore): array
{
return $this
->createQueryBuilder('work_month')
->select('work_month', 'teamer')
->innerJoin('work_month.teamer', 'teamer')
->andWhere('work_month.status IN (:awaiting)')
->andWhere('work_month.syncedAt <= :importedBefore')
->andWhere('teamer.deletedAt IS NULL')
->setParameter('awaiting', [WorkMonthStatus::OPEN, WorkMonthStatus::REJECTED])
->setParameter('importedBefore', $importedBefore)
->addOrderBy('teamer.lastName', \SortDirection::Ascending)
->getQuery()
->getResult()
;
}
private function applyFilterSettings(WorkMonthFilterDto $filterDto, QueryBuilder $qb): void
{
if (null !== $filterDto->getTeamerName()) {
$qb
->andWhere($qb->expr()->orX(
$qb->expr()->like('teamer.firstName', ':teamerName'),
$qb->expr()->like('teamer.lastName', ':teamerName')
))
->setParameter('teamerName', '%'.$this->escapeLikeWildcards($filterDto->getTeamerName()).'%')
;
}
if (null !== $period = $filterDto->getPeriod()) {
$qb
->andWhere('work_month.year = :year')
->andWhere('work_month.month = :month')
->setParameter('year', (int) $period->format('Y'))
->setParameter('month', (int) $period->format('n'))
;
}
if (0 < count($filterDto->getStatuses())) {
$qb
->andWhere('work_month.status IN (:statuses)')
->setParameter('statuses', $filterDto->getStatuses())
;
} elseif (!$filterDto->isIncludeApproved()) {
// Only a default, so an explicit status choice still wins.
$qb
->andWhere('work_month.status != :approved')
->setParameter('approved', WorkMonthStatus::APPROVED)
;
}
if ($filterDto->isIncludeMismatchedOnly()) {
$qb->andWhere('work_month.mismatchDetectedAt IS NOT NULL');
}
}
}
+28 -9
View File
@@ -8,6 +8,7 @@ use App\Model\DocumentFilterDto;
use App\Repository\Traits\QueryHelperTrait;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -46,17 +47,17 @@ class UploadRepository extends ServiceEntityRepository
;
}
/**
* The picker behind every "choose a document" field, restricted to the global library.
*
* Without the type condition it offers every upload in the system - Honorarverträge,
* Honorarnoten, Belege, Führerscheine - so picking one would have pointed a setting at a
* teamer's private file.
*/
public function getAutocompletionData(string $search): array
{
$qb = $this->createQueryBuilder('upload');
$documents = $qb
->where($qb->expr()->orX(
$qb->expr()->like('upload.originalFilename', ':filename'),
$qb->expr()->like('upload.displayName', ':filename')
))
->setParameter('filename', '%'.$this->escapeLikeWildcards($search).'%')
->orderBy('upload.originalFilename', \SortDirection::Ascending)
$documents = $this
->createAutocompletionQueryBuilder($search)
->getQuery()
->getResult()
;
@@ -79,6 +80,24 @@ class UploadRepository extends ServiceEntityRepository
return $data;
}
private function createAutocompletionQueryBuilder(string $search): QueryBuilder
{
$qb = $this->createQueryBuilder('upload');
return $qb
->where($qb->expr()->andX(
$qb->expr()->eq('upload.type', ':type'),
$qb->expr()->orX(
$qb->expr()->like('upload.originalFilename', ':filename'),
$qb->expr()->like('upload.displayName', ':filename')
)
))
->setParameter('type', Upload::TYPE_DOCUMENT)
->setParameter('filename', '%'.$this->escapeLikeWildcards($search).'%')
->orderBy('upload.originalFilename', \SortDirection::Ascending)
;
}
public function getBatchDownloadListQuery(
string $type = Upload::TYPE_INVOICE,
string $status = Upload::STATUS_PAID,
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff. Show more