feat: import work hours from SaaS and exchange signed time sheet
This commit is contained in:
1 parent
7cf39850e6
commit
1a7c272414
73 files changed
+10488
-72
No files matched your search
@@ -97,6 +97,12 @@ APPLICATION_FORM_API_KEY=
|
|||||||
# an invitation with a dead booking link is worse than none.
|
# an invitation with a dead booking link is worse than none.
|
||||||
APPLICATION_FORM_BOOKING_URL=
|
APPLICATION_FORM_BOOKING_URL=
|
||||||
|
|
||||||
|
# SaaS time tracking. Season teamers clock their hours here; app:saas:work-hours imports them.
|
||||||
|
# The API key travels in the URL path, so it never belongs in the committed file - set both
|
||||||
|
# values in .env.local / the server environment.
|
||||||
|
APP_SAAS_BASE_URL=
|
||||||
|
APP_SAAS_API_KEY=
|
||||||
|
|
||||||
###> symfony/mailjet-mailer ###
|
###> symfony/mailjet-mailer ###
|
||||||
# Bulk teamer mailings only, everything else goes out over MAILER_DSN. Set the real
|
# Bulk teamer mailings only, everything else goes out over MAILER_DSN. Set the real
|
||||||
# credentials in .env.local - the sending domain has to be verified in Mailjet (SPF,
|
# credentials in .env.local - the sending domain has to be verified in Mailjet (SPF,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"doctrine/doctrine-bundle": "^2.15",
|
"doctrine/doctrine-bundle": "^2.15",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.2",
|
"doctrine/doctrine-migrations-bundle": "^3.2",
|
||||||
"doctrine/orm": "^3.6",
|
"doctrine/orm": "^3.6",
|
||||||
|
"dompdf/dompdf": "^3.1",
|
||||||
"flagception/flagception-bundle": "^6.0",
|
"flagception/flagception-bundle": "^6.0",
|
||||||
"knplabs/knp-menu-bundle": "^3.2",
|
"knplabs/knp-menu-bundle": "^3.2",
|
||||||
"knplabs/knp-paginator-bundle": "^6.2",
|
"knplabs/knp-paginator-bundle": "^6.2",
|
||||||
|
|||||||
Generated
+301
-68
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "e4ae4ad86c4c01e26d770c2ab3e0fda4",
|
"content-hash": "3c22c1470cd1883d7c10f2e9800431e0",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "beberlei/doctrineextensions",
|
"name": "beberlei/doctrineextensions",
|
||||||
@@ -1491,6 +1491,161 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-02-08T16:21:46+00:00"
|
"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",
|
"name": "dragonmantank/cron-expression",
|
||||||
"version": "v3.6.0",
|
"version": "v3.6.0",
|
||||||
@@ -3307,6 +3462,73 @@
|
|||||||
},
|
},
|
||||||
"time": "2022-12-02T22:17:43+00:00"
|
"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",
|
"name": "monolog/monolog",
|
||||||
"version": "3.12.0",
|
"version": "3.12.0",
|
||||||
@@ -4920,6 +5142,84 @@
|
|||||||
},
|
},
|
||||||
"time": "2021-10-29T13:26:27+00:00"
|
"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",
|
"name": "scienta/doctrine-json-functions",
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
@@ -12888,73 +13188,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-09-06T14:25:24+00:00"
|
"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",
|
"name": "myclabs/deep-copy",
|
||||||
"version": "1.14.0",
|
"version": "1.14.0",
|
||||||
|
|||||||
@@ -228,6 +228,32 @@ texts:
|
|||||||
|
|
||||||
# Descriptions shown next to the form fields. They describe the placeholder itself rather
|
# Descriptions shown next to the form fields. They describe the placeholder itself rather
|
||||||
# than any one mail, so they are shared: a name means the same thing wherever it appears.
|
# than any one mail, so they are shared: a name means the same thing wherever it appears.
|
||||||
|
# Daily, for as long as a work month has no signed Arbeitszeitnachweis on it. The month
|
||||||
|
# is named rather than a deadline: there is none, the office simply wants it back.
|
||||||
|
reminder_work_month_upload:
|
||||||
|
label: 'Erinnerung: Arbeitszeitnachweis hochladen'
|
||||||
|
placeholders: ['workMonth']
|
||||||
|
subject: 'Reminder: Fehlender Arbeitszeitnachweis'
|
||||||
|
headline: 'Hallo aus Köln,'
|
||||||
|
body: |-
|
||||||
|
bitte denke daran, deinen unterschriebenen Arbeitszeitnachweis hochzuladen. Ohne ihn können wir den Monat nicht abrechnen.
|
||||||
|
|
||||||
|
**{workMonth}**
|
||||||
|
|
||||||
|
Du findest ihn im Portal unter "Mein Profil - Arbeitszeiten": dort herunterladen, unterschreiben und wieder hochladen.
|
||||||
|
|
||||||
|
work_month_rejected:
|
||||||
|
label: 'Arbeitszeitnachweis abgelehnt'
|
||||||
|
placeholders: ['workMonth', 'comment']
|
||||||
|
subject: 'Dein Arbeitszeitnachweis wurde abgelehnt'
|
||||||
|
headline: 'Hallo aus Köln,'
|
||||||
|
body: |-
|
||||||
|
leider können wir deinen Arbeitszeitnachweis für **{workMonth}** so nicht annehmen.
|
||||||
|
|
||||||
|
Begründung: {comment}
|
||||||
|
|
||||||
|
Bitte lade ihn im Portal unter "Mein Profil - Arbeitszeiten" noch einmal hoch.
|
||||||
|
|
||||||
placeholders:
|
placeholders:
|
||||||
bookingUrl: 'Buchungslink für den Team-Infocall, kommt aus der Konfiguration (APPLICATION_FORM_BOOKING_URL)'
|
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'
|
season: 'Kommende Saison in Kurzform, z. B. "26/27" - wird automatisch eingesetzt'
|
||||||
@@ -247,3 +273,4 @@ placeholders:
|
|||||||
dateTo: 'Ende des Einsatzes'
|
dateTo: 'Ende des Einsatzes'
|
||||||
diffInDays: 'Tage bis zum Beginn des Einsatzes'
|
diffInDays: 'Tage bis zum Beginn des Einsatzes'
|
||||||
tasks: 'Noch offene Punkte der Saison-Checkliste, kommagetrennt'
|
tasks: 'Noch offene Punkte der Saison-Checkliste, kommagetrennt'
|
||||||
|
workMonth: 'Monat der Arbeitszeiten, z. B. "März 2026"'
|
||||||
@@ -2,6 +2,7 @@ monolog:
|
|||||||
channels:
|
channels:
|
||||||
- deprecation
|
- deprecation
|
||||||
- bpn
|
- bpn
|
||||||
|
- saas
|
||||||
- audit
|
- audit
|
||||||
- api
|
- api
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ monolog:
|
|||||||
handler: exceptions
|
handler: exceptions
|
||||||
excluded_http_codes: [404, 405]
|
excluded_http_codes: [404, 405]
|
||||||
buffer_size: 50
|
buffer_size: 50
|
||||||
channels: ["!bpn", "!audit", "!api"]
|
channels: ["!bpn", "!saas", "!audit", "!api"]
|
||||||
exceptions:
|
exceptions:
|
||||||
type: rotating_file
|
type: rotating_file
|
||||||
path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
|
path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
|
||||||
@@ -24,6 +25,12 @@ monolog:
|
|||||||
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
|
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
|
||||||
max_files: 5
|
max_files: 5
|
||||||
level: info
|
level: info
|
||||||
|
saas:
|
||||||
|
channels: ["saas"]
|
||||||
|
type: rotating_file
|
||||||
|
path: '%kernel.logs_dir%/saas.%kernel.environment%.log'
|
||||||
|
max_files: 5
|
||||||
|
level: info
|
||||||
audit:
|
audit:
|
||||||
channels: [ "audit" ]
|
channels: [ "audit" ]
|
||||||
type: rotating_file
|
type: rotating_file
|
||||||
@@ -45,7 +52,7 @@ monolog:
|
|||||||
console:
|
console:
|
||||||
type: console
|
type: console
|
||||||
process_psr_3_messages: false
|
process_psr_3_messages: false
|
||||||
channels: ["!event", "!bpn", "!api", "!doctrine", "!console"]
|
channels: ["!event", "!bpn", "!saas", "!api", "!doctrine", "!console"]
|
||||||
|
|
||||||
when@dev:
|
when@dev:
|
||||||
monolog:
|
monolog:
|
||||||
@@ -82,7 +89,7 @@ when@prod:
|
|||||||
handler: exceptions
|
handler: exceptions
|
||||||
excluded_http_codes: [404, 405]
|
excluded_http_codes: [404, 405]
|
||||||
buffer_size: 50
|
buffer_size: 50
|
||||||
channels: ["!bpn", "!audit", "!api"]
|
channels: ["!bpn", "!saas", "!audit", "!api"]
|
||||||
exceptions:
|
exceptions:
|
||||||
type: rotating_file
|
type: rotating_file
|
||||||
path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
|
path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
|
||||||
@@ -94,6 +101,12 @@ when@prod:
|
|||||||
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
|
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
|
||||||
max_files: 5
|
max_files: 5
|
||||||
level: info
|
level: info
|
||||||
|
saas:
|
||||||
|
channels: ["saas"]
|
||||||
|
type: rotating_file
|
||||||
|
path: '%kernel.logs_dir%/saas.%kernel.environment%.log'
|
||||||
|
max_files: 5
|
||||||
|
level: info
|
||||||
audit:
|
audit:
|
||||||
channels: [ "audit" ]
|
channels: [ "audit" ]
|
||||||
type: rotating_file
|
type: rotating_file
|
||||||
@@ -113,4 +126,4 @@ when@prod:
|
|||||||
console:
|
console:
|
||||||
type: console
|
type: console
|
||||||
process_psr_3_messages: false
|
process_psr_3_messages: false
|
||||||
channels: ["!event", "!doctrine", "!bpn", "!audit", "!api"]
|
channels: ["!event", "!doctrine", "!bpn", "!saas", "!audit", "!api"]
|
||||||
@@ -60,6 +60,14 @@ oneup_uploader:
|
|||||||
namer: app.upload_namer
|
namer: app.upload_namer
|
||||||
storage:
|
storage:
|
||||||
directory: '%kernel.project_dir%/uploads/season_document_in'
|
directory: '%kernel.project_dir%/uploads/season_document_in'
|
||||||
|
# Signed Arbeitszeitnachweise coming back from a teamer. Same rule again: the key and
|
||||||
|
# the directory are exactly Upload::TYPE_WORK_MONTH.
|
||||||
|
work_month:
|
||||||
|
frontend: dropzone
|
||||||
|
use_orphanage: true
|
||||||
|
namer: app.upload_namer
|
||||||
|
storage:
|
||||||
|
directory: '%kernel.project_dir%/uploads/work_month'
|
||||||
chunks:
|
chunks:
|
||||||
maxage: 86400
|
maxage: 86400
|
||||||
storage:
|
storage:
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ when@prod:
|
|||||||
frequency: '30 1 * * *'
|
frequency: '30 1 * * *'
|
||||||
description: "Updates status of teamers depending on their disposition"
|
description: "Updates status of teamers depending on their disposition"
|
||||||
|
|
||||||
|
- task: app:saas:work-hours
|
||||||
|
frequency: '0 4 10 * *'
|
||||||
|
description: "Imports last month's working hours of season teamers from SaaS"
|
||||||
|
|
||||||
- task: oneup:uploader:clear-orphans
|
- task: oneup:uploader:clear-orphans
|
||||||
frequency: '30 * * * *'
|
frequency: '30 * * * *'
|
||||||
description: "Removes orphaned uploads"
|
description: "Removes orphaned uploads"
|
||||||
@@ -53,6 +57,10 @@ when@staging:
|
|||||||
frequency: '30 1 * * *'
|
frequency: '30 1 * * *'
|
||||||
description: "Updates status of teamers depending on their disposition"
|
description: "Updates status of teamers depending on their disposition"
|
||||||
|
|
||||||
|
- task: app:saas:work-hours
|
||||||
|
frequency: '0 4 10 * *'
|
||||||
|
description: "Imports last month's working hours of season teamers from SaaS"
|
||||||
|
|
||||||
- task: oneup:uploader:clear-orphans
|
- task: oneup:uploader:clear-orphans
|
||||||
frequency: '30 * * * *'
|
frequency: '30 * * * *'
|
||||||
description: "Removes orphaned uploads"
|
description: "Removes orphaned uploads"
|
||||||
|
|||||||
@@ -203,6 +203,20 @@ services:
|
|||||||
bpn_crm_house_manager_ids: '%bpn_crm_house_manager_ids%'
|
bpn_crm_house_manager_ids: '%bpn_crm_house_manager_ids%'
|
||||||
bpn_default_hotel_code: '%bpn_default_hotel_code%'
|
bpn_default_hotel_code: '%bpn_default_hotel_code%'
|
||||||
|
|
||||||
|
App\Saas\ApiClient:
|
||||||
|
arguments:
|
||||||
|
$logger: '@monolog.logger.saas'
|
||||||
|
$options:
|
||||||
|
base_url: '%env(APP_SAAS_BASE_URL)%'
|
||||||
|
api_key: '%env(APP_SAAS_API_KEY)%'
|
||||||
|
|
||||||
|
App\Saas\DataProvider\EmployeeDataProvider:
|
||||||
|
arguments:
|
||||||
|
$logger: '@monolog.logger.saas'
|
||||||
|
# The roster is slow and barely changes; six hours means one fetch per run and a
|
||||||
|
# new starter is picked up the same day.
|
||||||
|
$ttl: 21600
|
||||||
|
|
||||||
App\Twig\AppRuntime:
|
App\Twig\AppRuntime:
|
||||||
arguments:
|
arguments:
|
||||||
$intlExtension: '@twig.extension.intl'
|
$intlExtension: '@twig.extension.intl'
|
||||||
@@ -214,6 +228,15 @@ services:
|
|||||||
tags:
|
tags:
|
||||||
- name: monolog.processor
|
- name: monolog.processor
|
||||||
|
|
||||||
|
# Every SaaS url carries the api key as a path segment, so the request lines Symfony's
|
||||||
|
# http client writes - and the exception messages that repeat those urls - are redacted
|
||||||
|
# on the way into the log rather than suppressed.
|
||||||
|
App\Logging\RedactSaasCredentialsProcessor:
|
||||||
|
arguments:
|
||||||
|
$apiKey: '%env(APP_SAAS_API_KEY)%'
|
||||||
|
tags:
|
||||||
|
- name: monolog.processor
|
||||||
|
|
||||||
App\Logging\RequestInfoProcessor:
|
App\Logging\RequestInfoProcessor:
|
||||||
arguments:
|
arguments:
|
||||||
- '@request_stack'
|
- '@request_stack'
|
||||||
@@ -346,6 +369,11 @@ services:
|
|||||||
$options:
|
$options:
|
||||||
project_dir: '%kernel.project_dir%'
|
project_dir: '%kernel.project_dir%'
|
||||||
|
|
||||||
|
App\Service\Pdf\WorkMonthRenderer:
|
||||||
|
arguments:
|
||||||
|
$options:
|
||||||
|
project_dir: '%kernel.project_dir%'
|
||||||
|
|
||||||
App\Email\Mailer:
|
App\Email\Mailer:
|
||||||
arguments:
|
arguments:
|
||||||
$defaults:
|
$defaults:
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ $rsyncOptions = [
|
|||||||
'package.json',
|
'package.json',
|
||||||
'package-lock.json',
|
'package-lock.json',
|
||||||
'*.http',
|
'*.http',
|
||||||
|
'http-client*.json',
|
||||||
'postcss.config.js',
|
'postcss.config.js',
|
||||||
'phpunit.xml.dist',
|
'phpunit.xml.dist',
|
||||||
'public/.htaccess',
|
'public/.htaccess',
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"prod": {
|
||||||
|
"saas_base_url": "https://desktop.saas.de/rest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Work months imported from the SaaS time tracking, and the signed Arbeitszeitnachweis that
|
||||||
|
* belongs to each of them.
|
||||||
|
*
|
||||||
|
* Hand-trimmed: doctrine:migrations:diff also produced a long tail of datetime, JSON and
|
||||||
|
* TINYINT redeclarations across almost every table. That is the long-standing drift between
|
||||||
|
* the dev database and the mapping, not anything this feature changed, so none of it is here.
|
||||||
|
*/
|
||||||
|
final class Version20260921122743 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Adds teamer_work_month, the SaaS employee id on teamer and the work month relation on upload';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE teamer_work_month (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, year SMALLINT NOT NULL, month SMALLINT NOT NULL, days JSON NOT NULL, total_minutes INT NOT NULL, worked_days INT NOT NULL, absence_days INT NOT NULL, source_ist_minutes INT NOT NULL, status VARCHAR(255) NOT NULL, submitted_at DATETIME DEFAULT NULL, reviewed_at DATETIME DEFAULT NULL, reviewed_by VARCHAR(8) DEFAULT NULL, review_comment LONGTEXT DEFAULT NULL, synced_at DATETIME NOT NULL, warnings JSON DEFAULT NULL, mismatch_detected_at DATETIME DEFAULT NULL, mismatch_total_minutes INT DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, teamer_id INT NOT NULL, UNIQUE INDEX UNIQ_28857572D17F50A6 (uuid), UNIQUE INDEX teamer_work_month_unique (teamer_id, year, month), INDEX IDX_288575724302FF75 (teamer_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||||
|
$this->addSql('ALTER TABLE teamer_work_month ADD CONSTRAINT FK_288575724302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)');
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE teamer ADD saas_employee_uuid VARCHAR(36) DEFAULT NULL');
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE upload ADD teamer_work_month_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE upload ADD CONSTRAINT FK_17BDE61FFEAE833A FOREIGN KEY (teamer_work_month_id) REFERENCES teamer_work_month (id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_17BDE61FFEAE833A ON upload (teamer_work_month_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE upload DROP FOREIGN KEY FK_17BDE61FFEAE833A');
|
||||||
|
$this->addSql('DROP INDEX IDX_17BDE61FFEAE833A ON upload');
|
||||||
|
$this->addSql('ALTER TABLE upload DROP teamer_work_month_id');
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE teamer DROP saas_employee_uuid');
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE teamer_work_month DROP FOREIGN KEY FK_288575724302FF75');
|
||||||
|
$this->addSql('DROP TABLE teamer_work_month');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The monthly target and the month's total break time, both shown in the head of the
|
||||||
|
* Arbeitszeitnachweis next to the hours actually worked.
|
||||||
|
*
|
||||||
|
* Hand-trimmed, like Version20260921122743: doctrine:migrations:diff sweeps in the
|
||||||
|
* long-standing datetime and JSON drift of the dev database, none of which belongs here.
|
||||||
|
*
|
||||||
|
* The per-day start, end and break times that go with these live in the existing "days" JSON
|
||||||
|
* column, so they need no schema change. Rows imported before this migration simply carry
|
||||||
|
* neither, and are refreshed by the next import of a month that is not yet signed.
|
||||||
|
*/
|
||||||
|
final class Version20260921160000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Adds the monthly target and total break time to teamer_work_month';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE teamer_work_month ADD target_minutes INT DEFAULT 0 NOT NULL, ADD break_minutes INT DEFAULT 0 NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE teamer_work_month DROP target_minutes, DROP break_minutes');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The employee's department, shown in the head of the Arbeitszeitnachweis.
|
||||||
|
*
|
||||||
|
* Hand-trimmed like its two predecessors: doctrine:migrations:diff sweeps in the long-standing
|
||||||
|
* datetime and JSON drift of the dev database, none of which belongs here.
|
||||||
|
*
|
||||||
|
* Nullable, and not only because existing rows have none: a month made up entirely of holiday
|
||||||
|
* carries no booking to read a department from.
|
||||||
|
*/
|
||||||
|
final class Version20260921170000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Adds the department to teamer_work_month';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE teamer_work_month ADD department VARCHAR(255) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE teamer_work_month DROP department');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ use App\Service\Cron\FeedbackReminderService;
|
|||||||
use App\Service\Cron\FlushLogsService;
|
use App\Service\Cron\FlushLogsService;
|
||||||
use App\Service\Cron\SeasonTaskReminderService;
|
use App\Service\Cron\SeasonTaskReminderService;
|
||||||
use App\Service\Cron\UploadReminderService;
|
use App\Service\Cron\UploadReminderService;
|
||||||
|
use App\Service\Cron\WorkMonthReminderService;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
@@ -27,6 +28,7 @@ class CronCommand extends Command
|
|||||||
private readonly DispositionStatusService $dispositionStatusService,
|
private readonly DispositionStatusService $dispositionStatusService,
|
||||||
private readonly DispositionReminderService $dispositionReminderService,
|
private readonly DispositionReminderService $dispositionReminderService,
|
||||||
private readonly SeasonTaskReminderService $seasonTaskReminderService,
|
private readonly SeasonTaskReminderService $seasonTaskReminderService,
|
||||||
|
private readonly WorkMonthReminderService $workMonthReminderService,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -63,6 +65,9 @@ class CronCommand extends Command
|
|||||||
$message = $this->seasonTaskReminderService->sendTeamerDigest();
|
$message = $this->seasonTaskReminderService->sendTeamerDigest();
|
||||||
$io->info($message);
|
$io->info($message);
|
||||||
|
|
||||||
|
$message = $this->workMonthReminderService->sendReminders();
|
||||||
|
$io->info($message);
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,4 +23,6 @@ enum EmailTextKey: string
|
|||||||
case REMINDER_INVOICE_UPLOAD_FINAL = 'reminder_invoice_upload_final';
|
case REMINDER_INVOICE_UPLOAD_FINAL = 'reminder_invoice_upload_final';
|
||||||
case REMINDER_DISPOSITION = 'reminder_disposition';
|
case REMINDER_DISPOSITION = 'reminder_disposition';
|
||||||
case REMINDER_SEASON_TASKS = 'reminder_season_tasks';
|
case REMINDER_SEASON_TASKS = 'reminder_season_tasks';
|
||||||
|
case REMINDER_WORK_MONTH_UPLOAD = 'reminder_work_month_upload';
|
||||||
|
case WORK_MONTH_REJECTED = 'work_month_rejected';
|
||||||
}
|
}
|
||||||
@@ -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,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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,6 +182,21 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
|
|||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
private bool $allSeason = false;
|
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()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->uuid = Uuid::v4();
|
$this->uuid = Uuid::v4();
|
||||||
@@ -196,6 +211,7 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
|
|||||||
$this->dispositions = new ArrayCollection();
|
$this->dispositions = new ArrayCollection();
|
||||||
$this->seasonAssignments = new ArrayCollection();
|
$this->seasonAssignments = new ArrayCollection();
|
||||||
$this->customAvailabilities = new ArrayCollection();
|
$this->customAvailabilities = new ArrayCollection();
|
||||||
|
$this->workMonths = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __toString()
|
public function __toString()
|
||||||
@@ -1036,4 +1052,24 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
|
|||||||
|
|
||||||
return $this;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,13 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
|
|||||||
*/
|
*/
|
||||||
public const TYPE_SEASON_DOCUMENT_IN = 'season_document_in';
|
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_NEW = 'new';
|
||||||
public const STATUS_PENDING = 'pending';
|
public const STATUS_PENDING = 'pending';
|
||||||
public const STATUS_CHECKED = 'checked';
|
public const STATUS_CHECKED = 'checked';
|
||||||
@@ -88,6 +95,9 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
|
|||||||
#[ORM\ManyToOne(inversedBy: 'documents')]
|
#[ORM\ManyToOne(inversedBy: 'documents')]
|
||||||
private ?SeasonAssignment $seasonAssignment = null;
|
private ?SeasonAssignment $seasonAssignment = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(inversedBy: 'documents')]
|
||||||
|
private ?TeamerWorkMonth $teamerWorkMonth = null;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
private ?string $comment = null;
|
private ?string $comment = null;
|
||||||
|
|
||||||
@@ -162,6 +172,7 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
|
|||||||
self::TYPE_RECEIPT => 'Beleg',
|
self::TYPE_RECEIPT => 'Beleg',
|
||||||
self::TYPE_SEASON_DOCUMENT_OUT => 'Saisonunterlage',
|
self::TYPE_SEASON_DOCUMENT_OUT => 'Saisonunterlage',
|
||||||
self::TYPE_SEASON_DOCUMENT_IN => 'Rückläufer Saison',
|
self::TYPE_SEASON_DOCUMENT_IN => 'Rückläufer Saison',
|
||||||
|
self::TYPE_WORK_MONTH => 'Arbeitszeitnachweis',
|
||||||
default => 'Dokument',
|
default => 'Dokument',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -296,6 +307,18 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getTeamerWorkMonth(): ?TeamerWorkMonth
|
||||||
|
{
|
||||||
|
return $this->teamerWorkMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTeamerWorkMonth(?TeamerWorkMonth $teamerWorkMonth): static
|
||||||
|
{
|
||||||
|
$this->teamerWorkMonth = $teamerWorkMonth;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getComment(): ?string
|
public function getComment(): ?string
|
||||||
{
|
{
|
||||||
return $this->comment;
|
return $this->comment;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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,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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,6 +140,18 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
$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', [
|
$menu->addChild('Verfügbarkeiten', [
|
||||||
'route' => 'app_administrative_availability_index',
|
'route' => 'app_administrative_availability_index',
|
||||||
'linkAttributes' => [
|
'linkAttributes' => [
|
||||||
|
|||||||
@@ -70,6 +70,22 @@ class TeamerMenuBuilder extends AbstractMenuBuilder
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 & Foto', [
|
$profileMenu->addChild('Stammdaten & Foto', [
|
||||||
'route' => 'app_teamer_profile_index',
|
'route' => 'app_teamer_profile_index',
|
||||||
'linkAttributes' => [
|
'linkAttributes' => [
|
||||||
@@ -155,6 +171,13 @@ class TeamerMenuBuilder extends AbstractMenuBuilder
|
|||||||
return $menu;
|
return $menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isSeasonTeamer(): bool
|
||||||
|
{
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
|
return $user instanceof User && true === $user->getTeamer()?->isAllSeason();
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveSeasonAssignment(): ?SeasonAssignment
|
private function resolveSeasonAssignment(): ?SeasonAssignment
|
||||||
{
|
{
|
||||||
$user = $this->security->getUser();
|
$user = $this->security->getUser();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -174,6 +174,43 @@ class TeamerRepository extends ServiceEntityRepository
|
|||||||
return $qb;
|
return $qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
|
||||||
|
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
|
* @param bool $allSeasonOnly restricts the suggestions to all-season teamers, so a season
|
||||||
* assignment cannot be created for someone who works short term
|
* assignment cannot be created for someone who works short term
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Saas;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as HttpClientExceptionInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads time accounts from the SaaS time-tracking service.
|
||||||
|
*
|
||||||
|
* The API counts months from zero, and so do the date strings it answers with. Both
|
||||||
|
* conversions are confined to this class and to ResponseParser, so the rest of the
|
||||||
|
* application only ever deals in calendar months.
|
||||||
|
*/
|
||||||
|
class ApiClient
|
||||||
|
{
|
||||||
|
private array $config;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly HttpClientInterface $httpClient,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
array $options,
|
||||||
|
) {
|
||||||
|
$this->config = $this->resolveOptions($options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One employee's time account for one calendar month (1-12).
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getTimeAccount(string $email, int $year, int $month): array
|
||||||
|
{
|
||||||
|
if ($month < 1 || $month > 12) {
|
||||||
|
throw new ApiClientException(sprintf('%d is not a calendar month.', $month));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The api key travels in the path, not in a header or query string - the vendor's
|
||||||
|
// choice, not ours. Every url built here therefore carries a credential, which is what
|
||||||
|
// App\Logging\RedactSaasCredentialsProcessor exists to strip back out of the log.
|
||||||
|
//
|
||||||
|
// The employee is addressed by email. The roster offers a per-employee key that would
|
||||||
|
// serve instead, but swapping an address for a credential in the same URL is not an
|
||||||
|
// improvement.
|
||||||
|
$url = sprintf(
|
||||||
|
'%s/api/v2/time/account/%s/%s/%d/%d',
|
||||||
|
$this->baseUrl(),
|
||||||
|
rawurlencode($this->config['api_key']),
|
||||||
|
rawurlencode($email),
|
||||||
|
$year,
|
||||||
|
$month - 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->logger->info('Requesting time account', [
|
||||||
|
'email' => $email,
|
||||||
|
'year' => $year,
|
||||||
|
'month' => $month,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->httpClient->request('GET', $url, [
|
||||||
|
// One request per teamer, so a hanging host must not stall the whole run.
|
||||||
|
'timeout' => 10,
|
||||||
|
'max_duration' => 30,
|
||||||
|
])->toArray();
|
||||||
|
} catch (HttpClientExceptionInterface $e) {
|
||||||
|
// Covers transport, redirect, 4xx/5xx and JSON decoding alike.
|
||||||
|
throw new ApiClientException(sprintf('Time account request for %s (%04d-%02d) failed: %s', $email, $year, $month, $e->getMessage()), 0, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole employee roster.
|
||||||
|
*
|
||||||
|
* Slow, and the same for every teamer, so it is fetched once per run behind
|
||||||
|
* App\Saas\DataProvider\EmployeeDataProvider rather than per teamer.
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
public function getEmployees(): array
|
||||||
|
{
|
||||||
|
$url = sprintf('%s/api/v2/employees/%s', $this->baseUrl(), rawurlencode($this->config['api_key']));
|
||||||
|
|
||||||
|
$this->logger->info('Requesting employee roster');
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->httpClient->request('GET', $url, [
|
||||||
|
// Deliberately longer than the per-teamer call: this one is known to be slow.
|
||||||
|
'timeout' => 30,
|
||||||
|
'max_duration' => 60,
|
||||||
|
])->toArray();
|
||||||
|
} catch (HttpClientExceptionInterface $e) {
|
||||||
|
throw new ApiClientException(sprintf('Employee roster request failed: %s', $e->getMessage()), 0, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
private function baseUrl(): string
|
||||||
|
{
|
||||||
|
// Checked here rather than in the constructor, and the base url is deliberately not a
|
||||||
|
// scoped client: an unconfigured host would then fail while the container is being
|
||||||
|
// built, which takes the whole command down with it - "--help" included.
|
||||||
|
if ('' === $baseUrl = rtrim((string) $this->config['base_url'], '/')) {
|
||||||
|
throw new ApiClientException('APP_SAAS_BASE_URL is not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveOptions(array $options): array
|
||||||
|
{
|
||||||
|
$optionsResolver = new OptionsResolver();
|
||||||
|
$optionsResolver->setRequired(['base_url', 'api_key']);
|
||||||
|
$optionsResolver->setAllowedTypes('base_url', 'string');
|
||||||
|
$optionsResolver->setAllowedTypes('api_key', 'string');
|
||||||
|
|
||||||
|
return $optionsResolver->resolve($options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Saas;
|
||||||
|
|
||||||
|
class ApiClientException extends \Exception
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Saas\DataProvider;
|
||||||
|
|
||||||
|
use App\Saas\ApiClient;
|
||||||
|
use App\Saas\ApiClientException;
|
||||||
|
use App\Saas\Model\EmployeeDirectory;
|
||||||
|
use App\Saas\ResponseParser;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SaaS roster, fetched once and kept for a while.
|
||||||
|
*
|
||||||
|
* The endpoint is slow and answers the same thing for every teamer, so a monthly run that
|
||||||
|
* touches forty people should ask for it once. Follows App\BusProNet\DataProvider.
|
||||||
|
*
|
||||||
|
* Only the reduced projection is cached, never the response: the raw entries carry a
|
||||||
|
* per-employee api key, and the app cache writes to the filesystem.
|
||||||
|
*/
|
||||||
|
class EmployeeDataProvider
|
||||||
|
{
|
||||||
|
private const CACHE_KEY = 'saas_employees';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ApiClient $apiClient,
|
||||||
|
private readonly ResponseParser $responseParser,
|
||||||
|
private readonly CacheInterface $cache,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
private readonly int $ttl = 21600,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDirectory(): EmployeeDirectory
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$rows = $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) {
|
||||||
|
$item->expiresAfter($this->ttl);
|
||||||
|
|
||||||
|
$directory = $this->responseParser->parseEmployees($this->apiClient->getEmployees());
|
||||||
|
|
||||||
|
$this->logger->info('Fetched employee roster', ['employees' => $directory->count()]);
|
||||||
|
|
||||||
|
return $directory->toCacheable();
|
||||||
|
});
|
||||||
|
} catch (ApiClientException $e) {
|
||||||
|
// An unreachable roster must not stop the import: it is a cross-check and a
|
||||||
|
// source for the department, not the working time itself.
|
||||||
|
$this->logger->error('Unable to fetch the employee roster: '.$e->getMessage());
|
||||||
|
|
||||||
|
return EmployeeDirectory::fromEmployees([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return EmployeeDirectory::fromCacheable($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refresh(): EmployeeDirectory
|
||||||
|
{
|
||||||
|
$this->cache->delete(self::CACHE_KEY);
|
||||||
|
|
||||||
|
return $this->getDirectory();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Saas\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One employee of the SaaS, reduced to the four things this application has a use for.
|
||||||
|
*
|
||||||
|
* Everything else the roster reports is dropped on the way in, and deliberately: the raw
|
||||||
|
* entry carries a per-employee "apiKey" - a credential - alongside a birthday, a personnel
|
||||||
|
* number and an RFID token. None of that is needed to import working time, and the roster is
|
||||||
|
* cached to the filesystem, so none of it may survive this class.
|
||||||
|
*
|
||||||
|
* The per-employee key would work in place of the email address when addressing a time
|
||||||
|
* account, and is deliberately not used for it: that only trades one identifier in a URL for
|
||||||
|
* a credential in a URL, and this is machine-to-machine traffic where the address is the
|
||||||
|
* lesser exposure. Do not reintroduce it without deciding where the keys are to be stored.
|
||||||
|
*/
|
||||||
|
class Employee
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $email,
|
||||||
|
private readonly ?string $uuid,
|
||||||
|
private readonly ?string $department,
|
||||||
|
private readonly bool $hasLeft,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmail(): string
|
||||||
|
{
|
||||||
|
return $this->email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUuid(): ?string
|
||||||
|
{
|
||||||
|
return $this->uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDepartment(): ?string
|
||||||
|
{
|
||||||
|
return $this->department;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the employee has a date of separation, and so no longer books time.
|
||||||
|
*/
|
||||||
|
public function hasLeft(): bool
|
||||||
|
{
|
||||||
|
return $this->hasLeft;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Saas\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SaaS roster, by email address.
|
||||||
|
*
|
||||||
|
* Lookups are case-insensitive: the address is typed by hand on both sides, and the API is
|
||||||
|
* the one place where the two systems have to agree on it.
|
||||||
|
*/
|
||||||
|
class EmployeeDirectory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, Employee> $employees keyed by lower-cased email
|
||||||
|
*/
|
||||||
|
private function __construct(private readonly array $employees)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Employee[] $employees
|
||||||
|
*/
|
||||||
|
public static function fromEmployees(array $employees): self
|
||||||
|
{
|
||||||
|
$byEmail = [];
|
||||||
|
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
$byEmail[mb_strtolower($employee->getEmail())] = $employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self($byEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(?string $email): ?Employee
|
||||||
|
{
|
||||||
|
if (null === $email || '' === trim($email)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->employees[mb_strtolower(trim($email))] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function has(?string $email): bool
|
||||||
|
{
|
||||||
|
return null !== $this->find($email);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEmpty(): bool
|
||||||
|
{
|
||||||
|
return [] === $this->employees;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function count(): int
|
||||||
|
{
|
||||||
|
return count($this->employees);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{email: string, uuid: ?string, department: ?string, hasLeft: bool}>
|
||||||
|
*/
|
||||||
|
public function toCacheable(): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($this->employees as $key => $employee) {
|
||||||
|
$rows[$key] = [
|
||||||
|
'email' => $employee->getEmail(),
|
||||||
|
'uuid' => $employee->getUuid(),
|
||||||
|
'department' => $employee->getDepartment(),
|
||||||
|
'hasLeft' => $employee->hasLeft(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array{email: string, uuid: ?string, department: ?string, hasLeft: bool}> $rows
|
||||||
|
*/
|
||||||
|
public static function fromCacheable(array $rows): self
|
||||||
|
{
|
||||||
|
return self::fromEmployees(array_map(
|
||||||
|
static fn (array $row) => new Employee(
|
||||||
|
(string) $row['email'],
|
||||||
|
$row['uuid'] ?? null,
|
||||||
|
$row['department'] ?? null,
|
||||||
|
(bool) ($row['hasLeft'] ?? false),
|
||||||
|
),
|
||||||
|
array_values($rows),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Saas\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One employee's time account for one calendar month, reduced to what this application
|
||||||
|
* stores: the days with their net minutes and absence labels.
|
||||||
|
*
|
||||||
|
* Everything the SaaS reports about overtime accounts and payroll transfers is deliberately
|
||||||
|
* dropped. The header's own "ist" total survives as a cross-check against the days we summed
|
||||||
|
* ourselves, and "soll" because the sheet shows target against actual.
|
||||||
|
*/
|
||||||
|
class TimeAccount
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, TimeAccountDay> $days keyed by Y-m-d, ascending
|
||||||
|
* @param string[] $warnings
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly ?string $employeeUuid,
|
||||||
|
private readonly int $year,
|
||||||
|
private readonly int $month,
|
||||||
|
private readonly int $istMinutes,
|
||||||
|
private readonly int $targetMinutes,
|
||||||
|
private readonly ?string $department,
|
||||||
|
private readonly array $days,
|
||||||
|
private readonly array $warnings,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmployeeUuid(): ?string
|
||||||
|
{
|
||||||
|
return $this->employeeUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getYear(): int
|
||||||
|
{
|
||||||
|
return $this->year;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar month (1-12), never the API's zero-based index.
|
||||||
|
*/
|
||||||
|
public function getMonth(): int
|
||||||
|
{
|
||||||
|
return $this->month;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total the SaaS itself reported for the month.
|
||||||
|
*/
|
||||||
|
public function getIstMinutes(): int
|
||||||
|
{
|
||||||
|
return $this->istMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The month's target, as reported. There is no daily target: every row carries soll 0
|
||||||
|
* with sollType MONTH.
|
||||||
|
*/
|
||||||
|
public function getTargetMinutes(): int
|
||||||
|
{
|
||||||
|
return $this->targetMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The employee's department, as the bookings report it.
|
||||||
|
*
|
||||||
|
* Null when the month holds no booking at all - a month of nothing but holiday has
|
||||||
|
* nowhere to read it from.
|
||||||
|
*/
|
||||||
|
public function getDepartment(): ?string
|
||||||
|
{
|
||||||
|
return $this->department;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, TimeAccountDay>
|
||||||
|
*/
|
||||||
|
public function getDays(): array
|
||||||
|
{
|
||||||
|
return $this->days;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTotalMinutes(): int
|
||||||
|
{
|
||||||
|
return array_sum(array_map(static fn (TimeAccountDay $day) => $day->getMinutes(), $this->days));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countWorkedDays(): int
|
||||||
|
{
|
||||||
|
return count(array_filter($this->days, static fn (TimeAccountDay $day) => $day->getMinutes() > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countAbsenceDays(): int
|
||||||
|
{
|
||||||
|
return count(array_filter($this->days, static fn (TimeAccountDay $day) => $day->isAbsence()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function getWarnings(): array
|
||||||
|
{
|
||||||
|
return $this->warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasWarnings(): bool
|
||||||
|
{
|
||||||
|
return [] !== $this->warnings;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Saas\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One calendar day of a time account: the span it was worked in, the net minutes inside that
|
||||||
|
* span, the breaks taken, and - if the day was booked off - the absence label the SaaS uses
|
||||||
|
* for it ("Ferien", "Ruhetag 24/25", ...).
|
||||||
|
*
|
||||||
|
* A day can be both: an absence label does not imply zero minutes.
|
||||||
|
*/
|
||||||
|
class TimeAccountDay
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly \DateTimeImmutable $date,
|
||||||
|
private readonly int $minutes,
|
||||||
|
private readonly ?string $holidayType,
|
||||||
|
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 getHolidayType(): ?string
|
||||||
|
{
|
||||||
|
return $this->holidayType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First clock-in of the day as "HH:MM", or null when nothing with a duration was booked.
|
||||||
|
*/
|
||||||
|
public function getStartTime(): ?string
|
||||||
|
{
|
||||||
|
return $this->startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last clock-out of the day as "HH:MM".
|
||||||
|
*/
|
||||||
|
public function getEndTime(): ?string
|
||||||
|
{
|
||||||
|
return $this->endTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything booked as a break that day. The worked minutes are already net of it; this is
|
||||||
|
* the figure the sheet shows next to them.
|
||||||
|
*/
|
||||||
|
public function getBreakMinutes(): int
|
||||||
|
{
|
||||||
|
return $this->breakMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAbsence(): bool
|
||||||
|
{
|
||||||
|
return null !== $this->holidayType;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Saas;
|
||||||
|
|
||||||
|
use App\Saas\Model\Employee;
|
||||||
|
use App\Saas\Model\EmployeeDirectory;
|
||||||
|
use App\Saas\Model\TimeAccount;
|
||||||
|
use App\Saas\Model\TimeAccountDay;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a raw time-account payload into the handful of facts this application keeps.
|
||||||
|
*
|
||||||
|
* Two properties of the payload drive everything here:
|
||||||
|
*
|
||||||
|
* 1. Months are zero-based in the response as well as in the request, and the "date"
|
||||||
|
* strings carry that index verbatim - a March booking arrives as "2026-02-31". Those
|
||||||
|
* strings must never reach a date parser: PHP rolls "2026-02-31" over to 2026-03-03,
|
||||||
|
* December arrives as "2026-11-31" and January as "2026-00-15". They are split and
|
||||||
|
* rebuilt one month later instead, then validated.
|
||||||
|
* 2. The row schema is not stable. Booking rows carry some 45 keys, the derived rows the
|
||||||
|
* SaaS inserts for breaks and absences carry about 20, and header fields come and go
|
||||||
|
* between months. Nothing is assumed to be present.
|
||||||
|
*/
|
||||||
|
class ResponseParser
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* A break segment. Carries its length in "pause" and never any working time, so it
|
||||||
|
* contributes nothing to a day's total - the minutes on booking rows are already net
|
||||||
|
* of breaks.
|
||||||
|
*/
|
||||||
|
private const ANNOTATION_BREAK = -1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A full-day absence. This row, not a day summing to zero, is what marks a day off:
|
||||||
|
* "holidayType" names it, and a day can carry both an absence and booked work.
|
||||||
|
*/
|
||||||
|
private const ANNOTATION_ABSENCE = -4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces the employee roster to the four fields this application uses.
|
||||||
|
*
|
||||||
|
* The raw entries carry a per-employee "apiKey" - a credential - next to a birthday, a
|
||||||
|
* personnel number and an RFID token. The roster is cached to the filesystem, so the
|
||||||
|
* reduction happens here, before anything can hold on to the rest.
|
||||||
|
*/
|
||||||
|
public function parseEmployees(array $data): EmployeeDirectory
|
||||||
|
{
|
||||||
|
$employees = [];
|
||||||
|
|
||||||
|
foreach ($data as $row) {
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = $row['email'] ?? null;
|
||||||
|
|
||||||
|
// The address is the only key this is useful by; an entry without one is not.
|
||||||
|
if (!is_string($email) || '' === trim($email)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uuid = $row['uuid'] ?? null;
|
||||||
|
$department = $row['mainDepartment'] ?? null;
|
||||||
|
$separation = $row['dateOfSeparation'] ?? null;
|
||||||
|
|
||||||
|
$employees[] = new Employee(
|
||||||
|
trim($email),
|
||||||
|
is_string($uuid) && '' !== $uuid ? $uuid : null,
|
||||||
|
is_string($department) && '' !== trim($department) ? trim($department) : null,
|
||||||
|
is_string($separation) && '' !== trim($separation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return EmployeeDirectory::fromEmployees($employees);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $month the requested calendar month (1-12)
|
||||||
|
*/
|
||||||
|
public function parseTimeAccount(array $data, int $year, int $month): TimeAccount
|
||||||
|
{
|
||||||
|
$warnings = [];
|
||||||
|
|
||||||
|
$this->checkHeader($data, $year, $month, $warnings);
|
||||||
|
|
||||||
|
/** @var array<string, array{minutes: int, breakMinutes: int, holidayType: ?string, start: ?string, end: ?string}> $byDate */
|
||||||
|
$byDate = [];
|
||||||
|
|
||||||
|
/** @var string[] $departments */
|
||||||
|
$departments = [];
|
||||||
|
$rows = $data['list'] ?? [];
|
||||||
|
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
$warnings[] = 'The payload carried no readable "list" of bookings.';
|
||||||
|
$rows = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = $this->rebaseDate($row['date'] ?? null, $year, $month, $warnings);
|
||||||
|
|
||||||
|
if (null === $date) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = $date->format('Y-m-d');
|
||||||
|
$byDate[$key] ??= [
|
||||||
|
'minutes' => 0,
|
||||||
|
'breakMinutes' => 0,
|
||||||
|
'holidayType' => null,
|
||||||
|
'start' => null,
|
||||||
|
'end' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$annotation = (int) ($row['annotation'] ?? 0);
|
||||||
|
|
||||||
|
if ($annotation < 0
|
||||||
|
&& self::ANNOTATION_BREAK !== $annotation
|
||||||
|
&& self::ANNOTATION_ABSENCE !== $annotation
|
||||||
|
) {
|
||||||
|
// Positive annotations are a bitmask of booking attributes and need no
|
||||||
|
// interpretation, but the negative ones are distinct row kinds. We only
|
||||||
|
// know three of them; report the rest rather than quietly ignoring them.
|
||||||
|
$warnings[] = sprintf('Unknown row kind %d on %s.', $annotation, $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
$byDate[$key]['minutes'] += (int) ($row['ist'] ?? 0);
|
||||||
|
$byDate[$key]['breakMinutes'] += (int) ($row['pause'] ?? 0);
|
||||||
|
|
||||||
|
$department = $row['department'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($department) && '' !== trim($department)) {
|
||||||
|
$departments[trim($department)] = trim($department);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($annotation >= 0) {
|
||||||
|
// Only bookings define the working day's span. The absence marker carries a
|
||||||
|
// placeholder 23:59:59 that would otherwise become every free day's start
|
||||||
|
// time, and a break cannot extend the day past the last clock-out.
|
||||||
|
$this->widenSpan($byDate[$key], $row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::ANNOTATION_ABSENCE === $annotation) {
|
||||||
|
$holidayType = $row['holidayType'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($holidayType) && '' !== trim($holidayType)) {
|
||||||
|
$byDate[$key]['holidayType'] = trim($holidayType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($byDate);
|
||||||
|
|
||||||
|
$days = [];
|
||||||
|
|
||||||
|
foreach ($byDate as $key => $values) {
|
||||||
|
// Nothing was booked, so the only rows were markers - a rest day, or an absence.
|
||||||
|
// Those carry a time, but the day has no span worth showing.
|
||||||
|
if (0 === $values['minutes'] && 0 === $values['breakMinutes']) {
|
||||||
|
$values['start'] = null;
|
||||||
|
$values['end'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$days[$key] = new TimeAccountDay(
|
||||||
|
new \DateTimeImmutable($key),
|
||||||
|
$values['minutes'],
|
||||||
|
$values['holidayType'],
|
||||||
|
$this->shortTime($values['start']),
|
||||||
|
$this->shortTime($values['end']),
|
||||||
|
$values['breakMinutes'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$istMinutes = (int) ($data['ist'] ?? 0);
|
||||||
|
$summed = array_sum(array_column($byDate, 'minutes'));
|
||||||
|
|
||||||
|
if ($summed !== $istMinutes) {
|
||||||
|
// Nothing is dropped on purpose, so these two must agree. A mismatch is the
|
||||||
|
// first sign that the date convention or the row semantics have changed.
|
||||||
|
$warnings[] = sprintf(
|
||||||
|
'The days add up to %d minutes but the month reports %d.',
|
||||||
|
$summed,
|
||||||
|
$istMinutes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (1 < count($departments)) {
|
||||||
|
// One department per employee is what this is supposed to be; if a month ever
|
||||||
|
// spans two, whoever reads the sheet should know rather than see an arbitrary one.
|
||||||
|
$warnings[] = sprintf('The month reports more than one department: %s.', implode(', ', $departments));
|
||||||
|
}
|
||||||
|
|
||||||
|
$employeeUuid = $data['employee'] ?? null;
|
||||||
|
|
||||||
|
return new TimeAccount(
|
||||||
|
is_string($employeeUuid) && '' !== $employeeUuid ? $employeeUuid : null,
|
||||||
|
$year,
|
||||||
|
$month,
|
||||||
|
$istMinutes,
|
||||||
|
// The target is a monthly figure: every row carries soll 0 with sollType MONTH,
|
||||||
|
// so there is no such thing as a daily target to show.
|
||||||
|
(int) ($data['soll'] ?? 0),
|
||||||
|
// Carried on every booking row, identically; the derived rows for breaks and
|
||||||
|
// absences have none, so a month of pure holiday leaves this null.
|
||||||
|
reset($departments) ?: null,
|
||||||
|
$days,
|
||||||
|
$warnings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stretches a day's span to cover one more booking.
|
||||||
|
*
|
||||||
|
* Rows with no duration are kept: a day often opens with a zero-length row at the moment
|
||||||
|
* the teamer clocked in, and skipping those moved the start of the day to the end of the
|
||||||
|
* first break. Rest days carry such a row too, which is why a day with nothing booked
|
||||||
|
* drops its span again once every row has been seen.
|
||||||
|
*
|
||||||
|
* @param array{start: ?string, end: ?string} $day
|
||||||
|
*/
|
||||||
|
private function widenSpan(array &$day, array $row): void
|
||||||
|
{
|
||||||
|
$start = $this->timeOf($row['startTime'] ?? null);
|
||||||
|
$end = $this->timeOf($row['endTime'] ?? null);
|
||||||
|
|
||||||
|
if (null === $start || null === $end) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "HH:MM:SS" sorts chronologically as a string, and no sample crosses midnight.
|
||||||
|
$day['start'] = null === $day['start'] ? $start : min($day['start'], $start);
|
||||||
|
$day['end'] = null === $day['end'] ? $end : max($day['end'], $end);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function timeOf(mixed $raw): ?string
|
||||||
|
{
|
||||||
|
return is_string($raw) && 1 === preg_match('/^\d{2}:\d{2}:\d{2}$/', $raw) ? $raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shortTime(?string $time): ?string
|
||||||
|
{
|
||||||
|
return null !== $time ? substr($time, 0, 5) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $warnings
|
||||||
|
*/
|
||||||
|
private function checkHeader(array $data, int $year, int $month, array &$warnings): void
|
||||||
|
{
|
||||||
|
if (isset($data['year']) && (int) $data['year'] !== $year) {
|
||||||
|
$warnings[] = sprintf('The payload is for year %d, not %d.', (int) $data['year'], $year);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The header reports the same zero-based index that was requested.
|
||||||
|
if (isset($data['month']) && (int) $data['month'] !== $month - 1) {
|
||||||
|
$warnings[] = sprintf(
|
||||||
|
'The payload is for month index %d, not %d.',
|
||||||
|
(int) $data['month'],
|
||||||
|
$month - 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds a zero-based response date as a real one, or reports why it could not.
|
||||||
|
*
|
||||||
|
* @param string[] $warnings
|
||||||
|
*/
|
||||||
|
private function rebaseDate(mixed $raw, int $year, int $month, array &$warnings): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
if (!is_string($raw) || 1 !== preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $raw, $matches)) {
|
||||||
|
$warnings[] = sprintf(
|
||||||
|
'Skipped a row with an unreadable date (%s).',
|
||||||
|
is_scalar($raw) ? '"'.$raw.'"' : get_debug_type($raw),
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rowYear = (int) $matches[1];
|
||||||
|
$rowMonth = (int) $matches[2] + 1;
|
||||||
|
$rowDay = (int) $matches[3];
|
||||||
|
|
||||||
|
if ($rowYear !== $year || $rowMonth !== $month || !checkdate($rowMonth, $rowDay, $rowYear)) {
|
||||||
|
$warnings[] = sprintf('Skipped a row dated "%s": that is not a day of %04d-%02d.', $raw, $year, $month);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = \DateTimeImmutable::createFromFormat(
|
||||||
|
'!Y-m-d',
|
||||||
|
sprintf('%04d-%02d-%02d', $rowYear, $rowMonth, $rowDay),
|
||||||
|
);
|
||||||
|
|
||||||
|
return false !== $date ? $date : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\Common;
|
||||||
|
|
||||||
|
use App\Enum\WorkMonthStatus;
|
||||||
|
use App\Model\AbstractFilterDto;
|
||||||
|
use App\Model\WorkMonthFilterDto;
|
||||||
|
|
||||||
|
class WorkMonthFilterHandler extends AbstractFilterHandler
|
||||||
|
{
|
||||||
|
protected string $namespace = 'filter:work_month';
|
||||||
|
protected string $modelClass = WorkMonthFilterDto::class;
|
||||||
|
|
||||||
|
public function getFilterSettings(): WorkMonthFilterDto
|
||||||
|
{
|
||||||
|
$filterDto = new $this->modelClass();
|
||||||
|
|
||||||
|
if (null === $data = $this->getSession()->get($this->namespace)) {
|
||||||
|
return $filterDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filterDto
|
||||||
|
->setTeamerName($data['teamer_name'] ?? null)
|
||||||
|
->setIncludeApproved((bool) ($data['include_approved'] ?? false))
|
||||||
|
->setIncludeMismatchedOnly((bool) ($data['include_mismatched_only'] ?? false))
|
||||||
|
;
|
||||||
|
|
||||||
|
if (null !== ($period = $data['period'] ?? null)) {
|
||||||
|
$filterDto->setPeriod(new \DateTimeImmutable($period));
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryFrom rather than from: a status dropped from the enum must not fatal a session
|
||||||
|
// written before it went away.
|
||||||
|
$statuses = array_filter(array_map(
|
||||||
|
static fn (string $value) => WorkMonthStatus::tryFrom($value),
|
||||||
|
$data['statuses'] ?? [],
|
||||||
|
));
|
||||||
|
|
||||||
|
return $filterDto->setStatuses(array_values($statuses));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function saveFilterSettings(AbstractFilterDto $filterDto): void
|
||||||
|
{
|
||||||
|
/* @var WorkMonthFilterDto $filterDto */
|
||||||
|
$this->getSession()->set($this->namespace, [
|
||||||
|
'teamer_name' => $filterDto->getTeamerName(),
|
||||||
|
'period' => $filterDto->getPeriod()?->format('Y-m-d'),
|
||||||
|
'statuses' => array_map(
|
||||||
|
static fn (WorkMonthStatus $status) => $status->value,
|
||||||
|
$filterDto->getStatuses(),
|
||||||
|
),
|
||||||
|
'include_approved' => $filterDto->isIncludeApproved(),
|
||||||
|
'include_mismatched_only' => $filterDto->isIncludeMismatchedOnly(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\Cron;
|
||||||
|
|
||||||
|
use App\Config\EmailTextKey;
|
||||||
|
use App\Email\Mailer;
|
||||||
|
use App\Repository\TeamerWorkMonthRepository;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chases the teamers who still owe us a signed Arbeitszeitnachweis.
|
||||||
|
*
|
||||||
|
* Idempotent without recording anything, like the other reminders here: it picks exactly one
|
||||||
|
* weekday, so a month is chased once a week for as long as it stays open, and a second run on
|
||||||
|
* the same day would simply send the same mail again.
|
||||||
|
*
|
||||||
|
* Only an upload stops it. An import that changes the hours does not, and neither does a
|
||||||
|
* rejection - a rejected month is one the teamer still has to deal with.
|
||||||
|
*/
|
||||||
|
class WorkMonthReminderService
|
||||||
|
{
|
||||||
|
private const DIGEST_WEEKDAY = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Days between the import and the first reminder. The import runs on the 10th, so this
|
||||||
|
* leaves the teamer the best part of a week before anyone nags.
|
||||||
|
*/
|
||||||
|
private const GRACE_DAYS = 5;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly TeamerWorkMonthRepository $workMonthRepository,
|
||||||
|
private readonly Mailer $mailer,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendReminders(?\DateTimeImmutable $today = null): string
|
||||||
|
{
|
||||||
|
$today = ($today ?? new \DateTimeImmutable('today'))->setTime(0, 0);
|
||||||
|
|
||||||
|
if (self::DIGEST_WEEKDAY !== (int) $today->format('N')) {
|
||||||
|
$message = 'Work month reminders are only sent on mondays';
|
||||||
|
$this->logger->info($message);
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
$months = $this->workMonthRepository->findAwaitingDocument(
|
||||||
|
$today->modify(sprintf('-%d days', self::GRACE_DAYS))
|
||||||
|
);
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
foreach ($months as $workMonth) {
|
||||||
|
$email = $workMonth->getTeamer()?->getCommunication()?->getEmail();
|
||||||
|
|
||||||
|
if (null === $email || '' === trim($email)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->mailer->createAndSendText(EmailTextKey::REMINDER_WORK_MONTH_UPLOAD, [
|
||||||
|
'workMonth' => $workMonth->getPeriodLabel(),
|
||||||
|
], [
|
||||||
|
'to' => $email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
++$count;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = 'Sent '.$count.' work month reminders to teamers';
|
||||||
|
$this->logger->info($message);
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\Pdf;
|
||||||
|
|
||||||
|
use App\Entity\TeamerWorkMonth;
|
||||||
|
use Dompdf\Dompdf;
|
||||||
|
use Dompdf\Options;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the Arbeitszeitnachweis a teamer signs for one month.
|
||||||
|
*
|
||||||
|
* Deliberately a sibling of AbstractPdfRenderer rather than a subclass: that base exists for
|
||||||
|
* the FPDI renderers, which stamp text at fixed millimetre coordinates onto a pre-made PDF.
|
||||||
|
* That works for the one-page contract and invoice forms but not for a table whose length
|
||||||
|
* depends on the month, so this one goes through Twig and Dompdf instead. Dompdf's bundled
|
||||||
|
* DejaVu fonts also carry umlauts natively, where the FPDI path has to convert every string
|
||||||
|
* to ISO-8859-1 first (see Pdf::encodeString()).
|
||||||
|
*/
|
||||||
|
class WorkMonthRenderer
|
||||||
|
{
|
||||||
|
private array $config;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Environment $twig,
|
||||||
|
array $options,
|
||||||
|
) {
|
||||||
|
$this->config = $this->resolveOptions($options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(TeamerWorkMonth $workMonth): string
|
||||||
|
{
|
||||||
|
$options = new Options();
|
||||||
|
// Nothing in the document is fetched from anywhere: the stylesheet is inline and
|
||||||
|
// there are no images, so the renderer never needs to leave the project directory.
|
||||||
|
$options->setIsRemoteEnabled(false);
|
||||||
|
$options->setChroot($this->config['project_dir']);
|
||||||
|
$options->setDefaultFont('DejaVu Sans');
|
||||||
|
$options->setDefaultPaperSize('A4');
|
||||||
|
$options->setDefaultPaperOrientation('portrait');
|
||||||
|
|
||||||
|
$dompdf = new Dompdf($options);
|
||||||
|
$dompdf->loadHtml($this->twig->render('pdf/work_month.html.twig', [
|
||||||
|
'workMonth' => $workMonth,
|
||||||
|
'teamer' => $workMonth->getTeamer(),
|
||||||
|
'days' => $workMonth->getDays()->forCalendarMonth($workMonth->getYear(), $workMonth->getMonth()),
|
||||||
|
'logo' => $this->logoPath(),
|
||||||
|
]), 'UTF-8');
|
||||||
|
$dompdf->render();
|
||||||
|
|
||||||
|
return (string) $dompdf->output();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The letterhead logo, as an absolute path inside the chroot.
|
||||||
|
*
|
||||||
|
* Read from the filesystem rather than embedded: remote loading is off and the chroot is
|
||||||
|
* the project directory, so Dompdf can open this and nothing outside it. Null when the
|
||||||
|
* file is missing, because a working-time record is worth printing without its letterhead.
|
||||||
|
*/
|
||||||
|
private function logoPath(): ?string
|
||||||
|
{
|
||||||
|
$path = $this->config['project_dir'].'/assets/images/logo.png';
|
||||||
|
|
||||||
|
return is_readable($path) ? $path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arbeitszeitnachweis_Test_2026-03.pdf.
|
||||||
|
*/
|
||||||
|
public function filename(TeamerWorkMonth $workMonth): string
|
||||||
|
{
|
||||||
|
$lastName = $workMonth->getTeamer()?->getLastName() ?? 'Teamer';
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'Arbeitszeitnachweis_%s_%04d-%02d.pdf',
|
||||||
|
preg_replace('/[^A-Za-z0-9]+/', '_', $lastName),
|
||||||
|
$workMonth->getYear(),
|
||||||
|
$workMonth->getMonth(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveOptions(array $options): array
|
||||||
|
{
|
||||||
|
$optionsResolver = new OptionsResolver();
|
||||||
|
$optionsResolver->setRequired(['project_dir']);
|
||||||
|
$optionsResolver->setAllowedTypes('project_dir', 'string');
|
||||||
|
|
||||||
|
return $optionsResolver->resolve($options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\Saas;
|
||||||
|
|
||||||
|
use App\Entity\Teamer;
|
||||||
|
use App\Entity\TeamerWorkMonth;
|
||||||
|
use App\Enum\WorkMonthStatus;
|
||||||
|
use App\Model\WorkHoursSyncResult;
|
||||||
|
use App\Model\WorkMonthDays;
|
||||||
|
use App\Repository\TeamerRepository;
|
||||||
|
use App\Repository\TeamerWorkMonthRepository;
|
||||||
|
use App\Saas\ApiClient;
|
||||||
|
use App\Saas\ApiClientException;
|
||||||
|
use App\Saas\DataProvider\EmployeeDataProvider;
|
||||||
|
use App\Saas\ResponseParser;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports working time from the SaaS into TeamerWorkMonth records.
|
||||||
|
*
|
||||||
|
* Runs once a month for every season teamer, and on demand for a single one. Both go
|
||||||
|
* through syncTeamer(), so the admin button and the scheduled command cannot drift apart.
|
||||||
|
*/
|
||||||
|
class WorkHoursSynchronizer
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ApiClient $apiClient,
|
||||||
|
private readonly ResponseParser $responseParser,
|
||||||
|
private readonly EmployeeDataProvider $employeeDataProvider,
|
||||||
|
private readonly TeamerRepository $teamerRepository,
|
||||||
|
private readonly TeamerWorkMonthRepository $workMonthRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The season teamers one month's import covers.
|
||||||
|
*
|
||||||
|
* @return Teamer[]
|
||||||
|
*/
|
||||||
|
public function findEligibleTeamers(int $year, int $month): array
|
||||||
|
{
|
||||||
|
$firstDay = $this->firstDayOf($year, $month);
|
||||||
|
|
||||||
|
return $this->teamerRepository->findSeasonTeamersForMonth(
|
||||||
|
$firstDay,
|
||||||
|
$firstDay->modify('last day of this month'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return WorkHoursSyncResult[]
|
||||||
|
*/
|
||||||
|
public function syncMonth(int $year, int $month, bool $dryRun = false, ?callable $onProgress = null): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($this->findEligibleTeamers($year, $month) as $teamer) {
|
||||||
|
$result = $this->syncTeamer($teamer, $year, $month, $dryRun);
|
||||||
|
$results[] = $result;
|
||||||
|
|
||||||
|
if (null !== $onProgress) {
|
||||||
|
$onProgress($result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function syncTeamer(Teamer $teamer, int $year, int $month, bool $dryRun = false): WorkHoursSyncResult
|
||||||
|
{
|
||||||
|
$email = $teamer->getCommunication()?->getEmail();
|
||||||
|
|
||||||
|
if (null === $email || '' === trim($email)) {
|
||||||
|
return WorkHoursSyncResult::skipped($teamer, 'Keine E-Mail-Adresse hinterlegt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$directory = $this->employeeDataProvider->getDirectory();
|
||||||
|
$employee = $directory->find($email);
|
||||||
|
|
||||||
|
// An empty roster means the roster call failed, not that nobody is registered, so it
|
||||||
|
// must never be read as "skip everyone" - the import goes ahead without it.
|
||||||
|
if (null === $employee && !$directory->isEmpty()) {
|
||||||
|
return WorkHoursSyncResult::skipped(
|
||||||
|
$teamer,
|
||||||
|
sprintf('Nicht in der SaaS registriert (%s).', $email),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = $this->apiClient->getTimeAccount($email, $year, $month);
|
||||||
|
} catch (ApiClientException $e) {
|
||||||
|
$this->logger->error('Work hours import failed', [
|
||||||
|
'teamer' => $teamer->getFullName(),
|
||||||
|
'year' => $year,
|
||||||
|
'month' => $month,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return WorkHoursSyncResult::failed($teamer, $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$account = $this->responseParser->parseTimeAccount($payload, $year, $month);
|
||||||
|
$days = WorkMonthDays::fromTimeAccount($account);
|
||||||
|
$workMonth = $this->workMonthRepository->findOneForTeamerAndMonth($teamer, $year, $month);
|
||||||
|
|
||||||
|
if (null !== $workMonth && $workMonth->getStatus()->locksHours()) {
|
||||||
|
return $this->compareLocked($workMonth, $days, $dryRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $workMonth) {
|
||||||
|
$workMonth = (new TeamerWorkMonth())
|
||||||
|
->setTeamer($teamer)
|
||||||
|
->setYear($year)
|
||||||
|
->setMonth($month)
|
||||||
|
;
|
||||||
|
$this->entityManager->persist($workMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workMonth
|
||||||
|
->setDays($days)
|
||||||
|
->setSourceIstMinutes($account->getIstMinutes())
|
||||||
|
->setTargetMinutes($account->getTargetMinutes())
|
||||||
|
// Keep the last known one: a month of pure holiday reports none, and blanking it
|
||||||
|
// would lose what earlier months already established.
|
||||||
|
// The bookings first, the roster second: a month made up entirely of holiday has
|
||||||
|
// no booking to read a department from, and the roster always does.
|
||||||
|
->setDepartment($account->getDepartment() ?? $employee?->getDepartment() ?? $workMonth->getDepartment())
|
||||||
|
->setWarnings($account->getWarnings())
|
||||||
|
->setSyncedAt(new \DateTimeImmutable())
|
||||||
|
->clearMismatch()
|
||||||
|
;
|
||||||
|
|
||||||
|
$employeeUuid = $account->getEmployeeUuid() ?? $employee?->getUuid();
|
||||||
|
|
||||||
|
if (null !== $employeeUuid && $teamer->getSaasEmployeeUuid() !== $employeeUuid) {
|
||||||
|
$teamer->setSaasEmployeeUuid($employeeUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$dryRun) {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info('Work hours imported', [
|
||||||
|
'teamer' => $teamer->getFullName(),
|
||||||
|
'year' => $year,
|
||||||
|
'month' => $month,
|
||||||
|
'minutes' => $days->getTotalMinutes(),
|
||||||
|
'days' => count($days->toArray()),
|
||||||
|
'warnings' => count($account->getWarnings()),
|
||||||
|
'dryRun' => $dryRun,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return WorkHoursSyncResult::imported($teamer, $days->getTotalMinutes(), $account->getWarnings());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases a signed month back to the importer, for when a recorded discrepancy turns
|
||||||
|
* out to be a genuine correction the teamer has to sign again.
|
||||||
|
*/
|
||||||
|
public function unlock(TeamerWorkMonth $workMonth): void
|
||||||
|
{
|
||||||
|
$workMonth
|
||||||
|
->setStatus(WorkMonthStatus::OPEN)
|
||||||
|
->setSubmittedAt(null)
|
||||||
|
->setReviewedAt(null)
|
||||||
|
->setReviewedBy(null)
|
||||||
|
->setReviewComment(null)
|
||||||
|
->clearMismatch()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A signed month keeps the hours it was signed with. The fresh figures are only
|
||||||
|
* compared, so the office can see that the SaaS has since changed its mind.
|
||||||
|
*/
|
||||||
|
private function compareLocked(TeamerWorkMonth $workMonth, WorkMonthDays $days, bool $dryRun): WorkHoursSyncResult
|
||||||
|
{
|
||||||
|
$teamer = $workMonth->getTeamer();
|
||||||
|
$fresh = $days->getTotalMinutes();
|
||||||
|
|
||||||
|
if ($workMonth->getTotalMinutes() === $fresh) {
|
||||||
|
$workMonth->clearMismatch();
|
||||||
|
|
||||||
|
if (!$dryRun) {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return WorkHoursSyncResult::locked(
|
||||||
|
$teamer,
|
||||||
|
'Bereits eingereicht, Stunden unverändert.',
|
||||||
|
$workMonth->getTotalMinutes(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workMonth->recordMismatch($fresh);
|
||||||
|
|
||||||
|
if (!$dryRun) {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->warning('Work hours changed after submission', [
|
||||||
|
'teamer' => $teamer->getFullName(),
|
||||||
|
'year' => $workMonth->getYear(),
|
||||||
|
'month' => $workMonth->getMonth(),
|
||||||
|
'signed' => $workMonth->getTotalMinutes(),
|
||||||
|
'reported' => $fresh,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return WorkHoursSyncResult::locked(
|
||||||
|
$teamer,
|
||||||
|
sprintf(
|
||||||
|
'Bereits eingereicht; die SaaS meldet jetzt %d statt %d Minuten.',
|
||||||
|
$fresh,
|
||||||
|
$workMonth->getTotalMinutes(),
|
||||||
|
),
|
||||||
|
$workMonth->getTotalMinutes(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function firstDayOf(int $year, int $month): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
$first = \DateTimeImmutable::createFromFormat('!Y-n-j', sprintf('%04d-%d-1', $year, $month));
|
||||||
|
|
||||||
|
if (false === $first) {
|
||||||
|
throw new \InvalidArgumentException(sprintf('%04d-%02d is not a month.', $year, $month));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $first;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ class AppExtension extends AbstractExtension
|
|||||||
new TwigFilter('bpn_pickup_label', [AppRuntime::class, 'bpnPickupLabel']),
|
new TwigFilter('bpn_pickup_label', [AppRuntime::class, 'bpnPickupLabel']),
|
||||||
new TwigFilter('upload_status_badge', [AppRuntime::class, 'renderUploadStatusBadge'], ['is_safe' => ['html']]),
|
new TwigFilter('upload_status_badge', [AppRuntime::class, 'renderUploadStatusBadge'], ['is_safe' => ['html']]),
|
||||||
new TwigFilter('upload_filename_full', [AppRuntime::class, 'getFilenameWithFolder']),
|
new TwigFilter('upload_filename_full', [AppRuntime::class, 'getFilenameWithFolder']),
|
||||||
|
new TwigFilter('duration_hm', [AppRuntime::class, 'formatDurationHoursMinutes']),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,24 @@ class AppRuntime implements RuntimeExtensionInterface
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minutes as hours and minutes, the way a timesheet reads them: 510 becomes "8:30".
|
||||||
|
*
|
||||||
|
* Hours are not padded and not capped at 24 - a month total is a perfectly ordinary
|
||||||
|
* "195:45". Null is a day the SaaS never reported, which is not the same as zero.
|
||||||
|
*/
|
||||||
|
public function formatDurationHoursMinutes(?int $minutes, string $empty = '–'): string
|
||||||
|
{
|
||||||
|
if (null === $minutes) {
|
||||||
|
return $empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sign = $minutes < 0 ? '-' : '';
|
||||||
|
$minutes = abs($minutes);
|
||||||
|
|
||||||
|
return sprintf('%s%d:%02d', $sign, intdiv($minutes, 60), $minutes % 60);
|
||||||
|
}
|
||||||
|
|
||||||
public function seasonAssignmentLabel(SeasonAssignment $seasonAssignment): string
|
public function seasonAssignmentLabel(SeasonAssignment $seasonAssignment): string
|
||||||
{
|
{
|
||||||
return $this->seasonAssignmentLabeler->label($seasonAssignment);
|
return $this->seasonAssignmentLabeler->label($seasonAssignment);
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
{% extends 'admin/layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Arbeitszeiten{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-start justify-between pb-4">
|
||||||
|
<h1 class="text-2xl font-bold">
|
||||||
|
Arbeitszeiten
|
||||||
|
</h1>
|
||||||
|
<div class="flex flex-col items-end space-y-2 md:flex-row md:items-center md:space-x-2 md:space-y-0">
|
||||||
|
<button type="button"
|
||||||
|
class="{{ html_classes('btn btn--small', { 'btn--secondary': filterDto.active }) }}"
|
||||||
|
title="Arbeitszeiten filtern"
|
||||||
|
hx-get="{{ path('app_common_work_month_filter', { 'r': return_url() }) }}"
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="beforeend">
|
||||||
|
{{ icon('filter', 'w-4 h-4 shrink-0') }}
|
||||||
|
{% if filterDto.active %}
|
||||||
|
<span class="whitespace-nowrap">{{ filterDto.activeFiltersCount }} Filter aktiv</span>
|
||||||
|
{% else %}
|
||||||
|
<span>Filter</span>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
{% if filterDto.active %}
|
||||||
|
<a href="{{ path('app_common_work_month_filter_reset', { 'r': return_url() }) }}" class="btn btn--small btn--secondary">
|
||||||
|
Reset
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-table-wrapper">
|
||||||
|
<div class="data-table-wrapper__inner">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ knp_pagination_sortable(pagination, 'Name', 'teamer.lastName') }}</th>
|
||||||
|
<th>Vorname</th>
|
||||||
|
<th>{{ knp_pagination_sortable(pagination, 'Monat', 'work_month.year') }}</th>
|
||||||
|
<th class="text-right">Stunden</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Nachweis</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for workMonth in pagination %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ path('app_administrative_teamer_profile', { 'uuid': workMonth.teamer.uuid, 'r': return_url() }) }}">
|
||||||
|
<span class="whitespace-nowrap">{{ workMonth.teamer.lastName }}</span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="whitespace-nowrap">{{ workMonth.teamer.firstName }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="whitespace-nowrap">{{ workMonth.periodLabel }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<span class="whitespace-nowrap">{{ workMonth.totalMinutes|duration_hm }}</span>
|
||||||
|
{% if workMonth.hasMismatch %}
|
||||||
|
<div class="text-xs text-red-600 whitespace-nowrap"
|
||||||
|
title="Die SaaS meldet inzwischen andere Stunden für diesen bereits eingereichten Monat.">
|
||||||
|
jetzt {{ workMonth.mismatchTotalMinutes|duration_hm }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if workMonth.warnings is not empty %}
|
||||||
|
<div class="text-xs text-amber-700 whitespace-nowrap"
|
||||||
|
title="{{ workMonth.warnings|join(' | ') }}">
|
||||||
|
{{ workMonth.warnings|length }} Hinweis(e)
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="upload-status-badge {{ workMonth.status.badgeClass }}">
|
||||||
|
{{ workMonth.status.label }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if workMonth.currentDocument %}
|
||||||
|
<a href="{{ path('app_common_download', { 'uuid': workMonth.currentDocument.uuid }) }}"
|
||||||
|
class="flex items-center space-x-1">
|
||||||
|
{{ icon('download', 'w-4 h-4 shrink-0') }}
|
||||||
|
<span class="whitespace-nowrap">{{ workMonth.submittedAt|date('d.m.Y') }}</span>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400">–</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<twig:DropDown>
|
||||||
|
<a href="{{ path('app_admin_work_month_pdf', { 'uuid': workMonth.uuid }) }}"
|
||||||
|
class="text-gray-700 hover:text-primary block px-4 py-2 text-sm"
|
||||||
|
role="menuitem"
|
||||||
|
tabindex="-1"
|
||||||
|
target="_blank">
|
||||||
|
Nachweis ansehen
|
||||||
|
</a>
|
||||||
|
{% if workMonth.status.value == 'submitted' %}
|
||||||
|
<a href="{{ path('app_admin_work_month_review', { 'uuid': workMonth.uuid, 'r': return_url() }) }}"
|
||||||
|
class="text-gray-700 hover:text-primary block px-4 py-2 text-sm"
|
||||||
|
role="menuitem"
|
||||||
|
tabindex="-1"
|
||||||
|
hx-get="{{ path('app_admin_work_month_review', { 'uuid': workMonth.uuid, 'r': return_url() }) }}"
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="beforeend">
|
||||||
|
Prüfen
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="{{ path('app_admin_work_month_sync', { 'uuid': workMonth.uuid, 'r': return_url() }) }}">
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full text-left text-gray-700 hover:text-primary block px-4 py-2 text-sm"
|
||||||
|
role="menuitem"
|
||||||
|
tabindex="-1">
|
||||||
|
Erneut abrufen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% if workMonth.status.value in ['submitted', 'approved'] %}
|
||||||
|
<form method="post" action="{{ path('app_admin_work_month_unlock', { 'uuid': workMonth.uuid, 'r': return_url() }) }}">
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full text-left text-gray-700 hover:text-primary block px-4 py-2 text-sm"
|
||||||
|
role="menuitem"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick="return confirm('Der Monat wird wieder geöffnet und muss neu unterschrieben werden. Fortfahren?')">
|
||||||
|
Wieder öffnen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</twig:DropDown>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-gray-500">
|
||||||
|
Keine Arbeitszeiten gefunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ knp_pagination_render(pagination) }}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{% extends 'htmx_modal.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Arbeitszeitnachweis prüfen{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ form_start(form, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML' } }) }}
|
||||||
|
<div class="pb-4">
|
||||||
|
<div class="font-bold">
|
||||||
|
{{ workMonth.teamer }} · {{ workMonth.periodLabel }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
{{ workMonth.totalMinutes|duration_hm }} Stunden an {{ workMonth.workedDays }} Tagen,
|
||||||
|
eingereicht am {{ workMonth.submittedAt|date('d.m.Y') }}
|
||||||
|
</div>
|
||||||
|
{% if workMonth.hasMismatch %}
|
||||||
|
<div class="text-sm text-red-600 pt-2">
|
||||||
|
Achtung: die SaaS meldet für diesen Monat inzwischen
|
||||||
|
{{ workMonth.mismatchTotalMinutes|duration_hm }} Stunden.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col space-y-1 pb-4">
|
||||||
|
{% if workMonth.currentDocument %}
|
||||||
|
<a href="{{ path('app_common_download', { 'uuid': workMonth.currentDocument.uuid }) }}"
|
||||||
|
class="flex items-center space-x-1">
|
||||||
|
{{ icon('download') }}
|
||||||
|
<span class="flex-shrink-0">Hochgeladenen Nachweis ansehen</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ path('app_admin_work_month_pdf', { 'uuid': workMonth.uuid }) }}"
|
||||||
|
class="flex items-center space-x-1"
|
||||||
|
target="_blank">
|
||||||
|
{{ icon('pdf') }}
|
||||||
|
<span class="flex-shrink-0">Nachweis aus den Daten erzeugen</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col space-y-2 pb-4">
|
||||||
|
{{ form_row(form.status) }}
|
||||||
|
{{ form_row(form.comment) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
{{ form_rest(form) }}
|
||||||
|
{{ form_end(form) }}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{% extends 'htmx_modal.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Arbeitszeiten filtern{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ form_start(filterForm, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML'} }) }}
|
||||||
|
<div class="flex flex-col space-y-2 pb-2">
|
||||||
|
{{ form_row(filterForm.teamerName) }}
|
||||||
|
{{ form_row(filterForm.period) }}
|
||||||
|
{{ form_row(filterForm.statuses) }}
|
||||||
|
{{ form_row(filterForm.includeApproved) }}
|
||||||
|
{{ form_row(filterForm.includeMismatchedOnly) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
{{ form_widget(filterForm.apply, { 'attr': { 'class': 'btn' } }) }}
|
||||||
|
{{ form_widget(filterForm.reset, { 'attr': { 'class': 'btn btn--secondary' } }) }}
|
||||||
|
</div>
|
||||||
|
{{ form_rest(filterForm) }}
|
||||||
|
{{ form_end(filterForm) }}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
{#
|
||||||
|
The Arbeitszeitnachweis: one month of working time for one teamer, to be printed,
|
||||||
|
signed and uploaded again.
|
||||||
|
|
||||||
|
Rendered by App\Service\Pdf\WorkMonthRenderer through Dompdf, not by the FPDI renderers
|
||||||
|
the contract and the invoice use - a table of 28 to 31 rows is exactly what coordinate
|
||||||
|
stamping cannot do. Styles have to be inline and simple: Dompdf supports little beyond
|
||||||
|
CSS 2.1, and nothing may be loaded from outside the document.
|
||||||
|
#}
|
||||||
|
<style>
|
||||||
|
@page { margin: 18mm 15mm 20mm 15mm; }
|
||||||
|
|
||||||
|
body { font-family: "DejaVu Sans", sans-serif; font-size: 9pt; color: #000; }
|
||||||
|
|
||||||
|
h1 { font-size: 14pt; margin: 0 0 2mm 0; }
|
||||||
|
|
||||||
|
/* A table rather than a float: Dompdf lays tables out reliably, floats less so. */
|
||||||
|
table.letterhead { width: 100%; border-collapse: collapse; margin-bottom: 4mm; }
|
||||||
|
table.letterhead td { padding: 0; vertical-align: top; }
|
||||||
|
table.letterhead td.mark { text-align: right; width: 42mm; }
|
||||||
|
table.letterhead img { width: 38mm; height: 11.6mm; }
|
||||||
|
|
||||||
|
.meta { width: 100%; margin-bottom: 6mm; font-size: 9pt; }
|
||||||
|
.meta td { padding: 0.6mm 0; vertical-align: top; }
|
||||||
|
.meta .label { color: #444; width: 32mm; }
|
||||||
|
|
||||||
|
table.days { width: 100%; border-collapse: collapse; }
|
||||||
|
table.days th,
|
||||||
|
table.days td { border: 0.3pt solid #999; padding: 1.2mm 1.6mm; }
|
||||||
|
table.days th { background-color: #eee; font-size: 8.5pt; text-align: left; }
|
||||||
|
table.days td.num { text-align: right; }
|
||||||
|
table.days tr.weekend td { background-color: #f6f6f6; }
|
||||||
|
table.days tr.total td { font-weight: bold; background-color: #eee; }
|
||||||
|
|
||||||
|
.signature { margin-top: 14mm; }
|
||||||
|
.signature td { padding-top: 12mm; font-size: 8pt; color: #444; }
|
||||||
|
.signature .line { border-top: 0.4pt solid #000; width: 70mm; }
|
||||||
|
|
||||||
|
.note { margin-top: 6mm; font-size: 7.5pt; color: #555; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<table class="letterhead">
|
||||||
|
<tr>
|
||||||
|
<td><h1>Arbeitszeitnachweis</h1></td>
|
||||||
|
<td class="mark">
|
||||||
|
{% if logo|default(null) %}
|
||||||
|
<img src="{{ logo }}" alt="E&P Reisen Events">
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table class="meta">
|
||||||
|
<tr>
|
||||||
|
<td class="label">Teamer:in</td>
|
||||||
|
<td>{{ teamer.fullName }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if workMonth.department %}
|
||||||
|
<tr>
|
||||||
|
<td class="label">Abteilung</td>
|
||||||
|
<td>{{ workMonth.department }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr>
|
||||||
|
<td class="label">Monat</td>
|
||||||
|
<td>{{ workMonth.period|format_datetime(pattern='LLLL yyyy', locale='de') }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Sollstunden</td>
|
||||||
|
<td>{{ workMonth.targetMinutes|duration_hm }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Iststunden</td>
|
||||||
|
<td><strong>{{ workMonth.totalMinutes|duration_hm }}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Saldo</td>
|
||||||
|
<td>{{ workMonth.balanceMinutes > 0 ? '+' : '' }}{{ workMonth.balanceMinutes|duration_hm }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Pausen gesamt</td>
|
||||||
|
<td>{{ workMonth.breakMinutes|duration_hm }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Arbeitstage</td>
|
||||||
|
<td>{{ workMonth.workedDays }}{% if workMonth.absenceDays > 0 %}, freie Tage: {{ workMonth.absenceDays }}{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table class="days">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 21mm;">Datum</th>
|
||||||
|
<th style="width: 13mm;">Tag</th>
|
||||||
|
<th style="width: 15mm;">Beginn</th>
|
||||||
|
<th style="width: 15mm;">Ende</th>
|
||||||
|
<th style="width: 15mm;">Pause</th>
|
||||||
|
<th style="width: 18mm;">Stunden</th>
|
||||||
|
<th>Abwesenheit</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for day in days %}
|
||||||
|
<tr{% if day.weekend %} class="weekend"{% endif %}>
|
||||||
|
<td>{{ day.date|date('d.m.Y') }}</td>
|
||||||
|
<td>{{ day.date|format_datetime(pattern='EEE', locale='de') }}</td>
|
||||||
|
<td class="num">{{ day.startTime ?? '' }}</td>
|
||||||
|
<td class="num">{{ day.endTime ?? '' }}</td>
|
||||||
|
<td class="num">{{ day.breakMinutes > 0 ? day.breakMinutes|duration_hm : '' }}</td>
|
||||||
|
<td class="num">{{ day.minutes|duration_hm }}</td>
|
||||||
|
<td>{{ day.absence ?? '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
<tr class="total">
|
||||||
|
<td colspan="4">Gesamt</td>
|
||||||
|
<td class="num">{{ workMonth.breakMinutes|duration_hm }}</td>
|
||||||
|
<td class="num">{{ workMonth.totalMinutes|duration_hm }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table class="signature">
|
||||||
|
<tr>
|
||||||
|
<td style="width: 50%;">
|
||||||
|
<div class="line"></div>
|
||||||
|
Ort, Datum
|
||||||
|
</td>
|
||||||
|
<td style="width: 50%;">
|
||||||
|
<div class="line"></div>
|
||||||
|
Unterschrift {{ teamer.fullName }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p class="note">
|
||||||
|
Bitte den Nachweis unterschrieben wieder im Team-Portal hochladen.
|
||||||
|
Stand der Daten: {{ workMonth.syncedAt|date('d.m.Y H:i') }} Uhr.
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{% extends 'teamer/layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Arbeitszeiten{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-screen-md mx-auto">
|
||||||
|
<h1 class="text-2xl font-bold pb-2">
|
||||||
|
Arbeitszeiten
|
||||||
|
</h1>
|
||||||
|
<p class="pb-8 text-gray-700">
|
||||||
|
Hier findest du für jeden abgerechneten Monat deinen Arbeitszeitnachweis.
|
||||||
|
Bitte lade ihn herunter, unterschreibe ihn und lade ihn anschließend wieder hoch.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if workMonths is empty %}
|
||||||
|
<div class="text-gray-500">
|
||||||
|
Es liegen noch keine Arbeitszeiten vor. Ein Monat erscheint hier, sobald er
|
||||||
|
abgerechnet wurde.
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<dl class="divide-y divide-gray-100">
|
||||||
|
{% for workMonth in workMonths %}
|
||||||
|
<div class="py-6 first:pt-0 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||||
|
<dt class="font-bold leading-6 text-gray-900">
|
||||||
|
{{ workMonth.period|format_datetime(pattern='LLLL yyyy', locale='de') }}
|
||||||
|
<div class="text-sm font-normal text-gray-500">
|
||||||
|
{{ workMonth.totalMinutes|duration_hm }} Stunden
|
||||||
|
an {{ workMonth.workedDays }} Tagen
|
||||||
|
</div>
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm leading-6 sm:mt-0 sm:col-span-2">
|
||||||
|
<div class="pb-2">
|
||||||
|
<span class="upload-status-badge {{ workMonth.status.badgeClass }}">
|
||||||
|
{{ workMonth.status.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if workMonth.status.value == 'rejected' and workMonth.reviewComment %}
|
||||||
|
<div class="pb-2 text-red-600">
|
||||||
|
{{ workMonth.reviewComment }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex flex-col space-y-1">
|
||||||
|
<a href="{{ path('app_teamer_work_month_pdf', { 'uuid': workMonth.uuid }) }}"
|
||||||
|
class="flex items-center space-x-1">
|
||||||
|
{{ icon('download') }}
|
||||||
|
<span class="flex-shrink-0">Nachweis herunterladen</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if workMonth.status.value != 'approved' %}
|
||||||
|
<a href="{{ path('app_teamer_work_month_upload', { 'uuid': workMonth.uuid }) }}"
|
||||||
|
class="flex items-center space-x-1"
|
||||||
|
hx-get="{{ path('app_teamer_work_month_upload', { 'uuid': workMonth.uuid }) }}"
|
||||||
|
hx-target="#htmx-modal"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
{{ icon('upload') }}
|
||||||
|
<span class="flex-shrink-0">
|
||||||
|
{{ workMonth.currentDocument ? 'Erneut hochladen' : 'Unterschrieben hochladen' }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if workMonth.currentDocument %}
|
||||||
|
<a href="{{ path('app_common_download', { 'uuid': workMonth.currentDocument.uuid }) }}"
|
||||||
|
class="flex items-center space-x-1 text-gray-500">
|
||||||
|
{{ icon('document') }}
|
||||||
|
<span class="flex-shrink-0">
|
||||||
|
Hochgeladen am {{ workMonth.submittedAt|date('d.m.Y') }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends 'htmx_modal.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Arbeitszeitnachweis hochladen{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% set formAttr = stimulus_controller('form-upload-guard')|stimulus_action('form-upload-guard', 'guard', 'submit') %}
|
||||||
|
{{ form_start(form, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML' }|merge(formAttr.toArray()) }) }}
|
||||||
|
<div class="pb-4">
|
||||||
|
Lade hier deinen unterschriebenen Arbeitszeitnachweis für
|
||||||
|
{{ workMonth.period|format_datetime(pattern='LLLL yyyy', locale='de') }} hoch.
|
||||||
|
Ein Foto genügt, solange alles lesbar ist.
|
||||||
|
</div>
|
||||||
|
<div class="pb-4">
|
||||||
|
{% include '_partials/_upload_collection_form.html.twig' with {
|
||||||
|
'endpoint_upload': path('_uploader_upload_work_month'),
|
||||||
|
'max_filesize': 5,
|
||||||
|
'max_files': 2,
|
||||||
|
'accepted_files': 'image/jpg,image/jpeg,application/pdf',
|
||||||
|
} %}
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn" {{ stimulus_target('form-upload-guard', 'submit') }}>
|
||||||
|
Nachweis speichern
|
||||||
|
</button>
|
||||||
|
{{ form_rest(form) }}
|
||||||
|
{{ form_end(form) }}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Logging;
|
||||||
|
|
||||||
|
use App\Logging\RedactSaasCredentialsProcessor;
|
||||||
|
use Monolog\Level;
|
||||||
|
use Monolog\LogRecord;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class RedactSaasCredentialsProcessorTest extends TestCase
|
||||||
|
{
|
||||||
|
private const KEY = 'sk-live-0123456789abcdef';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line Symfony's http client writes for every request it makes.
|
||||||
|
*/
|
||||||
|
public function testItRedactsTheKeyOutOfARequestLine(): void
|
||||||
|
{
|
||||||
|
$record = $this->process(sprintf(
|
||||||
|
'Request: "GET https://saas.example/api/v2/time/account/%s/theo%%40example.com/2026/2"',
|
||||||
|
self::KEY,
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString(self::KEY, $record->message);
|
||||||
|
$this->assertStringContainsString('/time/account/[redacted]', $record->message);
|
||||||
|
$this->assertStringContainsString('theo%40example.com/2026/2', $record->message, 'Only the key goes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one a silenced client would have missed: an HTTP error repeats the url inside the
|
||||||
|
* exception message, which then travels into whichever channel catches it.
|
||||||
|
*/
|
||||||
|
public function testItRedactsTheKeyOutOfAnExceptionMessage(): void
|
||||||
|
{
|
||||||
|
$record = $this->process(
|
||||||
|
'Work hours import failed',
|
||||||
|
['error' => sprintf('HTTP 500 returned for "https://saas.example/api/v2/time/account/%s/[email protected]/2026/2".', self::KEY)],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString(self::KEY, json_encode($record->context));
|
||||||
|
$this->assertStringContainsString('[redacted]', $record->context['error']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRedactsTheRosterAndDepartmentEndpointsToo(): void
|
||||||
|
{
|
||||||
|
$record = $this->process(sprintf(
|
||||||
|
'GET https://saas.example/api/v2/employees/%s and /api/v2/employee/departments/%s',
|
||||||
|
self::KEY,
|
||||||
|
self::KEY,
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString(self::KEY, $record->message);
|
||||||
|
$this->assertStringContainsString('/employees/[redacted]', $record->message);
|
||||||
|
$this->assertStringContainsString('/employee/departments/[redacted]', $record->message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roster hands out a key per employee. None is configured here, so only the pattern
|
||||||
|
* can catch those - which is why the pattern pass exists alongside the exact one.
|
||||||
|
*/
|
||||||
|
public function testAnUnknownKeyInAKeyPositionIsStillRedacted(): void
|
||||||
|
{
|
||||||
|
$record = $this->process('Request: "GET https://saas.example/api/v2/time/account/some-other-key/[email protected]/2026/2"');
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('some-other-key', $record->message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItReachesIntoNestedContext(): void
|
||||||
|
{
|
||||||
|
$record = $this->process('failed', [
|
||||||
|
'request' => ['url' => 'https://saas.example/api/v2/employees/'.self::KEY],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString(self::KEY, json_encode($record->context));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItLeavesOrdinaryRecordsAlone(): void
|
||||||
|
{
|
||||||
|
$record = $this->process('Work hours imported', ['teamer' => 'Test, Theo', 'minutes' => 480]);
|
||||||
|
|
||||||
|
$this->assertSame('Work hours imported', $record->message);
|
||||||
|
$this->assertSame(['teamer' => 'Test, Theo', 'minutes' => 480], $record->context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An unconfigured key is an empty string, which must never be treated as a secret to
|
||||||
|
* replace - that would rewrite every character of every message.
|
||||||
|
*/
|
||||||
|
public function testAnUnconfiguredKeyRedactsNothingByValue(): void
|
||||||
|
{
|
||||||
|
$processor = new RedactSaasCredentialsProcessor('');
|
||||||
|
$record = $processor(new LogRecord(new \DateTimeImmutable(), 'app', Level::Info, 'plain message'));
|
||||||
|
|
||||||
|
$this->assertSame('plain message', $record->message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function process(string $message, array $context = []): LogRecord
|
||||||
|
{
|
||||||
|
$processor = new RedactSaasCredentialsProcessor(self::KEY);
|
||||||
|
|
||||||
|
return $processor(new LogRecord(new \DateTimeImmutable(), 'app', Level::Info, $message, $context));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Model;
|
||||||
|
|
||||||
|
use App\Model\WorkMonthDays;
|
||||||
|
use App\Saas\Model\TimeAccount;
|
||||||
|
use App\Saas\Model\TimeAccountDay;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class WorkMonthDaysTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testItRoundTripsThroughTheStoredShape(): void
|
||||||
|
{
|
||||||
|
$stored = [
|
||||||
|
'2026-03-01' => ['minutes' => 285],
|
||||||
|
'2026-03-05' => ['minutes' => 0, 'absence' => 'Ruhetag 24/25'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->assertSame($stored, WorkMonthDays::fromArray($stored)->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItSortsAndDropsEntriesItCannotRead(): void
|
||||||
|
{
|
||||||
|
$days = WorkMonthDays::fromArray([
|
||||||
|
'2026-03-05' => ['minutes' => 60],
|
||||||
|
'not-a-date' => ['minutes' => 120],
|
||||||
|
'2026-03-01' => ['minutes' => 30, 'absence' => ''],
|
||||||
|
'2026-03-09' => 'not an entry',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(['2026-03-01', '2026-03-05'], array_keys($days->toArray()));
|
||||||
|
$this->assertArrayNotHasKey('absence', $days->toArray()['2026-03-01'], 'An empty label is no label.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAMissingMinutesKeyCountsAsZero(): void
|
||||||
|
{
|
||||||
|
$days = WorkMonthDays::fromArray(['2026-03-01' => ['absence' => 'Ferien']]);
|
||||||
|
|
||||||
|
$this->assertSame(0, $days->getTotalMinutes());
|
||||||
|
$this->assertSame(1, $days->countAbsenceDays());
|
||||||
|
$this->assertSame(0, $days->countWorkedDays());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItSummarisesTheMonth(): void
|
||||||
|
{
|
||||||
|
$days = WorkMonthDays::fromArray([
|
||||||
|
'2026-03-01' => ['minutes' => 285],
|
||||||
|
'2026-03-05' => ['minutes' => 0, 'absence' => 'Ruhetag 24/25'],
|
||||||
|
'2026-03-06' => ['minutes' => 600],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(885, $days->getTotalMinutes());
|
||||||
|
$this->assertSame(2, $days->countWorkedDays());
|
||||||
|
$this->assertSame(1, $days->countAbsenceDays());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItBuildsFromATimeAccount(): void
|
||||||
|
{
|
||||||
|
$account = new TimeAccount('e-1', 2026, 3, 345, 9900, 'Operations', [
|
||||||
|
'2026-03-01' => new TimeAccountDay(new \DateTimeImmutable('2026-03-01'), 285, null, '09:00', '14:45', 60),
|
||||||
|
'2026-03-05' => new TimeAccountDay(new \DateTimeImmutable('2026-03-05'), 60, 'Ferien'),
|
||||||
|
], []);
|
||||||
|
|
||||||
|
$this->assertSame([
|
||||||
|
'2026-03-01' => ['minutes' => 285, 'break' => 60, 'start' => '09:00', 'end' => '14:45'],
|
||||||
|
'2026-03-05' => ['minutes' => 60, 'absence' => 'Ferien'],
|
||||||
|
], WorkMonthDays::fromTimeAccount($account)->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testASpanNeedsBothEnds(): void
|
||||||
|
{
|
||||||
|
$days = WorkMonthDays::fromArray([
|
||||||
|
'2026-03-01' => ['minutes' => 60, 'start' => '09:00'],
|
||||||
|
'2026-03-02' => ['minutes' => 60, 'start' => '09:00', 'end' => 'noon'],
|
||||||
|
'2026-03-03' => ['minutes' => 60, 'start' => '09:00', 'end' => '10:00'],
|
||||||
|
])->forCalendarMonth(2026, 3);
|
||||||
|
|
||||||
|
$this->assertFalse($days[0]->hasSpan());
|
||||||
|
$this->assertFalse($days[1]->hasSpan());
|
||||||
|
$this->assertTrue($days[2]->hasSpan());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItTotalsTheBreaks(): void
|
||||||
|
{
|
||||||
|
$days = WorkMonthDays::fromArray([
|
||||||
|
'2026-03-01' => ['minutes' => 285, 'break' => 60],
|
||||||
|
'2026-03-02' => ['minutes' => 405, 'break' => 150],
|
||||||
|
'2026-03-03' => ['minutes' => 180],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(210, $days->getTotalBreakMinutes());
|
||||||
|
$this->assertSame(0, $days->forCalendarMonth(2026, 3)[2]->getBreakMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar, not the payload, decides how many rows a month has: days the SaaS never
|
||||||
|
* reported still need a line in the table and in the PDF.
|
||||||
|
*/
|
||||||
|
public function testItFillsEveryCalendarDayOfALongMonth(): void
|
||||||
|
{
|
||||||
|
$days = WorkMonthDays::fromArray(['2026-03-05' => ['minutes' => 0, 'absence' => 'Ruhetag 24/25']]);
|
||||||
|
$calendar = $days->forCalendarMonth(2026, 3);
|
||||||
|
|
||||||
|
$this->assertCount(31, $calendar);
|
||||||
|
$this->assertSame('2026-03-01', $calendar[0]->getDate()->format('Y-m-d'));
|
||||||
|
$this->assertSame('2026-03-31', $calendar[30]->getDate()->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItFillsEveryCalendarDayOfAShortMonth(): void
|
||||||
|
{
|
||||||
|
$this->assertCount(28, WorkMonthDays::fromArray([])->forCalendarMonth(2026, 2));
|
||||||
|
$this->assertCount(29, WorkMonthDays::fromArray([])->forCalendarMonth(2024, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Not reported" and "reported as zero" are different things: a rest day is a real
|
||||||
|
* booking of nothing, an unreported day is a gap.
|
||||||
|
*/
|
||||||
|
public function testItSeparatesUnreportedDaysFromZeroDays(): void
|
||||||
|
{
|
||||||
|
$calendar = WorkMonthDays::fromArray([
|
||||||
|
'2026-03-05' => ['minutes' => 0, 'absence' => 'Ruhetag 24/25'],
|
||||||
|
])->forCalendarMonth(2026, 3);
|
||||||
|
|
||||||
|
$restDay = $calendar[4];
|
||||||
|
$this->assertSame('2026-03-05', $restDay->getDate()->format('Y-m-d'));
|
||||||
|
$this->assertTrue($restDay->isReported());
|
||||||
|
$this->assertSame(0, $restDay->getMinutes());
|
||||||
|
$this->assertSame('Ruhetag 24/25', $restDay->getAbsence());
|
||||||
|
|
||||||
|
$gap = $calendar[0];
|
||||||
|
$this->assertFalse($gap->isReported());
|
||||||
|
$this->assertNull($gap->getMinutes());
|
||||||
|
$this->assertFalse($gap->isAbsence());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItKnowsWeekends(): void
|
||||||
|
{
|
||||||
|
$calendar = WorkMonthDays::fromArray([])->forCalendarMonth(2026, 3);
|
||||||
|
|
||||||
|
$this->assertTrue($calendar[0]->isWeekend(), '1 March 2026 is a Sunday.');
|
||||||
|
$this->assertFalse($calendar[1]->isWeekend());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Repository;
|
||||||
|
|
||||||
|
use App\Entity\TeamerWorkMonth;
|
||||||
|
use App\Enum\WorkMonthStatus;
|
||||||
|
use App\Model\WorkMonthFilterDto;
|
||||||
|
use App\Repository\TeamerWorkMonthRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\Query;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list's defaults are the part worth pinning: what an admin sees when they have chosen
|
||||||
|
* no filter at all is decided in DQL, not in the template.
|
||||||
|
*/
|
||||||
|
class TeamerWorkMonthRepositoryTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
public function testTheListHidesApprovedMonthsUntilAskedFor(): void
|
||||||
|
{
|
||||||
|
$query = $this->createListQuery(new WorkMonthFilterDto());
|
||||||
|
|
||||||
|
$this->assertStringContainsString('work_month.status != :approved', $query->getDQL());
|
||||||
|
$this->assertSame(WorkMonthStatus::APPROVED, $query->getParameter('approved')->getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApprovedMonthsComeBackWhenTheFlagIsSet(): void
|
||||||
|
{
|
||||||
|
$query = $this->createListQuery((new WorkMonthFilterDto())->setIncludeApproved(true));
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString(':approved', $query->getDQL());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An explicit status choice has to win over the "hide approved" default, or picking
|
||||||
|
* "Geprüft" from the filter would return nothing at all.
|
||||||
|
*/
|
||||||
|
public function testAnExplicitStatusChoiceOverridesTheDefault(): void
|
||||||
|
{
|
||||||
|
$query = $this->createListQuery(
|
||||||
|
(new WorkMonthFilterDto())->setStatuses([WorkMonthStatus::APPROVED])
|
||||||
|
);
|
||||||
|
|
||||||
|
$dql = $query->getDQL();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('work_month.status IN (:statuses)', $dql);
|
||||||
|
$this->assertStringNotContainsString(':approved', $dql);
|
||||||
|
$this->assertSame([WorkMonthStatus::APPROVED], $query->getParameter('statuses')->getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFilteringByMonthSplitsThePeriodIntoItsColumns(): void
|
||||||
|
{
|
||||||
|
$query = $this->createListQuery(
|
||||||
|
(new WorkMonthFilterDto())->setPeriod(new \DateTimeImmutable('2026-03-01'))
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('work_month.year = :year', $query->getDQL());
|
||||||
|
$this->assertStringContainsString('work_month.month = :month', $query->getDQL());
|
||||||
|
$this->assertSame(2026, $query->getParameter('year')->getValue());
|
||||||
|
$this->assertSame(3, $query->getParameter('month')->getValue(), 'The calendar month, not the API index.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reminder must not chase someone who has already sent the sheet in, nor a deleted
|
||||||
|
* teamer.
|
||||||
|
*/
|
||||||
|
public function testTheReminderQueryOnlyLooksAtMonthsStillWaiting(): void
|
||||||
|
{
|
||||||
|
$repository = $this->createRepository();
|
||||||
|
$months = $repository->findAwaitingDocument(new \DateTimeImmutable('2026-03-04'));
|
||||||
|
|
||||||
|
// db_test starts empty, so this asserts the query runs at all - the semantics above
|
||||||
|
// are what the DQL assertions cover.
|
||||||
|
$this->assertSame([], $months);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createListQuery(WorkMonthFilterDto $filterDto): Query
|
||||||
|
{
|
||||||
|
return $this->createRepository()->getListQuery($filterDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createRepository(): TeamerWorkMonthRepository
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
|
||||||
|
/** @var EntityManagerInterface $entityManager */
|
||||||
|
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
/** @var TeamerWorkMonthRepository $repository */
|
||||||
|
$repository = $entityManager->getRepository(TeamerWorkMonth::class);
|
||||||
|
|
||||||
|
return $repository;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,510 @@
|
|||||||
|
{
|
||||||
|
"surcharge": 0,
|
||||||
|
"PAYOFF_TIMEACCOUNT": 0,
|
||||||
|
"year": 2026,
|
||||||
|
"istOnWeekend": 0,
|
||||||
|
"TIMEACCOUNT_PAYOFF": 0,
|
||||||
|
"lastMonth": 0,
|
||||||
|
"outOfWork": 19.0,
|
||||||
|
"ist": 480,
|
||||||
|
"autoOvertime": 0,
|
||||||
|
"employee": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"hasTime": true,
|
||||||
|
"current": null,
|
||||||
|
"isReadOnly": false,
|
||||||
|
"inactive": false,
|
||||||
|
"PAYOFF_OVERTIMEACCOUNT": 0,
|
||||||
|
"today": {
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0
|
||||||
|
},
|
||||||
|
"OVERTIMEACCOUNT_TIMEACCOUNT": 0,
|
||||||
|
"end": null,
|
||||||
|
"traffic": {
|
||||||
|
"active": false
|
||||||
|
},
|
||||||
|
"TIMEACCOUNT_OVERTIMEACCOUNT": 0,
|
||||||
|
"start": "2021-10-22",
|
||||||
|
"soll": 2661,
|
||||||
|
"hasProject": false,
|
||||||
|
"saldo": -2181,
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-01",
|
||||||
|
"surcharge": 0.0,
|
||||||
|
"startOriginalDate": "2026-08-01",
|
||||||
|
"endDate": "2026-08-01",
|
||||||
|
"platformStart": "Manuell",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"ist": 360,
|
||||||
|
"uuid": "cb0aaf79-d41d-4001-9ace-bc95f60dd856",
|
||||||
|
"hasSurcharge": false,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"startOriginal": "2026-08-01 10:00:00",
|
||||||
|
"responsibility": null,
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "10:00:00",
|
||||||
|
"end": "2026-08-01 16:00:00",
|
||||||
|
"department": "Operations",
|
||||||
|
"endOriginal": "2026-08-01 16:00:00",
|
||||||
|
"annotation": 8,
|
||||||
|
"endOriginalDate": "2026-08-01",
|
||||||
|
"startOriginalTime": "10:00:00",
|
||||||
|
"start": "2026-08-01 10:00:00",
|
||||||
|
"soll": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"transfer": null,
|
||||||
|
"holidayType": null,
|
||||||
|
"platformEnd": "Manuell",
|
||||||
|
"pausemin": 0,
|
||||||
|
"kilometer": 0.0,
|
||||||
|
"endTime": "16:00:00",
|
||||||
|
"startDate": "2026-08-01",
|
||||||
|
"isDummy": 0,
|
||||||
|
"status": "ACCEPTED",
|
||||||
|
"endOriginalTime": "16:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-01",
|
||||||
|
"annotation": -1,
|
||||||
|
"endDate": "2026-08-01",
|
||||||
|
"start": "2026-08-01 16:00:00",
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 180,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": null,
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "16:00:00",
|
||||||
|
"end": "2026-08-01 19:00:00",
|
||||||
|
"endTime": "19:00:00",
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-01",
|
||||||
|
"surcharge": 0.0,
|
||||||
|
"endDate": "2026-08-01",
|
||||||
|
"platformStart": "Manuell",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"ist": 120,
|
||||||
|
"uuid": "9652275f-0b76-40b5-b67a-21ef8b7f0f5f",
|
||||||
|
"hasSurcharge": false,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"startOriginal": null,
|
||||||
|
"responsibility": null,
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "19:00:00",
|
||||||
|
"end": "2026-08-01 21:00:00",
|
||||||
|
"department": "Operations",
|
||||||
|
"endOriginal": null,
|
||||||
|
"annotation": 8,
|
||||||
|
"start": "2026-08-01 19:00:00",
|
||||||
|
"soll": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"transfer": null,
|
||||||
|
"holidayType": null,
|
||||||
|
"platformEnd": "Manuell",
|
||||||
|
"pausemin": 0,
|
||||||
|
"kilometer": 0.0,
|
||||||
|
"endTime": "21:00:00",
|
||||||
|
"startDate": "2026-08-01",
|
||||||
|
"isDummy": 0,
|
||||||
|
"status": "ACCEPTED"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-02",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-02 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-02"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-03",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-03 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-03"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-04",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-04 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-04"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-05",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-05 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-05"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-06",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-06 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-06"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-07",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-07 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-07"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-08",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-08 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-08"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-09",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-09 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-09"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-10",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-10 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-11",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-11 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-12",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-12 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-13",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-13 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-13"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-14",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-14 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-15",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-15 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-15"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-16",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-16 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-17",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-17 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-17"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-18",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-18 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-18"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-19",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-19 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-19"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-20",
|
||||||
|
"annotation": -4,
|
||||||
|
"endDate": null,
|
||||||
|
"start": "2026-08-20 12:40:01",
|
||||||
|
"breakCalculationActive": true,
|
||||||
|
"soll": 0,
|
||||||
|
"ist": 0,
|
||||||
|
"pause": 0,
|
||||||
|
"surchargeMinutes": 0,
|
||||||
|
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||||
|
"holidayType": "Ferien",
|
||||||
|
"sollType": "MONTH",
|
||||||
|
"pausemin": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"startTime": "23:59:59",
|
||||||
|
"endTime": null,
|
||||||
|
"isDummy": 0,
|
||||||
|
"startDate": "2026-08-20"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"overtimeAccountMonth": 5929,
|
||||||
|
"saldo2": -2181,
|
||||||
|
"onWork": 1,
|
||||||
|
"unpaidOvertime": 0,
|
||||||
|
"month": 8,
|
||||||
|
"OVERTIMEACCOUNT_PAYOFF": 0,
|
||||||
|
"overtimeAccount": 5929
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Saas;
|
||||||
|
|
||||||
|
use App\Saas\ApiClient;
|
||||||
|
use App\Saas\ApiClientException;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\HttpClient\MockHttpClient;
|
||||||
|
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||||
|
|
||||||
|
class ApiClientTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The whole point of the calendar-month argument: the app never has to know that the
|
||||||
|
* API counts from zero.
|
||||||
|
*
|
||||||
|
* @dataProvider provideMonths
|
||||||
|
*/
|
||||||
|
public function testItTranslatesTheCalendarMonthIntoTheApiIndex(int $month, string $expectedPath): void
|
||||||
|
{
|
||||||
|
$requested = null;
|
||||||
|
|
||||||
|
$client = $this->createClient(function (string $method, string $url) use (&$requested) {
|
||||||
|
$requested = $url;
|
||||||
|
|
||||||
|
return new MockResponse('{"ist": 0, "list": []}', ['response_headers' => ['content-type' => 'application/json']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$client->getTimeAccount('[email protected]', 2026, $month);
|
||||||
|
|
||||||
|
$this->assertSame('https://saas.example'.$expectedPath, $requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function provideMonths(): iterable
|
||||||
|
{
|
||||||
|
yield 'January is index 0' => [1, '/api/v2/time/account/secret%2Fkey/theo%40example.com/2026/0'];
|
||||||
|
yield 'March is index 2' => [3, '/api/v2/time/account/secret%2Fkey/theo%40example.com/2026/2'];
|
||||||
|
yield 'December is index 11' => [12, '/api/v2/time/account/secret%2Fkey/theo%40example.com/2026/11'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both the key and the address sit in the path, so both have to survive a slash or an
|
||||||
|
* "@" without changing the route.
|
||||||
|
*/
|
||||||
|
public function testItEscapesTheKeyAndTheAddress(): void
|
||||||
|
{
|
||||||
|
$requested = null;
|
||||||
|
|
||||||
|
$client = $this->createClient(function (string $method, string $url) use (&$requested) {
|
||||||
|
$requested = $url;
|
||||||
|
|
||||||
|
return new MockResponse('{"ist": 0, "list": []}', ['response_headers' => ['content-type' => 'application/json']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$client->getTimeAccount('a [email protected]', 2026, 3);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('/secret%2Fkey/', $requested);
|
||||||
|
$this->assertStringContainsString('/a%20b%2Bc%40example.com/', $requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItReturnsTheDecodedPayload(): void
|
||||||
|
{
|
||||||
|
$client = $this->createClient(fn () => new MockResponse(
|
||||||
|
'{"ist": 480, "employee": "e-1", "list": []}',
|
||||||
|
['response_headers' => ['content-type' => 'application/json']],
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
['ist' => 480, 'employee' => 'e-1', 'list' => []],
|
||||||
|
$client->getTimeAccount('[email protected]', 2026, 9),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnErrorStatusBecomesAnApiClientException(): void
|
||||||
|
{
|
||||||
|
$client = $this->createClient(fn () => new MockResponse('nope', ['http_code' => 500]));
|
||||||
|
|
||||||
|
$this->expectException(ApiClientException::class);
|
||||||
|
$this->expectExceptionMessageMatches('/[email protected] \(2026-03\)/');
|
||||||
|
|
||||||
|
$client->getTimeAccount('[email protected]', 2026, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnreadableJsonBecomesAnApiClientException(): void
|
||||||
|
{
|
||||||
|
$client = $this->createClient(fn () => new MockResponse(
|
||||||
|
'not json',
|
||||||
|
['response_headers' => ['content-type' => 'application/json']],
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->expectException(ApiClientException::class);
|
||||||
|
|
||||||
|
$client->getTimeAccount('[email protected]', 2026, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRefusesAMonthThatIsNotACalendarMonth(): void
|
||||||
|
{
|
||||||
|
$client = $this->createClient(fn () => new MockResponse('{}'));
|
||||||
|
|
||||||
|
$this->expectException(ApiClientException::class);
|
||||||
|
$this->expectExceptionMessage('0 is not a calendar month.');
|
||||||
|
|
||||||
|
$client->getTimeAccount('[email protected]', 2026, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnUnconfiguredBaseUrlFailsAtCallTimeNotAtBoot(): void
|
||||||
|
{
|
||||||
|
// The command has to stay usable - "--help" included - on a machine that has no
|
||||||
|
// credentials, so this cannot be a container-time failure.
|
||||||
|
$client = new ApiClient(
|
||||||
|
new MockHttpClient(fn () => new MockResponse('{}')),
|
||||||
|
new NullLogger(),
|
||||||
|
['base_url' => '', 'api_key' => 'k'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->expectException(ApiClientException::class);
|
||||||
|
$this->expectExceptionMessage('APP_SAAS_BASE_URL is not configured.');
|
||||||
|
|
||||||
|
$client->getTimeAccount('[email protected]', 2026, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createClient(callable $handler): ApiClient
|
||||||
|
{
|
||||||
|
return new ApiClient(
|
||||||
|
new MockHttpClient($handler),
|
||||||
|
new NullLogger(),
|
||||||
|
['base_url' => 'https://saas.example', 'api_key' => 'secret/key'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Saas;
|
||||||
|
|
||||||
|
use App\Saas\Model\EmployeeDirectory;
|
||||||
|
use App\Saas\ResponseParser;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class EmployeeDirectoryTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The point of the projection: the roster answers with a per-employee api key next to a
|
||||||
|
* birthday, a personnel number and an RFID token, and the result of this is cached to the
|
||||||
|
* filesystem. None of it may get that far.
|
||||||
|
*/
|
||||||
|
public function testTheProjectionKeepsNoCredentialsOrPersonalData(): void
|
||||||
|
{
|
||||||
|
$directory = (new ResponseParser())->parseEmployees($this->roster());
|
||||||
|
$serialised = json_encode($directory->toCacheable(), JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('s3cr3t', $serialised, 'The api key must not survive.');
|
||||||
|
$this->assertStringNotContainsString('apiKey', $serialised);
|
||||||
|
$this->assertStringNotContainsString('1990', $serialised, 'The birthday must not survive.');
|
||||||
|
$this->assertStringNotContainsString('RFID', $serialised);
|
||||||
|
$this->assertStringNotContainsString('personnelNumber', $serialised);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItReadsTheFieldsWeActuallyUse(): void
|
||||||
|
{
|
||||||
|
$directory = (new ResponseParser())->parseEmployees($this->roster());
|
||||||
|
$theo = $directory->find('[email protected]');
|
||||||
|
|
||||||
|
$this->assertNotNull($theo);
|
||||||
|
$this->assertSame('Operations', $theo->getDepartment());
|
||||||
|
$this->assertSame('aaaaaaaa-894a-4e63-9c8d-5777e6afdc91', $theo->getUuid());
|
||||||
|
$this->assertFalse($theo->hasLeft());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testADateOfSeparationMarksSomeoneAsGone(): void
|
||||||
|
{
|
||||||
|
$directory = (new ResponseParser())->parseEmployees($this->roster());
|
||||||
|
|
||||||
|
$this->assertTrue($directory->find('[email protected]')->hasLeft());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both systems have the address typed in by hand, so the one place they have to agree on
|
||||||
|
* it cannot be case-sensitive.
|
||||||
|
*/
|
||||||
|
public function testLookupIgnoresCaseAndSurroundingSpace(): void
|
||||||
|
{
|
||||||
|
$directory = (new ResponseParser())->parseEmployees($this->roster());
|
||||||
|
|
||||||
|
$this->assertTrue($directory->has('[email protected]'));
|
||||||
|
$this->assertTrue($directory->has(' [email protected] '));
|
||||||
|
$this->assertFalse($directory->has('[email protected]'));
|
||||||
|
$this->assertFalse($directory->has(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnEntryWithoutAnAddressIsDropped(): void
|
||||||
|
{
|
||||||
|
$directory = (new ResponseParser())->parseEmployees([
|
||||||
|
['mainDepartment' => 'Operations', 'email' => ''],
|
||||||
|
['mainDepartment' => 'Operations'],
|
||||||
|
'not an entry',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue($directory->isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItSurvivesTheCacheRoundTrip(): void
|
||||||
|
{
|
||||||
|
$directory = (new ResponseParser())->parseEmployees($this->roster());
|
||||||
|
$restored = EmployeeDirectory::fromCacheable($directory->toCacheable());
|
||||||
|
|
||||||
|
$this->assertSame($directory->count(), $restored->count());
|
||||||
|
$this->assertSame('Operations', $restored->find('[email protected]')->getDepartment());
|
||||||
|
$this->assertTrue($restored->find('[email protected]')->hasLeft());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An unreachable roster produces an empty one, and the importer reads that as "no roster"
|
||||||
|
* rather than "nobody is registered".
|
||||||
|
*/
|
||||||
|
public function testAnEmptyDirectoryIsRecognisable(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(EmployeeDirectory::fromEmployees([])->isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function roster(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'personnelNumber' => '4711',
|
||||||
|
'mainDepartment' => 'Standard-Abteilung',
|
||||||
|
'email' => '[email protected]',
|
||||||
|
'givenName' => 'Admin',
|
||||||
|
'lastName' => 'Hausmanagement',
|
||||||
|
'birthday' => '1990-05-01',
|
||||||
|
'joinDate' => '2021-00-01',
|
||||||
|
'dateOfSeparation' => null,
|
||||||
|
'RFID' => 'abc',
|
||||||
|
'uuid' => 'bbbbbbbb-0000-0000-0000-000000000000',
|
||||||
|
'apiKey' => 's3cr3t-admin',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'personnelNumber' => '',
|
||||||
|
'mainDepartment' => 'Operations',
|
||||||
|
'email' => '[email protected]',
|
||||||
|
'givenName' => 'Theo',
|
||||||
|
'lastName' => 'Test',
|
||||||
|
'birthday' => null,
|
||||||
|
'joinDate' => '2021-10-22',
|
||||||
|
'dateOfSeparation' => null,
|
||||||
|
'RFID' => '',
|
||||||
|
'uuid' => 'aaaaaaaa-894a-4e63-9c8d-5777e6afdc91',
|
||||||
|
'apiKey' => 's3cr3t-theo',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'mainDepartment' => 'Operations',
|
||||||
|
'email' => '[email protected]',
|
||||||
|
'dateOfSeparation' => '2025-05-31',
|
||||||
|
'uuid' => 'cccccccc-0000-0000-0000-000000000000',
|
||||||
|
'apiKey' => 's3cr3t-ex',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Saas;
|
||||||
|
|
||||||
|
use App\Saas\Model\TimeAccount;
|
||||||
|
use App\Saas\ResponseParser;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fixtures are real responses with the free-text comments, IP addresses, coordinates,
|
||||||
|
* device names and the employee name removed - none of which this application stores.
|
||||||
|
*
|
||||||
|
* Both were requested with a zero-based month, so their date strings are one month behind
|
||||||
|
* the days they describe: the "september" fixture was asked for month 8 and the "march" one
|
||||||
|
* for month 2.
|
||||||
|
*/
|
||||||
|
class ResponseParserTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testSeptemberFixtureAddsUpToTheReportedTotal(): void
|
||||||
|
{
|
||||||
|
$account = $this->parse('saas_time_account_september.json', 2026, 9);
|
||||||
|
|
||||||
|
$this->assertSame('aaaaaaaa-894a-4e63-9c8d-5777e6afdc91', $account->getEmployeeUuid());
|
||||||
|
$this->assertSame(480, $account->getIstMinutes());
|
||||||
|
$this->assertSame(480, $account->getTotalMinutes(), 'Nothing is dropped, so the days must match the header.');
|
||||||
|
$this->assertSame([], $account->getWarnings());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSeptemberFixtureIsRebasedOutOfAugust(): void
|
||||||
|
{
|
||||||
|
$account = $this->parse('saas_time_account_september.json', 2026, 9);
|
||||||
|
$keys = array_keys($account->getDays());
|
||||||
|
|
||||||
|
$this->assertCount(20, $keys);
|
||||||
|
$this->assertSame('2026-09-01', reset($keys));
|
||||||
|
$this->assertSame('2026-09-20', end($keys));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSeptemberFixtureMarksHolidays(): void
|
||||||
|
{
|
||||||
|
$account = $this->parse('saas_time_account_september.json', 2026, 9);
|
||||||
|
|
||||||
|
$this->assertSame(19, $account->countAbsenceDays());
|
||||||
|
$this->assertSame(1, $account->countWorkedDays());
|
||||||
|
$this->assertSame('Ferien', $account->getDays()['2026-09-02']->getHolidayType());
|
||||||
|
$this->assertSame(480, $account->getDays()['2026-09-01']->getMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMarchFixtureAddsUpToTheReportedTotal(): void
|
||||||
|
{
|
||||||
|
$account = $this->parse('saas_time_account_march.json', 2026, 3);
|
||||||
|
|
||||||
|
$this->assertSame(11748, $account->getIstMinutes());
|
||||||
|
$this->assertSame(11748, $account->getTotalMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The decisive case: the payload's last day is "2026-02-31". February has 28 days, so a
|
||||||
|
* date parser would have rolled it into March 3rd and collided with a real row.
|
||||||
|
*/
|
||||||
|
public function testMarchFixtureKeepsAllThirtyOneDays(): void
|
||||||
|
{
|
||||||
|
$account = $this->parse('saas_time_account_march.json', 2026, 3);
|
||||||
|
$keys = array_keys($account->getDays());
|
||||||
|
|
||||||
|
$this->assertCount(31, $keys);
|
||||||
|
$this->assertSame('2026-03-01', reset($keys));
|
||||||
|
$this->assertSame('2026-03-31', end($keys));
|
||||||
|
$this->assertSame(450, $account->getDays()['2026-03-31']->getMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rest day arrives as two rows on the same date - a zero-length booking and the
|
||||||
|
* absence marker - and must still be one day worth zero minutes.
|
||||||
|
*/
|
||||||
|
public function testRestDaysAreCountedOnce(): void
|
||||||
|
{
|
||||||
|
$account = $this->parse('saas_time_account_march.json', 2026, 3);
|
||||||
|
|
||||||
|
$this->assertSame(4, $account->countAbsenceDays());
|
||||||
|
|
||||||
|
foreach (['2026-03-05', '2026-03-09', '2026-03-16', '2026-03-24'] as $key) {
|
||||||
|
$this->assertSame('Ruhetag 24/25', $account->getDays()[$key]->getHolidayType());
|
||||||
|
$this->assertSame(0, $account->getDays()[$key]->getMinutes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2026-03-12 has no manually booked row at all and still carries a full day of work,
|
||||||
|
* which is why zero minutes can never stand in for "day off".
|
||||||
|
*/
|
||||||
|
public function testAWorkedDayWithoutManualRowsStillCounts(): void
|
||||||
|
{
|
||||||
|
$account = $this->parse('saas_time_account_march.json', 2026, 3);
|
||||||
|
$day = $account->getDays()['2026-03-12'];
|
||||||
|
|
||||||
|
$this->assertSame(510, $day->getMinutes());
|
||||||
|
$this->assertFalse($day->isAbsence());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownRowKindIsReported(): void
|
||||||
|
{
|
||||||
|
$account = $this->parse('saas_time_account_march.json', 2026, 3);
|
||||||
|
|
||||||
|
$this->assertSame(['Unknown row kind -2 on 2026-03-11.'], $account->getWarnings());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* January is index 0 and December index 11, so their date strings are "2026-00-xx" and
|
||||||
|
* "2026-11-31" - the two the fixtures cannot show and the two that break naive parsing.
|
||||||
|
*/
|
||||||
|
public function testJanuaryIsRebasedFromIndexZero(): void
|
||||||
|
{
|
||||||
|
$account = (new ResponseParser())->parseTimeAccount([
|
||||||
|
'employee' => 'e-1',
|
||||||
|
'year' => 2026,
|
||||||
|
'month' => 0,
|
||||||
|
'ist' => 120,
|
||||||
|
'list' => [
|
||||||
|
['date' => '2026-00-15', 'annotation' => 8, 'ist' => 120],
|
||||||
|
],
|
||||||
|
], 2026, 1);
|
||||||
|
|
||||||
|
$this->assertSame(['2026-01-15'], array_keys($account->getDays()));
|
||||||
|
$this->assertSame([], $account->getWarnings());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDecemberIsRebasedPastAShorterNovember(): void
|
||||||
|
{
|
||||||
|
$account = (new ResponseParser())->parseTimeAccount([
|
||||||
|
'employee' => 'e-1',
|
||||||
|
'year' => 2026,
|
||||||
|
'month' => 11,
|
||||||
|
'ist' => 60,
|
||||||
|
'list' => [
|
||||||
|
['date' => '2026-11-31', 'annotation' => 128, 'ist' => 60],
|
||||||
|
],
|
||||||
|
], 2026, 12);
|
||||||
|
|
||||||
|
$this->assertSame(['2026-12-31'], array_keys($account->getDays()));
|
||||||
|
$this->assertSame([], $account->getWarnings());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDaysOutsideTheRequestedMonthAreSkippedAndReported(): void
|
||||||
|
{
|
||||||
|
$account = (new ResponseParser())->parseTimeAccount([
|
||||||
|
'year' => 2026,
|
||||||
|
'month' => 2,
|
||||||
|
'ist' => 0,
|
||||||
|
'list' => [
|
||||||
|
['date' => '2026-03-01', 'annotation' => 8, 'ist' => 90],
|
||||||
|
['date' => 'nonsense', 'annotation' => 8, 'ist' => 30],
|
||||||
|
],
|
||||||
|
], 2026, 3);
|
||||||
|
|
||||||
|
$this->assertSame([], $account->getDays());
|
||||||
|
$this->assertCount(2, $account->getWarnings());
|
||||||
|
$this->assertStringContainsString('not a day of 2026-03', $account->getWarnings()[0]);
|
||||||
|
$this->assertStringContainsString('unreadable date', $account->getWarnings()[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the days and the header ever stop agreeing, the date convention or the row
|
||||||
|
* semantics have moved. That has to be loud rather than quietly wrong.
|
||||||
|
*/
|
||||||
|
public function testATotalThatDisagreesWithTheHeaderIsReported(): void
|
||||||
|
{
|
||||||
|
$account = (new ResponseParser())->parseTimeAccount([
|
||||||
|
'year' => 2026,
|
||||||
|
'month' => 2,
|
||||||
|
'ist' => 999,
|
||||||
|
'list' => [
|
||||||
|
['date' => '2026-02-01', 'annotation' => 8, 'ist' => 90],
|
||||||
|
],
|
||||||
|
], 2026, 3);
|
||||||
|
|
||||||
|
$this->assertSame(90, $account->getTotalMinutes());
|
||||||
|
$this->assertSame(['The days add up to 90 minutes but the month reports 999.'], $account->getWarnings());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnEmptyPayloadIsTolerated(): void
|
||||||
|
{
|
||||||
|
$account = (new ResponseParser())->parseTimeAccount([], 2026, 3);
|
||||||
|
|
||||||
|
$this->assertNull($account->getEmployeeUuid());
|
||||||
|
$this->assertSame([], $account->getDays());
|
||||||
|
$this->assertSame(0, $account->getTotalMinutes());
|
||||||
|
$this->assertSame([], $account->getWarnings());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItReadsTheMonthlyTarget(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(9900, $this->parse('saas_time_account_march.json', 2026, 3)->getTargetMinutes());
|
||||||
|
$this->assertSame(2661, $this->parse('saas_time_account_september.json', 2026, 9)->getTargetMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItReadsTheDepartment(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('Operations', $this->parse('saas_time_account_march.json', 2026, 3)->getDepartment());
|
||||||
|
$this->assertSame('Operations', $this->parse('saas_time_account_september.json', 2026, 9)->getDepartment());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only the full booking rows carry it, so a month of nothing but holiday has nowhere to
|
||||||
|
* read it from. That is a missing value, not an error.
|
||||||
|
*/
|
||||||
|
public function testAMonthWithoutBookingsHasNoDepartment(): void
|
||||||
|
{
|
||||||
|
$account = (new ResponseParser())->parseTimeAccount([
|
||||||
|
'year' => 2026,
|
||||||
|
'month' => 2,
|
||||||
|
'ist' => 0,
|
||||||
|
'list' => [
|
||||||
|
['date' => '2026-02-02', 'annotation' => -4, 'ist' => 0, 'holidayType' => 'Ferien'],
|
||||||
|
],
|
||||||
|
], 2026, 3);
|
||||||
|
|
||||||
|
$this->assertNull($account->getDepartment());
|
||||||
|
$this->assertSame([], $account->getWarnings());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTwoDepartmentsInOneMonthAreReported(): void
|
||||||
|
{
|
||||||
|
$account = (new ResponseParser())->parseTimeAccount([
|
||||||
|
'year' => 2026,
|
||||||
|
'month' => 2,
|
||||||
|
'ist' => 120,
|
||||||
|
'list' => [
|
||||||
|
['date' => '2026-02-02', 'annotation' => 8, 'ist' => 60, 'department' => 'Operations'],
|
||||||
|
['date' => '2026-02-03', 'annotation' => 8, 'ist' => 60, 'department' => 'Service'],
|
||||||
|
],
|
||||||
|
], 2026, 3);
|
||||||
|
|
||||||
|
$this->assertSame('Operations', $account->getDepartment(), 'The first one wins.');
|
||||||
|
$this->assertSame(
|
||||||
|
['The month reports more than one department: Operations, Service.'],
|
||||||
|
$account->getWarnings()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItSpansTheDayFromFirstClockInToLastClockOut(): void
|
||||||
|
{
|
||||||
|
$day = $this->parse('saas_time_account_september.json', 2026, 9)->getDays()['2026-09-01'];
|
||||||
|
|
||||||
|
// Booked 10:00-16:00, 16:00-19:00 as a break, then 19:00-21:00.
|
||||||
|
$this->assertSame('10:00', $day->getStartTime());
|
||||||
|
$this->assertSame('21:00', $day->getEndTime());
|
||||||
|
$this->assertSame(180, $day->getBreakMinutes());
|
||||||
|
$this->assertSame(480, $day->getMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A day often opens with a zero-length row at the moment the teamer clocked in. Skipping
|
||||||
|
* those moved the start of 3 March from 08:00 to 09:00, the end of its first break.
|
||||||
|
*/
|
||||||
|
public function testAZeroLengthClockInStillOpensTheDay(): void
|
||||||
|
{
|
||||||
|
$day = $this->parse('saas_time_account_march.json', 2026, 3)->getDays()['2026-03-03'];
|
||||||
|
|
||||||
|
$this->assertSame('08:00', $day->getStartTime());
|
||||||
|
$this->assertSame('19:30', $day->getEndTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rest days carry a zero-length marker too, and absences a placeholder 23:59:59. Neither
|
||||||
|
* is a working day, so neither gets a span.
|
||||||
|
*/
|
||||||
|
public function testADayWithNothingBookedHasNoSpan(): void
|
||||||
|
{
|
||||||
|
$restDay = $this->parse('saas_time_account_march.json', 2026, 3)->getDays()['2026-03-05'];
|
||||||
|
$holiday = $this->parse('saas_time_account_september.json', 2026, 9)->getDays()['2026-09-02'];
|
||||||
|
|
||||||
|
$this->assertNull($restDay->getStartTime());
|
||||||
|
$this->assertNull($restDay->getEndTime());
|
||||||
|
$this->assertNull($holiday->getStartTime());
|
||||||
|
$this->assertSame(0, $holiday->getBreakMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The invariant behind the whole sheet: the span a day was worked in equals the hours on
|
||||||
|
* it plus the breaks inside it.
|
||||||
|
*
|
||||||
|
* 11 March is the one exception in this data, and deliberately excluded: it carries the
|
||||||
|
* unknown -2 row, five and a half hours the SaaS counts as neither work nor break. That
|
||||||
|
* row is already reported as a warning.
|
||||||
|
*/
|
||||||
|
public function testEverySpanAccountsForItsHoursAndBreaks(): void
|
||||||
|
{
|
||||||
|
$account = $this->parse('saas_time_account_march.json', 2026, 3);
|
||||||
|
$checked = 0;
|
||||||
|
|
||||||
|
foreach ($account->getDays() as $key => $day) {
|
||||||
|
if (null === $day->getStartTime() || '2026-03-11' === $key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$startHour, $startMinute] = explode(':', $day->getStartTime());
|
||||||
|
[$endHour, $endMinute] = explode(':', $day->getEndTime());
|
||||||
|
$span = ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
$day->getMinutes() + $day->getBreakMinutes(),
|
||||||
|
$span,
|
||||||
|
sprintf('%s does not add up.', $key)
|
||||||
|
);
|
||||||
|
++$checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame(26, $checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBreakMinutesAddUpToTheMonth(): void
|
||||||
|
{
|
||||||
|
$account = $this->parse('saas_time_account_march.json', 2026, 3);
|
||||||
|
|
||||||
|
$total = 0;
|
||||||
|
|
||||||
|
foreach ($account->getDays() as $day) {
|
||||||
|
$total += $day->getBreakMinutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame(3265, $total);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parse(string $filename, int $year, int $month): TimeAccount
|
||||||
|
{
|
||||||
|
$payload = json_decode(
|
||||||
|
file_get_contents(__DIR__.'/../Resources/'.$filename),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
JSON_THROW_ON_ERROR,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (new ResponseParser())->parseTimeAccount($payload, $year, $month);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Cron;
|
||||||
|
|
||||||
|
use App\Config\EmailTextKey;
|
||||||
|
use App\Email\Mailer;
|
||||||
|
use App\Entity\Embeddable\Communication;
|
||||||
|
use App\Entity\Teamer;
|
||||||
|
use App\Entity\TeamerWorkMonth;
|
||||||
|
use App\Repository\TeamerWorkMonthRepository;
|
||||||
|
use App\Service\Cron\WorkMonthReminderService;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the rules the reminder applies: which day it runs on, how long it waits after an
|
||||||
|
* import, and who it can actually reach. The query itself is the repository's business.
|
||||||
|
*/
|
||||||
|
class WorkMonthReminderServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private TeamerWorkMonthRepository&MockObject $repository;
|
||||||
|
private Mailer&MockObject $mailer;
|
||||||
|
private WorkMonthReminderService $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->repository = $this->createMock(TeamerWorkMonthRepository::class);
|
||||||
|
$this->mailer = $this->createMock(Mailer::class);
|
||||||
|
|
||||||
|
$this->service = new WorkMonthReminderService(
|
||||||
|
$this->repository,
|
||||||
|
$this->mailer,
|
||||||
|
new NullLogger(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* app:cron runs daily, so the weekday gate is the only thing stopping a teamer being
|
||||||
|
* chased every morning.
|
||||||
|
*/
|
||||||
|
public function testItOnlyRunsOnMondays(): void
|
||||||
|
{
|
||||||
|
$this->repository->expects($this->never())->method('findAwaitingDocument');
|
||||||
|
$this->mailer->expects($this->never())->method('createAndSendText');
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
'Work month reminders are only sent on mondays',
|
||||||
|
$this->service->sendReminders(new \DateTimeImmutable('2026-03-10')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItLeavesTheTeamerAGracePeriodAfterTheImport(): void
|
||||||
|
{
|
||||||
|
$this->repository
|
||||||
|
->expects($this->once())
|
||||||
|
->method('findAwaitingDocument')
|
||||||
|
->with($this->callback(
|
||||||
|
static fn (\DateTimeImmutable $cutoff) => '2026-03-04' === $cutoff->format('Y-m-d')
|
||||||
|
))
|
||||||
|
->willReturn([])
|
||||||
|
;
|
||||||
|
|
||||||
|
// 9 March 2026 is a Monday; the import ran on the 10th of the month before.
|
||||||
|
$this->service->sendReminders(new \DateTimeImmutable('2026-03-09'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItMailsOneReminderPerOpenMonth(): void
|
||||||
|
{
|
||||||
|
$this->repository->method('findAwaitingDocument')->willReturn([
|
||||||
|
$this->createWorkMonth(2026, 1),
|
||||||
|
$this->createWorkMonth(2026, 2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sent = [];
|
||||||
|
$this->mailer
|
||||||
|
->expects($this->exactly(2))
|
||||||
|
->method('createAndSendText')
|
||||||
|
->willReturnCallback(function (EmailTextKey $key, array $placeholders, array $options) use (&$sent) {
|
||||||
|
$sent[] = [$key, $placeholders, $options];
|
||||||
|
})
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
'Sent 2 work month reminders to teamers',
|
||||||
|
$this->service->sendReminders(new \DateTimeImmutable('2026-03-09')),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(EmailTextKey::REMINDER_WORK_MONTH_UPLOAD, $sent[0][0]);
|
||||||
|
$this->assertSame('Januar 2026', $sent[0][1]['workMonth']);
|
||||||
|
$this->assertSame('Februar 2026', $sent[1][1]['workMonth']);
|
||||||
|
$this->assertSame('[email protected]', $sent[0][2]['to']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testATeamerWithoutAnAddressIsSkippedSilently(): void
|
||||||
|
{
|
||||||
|
$this->repository->method('findAwaitingDocument')->willReturn([
|
||||||
|
$this->createWorkMonth(2026, 1, null),
|
||||||
|
$this->createWorkMonth(2026, 2, ' '),
|
||||||
|
$this->createWorkMonth(2026, 3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mailer->expects($this->once())->method('createAndSendText');
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
'Sent 1 work month reminders to teamers',
|
||||||
|
$this->service->sendReminders(new \DateTimeImmutable('2026-03-09')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createWorkMonth(int $year, int $month, ?string $email = '[email protected]'): TeamerWorkMonth
|
||||||
|
{
|
||||||
|
$communication = new Communication();
|
||||||
|
$communication->setEmail($email);
|
||||||
|
|
||||||
|
$teamer = new Teamer();
|
||||||
|
$teamer->setFirstName('Theo')->setLastName('Test')->setCommunication($communication);
|
||||||
|
|
||||||
|
return (new TeamerWorkMonth())
|
||||||
|
->setTeamer($teamer)
|
||||||
|
->setYear($year)
|
||||||
|
->setMonth($month)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Pdf;
|
||||||
|
|
||||||
|
use App\Entity\Teamer;
|
||||||
|
use App\Entity\TeamerWorkMonth;
|
||||||
|
use App\Model\WorkMonthDays;
|
||||||
|
use App\Service\Pdf\WorkMonthRenderer;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a real document rather than mocking Twig: the point of the test is that the
|
||||||
|
* template, the duration filter and Dompdf agree with each other.
|
||||||
|
*/
|
||||||
|
class WorkMonthRendererTest extends WebTestCase
|
||||||
|
{
|
||||||
|
public function testItRendersAPdf(): void
|
||||||
|
{
|
||||||
|
$pdf = $this->render($this->createWorkMonth());
|
||||||
|
|
||||||
|
$this->assertStringStartsWith('%PDF', $pdf);
|
||||||
|
$this->assertGreaterThan(2000, strlen($pdf), 'A month of rows should not fit in a stub.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A month whose days the SaaS never reported still has to produce a full sheet, so the
|
||||||
|
* teamer can write on the empty lines.
|
||||||
|
*/
|
||||||
|
public function testItPlacesTheLogoInTheTopRightCorner(): void
|
||||||
|
{
|
||||||
|
$pdf = $this->render($this->createWorkMonth());
|
||||||
|
|
||||||
|
// A4 is 595.28 x 841.89pt and the page margins are 15mm at the side, 18mm at the top,
|
||||||
|
// so the content corner sits at 552.76 / 790.87. Asserting the placement rather than
|
||||||
|
// the markup is the only way to know the logo actually landed there.
|
||||||
|
$this->assertSame(1, preg_match(
|
||||||
|
'#([\d.]+) 0 0 ([\d.]+) ([\d.]+) ([\d.]+) cm\s*/\w+ Do#',
|
||||||
|
$this->contentStreams($pdf),
|
||||||
|
$placement
|
||||||
|
), 'The logo should be drawn exactly once.');
|
||||||
|
|
||||||
|
[, $width, $height, $x, $y] = array_map('floatval', $placement);
|
||||||
|
|
||||||
|
$this->assertEqualsWithDelta(552.76, $x + $width, 1.0, 'Flush with the right margin.');
|
||||||
|
$this->assertEqualsWithDelta(790.87, $y + $height, 1.0, 'Flush with the top margin.');
|
||||||
|
$this->assertEqualsWithDelta(230 / 70, $width / $height, 0.05, 'The logo must not be stretched.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A working-time record is worth printing without its letterhead.
|
||||||
|
*/
|
||||||
|
public function testItRendersWithoutALogo(): void
|
||||||
|
{
|
||||||
|
$renderer = new WorkMonthRenderer(
|
||||||
|
static::getContainer()->get(Environment::class),
|
||||||
|
['project_dir' => sys_get_temp_dir()],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertStringStartsWith('%PDF', $renderer->render($this->createWorkMonth()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function contentStreams(string $pdf): string
|
||||||
|
{
|
||||||
|
preg_match_all('#stream\r?\n(.*?)endstream#s', $pdf, $matches);
|
||||||
|
|
||||||
|
$decoded = '';
|
||||||
|
|
||||||
|
foreach ($matches[1] as $stream) {
|
||||||
|
$inflated = @gzuncompress(ltrim($stream, "\r\n"));
|
||||||
|
|
||||||
|
if (false !== $inflated) {
|
||||||
|
$decoded .= $inflated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRendersAMonthWithoutAnyBookings(): void
|
||||||
|
{
|
||||||
|
$workMonth = (new TeamerWorkMonth())
|
||||||
|
->setTeamer($this->createTeamer())
|
||||||
|
->setYear(2026)
|
||||||
|
->setMonth(2)
|
||||||
|
->setDays(WorkMonthDays::fromArray([]))
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->assertStringStartsWith('%PDF', $this->render($workMonth));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheFilenameCarriesTheTeamerAndTheMonth(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(
|
||||||
|
'Arbeitszeitnachweis_Test_2026-03.pdf',
|
||||||
|
$this->createRenderer()->filename($this->createWorkMonth()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheFilenameSurvivesAnAwkwardName(): void
|
||||||
|
{
|
||||||
|
$renderer = $this->createRenderer();
|
||||||
|
$workMonth = $this->createWorkMonth();
|
||||||
|
$workMonth->getTeamer()->setLastName('Müller-Lüdenscheidt');
|
||||||
|
|
||||||
|
$this->assertSame('Arbeitszeitnachweis_M_ller_L_denscheidt_2026-03.pdf', $renderer->filename($workMonth));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function render(TeamerWorkMonth $workMonth): string
|
||||||
|
{
|
||||||
|
return $this->createRenderer()->render($workMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createRenderer(): WorkMonthRenderer
|
||||||
|
{
|
||||||
|
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
|
||||||
|
|
||||||
|
/** @var Environment $twig */
|
||||||
|
$twig = static::getContainer()->get(Environment::class);
|
||||||
|
|
||||||
|
return new WorkMonthRenderer($twig, ['project_dir' => $projectDir]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createWorkMonth(): TeamerWorkMonth
|
||||||
|
{
|
||||||
|
return (new TeamerWorkMonth())
|
||||||
|
->setTeamer($this->createTeamer())
|
||||||
|
->setYear(2026)
|
||||||
|
->setMonth(3)
|
||||||
|
->setDays(WorkMonthDays::fromArray([
|
||||||
|
'2026-03-01' => ['minutes' => 285],
|
||||||
|
'2026-03-05' => ['minutes' => 0, 'absence' => 'Ruhetag 24/25'],
|
||||||
|
'2026-03-12' => ['minutes' => 510],
|
||||||
|
]))
|
||||||
|
->setSourceIstMinutes(795)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTeamer(): Teamer
|
||||||
|
{
|
||||||
|
return (new Teamer())->setFirstName('Theo')->setLastName('Test');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Saas;
|
||||||
|
|
||||||
|
use App\Entity\Embeddable\Communication;
|
||||||
|
use App\Entity\Teamer;
|
||||||
|
use App\Entity\TeamerWorkMonth;
|
||||||
|
use App\Enum\WorkHoursSyncOutcome;
|
||||||
|
use App\Enum\WorkMonthStatus;
|
||||||
|
use App\Model\WorkMonthDays;
|
||||||
|
use App\Repository\TeamerRepository;
|
||||||
|
use App\Repository\TeamerWorkMonthRepository;
|
||||||
|
use App\Saas\ApiClient;
|
||||||
|
use App\Saas\ApiClientException;
|
||||||
|
use App\Saas\DataProvider\EmployeeDataProvider;
|
||||||
|
use App\Saas\Model\Employee;
|
||||||
|
use App\Saas\Model\EmployeeDirectory;
|
||||||
|
use App\Saas\ResponseParser;
|
||||||
|
use App\Service\Saas\WorkHoursSynchronizer;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
|
class WorkHoursSynchronizerTest extends TestCase
|
||||||
|
{
|
||||||
|
private ApiClient&MockObject $apiClient;
|
||||||
|
private EmployeeDataProvider&MockObject $employeeDataProvider;
|
||||||
|
private TeamerWorkMonthRepository&MockObject $workMonthRepository;
|
||||||
|
private EntityManagerInterface&MockObject $entityManager;
|
||||||
|
private WorkHoursSynchronizer $synchronizer;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->apiClient = $this->createMock(ApiClient::class);
|
||||||
|
$this->workMonthRepository = $this->createMock(TeamerWorkMonthRepository::class);
|
||||||
|
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$this->employeeDataProvider = $this->createMock(EmployeeDataProvider::class);
|
||||||
|
|
||||||
|
// Registered by default; the tests that care say otherwise.
|
||||||
|
$this->employeeDataProvider->method('getDirectory')->willReturn(
|
||||||
|
EmployeeDirectory::fromEmployees([
|
||||||
|
new Employee('[email protected]', 'e-1', 'Operations', false),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->synchronizer = new WorkHoursSynchronizer(
|
||||||
|
$this->apiClient,
|
||||||
|
new ResponseParser(),
|
||||||
|
$this->employeeDataProvider,
|
||||||
|
$this->createMock(TeamerRepository::class),
|
||||||
|
$this->workMonthRepository,
|
||||||
|
$this->entityManager,
|
||||||
|
new NullLogger(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testATeamerWithoutAnAddressIsSkippedRatherThanFailed(): void
|
||||||
|
{
|
||||||
|
$this->apiClient->expects($this->never())->method('getTimeAccount');
|
||||||
|
|
||||||
|
$result = $this->synchronizer->syncTeamer($this->createTeamer(null), 2026, 3);
|
||||||
|
|
||||||
|
$this->assertSame(WorkHoursSyncOutcome::SKIPPED, $result->getOutcome());
|
||||||
|
$this->assertFalse($result->isFailure());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One unreachable teamer must not take the rest of the month's import down with it.
|
||||||
|
*/
|
||||||
|
public function testARequestFailureIsReportedWithoutWriting(): void
|
||||||
|
{
|
||||||
|
$this->apiClient->method('getTimeAccount')->willThrowException(new ApiClientException('gateway said no'));
|
||||||
|
$this->entityManager->expects($this->never())->method('flush');
|
||||||
|
|
||||||
|
$result = $this->synchronizer->syncTeamer($this->createTeamer(), 2026, 3);
|
||||||
|
|
||||||
|
$this->assertSame(WorkHoursSyncOutcome::FAILED, $result->getOutcome());
|
||||||
|
$this->assertTrue($result->isFailure());
|
||||||
|
$this->assertSame('gateway said no', $result->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catching this up front turns N failed requests into one legible line.
|
||||||
|
*/
|
||||||
|
public function testATeamerMissingFromTheRosterIsSkippedWithoutAnyRequest(): void
|
||||||
|
{
|
||||||
|
$provider = $this->createMock(EmployeeDataProvider::class);
|
||||||
|
$provider->method('getDirectory')->willReturn(
|
||||||
|
EmployeeDirectory::fromEmployees([new Employee('[email protected]', 'e-9', 'Service', false)])
|
||||||
|
);
|
||||||
|
|
||||||
|
$apiClient = $this->createMock(ApiClient::class);
|
||||||
|
$apiClient->expects($this->never())->method('getTimeAccount');
|
||||||
|
|
||||||
|
$synchronizer = new WorkHoursSynchronizer(
|
||||||
|
$apiClient,
|
||||||
|
new ResponseParser(),
|
||||||
|
$provider,
|
||||||
|
$this->createMock(TeamerRepository::class),
|
||||||
|
$this->workMonthRepository,
|
||||||
|
$this->entityManager,
|
||||||
|
new NullLogger(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $synchronizer->syncTeamer($this->createTeamer(), 2026, 3);
|
||||||
|
|
||||||
|
$this->assertSame(WorkHoursSyncOutcome::SKIPPED, $result->getOutcome());
|
||||||
|
$this->assertStringContainsString('Nicht in der SaaS registriert', $result->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An unreachable roster comes back empty. Reading that as "nobody is registered" would
|
||||||
|
* silently skip the entire month.
|
||||||
|
*/
|
||||||
|
public function testAnUnreachableRosterDoesNotSkipEveryone(): void
|
||||||
|
{
|
||||||
|
$provider = $this->createMock(EmployeeDataProvider::class);
|
||||||
|
$provider->method('getDirectory')->willReturn(EmployeeDirectory::fromEmployees([]));
|
||||||
|
|
||||||
|
$this->apiClient->method('getTimeAccount')->willReturn($this->payload());
|
||||||
|
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null);
|
||||||
|
|
||||||
|
$synchronizer = new WorkHoursSynchronizer(
|
||||||
|
$this->apiClient,
|
||||||
|
new ResponseParser(),
|
||||||
|
$provider,
|
||||||
|
$this->createMock(TeamerRepository::class),
|
||||||
|
$this->workMonthRepository,
|
||||||
|
$this->entityManager,
|
||||||
|
new NullLogger(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
WorkHoursSyncOutcome::IMPORTED,
|
||||||
|
$synchronizer->syncTeamer($this->createTeamer(), 2026, 3)->getOutcome(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A month of nothing but holiday carries no booking to read a department from; the roster
|
||||||
|
* is the fallback that keeps the sheet's header complete.
|
||||||
|
*/
|
||||||
|
public function testTheRosterSuppliesTheDepartmentWhenTheBookingsCannot(): void
|
||||||
|
{
|
||||||
|
$teamer = $this->createTeamer();
|
||||||
|
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null);
|
||||||
|
$this->apiClient->method('getTimeAccount')->willReturn([
|
||||||
|
'employee' => 'e-1',
|
||||||
|
'year' => 2026,
|
||||||
|
'month' => 2,
|
||||||
|
'ist' => 0,
|
||||||
|
'list' => [
|
||||||
|
['date' => '2026-02-02', 'annotation' => -4, 'ist' => 0, 'holidayType' => 'Ferien'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$persisted = null;
|
||||||
|
$this->entityManager->method('persist')->willReturnCallback(function (object $entity) use (&$persisted) {
|
||||||
|
$persisted = $entity;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->synchronizer->syncTeamer($teamer, 2026, 3);
|
||||||
|
|
||||||
|
$this->assertSame('Operations', $persisted->getDepartment());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testANewMonthIsCreatedAndLearnsTheEmployeeId(): void
|
||||||
|
{
|
||||||
|
$teamer = $this->createTeamer();
|
||||||
|
$this->apiClient->method('getTimeAccount')->willReturn($this->payload());
|
||||||
|
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null);
|
||||||
|
|
||||||
|
$persisted = null;
|
||||||
|
$this->entityManager
|
||||||
|
->expects($this->once())
|
||||||
|
->method('persist')
|
||||||
|
->willReturnCallback(function (object $entity) use (&$persisted) {
|
||||||
|
$persisted = $entity;
|
||||||
|
})
|
||||||
|
;
|
||||||
|
$this->entityManager->expects($this->once())->method('flush');
|
||||||
|
|
||||||
|
$result = $this->synchronizer->syncTeamer($teamer, 2026, 3);
|
||||||
|
|
||||||
|
$this->assertSame(WorkHoursSyncOutcome::IMPORTED, $result->getOutcome());
|
||||||
|
$this->assertInstanceOf(TeamerWorkMonth::class, $persisted);
|
||||||
|
$this->assertSame(2026, $persisted->getYear());
|
||||||
|
$this->assertSame(3, $persisted->getMonth());
|
||||||
|
$this->assertSame(345, $persisted->getTotalMinutes());
|
||||||
|
$this->assertSame(345, $persisted->getSourceIstMinutes());
|
||||||
|
$this->assertSame(2, $persisted->getWorkedDays());
|
||||||
|
$this->assertSame(1, $persisted->getAbsenceDays());
|
||||||
|
$this->assertSame(WorkMonthStatus::OPEN, $persisted->getStatus());
|
||||||
|
$this->assertSame('e-1', $teamer->getSaasEmployeeUuid());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testImportingTwiceLeavesOneRecordWithTheSameFigures(): void
|
||||||
|
{
|
||||||
|
$teamer = $this->createTeamer();
|
||||||
|
$existing = $this->createWorkMonth($teamer, WorkMonthStatus::OPEN);
|
||||||
|
|
||||||
|
$this->apiClient->method('getTimeAccount')->willReturn($this->payload());
|
||||||
|
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn($existing);
|
||||||
|
$this->entityManager->expects($this->never())->method('persist');
|
||||||
|
|
||||||
|
$this->synchronizer->syncTeamer($teamer, 2026, 3);
|
||||||
|
$this->synchronizer->syncTeamer($teamer, 2026, 3);
|
||||||
|
|
||||||
|
$this->assertSame(345, $existing->getTotalMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The heart of the lock: a signed month keeps the hours that were signed, and the fresh
|
||||||
|
* figures only leave a trace that the SaaS has changed its mind.
|
||||||
|
*/
|
||||||
|
public function testASubmittedMonthIsComparedButNeverOverwritten(): void
|
||||||
|
{
|
||||||
|
$teamer = $this->createTeamer();
|
||||||
|
$existing = $this->createWorkMonth($teamer, WorkMonthStatus::SUBMITTED);
|
||||||
|
|
||||||
|
$this->apiClient->method('getTimeAccount')->willReturn($this->payload(420, 60));
|
||||||
|
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn($existing);
|
||||||
|
|
||||||
|
$result = $this->synchronizer->syncTeamer($teamer, 2026, 3);
|
||||||
|
|
||||||
|
$this->assertSame(WorkHoursSyncOutcome::LOCKED, $result->getOutcome());
|
||||||
|
$this->assertSame(120, $existing->getTotalMinutes(), 'The signed hours must survive.');
|
||||||
|
$this->assertTrue($existing->hasMismatch());
|
||||||
|
$this->assertSame(480, $existing->getMismatchTotalMinutes());
|
||||||
|
$this->assertStringContainsString('480', $result->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnApprovedMonthThatStillAgreesClearsAnEarlierMismatch(): void
|
||||||
|
{
|
||||||
|
$teamer = $this->createTeamer();
|
||||||
|
$existing = $this->createWorkMonth($teamer, WorkMonthStatus::APPROVED);
|
||||||
|
$existing->recordMismatch(999);
|
||||||
|
|
||||||
|
$this->apiClient->method('getTimeAccount')->willReturn($this->payload(120, 0));
|
||||||
|
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn($existing);
|
||||||
|
|
||||||
|
$result = $this->synchronizer->syncTeamer($teamer, 2026, 3);
|
||||||
|
|
||||||
|
$this->assertSame(WorkHoursSyncOutcome::LOCKED, $result->getOutcome());
|
||||||
|
$this->assertFalse($existing->hasMismatch());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rejection usually means the hours themselves were wrong, so that month is not
|
||||||
|
* frozen - the next import has to be able to correct it.
|
||||||
|
*/
|
||||||
|
public function testARejectedMonthIsStillImported(): void
|
||||||
|
{
|
||||||
|
$teamer = $this->createTeamer();
|
||||||
|
$existing = $this->createWorkMonth($teamer, WorkMonthStatus::REJECTED);
|
||||||
|
|
||||||
|
$this->apiClient->method('getTimeAccount')->willReturn($this->payload());
|
||||||
|
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn($existing);
|
||||||
|
|
||||||
|
$result = $this->synchronizer->syncTeamer($teamer, 2026, 3);
|
||||||
|
|
||||||
|
$this->assertSame(WorkHoursSyncOutcome::IMPORTED, $result->getOutcome());
|
||||||
|
$this->assertSame(345, $existing->getTotalMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testADryRunWritesNothing(): void
|
||||||
|
{
|
||||||
|
$this->apiClient->method('getTimeAccount')->willReturn($this->payload());
|
||||||
|
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null);
|
||||||
|
$this->entityManager->expects($this->never())->method('flush');
|
||||||
|
|
||||||
|
$result = $this->synchronizer->syncTeamer($this->createTeamer(), 2026, 3, true);
|
||||||
|
|
||||||
|
$this->assertSame(WorkHoursSyncOutcome::IMPORTED, $result->getOutcome());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParserWarningsTravelOntoTheRecord(): void
|
||||||
|
{
|
||||||
|
$teamer = $this->createTeamer();
|
||||||
|
$payload = $this->payload();
|
||||||
|
$payload['list'][] = ['date' => '2026-02-11', 'annotation' => -2, 'ist' => 0];
|
||||||
|
|
||||||
|
$this->apiClient->method('getTimeAccount')->willReturn($payload);
|
||||||
|
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null);
|
||||||
|
|
||||||
|
$persisted = null;
|
||||||
|
$this->entityManager->method('persist')->willReturnCallback(function (object $entity) use (&$persisted) {
|
||||||
|
$persisted = $entity;
|
||||||
|
});
|
||||||
|
|
||||||
|
$result = $this->synchronizer->syncTeamer($teamer, 2026, 3);
|
||||||
|
|
||||||
|
$this->assertSame(['Unknown row kind -2 on 2026-03-11.'], $result->getWarnings());
|
||||||
|
$this->assertSame(['Unknown row kind -2 on 2026-03-11.'], $persisted->getWarnings());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* March, requested as index 2, so the payload's dates read "2026-02-xx".
|
||||||
|
*/
|
||||||
|
private function payload(int $firstDay = 285, int $secondDay = 60): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'employee' => 'e-1',
|
||||||
|
'year' => 2026,
|
||||||
|
'month' => 2,
|
||||||
|
'ist' => $firstDay + $secondDay,
|
||||||
|
'list' => [
|
||||||
|
['date' => '2026-02-02', 'annotation' => 8, 'ist' => $firstDay],
|
||||||
|
['date' => '2026-02-03', 'annotation' => 8, 'ist' => $secondDay],
|
||||||
|
['date' => '2026-02-05', 'annotation' => -4, 'ist' => 0, 'holidayType' => 'Ferien'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTeamer(?string $email = '[email protected]'): Teamer
|
||||||
|
{
|
||||||
|
$teamer = new Teamer();
|
||||||
|
$teamer->setFirstName('Theo')->setLastName('Test');
|
||||||
|
|
||||||
|
$communication = new Communication();
|
||||||
|
$communication->setEmail($email);
|
||||||
|
$teamer->setCommunication($communication);
|
||||||
|
|
||||||
|
return $teamer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createWorkMonth(Teamer $teamer, WorkMonthStatus $status): TeamerWorkMonth
|
||||||
|
{
|
||||||
|
return (new TeamerWorkMonth())
|
||||||
|
->setTeamer($teamer)
|
||||||
|
->setYear(2026)
|
||||||
|
->setMonth(3)
|
||||||
|
->setDays(WorkMonthDays::fromArray(['2026-03-02' => ['minutes' => 120]]))
|
||||||
|
->setSourceIstMinutes(120)
|
||||||
|
->setStatus($status)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user