From 1a7c272414f8d6a871da383ff4fa83386b6e3908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 21 Sep 2026 16:27:26 +0200 Subject: [PATCH] feat: import work hours from SaaS and exchange signed time sheet --- .env | 6 + composer.json | 1 + composer.lock | 369 +- config/email_texts.yaml | 27 + config/packages/monolog.yaml | 21 +- config/packages/oneup_uploader.yaml | 8 + config/packages/zenstruck_schedule.yaml | 8 + config/services.yaml | 28 + deploy.php | 1 + http-client.env.json | 5 + migrations/Version20260921122743.php | 48 + migrations/Version20260921160000.php | 37 + migrations/Version20260921170000.php | 35 + saas.http | 9 + src/Command/CronCommand.php | 5 + src/Command/SaasWorkHoursCommand.php | 228 + src/Config/EmailTextKey.php | 2 + .../Admin/WorkMonth/IndexController.php | 47 + .../Admin/WorkMonth/PdfController.php | 46 + .../Admin/WorkMonth/ReviewController.php | 117 + .../Admin/WorkMonth/SyncController.php | 72 + .../Common/WorkMonthFilterController.php | 53 + .../Teamer/WorkMonth/IndexController.php | 40 + .../Teamer/WorkMonth/PdfController.php | 56 + .../Teamer/WorkMonth/UploadController.php | 133 + src/Entity/Teamer.php | 36 + src/Entity/TeamerWorkMonth.php | 440 ++ src/Entity/Upload.php | 23 + src/Enum/WorkHoursSyncOutcome.php | 18 + src/Enum/WorkMonthStatus.php | 60 + src/Form/WorkMonthFilterType.php | 61 + src/Form/WorkMonthReviewType.php | 46 + .../RedactSaasCredentialsProcessor.php | 99 + src/Menu/AdminMenuBuilder.php | 12 + src/Menu/TeamerMenuBuilder.php | 23 + src/Model/WorkHoursSyncResult.php | 81 + src/Model/WorkMonthDay.php | 76 + src/Model/WorkMonthDays.php | 154 + src/Model/WorkMonthFilterDto.php | 95 + src/Model/WorkMonthReviewDto.php | 69 + src/Repository/TeamerRepository.php | 37 + src/Repository/TeamerWorkMonthRepository.php | 134 + src/Saas/ApiClient.php | 123 + src/Saas/ApiClientException.php | 7 + .../DataProvider/EmployeeDataProvider.php | 64 + src/Saas/Model/Employee.php | 50 + src/Saas/Model/EmployeeDirectory.php | 92 + src/Saas/Model/TimeAccount.php | 112 + src/Saas/Model/TimeAccountDay.php | 68 + src/Saas/ResponseParser.php | 300 ++ src/Service/Common/WorkMonthFilterHandler.php | 56 + src/Service/Cron/WorkMonthReminderService.php | 75 + src/Service/Pdf/WorkMonthRenderer.php | 92 + src/Service/Saas/WorkHoursSynchronizer.php | 233 + src/Twig/AppExtension.php | 1 + src/Twig/AppRuntime.php | 18 + templates/admin/work_month/index.html.twig | 146 + .../admin/work_month/modal_review.html.twig | 49 + .../common/modal_work_month_filter.html.twig | 20 + templates/pdf/work_month.html.twig | 139 + templates/teamer/work_month/index.html.twig | 80 + .../teamer/work_month/modal_upload.html.twig | 26 + .../RedactSaasCredentialsProcessorTest.php | 103 + tests/Model/WorkMonthDaysTest.php | 144 + .../TeamerWorkMonthRepositoryTest.php | 96 + tests/Resources/saas_time_account_march.json | 3886 +++++++++++++++++ .../saas_time_account_september.json | 510 +++ tests/Saas/ApiClientTest.php | 131 + tests/Saas/EmployeeDirectoryTest.php | 128 + tests/Saas/ResponseParserTest.php | 336 ++ .../Cron/WorkMonthReminderServiceTest.php | 127 + tests/Service/Pdf/WorkMonthRendererTest.php | 143 + .../Saas/WorkHoursSynchronizerTest.php | 339 ++ 73 files changed, 10488 insertions(+), 72 deletions(-) create mode 100644 http-client.env.json create mode 100644 migrations/Version20260921122743.php create mode 100644 migrations/Version20260921160000.php create mode 100644 migrations/Version20260921170000.php create mode 100644 saas.http create mode 100644 src/Command/SaasWorkHoursCommand.php create mode 100644 src/Controller/Admin/WorkMonth/IndexController.php create mode 100644 src/Controller/Admin/WorkMonth/PdfController.php create mode 100644 src/Controller/Admin/WorkMonth/ReviewController.php create mode 100644 src/Controller/Admin/WorkMonth/SyncController.php create mode 100644 src/Controller/Common/WorkMonthFilterController.php create mode 100644 src/Controller/Teamer/WorkMonth/IndexController.php create mode 100644 src/Controller/Teamer/WorkMonth/PdfController.php create mode 100644 src/Controller/Teamer/WorkMonth/UploadController.php create mode 100644 src/Entity/TeamerWorkMonth.php create mode 100644 src/Enum/WorkHoursSyncOutcome.php create mode 100644 src/Enum/WorkMonthStatus.php create mode 100644 src/Form/WorkMonthFilterType.php create mode 100644 src/Form/WorkMonthReviewType.php create mode 100644 src/Logging/RedactSaasCredentialsProcessor.php create mode 100644 src/Model/WorkHoursSyncResult.php create mode 100644 src/Model/WorkMonthDay.php create mode 100644 src/Model/WorkMonthDays.php create mode 100644 src/Model/WorkMonthFilterDto.php create mode 100644 src/Model/WorkMonthReviewDto.php create mode 100644 src/Repository/TeamerWorkMonthRepository.php create mode 100644 src/Saas/ApiClient.php create mode 100644 src/Saas/ApiClientException.php create mode 100644 src/Saas/DataProvider/EmployeeDataProvider.php create mode 100644 src/Saas/Model/Employee.php create mode 100644 src/Saas/Model/EmployeeDirectory.php create mode 100644 src/Saas/Model/TimeAccount.php create mode 100644 src/Saas/Model/TimeAccountDay.php create mode 100644 src/Saas/ResponseParser.php create mode 100644 src/Service/Common/WorkMonthFilterHandler.php create mode 100644 src/Service/Cron/WorkMonthReminderService.php create mode 100644 src/Service/Pdf/WorkMonthRenderer.php create mode 100644 src/Service/Saas/WorkHoursSynchronizer.php create mode 100644 templates/admin/work_month/index.html.twig create mode 100644 templates/admin/work_month/modal_review.html.twig create mode 100644 templates/common/modal_work_month_filter.html.twig create mode 100644 templates/pdf/work_month.html.twig create mode 100644 templates/teamer/work_month/index.html.twig create mode 100644 templates/teamer/work_month/modal_upload.html.twig create mode 100644 tests/Logging/RedactSaasCredentialsProcessorTest.php create mode 100644 tests/Model/WorkMonthDaysTest.php create mode 100644 tests/Repository/TeamerWorkMonthRepositoryTest.php create mode 100644 tests/Resources/saas_time_account_march.json create mode 100644 tests/Resources/saas_time_account_september.json create mode 100644 tests/Saas/ApiClientTest.php create mode 100644 tests/Saas/EmployeeDirectoryTest.php create mode 100644 tests/Saas/ResponseParserTest.php create mode 100644 tests/Service/Cron/WorkMonthReminderServiceTest.php create mode 100644 tests/Service/Pdf/WorkMonthRendererTest.php create mode 100644 tests/Service/Saas/WorkHoursSynchronizerTest.php diff --git a/.env b/.env index 368d188..f7a36f1 100644 --- a/.env +++ b/.env @@ -97,6 +97,12 @@ APPLICATION_FORM_API_KEY= # an invitation with a dead booking link is worse than none. APPLICATION_FORM_BOOKING_URL= +# SaaS time tracking. Season teamers clock their hours here; app:saas:work-hours imports them. +# The API key travels in the URL path, so it never belongs in the committed file - set both +# values in .env.local / the server environment. +APP_SAAS_BASE_URL= +APP_SAAS_API_KEY= + ###> symfony/mailjet-mailer ### # Bulk teamer mailings only, everything else goes out over MAILER_DSN. Set the real # credentials in .env.local - the sending domain has to be verified in Mailjet (SPF, diff --git a/composer.json b/composer.json index 640f537..b03c08c 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "doctrine/doctrine-bundle": "^2.15", "doctrine/doctrine-migrations-bundle": "^3.2", "doctrine/orm": "^3.6", + "dompdf/dompdf": "^3.1", "flagception/flagception-bundle": "^6.0", "knplabs/knp-menu-bundle": "^3.2", "knplabs/knp-paginator-bundle": "^6.2", diff --git a/composer.lock b/composer.lock index 40a3354..3f4b549 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e4ae4ad86c4c01e26d770c2ab3e0fda4", + "content-hash": "3c22c1470cd1883d7c10f2e9800431e0", "packages": [ { "name": "beberlei/doctrineextensions", @@ -1491,6 +1491,161 @@ }, "time": "2026-02-08T16:21:46+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.6", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.6" + }, + "time": "2026-07-20T12:29:38+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.6.0", @@ -3307,6 +3462,73 @@ }, "time": "2022-12-02T22:17:43+00:00" }, + { + "name": "masterminds/html5", + "version": "2.11.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", + "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.11.0" + }, + "time": "2026-08-18T06:18:41+00:00" + }, { "name": "monolog/monolog", "version": "3.12.0", @@ -4920,6 +5142,84 @@ }, "time": "2021-10-29T13:26:27+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.5.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/f284e63b6e891e0c28631e54ba06c3ed102a9ef3", + "reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.33 || 2.2.9", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.18", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.12", + "phpunit/phpunit": "8.5.54", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.6.2", + "rector/type-perfect": "1.0.0 || 2.1.4", + "squizlabs/php_codesniffer": "4.0.4" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.6.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.5.0" + }, + "time": "2026-09-20T15:02:00+00:00" + }, { "name": "scienta/doctrine-json-functions", "version": "6.5.0", @@ -12888,73 +13188,6 @@ }, "time": "2026-09-06T14:25:24+00:00" }, - { - "name": "masterminds/html5", - "version": "2.11.0", - "source": { - "type": "git", - "url": "https://github.com/Masterminds/html5-php.git", - "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", - "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "php": ">=7.4" - }, - "require-dev": { - "phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.7-dev" - } - }, - "autoload": { - "psr-4": { - "Masterminds\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Matt Butcher", - "email": "technosophos@gmail.com" - }, - { - "name": "Matt Farina", - "email": "matt@mattfarina.com" - }, - { - "name": "Asmir Mustafic", - "email": "goetas@gmail.com" - } - ], - "description": "An HTML5 parser and serializer.", - "homepage": "http://masterminds.github.io/html5-php", - "keywords": [ - "HTML5", - "dom", - "html", - "parser", - "querypath", - "serializer", - "xml" - ], - "support": { - "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.11.0" - }, - "time": "2026-08-18T06:18:41+00:00" - }, { "name": "myclabs/deep-copy", "version": "1.14.0", diff --git a/config/email_texts.yaml b/config/email_texts.yaml index 0847bc2..8a36c99 100644 --- a/config/email_texts.yaml +++ b/config/email_texts.yaml @@ -228,6 +228,32 @@ texts: # Descriptions shown next to the form fields. They describe the placeholder itself rather # than any one mail, so they are shared: a name means the same thing wherever it appears. + # Daily, for as long as a work month has no signed Arbeitszeitnachweis on it. The month + # is named rather than a deadline: there is none, the office simply wants it back. + reminder_work_month_upload: + label: 'Erinnerung: Arbeitszeitnachweis hochladen' + placeholders: ['workMonth'] + subject: 'Reminder: Fehlender Arbeitszeitnachweis' + headline: 'Hallo aus Köln,' + body: |- + bitte denke daran, deinen unterschriebenen Arbeitszeitnachweis hochzuladen. Ohne ihn können wir den Monat nicht abrechnen. + + **{workMonth}** + + Du findest ihn im Portal unter "Mein Profil - Arbeitszeiten": dort herunterladen, unterschreiben und wieder hochladen. + + work_month_rejected: + label: 'Arbeitszeitnachweis abgelehnt' + placeholders: ['workMonth', 'comment'] + subject: 'Dein Arbeitszeitnachweis wurde abgelehnt' + headline: 'Hallo aus Köln,' + body: |- + leider können wir deinen Arbeitszeitnachweis für **{workMonth}** so nicht annehmen. + + Begründung: {comment} + + Bitte lade ihn im Portal unter "Mein Profil - Arbeitszeiten" noch einmal hoch. + placeholders: bookingUrl: 'Buchungslink für den Team-Infocall, kommt aus der Konfiguration (APPLICATION_FORM_BOOKING_URL)' season: 'Kommende Saison in Kurzform, z. B. "26/27" - wird automatisch eingesetzt' @@ -247,3 +273,4 @@ placeholders: dateTo: 'Ende des Einsatzes' diffInDays: 'Tage bis zum Beginn des Einsatzes' tasks: 'Noch offene Punkte der Saison-Checkliste, kommagetrennt' + workMonth: 'Monat der Arbeitszeiten, z. B. "März 2026"' diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml index e2b2e0c..cdce850 100644 --- a/config/packages/monolog.yaml +++ b/config/packages/monolog.yaml @@ -2,6 +2,7 @@ monolog: channels: - deprecation - bpn + - saas - audit - api @@ -12,7 +13,7 @@ monolog: handler: exceptions excluded_http_codes: [404, 405] buffer_size: 50 - channels: ["!bpn", "!audit", "!api"] + channels: ["!bpn", "!saas", "!audit", "!api"] exceptions: type: rotating_file path: "%kernel.logs_dir%/framework.%kernel.environment%.log" @@ -24,6 +25,12 @@ monolog: path: '%kernel.logs_dir%/bpn.%kernel.environment%.log' max_files: 5 level: info + saas: + channels: ["saas"] + type: rotating_file + path: '%kernel.logs_dir%/saas.%kernel.environment%.log' + max_files: 5 + level: info audit: channels: [ "audit" ] type: rotating_file @@ -45,7 +52,7 @@ monolog: console: type: console process_psr_3_messages: false - channels: ["!event", "!bpn", "!api", "!doctrine", "!console"] + channels: ["!event", "!bpn", "!saas", "!api", "!doctrine", "!console"] when@dev: monolog: @@ -82,7 +89,7 @@ when@prod: handler: exceptions excluded_http_codes: [404, 405] buffer_size: 50 - channels: ["!bpn", "!audit", "!api"] + channels: ["!bpn", "!saas", "!audit", "!api"] exceptions: type: rotating_file path: "%kernel.logs_dir%/framework.%kernel.environment%.log" @@ -94,6 +101,12 @@ when@prod: path: '%kernel.logs_dir%/bpn.%kernel.environment%.log' max_files: 5 level: info + saas: + channels: ["saas"] + type: rotating_file + path: '%kernel.logs_dir%/saas.%kernel.environment%.log' + max_files: 5 + level: info audit: channels: [ "audit" ] type: rotating_file @@ -113,4 +126,4 @@ when@prod: console: type: console process_psr_3_messages: false - channels: ["!event", "!doctrine", "!bpn", "!audit", "!api"] + channels: ["!event", "!doctrine", "!bpn", "!saas", "!audit", "!api"] diff --git a/config/packages/oneup_uploader.yaml b/config/packages/oneup_uploader.yaml index bb490e2..c86dd1b 100644 --- a/config/packages/oneup_uploader.yaml +++ b/config/packages/oneup_uploader.yaml @@ -60,6 +60,14 @@ oneup_uploader: namer: app.upload_namer storage: directory: '%kernel.project_dir%/uploads/season_document_in' + # Signed Arbeitszeitnachweise coming back from a teamer. Same rule again: the key and + # the directory are exactly Upload::TYPE_WORK_MONTH. + work_month: + frontend: dropzone + use_orphanage: true + namer: app.upload_namer + storage: + directory: '%kernel.project_dir%/uploads/work_month' chunks: maxage: 86400 storage: diff --git a/config/packages/zenstruck_schedule.yaml b/config/packages/zenstruck_schedule.yaml index b15f53d..ea2d9fd 100644 --- a/config/packages/zenstruck_schedule.yaml +++ b/config/packages/zenstruck_schedule.yaml @@ -27,6 +27,10 @@ when@prod: frequency: '30 1 * * *' description: "Updates status of teamers depending on their disposition" + - task: app:saas:work-hours + frequency: '0 4 10 * *' + description: "Imports last month's working hours of season teamers from SaaS" + - task: oneup:uploader:clear-orphans frequency: '30 * * * *' description: "Removes orphaned uploads" @@ -53,6 +57,10 @@ when@staging: frequency: '30 1 * * *' description: "Updates status of teamers depending on their disposition" + - task: app:saas:work-hours + frequency: '0 4 10 * *' + description: "Imports last month's working hours of season teamers from SaaS" + - task: oneup:uploader:clear-orphans frequency: '30 * * * *' description: "Removes orphaned uploads" diff --git a/config/services.yaml b/config/services.yaml index 6f6dc3f..d4d6096 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -203,6 +203,20 @@ services: bpn_crm_house_manager_ids: '%bpn_crm_house_manager_ids%' bpn_default_hotel_code: '%bpn_default_hotel_code%' + App\Saas\ApiClient: + arguments: + $logger: '@monolog.logger.saas' + $options: + base_url: '%env(APP_SAAS_BASE_URL)%' + api_key: '%env(APP_SAAS_API_KEY)%' + + App\Saas\DataProvider\EmployeeDataProvider: + arguments: + $logger: '@monolog.logger.saas' + # The roster is slow and barely changes; six hours means one fetch per run and a + # new starter is picked up the same day. + $ttl: 21600 + App\Twig\AppRuntime: arguments: $intlExtension: '@twig.extension.intl' @@ -214,6 +228,15 @@ services: tags: - name: monolog.processor + # Every SaaS url carries the api key as a path segment, so the request lines Symfony's + # http client writes - and the exception messages that repeat those urls - are redacted + # on the way into the log rather than suppressed. + App\Logging\RedactSaasCredentialsProcessor: + arguments: + $apiKey: '%env(APP_SAAS_API_KEY)%' + tags: + - name: monolog.processor + App\Logging\RequestInfoProcessor: arguments: - '@request_stack' @@ -346,6 +369,11 @@ services: $options: project_dir: '%kernel.project_dir%' + App\Service\Pdf\WorkMonthRenderer: + arguments: + $options: + project_dir: '%kernel.project_dir%' + App\Email\Mailer: arguments: $defaults: diff --git a/deploy.php b/deploy.php index f8e13c8..da8f92d 100644 --- a/deploy.php +++ b/deploy.php @@ -46,6 +46,7 @@ $rsyncOptions = [ 'package.json', 'package-lock.json', '*.http', + 'http-client*.json', 'postcss.config.js', 'phpunit.xml.dist', 'public/.htaccess', diff --git a/http-client.env.json b/http-client.env.json new file mode 100644 index 0000000..a4dea5d --- /dev/null +++ b/http-client.env.json @@ -0,0 +1,5 @@ +{ + "prod": { + "saas_base_url": "https://desktop.saas.de/rest" + } +} diff --git a/migrations/Version20260921122743.php b/migrations/Version20260921122743.php new file mode 100644 index 0000000..16049e6 --- /dev/null +++ b/migrations/Version20260921122743.php @@ -0,0 +1,48 @@ +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'); + } +} diff --git a/migrations/Version20260921160000.php b/migrations/Version20260921160000.php new file mode 100644 index 0000000..3105869 --- /dev/null +++ b/migrations/Version20260921160000.php @@ -0,0 +1,37 @@ +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'); + } +} diff --git a/migrations/Version20260921170000.php b/migrations/Version20260921170000.php new file mode 100644 index 0000000..f595d96 --- /dev/null +++ b/migrations/Version20260921170000.php @@ -0,0 +1,35 @@ +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'); + } +} diff --git a/saas.http b/saas.http new file mode 100644 index 0000000..cfcdcab --- /dev/null +++ b/saas.http @@ -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 + diff --git a/src/Command/CronCommand.php b/src/Command/CronCommand.php index 960f74f..a65d8c0 100644 --- a/src/Command/CronCommand.php +++ b/src/Command/CronCommand.php @@ -8,6 +8,7 @@ use App\Service\Cron\FeedbackReminderService; use App\Service\Cron\FlushLogsService; use App\Service\Cron\SeasonTaskReminderService; use App\Service\Cron\UploadReminderService; +use App\Service\Cron\WorkMonthReminderService; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -27,6 +28,7 @@ class CronCommand extends Command private readonly DispositionStatusService $dispositionStatusService, private readonly DispositionReminderService $dispositionReminderService, private readonly SeasonTaskReminderService $seasonTaskReminderService, + private readonly WorkMonthReminderService $workMonthReminderService, ) { parent::__construct(); } @@ -63,6 +65,9 @@ class CronCommand extends Command $message = $this->seasonTaskReminderService->sendTeamerDigest(); $io->info($message); + $message = $this->workMonthReminderService->sendReminders(); + $io->info($message); + return Command::SUCCESS; } } diff --git a/src/Command/SaasWorkHoursCommand.php b/src/Command/SaasWorkHoursCommand.php new file mode 100644 index 0000000..5520a18 --- /dev/null +++ b/src/Command/SaasWorkHoursCommand.php @@ -0,0 +1,228 @@ +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]; + } +} diff --git a/src/Config/EmailTextKey.php b/src/Config/EmailTextKey.php index 7f934de..001cdc3 100644 --- a/src/Config/EmailTextKey.php +++ b/src/Config/EmailTextKey.php @@ -23,4 +23,6 @@ enum EmailTextKey: string case REMINDER_INVOICE_UPLOAD_FINAL = 'reminder_invoice_upload_final'; case REMINDER_DISPOSITION = 'reminder_disposition'; case REMINDER_SEASON_TASKS = 'reminder_season_tasks'; + case REMINDER_WORK_MONTH_UPLOAD = 'reminder_work_month_upload'; + case WORK_MONTH_REJECTED = 'work_month_rejected'; } diff --git a/src/Controller/Admin/WorkMonth/IndexController.php b/src/Controller/Admin/WorkMonth/IndexController.php new file mode 100644 index 0000000..19e8850 --- /dev/null +++ b/src/Controller/Admin/WorkMonth/IndexController.php @@ -0,0 +1,47 @@ +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, + ]); + } +} diff --git a/src/Controller/Admin/WorkMonth/PdfController.php b/src/Controller/Admin/WorkMonth/PdfController.php new file mode 100644 index 0000000..265e183 --- /dev/null +++ b/src/Controller/Admin/WorkMonth/PdfController.php @@ -0,0 +1,46 @@ +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; + } +} diff --git a/src/Controller/Admin/WorkMonth/ReviewController.php b/src/Controller/Admin/WorkMonth/ReviewController.php new file mode 100644 index 0000000..106e1c4 --- /dev/null +++ b/src/Controller/Admin/WorkMonth/ReviewController.php @@ -0,0 +1,117 @@ +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, + ]); + } +} diff --git a/src/Controller/Admin/WorkMonth/SyncController.php b/src/Controller/Admin/WorkMonth/SyncController.php new file mode 100644 index 0000000..5f9072d --- /dev/null +++ b/src/Controller/Admin/WorkMonth/SyncController.php @@ -0,0 +1,72 @@ +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')); + } +} diff --git a/src/Controller/Common/WorkMonthFilterController.php b/src/Controller/Common/WorkMonthFilterController.php new file mode 100644 index 0000000..062b8b9 --- /dev/null +++ b/src/Controller/Common/WorkMonthFilterController.php @@ -0,0 +1,53 @@ +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); + } +} diff --git a/src/Controller/Teamer/WorkMonth/IndexController.php b/src/Controller/Teamer/WorkMonth/IndexController.php new file mode 100644 index 0000000..fba71cb --- /dev/null +++ b/src/Controller/Teamer/WorkMonth/IndexController.php @@ -0,0 +1,40 @@ +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), + ]); + } +} diff --git a/src/Controller/Teamer/WorkMonth/PdfController.php b/src/Controller/Teamer/WorkMonth/PdfController.php new file mode 100644 index 0000000..8bff1a0 --- /dev/null +++ b/src/Controller/Teamer/WorkMonth/PdfController.php @@ -0,0 +1,56 @@ +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; + } +} diff --git a/src/Controller/Teamer/WorkMonth/UploadController.php b/src/Controller/Teamer/WorkMonth/UploadController.php new file mode 100644 index 0000000..dec6635 --- /dev/null +++ b/src/Controller/Teamer/WorkMonth/UploadController.php @@ -0,0 +1,133 @@ +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); + } +} diff --git a/src/Entity/Teamer.php b/src/Entity/Teamer.php index 69c7db8..36b5a50 100644 --- a/src/Entity/Teamer.php +++ b/src/Entity/Teamer.php @@ -182,6 +182,21 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa #[ORM\Column] private bool $allSeason = false; + /** + * The teamer's id in the SaaS time-tracking service, learned from the first successful + * import and kept because it is stable while the email address the import looks them up + * by is not. Written by the importer, never by hand. + */ + #[ORM\Column(length: 36, nullable: true)] + private ?string $saasEmployeeUuid = null; + + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'teamer', targetEntity: TeamerWorkMonth::class, cascade: ['remove'])] + #[ORM\OrderBy(['year' => \SortDirection::Descending, 'month' => \SortDirection::Descending])] + private Collection $workMonths; + public function __construct() { $this->uuid = Uuid::v4(); @@ -196,6 +211,7 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa $this->dispositions = new ArrayCollection(); $this->seasonAssignments = new ArrayCollection(); $this->customAvailabilities = new ArrayCollection(); + $this->workMonths = new ArrayCollection(); } public function __toString() @@ -1036,4 +1052,24 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa return $this; } + + public function getSaasEmployeeUuid(): ?string + { + return $this->saasEmployeeUuid; + } + + public function setSaasEmployeeUuid(?string $saasEmployeeUuid): static + { + $this->saasEmployeeUuid = $saasEmployeeUuid; + + return $this; + } + + /** + * @return Collection + */ + public function getWorkMonths(): Collection + { + return $this->workMonths; + } } diff --git a/src/Entity/TeamerWorkMonth.php b/src/Entity/TeamerWorkMonth.php new file mode 100644 index 0000000..b89ade2 --- /dev/null +++ b/src/Entity/TeamerWorkMonth.php @@ -0,0 +1,440 @@ + + */ + #[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 + */ + #[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 + */ + 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; + } +} diff --git a/src/Entity/Upload.php b/src/Entity/Upload.php index 165342a..4425077 100644 --- a/src/Entity/Upload.php +++ b/src/Entity/Upload.php @@ -36,6 +36,13 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface */ public const TYPE_SEASON_DOCUMENT_IN = 'season_document_in'; + /** + * A signed Arbeitszeitnachweis a teamer returns for one work month. These accumulate too, + * so a rejection and the re-upload answering it both stay on the month. Unlike the season + * documents this one is reviewed, so it carries a status rather than null. + */ + public const TYPE_WORK_MONTH = 'work_month'; + public const STATUS_NEW = 'new'; public const STATUS_PENDING = 'pending'; public const STATUS_CHECKED = 'checked'; @@ -88,6 +95,9 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface #[ORM\ManyToOne(inversedBy: 'documents')] private ?SeasonAssignment $seasonAssignment = null; + #[ORM\ManyToOne(inversedBy: 'documents')] + private ?TeamerWorkMonth $teamerWorkMonth = null; + #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $comment = null; @@ -162,6 +172,7 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface self::TYPE_RECEIPT => 'Beleg', self::TYPE_SEASON_DOCUMENT_OUT => 'Saisonunterlage', self::TYPE_SEASON_DOCUMENT_IN => 'Rückläufer Saison', + self::TYPE_WORK_MONTH => 'Arbeitszeitnachweis', default => 'Dokument', }; } @@ -296,6 +307,18 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface return $this; } + public function getTeamerWorkMonth(): ?TeamerWorkMonth + { + return $this->teamerWorkMonth; + } + + public function setTeamerWorkMonth(?TeamerWorkMonth $teamerWorkMonth): static + { + $this->teamerWorkMonth = $teamerWorkMonth; + + return $this; + } + public function getComment(): ?string { return $this->comment; diff --git a/src/Enum/WorkHoursSyncOutcome.php b/src/Enum/WorkHoursSyncOutcome.php new file mode 100644 index 0000000..6dfd4c0 --- /dev/null +++ b/src/Enum/WorkHoursSyncOutcome.php @@ -0,0 +1,18 @@ + '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, + }; + } +} diff --git a/src/Form/WorkMonthFilterType.php b/src/Form/WorkMonthFilterType.php new file mode 100644 index 0000000..50d7e37 --- /dev/null +++ b/src/Form/WorkMonthFilterType.php @@ -0,0 +1,61 @@ +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, + ]); + } +} diff --git a/src/Form/WorkMonthReviewType.php b/src/Form/WorkMonthReviewType.php new file mode 100644 index 0000000..23f8697 --- /dev/null +++ b/src/Form/WorkMonthReviewType.php @@ -0,0 +1,46 @@ +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, + ]); + } +} diff --git a/src/Logging/RedactSaasCredentialsProcessor.php b/src/Logging/RedactSaasCredentialsProcessor.php new file mode 100644 index 0000000..b5d8b0d --- /dev/null +++ b/src/Logging/RedactSaasCredentialsProcessor.php @@ -0,0 +1,99 @@ +/[/...]. + */ + 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; + } +} diff --git a/src/Menu/AdminMenuBuilder.php b/src/Menu/AdminMenuBuilder.php index 7f97ddc..802479b 100644 --- a/src/Menu/AdminMenuBuilder.php +++ b/src/Menu/AdminMenuBuilder.php @@ -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', [ 'route' => 'app_administrative_availability_index', 'linkAttributes' => [ diff --git a/src/Menu/TeamerMenuBuilder.php b/src/Menu/TeamerMenuBuilder.php index 439bdb3..f683c0d 100644 --- a/src/Menu/TeamerMenuBuilder.php +++ b/src/Menu/TeamerMenuBuilder.php @@ -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', [ 'route' => 'app_teamer_profile_index', 'linkAttributes' => [ @@ -155,6 +171,13 @@ class TeamerMenuBuilder extends AbstractMenuBuilder return $menu; } + private function isSeasonTeamer(): bool + { + $user = $this->security->getUser(); + + return $user instanceof User && true === $user->getTeamer()?->isAllSeason(); + } + private function resolveSeasonAssignment(): ?SeasonAssignment { $user = $this->security->getUser(); diff --git a/src/Model/WorkHoursSyncResult.php b/src/Model/WorkHoursSyncResult.php new file mode 100644 index 0000000..e41b138 --- /dev/null +++ b/src/Model/WorkHoursSyncResult.php @@ -0,0 +1,81 @@ +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; + } +} diff --git a/src/Model/WorkMonthDay.php b/src/Model/WorkMonthDay.php new file mode 100644 index 0000000..881063d --- /dev/null +++ b/src/Model/WorkMonthDay.php @@ -0,0 +1,76 @@ +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); + } +} diff --git a/src/Model/WorkMonthDays.php b/src/Model/WorkMonthDays.php new file mode 100644 index 0000000..aae1eda --- /dev/null +++ b/src/Model/WorkMonthDays.php @@ -0,0 +1,154 @@ + $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 + */ + 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; + } +} diff --git a/src/Model/WorkMonthFilterDto.php b/src/Model/WorkMonthFilterDto.php new file mode 100644 index 0000000..eca27eb --- /dev/null +++ b/src/Model/WorkMonthFilterDto.php @@ -0,0 +1,95 @@ +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; + } +} diff --git a/src/Model/WorkMonthReviewDto.php b/src/Model/WorkMonthReviewDto.php new file mode 100644 index 0000000..d87f8f0 --- /dev/null +++ b/src/Model/WorkMonthReviewDto.php @@ -0,0 +1,69 @@ +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() + ; + } + } +} diff --git a/src/Repository/TeamerRepository.php b/src/Repository/TeamerRepository.php index cfba4af..3c3de91 100644 --- a/src/Repository/TeamerRepository.php +++ b/src/Repository/TeamerRepository.php @@ -174,6 +174,43 @@ class TeamerRepository extends ServiceEntityRepository 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 * assignment cannot be created for someone who works short term diff --git a/src/Repository/TeamerWorkMonthRepository.php b/src/Repository/TeamerWorkMonthRepository.php new file mode 100644 index 0000000..efc7fac --- /dev/null +++ b/src/Repository/TeamerWorkMonthRepository.php @@ -0,0 +1,134 @@ + + * + * @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'); + } + } +} diff --git a/src/Saas/ApiClient.php b/src/Saas/ApiClient.php new file mode 100644 index 0000000..e443ee6 --- /dev/null +++ b/src/Saas/ApiClient.php @@ -0,0 +1,123 @@ +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); + } +} diff --git a/src/Saas/ApiClientException.php b/src/Saas/ApiClientException.php new file mode 100644 index 0000000..552f2b5 --- /dev/null +++ b/src/Saas/ApiClientException.php @@ -0,0 +1,7 @@ +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(); + } +} diff --git a/src/Saas/Model/Employee.php b/src/Saas/Model/Employee.php new file mode 100644 index 0000000..30bbeb1 --- /dev/null +++ b/src/Saas/Model/Employee.php @@ -0,0 +1,50 @@ +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; + } +} diff --git a/src/Saas/Model/EmployeeDirectory.php b/src/Saas/Model/EmployeeDirectory.php new file mode 100644 index 0000000..df4c905 --- /dev/null +++ b/src/Saas/Model/EmployeeDirectory.php @@ -0,0 +1,92 @@ + $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 + */ + 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 $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), + )); + } +} diff --git a/src/Saas/Model/TimeAccount.php b/src/Saas/Model/TimeAccount.php new file mode 100644 index 0000000..2b8fda0 --- /dev/null +++ b/src/Saas/Model/TimeAccount.php @@ -0,0 +1,112 @@ + $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 + */ + 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; + } +} diff --git a/src/Saas/Model/TimeAccountDay.php b/src/Saas/Model/TimeAccountDay.php new file mode 100644 index 0000000..74ff84f --- /dev/null +++ b/src/Saas/Model/TimeAccountDay.php @@ -0,0 +1,68 @@ +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; + } +} diff --git a/src/Saas/ResponseParser.php b/src/Saas/ResponseParser.php new file mode 100644 index 0000000..1d3ef66 --- /dev/null +++ b/src/Saas/ResponseParser.php @@ -0,0 +1,300 @@ +checkHeader($data, $year, $month, $warnings); + + /** @var array $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; + } +} diff --git a/src/Service/Common/WorkMonthFilterHandler.php b/src/Service/Common/WorkMonthFilterHandler.php new file mode 100644 index 0000000..5be3045 --- /dev/null +++ b/src/Service/Common/WorkMonthFilterHandler.php @@ -0,0 +1,56 @@ +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(), + ]); + } +} diff --git a/src/Service/Cron/WorkMonthReminderService.php b/src/Service/Cron/WorkMonthReminderService.php new file mode 100644 index 0000000..7a10a42 --- /dev/null +++ b/src/Service/Cron/WorkMonthReminderService.php @@ -0,0 +1,75 @@ +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; + } +} diff --git a/src/Service/Pdf/WorkMonthRenderer.php b/src/Service/Pdf/WorkMonthRenderer.php new file mode 100644 index 0000000..68a5f92 --- /dev/null +++ b/src/Service/Pdf/WorkMonthRenderer.php @@ -0,0 +1,92 @@ +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); + } +} diff --git a/src/Service/Saas/WorkHoursSynchronizer.php b/src/Service/Saas/WorkHoursSynchronizer.php new file mode 100644 index 0000000..7e50cbe --- /dev/null +++ b/src/Service/Saas/WorkHoursSynchronizer.php @@ -0,0 +1,233 @@ +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; + } +} diff --git a/src/Twig/AppExtension.php b/src/Twig/AppExtension.php index ecb35af..5f59a72 100644 --- a/src/Twig/AppExtension.php +++ b/src/Twig/AppExtension.php @@ -25,6 +25,7 @@ class AppExtension extends AbstractExtension new TwigFilter('bpn_pickup_label', [AppRuntime::class, 'bpnPickupLabel']), new TwigFilter('upload_status_badge', [AppRuntime::class, 'renderUploadStatusBadge'], ['is_safe' => ['html']]), new TwigFilter('upload_filename_full', [AppRuntime::class, 'getFilenameWithFolder']), + new TwigFilter('duration_hm', [AppRuntime::class, 'formatDurationHoursMinutes']), ]; } diff --git a/src/Twig/AppRuntime.php b/src/Twig/AppRuntime.php index 5d75ea1..203e71d 100644 --- a/src/Twig/AppRuntime.php +++ b/src/Twig/AppRuntime.php @@ -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 { return $this->seasonAssignmentLabeler->label($seasonAssignment); diff --git a/templates/admin/work_month/index.html.twig b/templates/admin/work_month/index.html.twig new file mode 100644 index 0000000..a2922c5 --- /dev/null +++ b/templates/admin/work_month/index.html.twig @@ -0,0 +1,146 @@ +{% extends 'admin/layout.html.twig' %} + +{% block title %}Arbeitszeiten{% endblock %} + +{% block content %} +
+

+ Arbeitszeiten +

+
+ + {% if filterDto.active %} + + Reset + + {% endif %} +
+
+ +
+
+ + + + + + + + + + + + + + {% for workMonth in pagination %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
{{ knp_pagination_sortable(pagination, 'Name', 'teamer.lastName') }}Vorname{{ knp_pagination_sortable(pagination, 'Monat', 'work_month.year') }}StundenStatusNachweis
+ + {{ workMonth.teamer.lastName }} + + + {{ workMonth.teamer.firstName }} + + {{ workMonth.periodLabel }} + + {{ workMonth.totalMinutes|duration_hm }} + {% if workMonth.hasMismatch %} +
+ jetzt {{ workMonth.mismatchTotalMinutes|duration_hm }} +
+ {% endif %} + {% if workMonth.warnings is not empty %} +
+ {{ workMonth.warnings|length }} Hinweis(e) +
+ {% endif %} +
+ + {{ workMonth.status.label }} + + + {% if workMonth.currentDocument %} + + {{ icon('download', 'w-4 h-4 shrink-0') }} + {{ workMonth.submittedAt|date('d.m.Y') }} + + {% else %} + – + {% endif %} + + + + Nachweis ansehen + + {% if workMonth.status.value == 'submitted' %} + + Prüfen + + {% endif %} +
+ +
+ {% if workMonth.status.value in ['submitted', 'approved'] %} +
+ +
+ {% endif %} +
+
+ Keine Arbeitszeiten gefunden. +
+
+
+ + {{ knp_pagination_render(pagination) }} +{% endblock %} diff --git a/templates/admin/work_month/modal_review.html.twig b/templates/admin/work_month/modal_review.html.twig new file mode 100644 index 0000000..837813b --- /dev/null +++ b/templates/admin/work_month/modal_review.html.twig @@ -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' } }) }} +
+
+ {{ workMonth.teamer }} · {{ workMonth.periodLabel }} +
+
+ {{ workMonth.totalMinutes|duration_hm }} Stunden an {{ workMonth.workedDays }} Tagen, + eingereicht am {{ workMonth.submittedAt|date('d.m.Y') }} +
+ {% if workMonth.hasMismatch %} +
+ Achtung: die SaaS meldet für diesen Monat inzwischen + {{ workMonth.mismatchTotalMinutes|duration_hm }} Stunden. +
+ {% endif %} +
+ + + +
+ {{ form_row(form.status) }} + {{ form_row(form.comment) }} +
+ + + {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/templates/common/modal_work_month_filter.html.twig b/templates/common/modal_work_month_filter.html.twig new file mode 100644 index 0000000..8e24f11 --- /dev/null +++ b/templates/common/modal_work_month_filter.html.twig @@ -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'} }) }} +
+ {{ form_row(filterForm.teamerName) }} + {{ form_row(filterForm.period) }} + {{ form_row(filterForm.statuses) }} + {{ form_row(filterForm.includeApproved) }} + {{ form_row(filterForm.includeMismatchedOnly) }} +
+
+ {{ form_widget(filterForm.apply, { 'attr': { 'class': 'btn' } }) }} + {{ form_widget(filterForm.reset, { 'attr': { 'class': 'btn btn--secondary' } }) }} +
+ {{ form_rest(filterForm) }} + {{ form_end(filterForm) }} +{% endblock %} diff --git a/templates/pdf/work_month.html.twig b/templates/pdf/work_month.html.twig new file mode 100644 index 0000000..22e4751 --- /dev/null +++ b/templates/pdf/work_month.html.twig @@ -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. +#} + + + + + + + +

Arbeitszeitnachweis

+ {% if logo|default(null) %} + E&P Reisen Events + {% endif %} +
+ + + + + + + {% if workMonth.department %} + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + + + + + +
Teamer:in{{ teamer.fullName }}
Abteilung{{ workMonth.department }}
Monat{{ workMonth.period|format_datetime(pattern='LLLL yyyy', locale='de') }}
Sollstunden{{ workMonth.targetMinutes|duration_hm }}
Iststunden{{ workMonth.totalMinutes|duration_hm }}
Saldo{{ workMonth.balanceMinutes > 0 ? '+' : '' }}{{ workMonth.balanceMinutes|duration_hm }}
Pausen gesamt{{ workMonth.breakMinutes|duration_hm }}
Arbeitstage{{ workMonth.workedDays }}{% if workMonth.absenceDays > 0 %}, freie Tage: {{ workMonth.absenceDays }}{% endif %}
+ + + + + + + + + + + + + + + {% for day in days %} + + + + + + + + + + {% endfor %} + + + + + + + +
DatumTagBeginnEndePauseStundenAbwesenheit
{{ day.date|date('d.m.Y') }}{{ day.date|format_datetime(pattern='EEE', locale='de') }}{{ day.startTime ?? '' }}{{ day.endTime ?? '' }}{{ day.breakMinutes > 0 ? day.breakMinutes|duration_hm : '' }}{{ day.minutes|duration_hm }}{{ day.absence ?? '' }}
Gesamt{{ workMonth.breakMinutes|duration_hm }}{{ workMonth.totalMinutes|duration_hm }}
+ + + + + + +
+
+ Ort, Datum +
+
+ Unterschrift {{ teamer.fullName }} +
+ +

+ Bitte den Nachweis unterschrieben wieder im Team-Portal hochladen. + Stand der Daten: {{ workMonth.syncedAt|date('d.m.Y H:i') }} Uhr. +

diff --git a/templates/teamer/work_month/index.html.twig b/templates/teamer/work_month/index.html.twig new file mode 100644 index 0000000..59551b2 --- /dev/null +++ b/templates/teamer/work_month/index.html.twig @@ -0,0 +1,80 @@ +{% extends 'teamer/layout.html.twig' %} + +{% block title %}Arbeitszeiten{% endblock %} + +{% block content %} +
+

+ Arbeitszeiten +

+

+ Hier findest du für jeden abgerechneten Monat deinen Arbeitszeitnachweis. + Bitte lade ihn herunter, unterschreibe ihn und lade ihn anschließend wieder hoch. +

+ + {% if workMonths is empty %} +
+ Es liegen noch keine Arbeitszeiten vor. Ein Monat erscheint hier, sobald er + abgerechnet wurde. +
+ {% else %} +
+ {% for workMonth in workMonths %} +
+
+ {{ workMonth.period|format_datetime(pattern='LLLL yyyy', locale='de') }} +
+ {{ workMonth.totalMinutes|duration_hm }} Stunden + an {{ workMonth.workedDays }} Tagen +
+
+
+
+ + {{ workMonth.status.label }} + +
+ + {% if workMonth.status.value == 'rejected' and workMonth.reviewComment %} +
+ {{ workMonth.reviewComment }} +
+ {% endif %} + + +
+
+ {% endfor %} +
+ {% endif %} +
+{% endblock %} diff --git a/templates/teamer/work_month/modal_upload.html.twig b/templates/teamer/work_month/modal_upload.html.twig new file mode 100644 index 0000000..c4e1199 --- /dev/null +++ b/templates/teamer/work_month/modal_upload.html.twig @@ -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()) }) }} +
+ 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. +
+
+ {% 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', + } %} +
+ + {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/tests/Logging/RedactSaasCredentialsProcessorTest.php b/tests/Logging/RedactSaasCredentialsProcessorTest.php new file mode 100644 index 0000000..8a046dd --- /dev/null +++ b/tests/Logging/RedactSaasCredentialsProcessorTest.php @@ -0,0 +1,103 @@ +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/a@b.de/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/a@b.de/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)); + } +} diff --git a/tests/Model/WorkMonthDaysTest.php b/tests/Model/WorkMonthDaysTest.php new file mode 100644 index 0000000..d1d168a --- /dev/null +++ b/tests/Model/WorkMonthDaysTest.php @@ -0,0 +1,144 @@ + ['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()); + } +} diff --git a/tests/Repository/TeamerWorkMonthRepositoryTest.php b/tests/Repository/TeamerWorkMonthRepositoryTest.php new file mode 100644 index 0000000..f2ab98b --- /dev/null +++ b/tests/Repository/TeamerWorkMonthRepositoryTest.php @@ -0,0 +1,96 @@ +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; + } +} diff --git a/tests/Resources/saas_time_account_march.json b/tests/Resources/saas_time_account_march.json new file mode 100644 index 0000000..1a7ab12 --- /dev/null +++ b/tests/Resources/saas_time_account_march.json @@ -0,0 +1,3886 @@ +{ + "surcharge": 0, + "PAYOFF_TIMEACCOUNT": 0, + "year": 2026, + "istOnWeekend": 4548, + "TIMEACCOUNT_PAYOFF": 0, + "lastMonth": 0, + "overtimeAccountMonthNext": 9619, + "outOfWork": 0.0, + "ist": 11748, + "autoOvertime": 1848, + "employee": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "hasTime": true, + "current": null, + "isReadOnly": false, + "inactive": false, + "PAYOFF_OVERTIMEACCOUNT": 0, + "today": null, + "OVERTIMEACCOUNT_TIMEACCOUNT": 0, + "end": null, + "traffic": { + "active": false + }, + "TIMEACCOUNT_OVERTIMEACCOUNT": 0, + "start": "2021-10-22", + "soll": 9900, + "hasProject": false, + "saldo": 0, + "list": [ + { + "date": "2026-02-01", + "surcharge": 0.0, + "startOriginalDate": "2026-02-01", + "endDate": "2026-02-01", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 240, + "uuid": "af5ab829-0e56-4bcd-8776-82cf724b5440", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-01 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-01 13:00:00", + "department": "Operations", + "endOriginal": "2026-02-01 13:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-01", + "startOriginalTime": "09:00:00", + "start": "2026-02-01 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "13:00:00", + "startDate": "2026-02-01", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "13:00:00" + }, + { + "date": "2026-02-01", + "annotation": -1, + "endDate": "2026-02-01", + "start": "2026-02-01 13:00:00", + "soll": 0, + "ist": 0, + "pause": 60, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "13:00:00", + "end": "2026-02-01 14:00:00", + "endTime": "14:00:00", + "isDummy": 0, + "startDate": "2026-02-01" + }, + { + "date": "2026-02-01", + "surcharge": 0.0, + "startOriginalDate": "2026-02-01", + "endDate": "2026-02-01", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 45, + "uuid": "adc6c80f-f80e-4eff-b4af-a86a5c5244b2", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-01 14:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "14:00:00", + "end": "2026-02-01 14:45:00", + "department": "Operations", + "endOriginal": "2026-02-01 14:30:00", + "annotation": 8, + "endOriginalDate": "2026-02-01", + "startOriginalTime": "14:00:00", + "start": "2026-02-01 14:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "14:45:00", + "startDate": "2026-02-01", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "14:30:00" + }, + { + "date": "2026-02-02", + "surcharge": 0.0, + "startOriginalDate": "2026-02-02", + "endDate": "2026-02-02", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 30, + "uuid": "9f1112ce-0088-4b5e-8eec-1cb52e481fb6", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-02 08:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "08:00:00", + "end": "2026-02-02 08:30:00", + "department": "Operations", + "endOriginal": "2026-02-02 08:30:00", + "annotation": 8, + "endOriginalDate": "2026-02-02", + "startOriginalTime": "08:00:00", + "start": "2026-02-02 08:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "08:30:00", + "startDate": "2026-02-02", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "08:30:00" + }, + { + "date": "2026-02-02", + "annotation": -1, + "endDate": "2026-02-02", + "start": "2026-02-02 08:30:00", + "soll": 0, + "ist": 0, + "pause": 30, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "08:30:00", + "end": "2026-02-02 09:00:00", + "endTime": "09:00:00", + "isDummy": 0, + "startDate": "2026-02-02" + }, + { + "date": "2026-02-02", + "surcharge": 0.0, + "startOriginalDate": "2026-02-02", + "endDate": "2026-02-02", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 300, + "uuid": "e56524b9-ade4-4a14-a116-a897a5fb1d92", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-02 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-02 14:00:00", + "department": "Operations", + "endOriginal": "2026-02-02 14:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-02", + "startOriginalTime": "09:00:00", + "start": "2026-02-02 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "14:00:00", + "startDate": "2026-02-02", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "14:00:00" + }, + { + "date": "2026-02-02", + "annotation": -1, + "endDate": "2026-02-02", + "start": "2026-02-02 14:00:00", + "soll": 0, + "ist": 0, + "pause": 120, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "14:00:00", + "end": "2026-02-02 16:00:00", + "endTime": "16:00:00", + "isDummy": 0, + "startDate": "2026-02-02" + }, + { + "date": "2026-02-02", + "surcharge": 0.0, + "startOriginalDate": "2026-02-02", + "endDate": "2026-02-02", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 75, + "uuid": "ee4f9b0b-9b02-4c2b-a16a-33be57ab6f57", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-02 16:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "16:00:00", + "end": "2026-02-02 17:15:00", + "department": "Operations", + "endOriginal": "2026-02-02 17:15:00", + "annotation": 8, + "endOriginalDate": "2026-02-02", + "startOriginalTime": "16:00:00", + "start": "2026-02-02 16:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "17:15:00", + "startDate": "2026-02-02", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "17:15:00" + }, + { + "date": "2026-02-03", + "surcharge": null, + "startOriginalDate": "2026-02-03", + "endDate": "2026-02-03", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 0, + "uuid": "b6b2c941-2796-4c86-8fe6-7b30c86ae19d", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-03 08:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "08:00:00", + "end": "2026-02-03 08:00:00", + "department": "Operations", + "endOriginal": "2026-02-03 08:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-03", + "startOriginalTime": "08:00:00", + "start": "2026-02-03 08:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "08:00:00", + "startDate": "2026-02-03", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "08:00:00" + }, + { + "date": "2026-02-03", + "annotation": -1, + "endDate": "2026-02-03", + "start": "2026-02-03 08:00:00", + "soll": 0, + "ist": 0, + "pause": 60, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "08:00:00", + "end": "2026-02-03 09:00:00", + "endTime": "09:00:00", + "isDummy": 0, + "startDate": "2026-02-03" + }, + { + "date": "2026-02-03", + "surcharge": 0.0, + "startOriginalDate": "2026-02-03", + "endDate": "2026-02-03", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 180, + "uuid": "a66397b8-89dd-48f6-927c-658e8a438dcc", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-03 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-03 12:00:00", + "department": "Operations", + "endOriginal": "2026-02-03 12:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-03", + "startOriginalTime": "09:00:00", + "start": "2026-02-03 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "12:00:00", + "startDate": "2026-02-03", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "12:00:00" + }, + { + "date": "2026-02-03", + "surcharge": 0.0, + "startOriginalDate": "2026-02-03", + "endDate": "2026-02-03", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 180, + "uuid": "011b38b5-3b33-4286-9389-a0f0257e6a68", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-03 12:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "12:00:00", + "end": "2026-02-03 15:00:00", + "department": "Operations", + "endOriginal": "2026-02-03 15:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-03", + "startOriginalTime": "12:00:00", + "start": "2026-02-03 12:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "15:00:00", + "startDate": "2026-02-03", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "15:00:00" + }, + { + "date": "2026-02-03", + "annotation": -1, + "endDate": "2026-02-03", + "start": "2026-02-03 15: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": "15:00:00", + "end": "2026-02-03 18:00:00", + "endTime": "18:00:00", + "isDummy": 0, + "startDate": "2026-02-03" + }, + { + "date": "2026-02-03", + "surcharge": null, + "startOriginalDate": "2026-02-03", + "endDate": "2026-02-03", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 0, + "uuid": "843f6ca5-2394-417e-a2cf-fb01318f118b", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-03 18:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "18:00:00", + "end": "2026-02-03 18:00:00", + "department": "Operations", + "endOriginal": "2026-02-03 18:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-03", + "startOriginalTime": "18:00:00", + "start": "2026-02-03 18:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "18:00:00", + "startDate": "2026-02-03", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "18:00:00" + }, + { + "date": "2026-02-03", + "annotation": -1, + "endDate": "2026-02-03", + "start": "2026-02-03 18:00:00", + "soll": 0, + "ist": 0, + "pause": 60, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "18:00:00", + "end": "2026-02-03 19:00:00", + "endTime": "19:00:00", + "isDummy": 0, + "startDate": "2026-02-03" + }, + { + "date": "2026-02-03", + "surcharge": 0.0, + "endDate": "2026-02-03", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 30, + "uuid": "6d76fcfe-a25b-4cd0-959c-76cdc81aba78", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": null, + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "19:00:00", + "end": "2026-02-03 19:30:00", + "department": "Operations", + "endOriginal": null, + "annotation": 8, + "start": "2026-02-03 19:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "19:30:00", + "startDate": "2026-02-03", + "isDummy": 0, + "status": "ACCEPTED" + }, + { + "date": "2026-02-04", + "surcharge": 0.0, + "startOriginalDate": "2026-02-04", + "endDate": "2026-02-04", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 180, + "uuid": "75da6fbe-21d9-4171-a739-a9425692edad", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-04 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-04 12:00:00", + "department": "Operations", + "endOriginal": "2026-02-04 12:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-04", + "startOriginalTime": "09:00:00", + "start": "2026-02-04 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "12:00:00", + "startDate": "2026-02-04", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "12:00:00" + }, + { + "date": "2026-02-05", + "surcharge": null, + "startOriginalDate": "2026-02-05", + "endDate": "2026-02-05", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 0, + "uuid": "a8100124-e603-4f12-b3fe-4d6d6242a27d", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-05 05:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "05:00:00", + "end": "2026-02-05 05:00:00", + "department": "Operations", + "endOriginal": "2026-02-05 05:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-05", + "startOriginalTime": "05:00:00", + "start": "2026-02-05 05:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": "Ruhetag 24/25", + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "05:00:00", + "startDate": "2026-02-05", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "05:00:00" + }, + { + "date": "2026-02-05", + "annotation": -4, + "endDate": null, + "start": "2026-02-05 12:51:15", + "breakCalculationActive": true, + "soll": 0, + "ist": 0, + "pause": 0, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": "Ruhetag 24/25", + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "23:59:59", + "endTime": null, + "isDummy": 0, + "startDate": "2026-02-05" + }, + { + "date": "2026-02-06", + "surcharge": 0.0, + "startOriginalDate": "2026-02-06", + "endDate": "2026-02-06", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 300, + "uuid": "6a8da09e-7325-46bb-a904-ea50c11c78e8", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-06 07:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "07:00:00", + "end": "2026-02-06 12:00:00", + "department": "Operations", + "endOriginal": "2026-02-06 12:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-06", + "startOriginalTime": "07:00:00", + "start": "2026-02-06 07:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "12:00:00", + "startDate": "2026-02-06", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "12:00:00" + }, + { + "date": "2026-02-06", + "annotation": -1, + "endDate": "2026-02-06", + "start": "2026-02-06 12:00:00", + "soll": 0, + "ist": 0, + "pause": 120, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "12:00:00", + "end": "2026-02-06 14:00:00", + "endTime": "14:00:00", + "isDummy": 0, + "startDate": "2026-02-06" + }, + { + "date": "2026-02-06", + "surcharge": 0.0, + "startOriginalDate": "2026-02-06", + "endDate": "2026-02-06", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 300, + "uuid": "26937a4f-47b2-4c10-9087-3ef71de191f9", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-06 14:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "14:00:00", + "end": "2026-02-06 19:00:00", + "department": "Operations", + "endOriginal": "2026-02-06 19:00:00", + "annotation": 8, + "endOriginalDate": "2026-02-06", + "startOriginalTime": "14:00:00", + "start": "2026-02-06 14:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "19:00:00", + "startDate": "2026-02-06", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "19:00:00" + }, + { + "date": "2026-02-07", + "surcharge": 0.0, + "startOriginalDate": "2026-02-07", + "endDate": "2026-02-07", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 600, + "uuid": "0c410d5e-0835-4384-8fe8-b8f561f564d1", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-07 07:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "07:00:00", + "end": "2026-02-07 17:00:00", + "department": "Operations", + "endOriginal": "2026-02-07 17:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-07", + "startOriginalTime": "07:00:00", + "start": "2026-02-07 07:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "17:00:00", + "startDate": "2026-02-07", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "17:00:00" + }, + { + "date": "2026-02-07", + "annotation": -1, + "endDate": "2026-02-07", + "start": "2026-02-07 17:00:00", + "soll": 0, + "ist": 0, + "pause": 120, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "17:00:00", + "end": "2026-02-07 19:00:00", + "endTime": "19:00:00", + "isDummy": 0, + "startDate": "2026-02-07" + }, + { + "date": "2026-02-07", + "surcharge": 0.0, + "startOriginalDate": "2026-02-07", + "endDate": "2026-02-07", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 60, + "uuid": "f009b0b5-a059-4e10-809c-6ac93e3e8ee4", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-07 19:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "19:00:00", + "end": "2026-02-07 20:00:00", + "department": "Operations", + "endOriginal": "2026-02-07 20:00:00", + "annotation": 8, + "endOriginalDate": "2026-02-07", + "startOriginalTime": "19:00:00", + "start": "2026-02-07 19:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "20:00:00", + "startDate": "2026-02-07", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "20:00:00" + }, + { + "date": "2026-02-08", + "surcharge": 0.0, + "startOriginalDate": "2026-02-08", + "endDate": "2026-02-08", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 240, + "uuid": "f405c102-5e21-4576-9d92-2b8f374cbff9", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-08 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-08 13:00:00", + "department": "Operations", + "endOriginal": "2026-02-08 13:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-08", + "startOriginalTime": "09:00:00", + "start": "2026-02-08 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "13:00:00", + "startDate": "2026-02-08", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "13:00:00" + }, + { + "date": "2026-02-08", + "annotation": -1, + "endDate": "2026-02-08", + "start": "2026-02-08 13:00:00", + "soll": 0, + "ist": 0, + "pause": 60, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "13:00:00", + "end": "2026-02-08 14:00:00", + "endTime": "14:00:00", + "isDummy": 0, + "startDate": "2026-02-08" + }, + { + "date": "2026-02-08", + "surcharge": 0.0, + "startOriginalDate": "2026-02-08", + "endDate": "2026-02-08", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 30, + "uuid": "f07da7b7-9fc6-455f-befa-9fb2109de7a0", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-08 14:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "14:00:00", + "end": "2026-02-08 14:30:00", + "department": "Operations", + "endOriginal": "2026-02-08 14:30:00", + "annotation": 8, + "endOriginalDate": "2026-02-08", + "startOriginalTime": "14:00:00", + "start": "2026-02-08 14:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "14:30:00", + "startDate": "2026-02-08", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "14:30:00" + }, + { + "date": "2026-02-08", + "annotation": -1, + "endDate": "2026-02-08", + "start": "2026-02-08 14:30:00", + "soll": 0, + "ist": 0, + "pause": 210, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "14:30:00", + "end": "2026-02-08 18:00:00", + "endTime": "18:00:00", + "isDummy": 0, + "startDate": "2026-02-08" + }, + { + "date": "2026-02-08", + "surcharge": 0.0, + "startOriginalDate": "2026-02-08", + "endDate": "2026-02-08", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 50, + "uuid": "c63fa583-d9c6-4746-b745-d0d787ca47f7", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-08 18:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "18:00:00", + "end": "2026-02-08 18:50:00", + "department": "Operations", + "endOriginal": "2026-02-08 18:50:00", + "annotation": 8, + "endOriginalDate": "2026-02-08", + "startOriginalTime": "18:00:00", + "start": "2026-02-08 18:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "18:50:00", + "startDate": "2026-02-08", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "18:50:00" + }, + { + "date": "2026-02-08", + "annotation": -1, + "endDate": "2026-02-08", + "start": "2026-02-08 18:50:00", + "soll": 0, + "ist": 0, + "pause": 70, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "18:50:00", + "end": "2026-02-08 20:00:00", + "endTime": "20:00:00", + "isDummy": 0, + "startDate": "2026-02-08" + }, + { + "date": "2026-02-08", + "surcharge": 0.0, + "startOriginalDate": "2026-02-08", + "endDate": "2026-02-08", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 60, + "uuid": "a30c65fc-8325-44ad-88dd-1366a9105566", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-08 20:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "20:00:00", + "end": "2026-02-08 21:00:00", + "department": "Operations", + "endOriginal": "2026-02-08 21:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-08", + "startOriginalTime": "20:00:00", + "start": "2026-02-08 20:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "21:00:00", + "startDate": "2026-02-08", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "21:00:00" + }, + { + "date": "2026-02-09", + "surcharge": null, + "startOriginalDate": "2026-02-09", + "endDate": "2026-02-09", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 0, + "uuid": "5eaf7f60-26ac-490d-a174-8023fd07f018", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-09 05:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "05:00:00", + "end": "2026-02-09 05:00:00", + "department": "Operations", + "endOriginal": "2026-02-09 05:00:00", + "annotation": 134217856, + "endOriginalDate": "2026-02-09", + "startOriginalTime": "05:00:00", + "start": "2026-02-09 05:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": "Ruhetag 24/25", + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "05:00:00", + "startDate": "2026-02-09", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "05:00:00" + }, + { + "date": "2026-02-09", + "annotation": -4, + "endDate": null, + "start": "2026-02-09 12:51:15", + "breakCalculationActive": true, + "soll": 0, + "ist": 0, + "pause": 0, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": "Ruhetag 24/25", + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "23:59:59", + "endTime": null, + "isDummy": 0, + "startDate": "2026-02-09" + }, + { + "date": "2026-02-10", + "surcharge": 0.0, + "startOriginalDate": "2026-02-10", + "endDate": "2026-02-10", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 30, + "uuid": "a9e78ad7-cae1-4ede-8631-f259ed3ce58a", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-10 08:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "08:00:00", + "end": "2026-02-10 08:30:00", + "department": "Operations", + "endOriginal": "2026-02-10 08:30:00", + "annotation": 128, + "endOriginalDate": "2026-02-10", + "startOriginalTime": "08:00:00", + "start": "2026-02-10 08:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "08:30:00", + "startDate": "2026-02-10", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "08:30:00" + }, + { + "date": "2026-02-10", + "annotation": -1, + "endDate": "2026-02-10", + "start": "2026-02-10 08:30:00", + "soll": 0, + "ist": 0, + "pause": 30, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "08:30:00", + "end": "2026-02-10 09:00:00", + "endTime": "09:00:00", + "isDummy": 0, + "startDate": "2026-02-10" + }, + { + "date": "2026-02-10", + "surcharge": 0.0, + "startOriginalDate": "2026-02-10", + "endDate": "2026-02-10", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 300, + "uuid": "8363406e-ee30-4eb0-84ba-8c40767a03b9", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-10 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-10 14:00:00", + "department": "Operations", + "endOriginal": "2026-02-10 14:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-10", + "startOriginalTime": "09:00:00", + "start": "2026-02-10 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "14:00:00", + "startDate": "2026-02-10", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "14:00:00" + }, + { + "date": "2026-02-10", + "annotation": -1, + "endDate": "2026-02-10", + "start": "2026-02-10 14:00:00", + "soll": 0, + "ist": 0, + "pause": 30, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "14:00:00", + "end": "2026-02-10 14:30:00", + "endTime": "14:30:00", + "isDummy": 0, + "startDate": "2026-02-10" + }, + { + "date": "2026-02-10", + "surcharge": 0.0, + "startOriginalDate": "2026-02-10", + "endDate": "2026-02-10", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 45, + "uuid": "9c3c9c94-fff1-4228-a46a-edcdc69f970f", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-10 14:30:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "14:30:00", + "end": "2026-02-10 15:15:00", + "department": "Operations", + "endOriginal": "2026-02-10 15:15:00", + "annotation": 8, + "endOriginalDate": "2026-02-10", + "startOriginalTime": "14:30:00", + "start": "2026-02-10 14:30:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "15:15:00", + "startDate": "2026-02-10", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "15:15:00" + }, + { + "date": "2026-02-10", + "annotation": -1, + "endDate": "2026-02-10", + "start": "2026-02-10 15:15:00", + "soll": 0, + "ist": 0, + "pause": 165, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "15:15:00", + "end": "2026-02-10 18:00:00", + "endTime": "18:00:00", + "isDummy": 0, + "startDate": "2026-02-10" + }, + { + "date": "2026-02-10", + "surcharge": 0.0, + "startOriginalDate": "2026-02-10", + "endDate": "2026-02-10", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 30, + "uuid": "0bec6d0b-7db1-4487-b9cf-58495d0efc5a", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-10 18:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "18:00:00", + "end": "2026-02-10 18:30:00", + "department": "Operations", + "endOriginal": "2026-02-10 18:30:00", + "annotation": 128, + "endOriginalDate": "2026-02-10", + "startOriginalTime": "18:00:00", + "start": "2026-02-10 18:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "18:30:00", + "startDate": "2026-02-10", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "18:30:00" + }, + { + "date": "2026-02-11", + "surcharge": 0.0, + "startOriginalDate": "2026-02-11", + "endDate": "2026-02-11", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 30, + "uuid": "b03783cb-26ff-482c-b9e7-4f32dd41e2df", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-11 08:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "08:00:00", + "end": "2026-02-11 08:30:00", + "department": "Operations", + "endOriginal": "2026-02-11 08:30:00", + "annotation": 128, + "endOriginalDate": "2026-02-11", + "startOriginalTime": "08:00:00", + "start": "2026-02-11 08:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "08:30:00", + "startDate": "2026-02-11", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "08:30:00" + }, + { + "date": "2026-02-11", + "annotation": -1, + "endDate": "2026-02-11", + "start": "2026-02-11 08:30:00", + "soll": 0, + "ist": 0, + "pause": 30, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "08:30:00", + "end": "2026-02-11 09:00:00", + "endTime": "09:00:00", + "isDummy": 0, + "startDate": "2026-02-11" + }, + { + "date": "2026-02-11", + "surcharge": 0.0, + "startOriginalDate": "2026-02-11", + "endDate": "2026-02-11", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 180, + "uuid": "31be19a3-1e44-47cd-aa16-25df77181595", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-11 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-11 12:00:00", + "department": "Operations", + "endOriginal": "2026-02-11 12:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-11", + "startOriginalTime": "09:00:00", + "start": "2026-02-11 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "12:00:00", + "startDate": "2026-02-11", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "12:00:00" + }, + { + "date": "2026-02-11", + "surcharge": 0.0, + "startOriginalDate": "2026-02-11", + "endDate": "2026-02-11", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 30, + "uuid": "3fe2d05f-15a7-40d2-a9e4-316eca8935b8", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-11 12:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "12:00:00", + "end": "2026-02-11 12:30:00", + "department": "Operations", + "endOriginal": "2026-02-11 12:30:00", + "annotation": 8, + "endOriginalDate": "2026-02-11", + "startOriginalTime": "12:00:00", + "start": "2026-02-11 12:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "12:30:00", + "startDate": "2026-02-11", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "12:30:00" + }, + { + "date": "2026-02-11", + "annotation": -2, + "endDate": "2026-02-11", + "start": "2026-02-11 12:30:00", + "soll": 0, + "ist": 0, + "pause": 0, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "12:30:00", + "end": "2026-02-11 18:00:00", + "endTime": "18:00:00", + "isDummy": 0, + "startDate": "2026-02-11" + }, + { + "date": "2026-02-11", + "surcharge": null, + "startOriginalDate": "2026-02-11", + "endDate": "2026-02-11", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 0, + "uuid": "69f43b70-1152-4e7c-818a-ec2f9b6e8b89", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-11 18:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "18:00:00", + "end": "2026-02-11 18:00:00", + "department": "Operations", + "endOriginal": "2026-02-11 18:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-11", + "startOriginalTime": "18:00:00", + "start": "2026-02-11 18:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "18:00:00", + "startDate": "2026-02-11", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "18:00:00" + }, + { + "date": "2026-02-12", + "surcharge": 0.0, + "startOriginalDate": "2026-02-12", + "endDate": "2026-02-12", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 30, + "uuid": "0cef049e-caba-461a-b751-ec4e2fb002b2", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-12 08:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "08:00:00", + "end": "2026-02-12 08:30:00", + "department": "Operations", + "endOriginal": "2026-02-12 08:30:00", + "annotation": 128, + "endOriginalDate": "2026-02-12", + "startOriginalTime": "08:00:00", + "start": "2026-02-12 08:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "08:30:00", + "startDate": "2026-02-12", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "08:30:00" + }, + { + "date": "2026-02-12", + "annotation": -1, + "endDate": "2026-02-12", + "start": "2026-02-12 08:30:00", + "soll": 0, + "ist": 0, + "pause": 30, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "08:30:00", + "end": "2026-02-12 09:00:00", + "endTime": "09:00:00", + "isDummy": 0, + "startDate": "2026-02-12" + }, + { + "date": "2026-02-12", + "surcharge": 0.0, + "startOriginalDate": "2026-02-12", + "endDate": "2026-02-12", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 300, + "uuid": "772d4e5c-93cd-46ee-9d02-2a1dd6d12ef8", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-12 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-12 14:00:00", + "department": "Operations", + "endOriginal": "2026-02-12 14:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-12", + "startOriginalTime": "09:00:00", + "start": "2026-02-12 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "14:00:00", + "startDate": "2026-02-12", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "14:00:00" + }, + { + "date": "2026-02-12", + "surcharge": 0.0, + "startOriginalDate": "2026-02-12", + "endDate": "2026-02-12", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 180, + "uuid": "fe49d91c-1b41-4e0a-993a-0b88d21522d8", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-12 14:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "14:00:00", + "end": "2026-02-12 17:00:00", + "department": "Operations", + "endOriginal": "2026-02-12 17:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-12", + "startOriginalTime": "14:00:00", + "start": "2026-02-12 14:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "17:00:00", + "startDate": "2026-02-12", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "17:00:00" + }, + { + "date": "2026-02-12", + "annotation": -1, + "endDate": "2026-02-12", + "start": "2026-02-12 17:00:00", + "soll": 0, + "ist": 0, + "pause": 60, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "17:00:00", + "end": "2026-02-12 18:00:00", + "endTime": "18:00:00", + "isDummy": 0, + "startDate": "2026-02-12" + }, + { + "date": "2026-02-12", + "surcharge": null, + "startOriginalDate": "2026-02-12", + "endDate": "2026-02-12", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 0, + "uuid": "f0a5ad2a-11d8-477b-8ef6-c4b31581cc12", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-12 18:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "18:00:00", + "end": "2026-02-12 18:00:00", + "department": "Operations", + "endOriginal": "2026-02-12 18:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-12", + "startOriginalTime": "18:00:00", + "start": "2026-02-12 18:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "18:00:00", + "startDate": "2026-02-12", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "18:00:00" + }, + { + "date": "2026-02-13", + "surcharge": 0.0, + "startOriginalDate": "2026-02-13", + "endDate": "2026-02-13", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 30, + "uuid": "7699294e-167d-4e27-8d8a-1ec95df7458c", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-13 08:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "08:00:00", + "end": "2026-02-13 08:30:00", + "department": "Operations", + "endOriginal": "2026-02-13 08:30:00", + "annotation": 128, + "endOriginalDate": "2026-02-13", + "startOriginalTime": "08:00:00", + "start": "2026-02-13 08:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "08:30:00", + "startDate": "2026-02-13", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "08:30:00" + }, + { + "date": "2026-02-13", + "annotation": -1, + "endDate": "2026-02-13", + "start": "2026-02-13 08:30:00", + "soll": 0, + "ist": 0, + "pause": 30, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "08:30:00", + "end": "2026-02-13 09:00:00", + "endTime": "09:00:00", + "isDummy": 0, + "startDate": "2026-02-13" + }, + { + "date": "2026-02-13", + "surcharge": 0.0, + "startOriginalDate": "2026-02-13", + "endDate": "2026-02-13", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 120, + "uuid": "2ce9792b-3064-4032-be07-a7515388662f", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-13 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-13 11:00:00", + "department": "Operations", + "endOriginal": "2026-02-13 11:00:00", + "annotation": 8, + "endOriginalDate": "2026-02-13", + "startOriginalTime": "09:00:00", + "start": "2026-02-13 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "11:00:00", + "startDate": "2026-02-13", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "11:00:00" + }, + { + "date": "2026-02-14", + "surcharge": 0.0, + "startOriginalDate": "2026-02-14", + "endDate": "2026-02-14", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 60, + "uuid": "c94ac2e1-561f-4865-97ee-ebbcb0865207", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-14 06:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "06:00:00", + "end": "2026-02-14 07:00:00", + "department": "Operations", + "endOriginal": "2026-02-14 07:00:00", + "annotation": 8, + "endOriginalDate": "2026-02-14", + "startOriginalTime": "06:00:00", + "start": "2026-02-14 06:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "07:00:00", + "startDate": "2026-02-14", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "07:00:00" + }, + { + "date": "2026-02-14", + "surcharge": 0.0, + "startOriginalDate": "2026-02-14", + "endDate": "2026-02-14", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 600, + "uuid": "a6150f0e-15f7-419c-a3cd-23d53ede24df", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-14 07:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "07:00:00", + "end": "2026-02-14 17:00:00", + "department": "Operations", + "endOriginal": "2026-02-14 17:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-14", + "startOriginalTime": "07:00:00", + "start": "2026-02-14 07:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "17:00:00", + "startDate": "2026-02-14", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "17:00:00" + }, + { + "date": "2026-02-15", + "surcharge": 0.0, + "startOriginalDate": "2026-02-15", + "endDate": "2026-02-15", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 300, + "uuid": "26c00778-1f1b-481c-a871-1a940d8c18bd", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-15 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-15 14:00:00", + "department": "Operations", + "endOriginal": "2026-02-15 14:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-15", + "startOriginalTime": "09:00:00", + "start": "2026-02-15 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "14:00:00", + "startDate": "2026-02-15", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "14:00:00" + }, + { + "date": "2026-02-16", + "surcharge": null, + "startOriginalDate": "2026-02-16", + "endDate": "2026-02-16", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 0, + "uuid": "883a1900-0c66-42f6-9276-fa1bc0462cfc", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-16 05:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "05:00:00", + "end": "2026-02-16 05:00:00", + "department": "Operations", + "endOriginal": "2026-02-16 05:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-16", + "startOriginalTime": "05:00:00", + "start": "2026-02-16 05:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": "Ruhetag 24/25", + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "05:00:00", + "startDate": "2026-02-16", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "05:00:00" + }, + { + "date": "2026-02-16", + "annotation": -4, + "endDate": null, + "start": "2026-02-16 12:51:15", + "breakCalculationActive": true, + "soll": 0, + "ist": 0, + "pause": 0, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": "Ruhetag 24/25", + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "23:59:59", + "endTime": null, + "isDummy": 0, + "startDate": "2026-02-16" + }, + { + "date": "2026-02-17", + "surcharge": 0.0, + "startOriginalDate": "2026-02-17", + "endDate": "2026-02-17", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 300, + "uuid": "db04246b-6248-4f5f-9c0f-87197d250495", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-17 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-17 14:00:00", + "department": "Operations", + "endOriginal": "2026-02-17 14:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-17", + "startOriginalTime": "09:00:00", + "start": "2026-02-17 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "14:00:00", + "startDate": "2026-02-17", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "14:00:00" + }, + { + "date": "2026-02-17", + "annotation": -1, + "endDate": "2026-02-17", + "start": "2026-02-17 14: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": "14:00:00", + "end": "2026-02-17 17:00:00", + "endTime": "17:00:00", + "isDummy": 0, + "startDate": "2026-02-17" + }, + { + "date": "2026-02-17", + "surcharge": 0.0, + "startOriginalDate": "2026-02-17", + "endDate": "2026-02-17", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 90, + "uuid": "da3f7767-aefb-4210-93db-b95158be10c9", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-17 17:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "17:00:00", + "end": "2026-02-17 18:30:00", + "department": "Operations", + "endOriginal": "2026-02-17 18:30:00", + "annotation": 8, + "endOriginalDate": "2026-02-17", + "startOriginalTime": "17:00:00", + "start": "2026-02-17 17:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "18:30:00", + "startDate": "2026-02-17", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "18:30:00" + }, + { + "date": "2026-02-18", + "surcharge": null, + "startOriginalDate": "2026-02-18", + "endDate": "2026-02-18", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 0, + "uuid": "fb247c61-7925-4aaf-a5b1-09118944a24e", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-18 08:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "08:00:00", + "end": "2026-02-18 08:00:00", + "department": "Operations", + "endOriginal": "2026-02-18 08:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-18", + "startOriginalTime": "08:00:00", + "start": "2026-02-18 08:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "08:00:00", + "startDate": "2026-02-18", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "08:00:00" + }, + { + "date": "2026-02-18", + "annotation": -1, + "endDate": "2026-02-18", + "start": "2026-02-18 08:00:00", + "soll": 0, + "ist": 0, + "pause": 60, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "08:00:00", + "end": "2026-02-18 09:00:00", + "endTime": "09:00:00", + "isDummy": 0, + "startDate": "2026-02-18" + }, + { + "date": "2026-02-18", + "surcharge": 0.0, + "startOriginalDate": "2026-02-18", + "endDate": "2026-02-18", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 180, + "uuid": "cac27d36-5ca0-40e7-9401-ae320592f561", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-18 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-18 12:00:00", + "department": "Operations", + "endOriginal": "2026-02-18 12:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-18", + "startOriginalTime": "09:00:00", + "start": "2026-02-18 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "12:00:00", + "startDate": "2026-02-18", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "12:00:00" + }, + { + "date": "2026-02-18", + "annotation": -1, + "endDate": "2026-02-18", + "start": "2026-02-18 12: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": "12:00:00", + "end": "2026-02-18 15:00:00", + "endTime": "15:00:00", + "isDummy": 0, + "startDate": "2026-02-18" + }, + { + "date": "2026-02-18", + "surcharge": 0.0, + "startOriginalDate": "2026-02-18", + "endDate": "2026-02-18", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 30, + "uuid": "0bed19d8-2ac1-4ff9-b070-958f1c87f6ae", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-18 15:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "15:00:00", + "end": "2026-02-18 15:30:00", + "department": "Operations", + "endOriginal": "2026-02-18 15:30:00", + "annotation": 8, + "endOriginalDate": "2026-02-18", + "startOriginalTime": "15:00:00", + "start": "2026-02-18 15:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "15:30:00", + "startDate": "2026-02-18", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "15:30:00" + }, + { + "date": "2026-02-18", + "annotation": -1, + "endDate": "2026-02-18", + "start": "2026-02-18 15:30:00", + "soll": 0, + "ist": 0, + "pause": 150, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "15:30:00", + "end": "2026-02-18 18:00:00", + "endTime": "18:00:00", + "isDummy": 0, + "startDate": "2026-02-18" + }, + { + "date": "2026-02-18", + "surcharge": null, + "startOriginalDate": "2026-02-18", + "endDate": "2026-02-18", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 0, + "uuid": "d8c33323-897a-477c-bfa4-55c431b97222", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-18 18:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "18:00:00", + "end": "2026-02-18 18:00:00", + "department": "Operations", + "endOriginal": "2026-02-18 18:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-18", + "startOriginalTime": "18:00:00", + "start": "2026-02-18 18:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "18:00:00", + "startDate": "2026-02-18", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "18:00:00" + }, + { + "date": "2026-02-19", + "surcharge": 0.0, + "startOriginalDate": "2026-02-19", + "endDate": "2026-02-19", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 30, + "uuid": "906bae6c-0f82-4733-8240-c16def7cd553", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-19 08:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "08:00:00", + "end": "2026-02-19 08:30:00", + "department": "Operations", + "endOriginal": "2026-02-19 08:30:00", + "annotation": 128, + "endOriginalDate": "2026-02-19", + "startOriginalTime": "08:00:00", + "start": "2026-02-19 08:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "08:30:00", + "startDate": "2026-02-19", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "08:30:00" + }, + { + "date": "2026-02-19", + "annotation": -1, + "endDate": "2026-02-19", + "start": "2026-02-19 08:30:00", + "soll": 0, + "ist": 0, + "pause": 30, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "08:30:00", + "end": "2026-02-19 09:00:00", + "endTime": "09:00:00", + "isDummy": 0, + "startDate": "2026-02-19" + }, + { + "date": "2026-02-19", + "surcharge": 0.0, + "startOriginalDate": "2026-02-19", + "endDate": "2026-02-19", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 420, + "uuid": "e0a8fbb7-abb2-4463-90cb-e0fc2b103057", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-19 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-19 16:00:00", + "department": "Operations", + "endOriginal": "2026-02-19 16:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-19", + "startOriginalTime": "09:00:00", + "start": "2026-02-19 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "16:00:00", + "startDate": "2026-02-19", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "16:00:00" + }, + { + "date": "2026-02-20", + "surcharge": 0.0, + "startOriginalDate": "2026-02-20", + "endDate": "2026-02-20", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 600, + "uuid": "edc6e16d-db01-4b0e-a425-014c506de990", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-20 07:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "07:00:00", + "end": "2026-02-20 17:00:00", + "department": "Operations", + "endOriginal": "2026-02-20 17:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-20", + "startOriginalTime": "07:00:00", + "start": "2026-02-20 07:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "17:00:00", + "startDate": "2026-02-20", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "17:00:00" + }, + { + "date": "2026-02-20", + "annotation": -1, + "endDate": "2026-02-20", + "start": "2026-02-20 17:00:00", + "soll": 0, + "ist": 0, + "pause": 120, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "17:00:00", + "end": "2026-02-20 19:00:00", + "endTime": "19:00:00", + "isDummy": 0, + "startDate": "2026-02-20" + }, + { + "date": "2026-02-20", + "surcharge": 0.0, + "startOriginalDate": "2026-02-20", + "endDate": "2026-02-20", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 60, + "uuid": "5da25767-ba61-4b1d-838d-931e6287dbfe", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-20 19:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 32, + "startTime": "19:00:00", + "end": "2026-02-20 20:00:00", + "department": "Operations", + "endOriginal": "2026-02-20 21:00:00", + "annotation": 8, + "endOriginalDate": "2026-02-20", + "startOriginalTime": "19:00:00", + "start": "2026-02-20 19:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "20:00:00", + "startDate": "2026-02-20", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "21:00:00" + }, + { + "date": "2026-02-20", + "surcharge": 0.0, + "startOriginalDate": "2026-02-20", + "endDate": "2026-02-20", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 60, + "uuid": "6192628b-390f-4c18-a737-cb8b1992b5a7", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-20 19:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "20:00:00", + "end": "2026-02-20 21:00:00", + "department": "Operations", + "endOriginal": "2026-02-20 21:00:00", + "annotation": 4104, + "endOriginalDate": "2026-02-20", + "startOriginalTime": "19:00:00", + "start": "2026-02-20 20: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-02-20", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "21:00:00" + }, + { + "date": "2026-02-21", + "surcharge": 0.0, + "startOriginalDate": "2026-02-21", + "endDate": "2026-02-21", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 90, + "uuid": "afe48971-7f4c-4462-8538-fa4e7d42f823", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-21 07:30:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "07:30:00", + "end": "2026-02-21 09:00:00", + "department": "Operations", + "endOriginal": "2026-02-21 09:00:00", + "annotation": 8, + "endOriginalDate": "2026-02-21", + "startOriginalTime": "07:30:00", + "start": "2026-02-21 07:30:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "09:00:00", + "startDate": "2026-02-21", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "09:00:00" + }, + { + "date": "2026-02-21", + "surcharge": 0.0, + "startOriginalDate": "2026-02-21", + "endDate": "2026-02-21", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 300, + "uuid": "2acc0819-1a17-4d08-aae9-984bf521d10e", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-21 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-21 14:00:00", + "department": "Operations", + "endOriginal": "2026-02-21 14:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-21", + "startOriginalTime": "09:00:00", + "start": "2026-02-21 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "14:00:00", + "startDate": "2026-02-21", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "14:00:00" + }, + { + "date": "2026-02-22", + "surcharge": 0.0, + "startOriginalDate": "2026-02-22", + "endDate": "2026-02-22", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 43, + "uuid": "bc33a8ea-d39b-4739-884a-a30dc0a5a12b", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-22 06:17:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "06:17:00", + "end": "2026-02-22 07:00:00", + "department": "Operations", + "endOriginal": "2026-02-22 07:00:00", + "annotation": 8, + "endOriginalDate": "2026-02-22", + "startOriginalTime": "06:17:00", + "start": "2026-02-22 06:17:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "07:00:00", + "startDate": "2026-02-22", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "07:00:00" + }, + { + "date": "2026-02-22", + "surcharge": 0.0, + "startOriginalDate": "2026-02-22", + "endDate": "2026-02-22", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 600, + "uuid": "ab07b745-24ab-44a0-9157-dc0466de1265", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-22 07:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "07:00:00", + "end": "2026-02-22 17:00:00", + "department": "Operations", + "endOriginal": "2026-02-22 17:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-22", + "startOriginalTime": "07:00:00", + "start": "2026-02-22 07:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "17:00:00", + "startDate": "2026-02-22", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "17:00:00" + }, + { + "date": "2026-02-23", + "surcharge": 0.0, + "startOriginalDate": "2026-02-23", + "endDate": "2026-02-23", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 300, + "uuid": "f714f256-1d17-4a70-907b-f68496a66048", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-23 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-23 14:00:00", + "department": "Operations", + "endOriginal": "2026-02-23 14:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-23", + "startOriginalTime": "09:00:00", + "start": "2026-02-23 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "14:00:00", + "startDate": "2026-02-23", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "14:00:00" + }, + { + "date": "2026-02-24", + "surcharge": null, + "startOriginalDate": "2026-02-24", + "endDate": "2026-02-24", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 0, + "uuid": "797610b3-5544-4112-a8ae-ed8432553503", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-24 05:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "05:00:00", + "end": "2026-02-24 05:00:00", + "department": "Operations", + "endOriginal": "2026-02-24 05:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-24", + "startOriginalTime": "05:00:00", + "start": "2026-02-24 05:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": "Ruhetag 24/25", + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "05:00:00", + "startDate": "2026-02-24", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "05:00:00" + }, + { + "date": "2026-02-24", + "annotation": -4, + "endDate": null, + "start": "2026-02-24 12:51:15", + "breakCalculationActive": true, + "soll": 0, + "ist": 0, + "pause": 0, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": "Ruhetag 24/25", + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "23:59:59", + "endTime": null, + "isDummy": 0, + "startDate": "2026-02-24" + }, + { + "date": "2026-02-25", + "surcharge": 0.0, + "startOriginalDate": "2026-02-25", + "endDate": "2026-02-25", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 180, + "uuid": "a73cb575-148d-4031-86d2-d6788d980064", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-25 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-25 12:00:00", + "department": "Operations", + "endOriginal": "2026-02-25 12:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-25", + "startOriginalTime": "09:00:00", + "start": "2026-02-25 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "12:00:00", + "startDate": "2026-02-25", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "12:00:00" + }, + { + "date": "2026-02-25", + "surcharge": 0.0, + "startOriginalDate": "2026-02-25", + "endDate": "2026-02-25", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 90, + "uuid": "d77afa50-4a42-4038-90cd-9049d8e8e009", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-25 12:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "12:00:00", + "end": "2026-02-25 13:30:00", + "department": "Operations", + "endOriginal": "2026-02-25 13:30:00", + "annotation": 8, + "endOriginalDate": "2026-02-25", + "startOriginalTime": "12:00:00", + "start": "2026-02-25 12:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "13:30:00", + "startDate": "2026-02-25", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "13:30:00" + }, + { + "date": "2026-02-26", + "surcharge": 0.0, + "startOriginalDate": "2026-02-26", + "endDate": "2026-02-26", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 30, + "uuid": "435d30f6-6209-4083-b105-c4d56cf10503", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-26 08:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "08:00:00", + "end": "2026-02-26 08:30:00", + "department": "Operations", + "endOriginal": "2026-02-26 08:30:00", + "annotation": 128, + "endOriginalDate": "2026-02-26", + "startOriginalTime": "08:00:00", + "start": "2026-02-26 08:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "08:30:00", + "startDate": "2026-02-26", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "08:30:00" + }, + { + "date": "2026-02-26", + "annotation": -1, + "endDate": "2026-02-26", + "start": "2026-02-26 08:30:00", + "soll": 0, + "ist": 0, + "pause": 30, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "08:30:00", + "end": "2026-02-26 09:00:00", + "endTime": "09:00:00", + "isDummy": 0, + "startDate": "2026-02-26" + }, + { + "date": "2026-02-26", + "surcharge": 0.0, + "startOriginalDate": "2026-02-26", + "endDate": "2026-02-26", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 300, + "uuid": "6702df55-a14f-4333-93f6-a7fc1ccd050c", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-26 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-26 14:00:00", + "department": "Operations", + "endOriginal": "2026-02-26 14:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-26", + "startOriginalTime": "09:00:00", + "start": "2026-02-26 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "14:00:00", + "startDate": "2026-02-26", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "14:00:00" + }, + { + "date": "2026-02-26", + "annotation": -1, + "endDate": "2026-02-26", + "start": "2026-02-26 14:00:00", + "soll": 0, + "ist": 0, + "pause": 60, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "14:00:00", + "end": "2026-02-26 15:00:00", + "endTime": "15:00:00", + "isDummy": 0, + "startDate": "2026-02-26" + }, + { + "date": "2026-02-26", + "surcharge": 0.0, + "startOriginalDate": "2026-02-26", + "endDate": "2026-02-26", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 120, + "uuid": "3f3d9a5e-5867-46a9-8a8e-47b95fcc1365", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-26 15:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "15:00:00", + "end": "2026-02-26 17:00:00", + "department": "Operations", + "endOriginal": "2026-02-26 17:00:00", + "annotation": 8, + "endOriginalDate": "2026-02-26", + "startOriginalTime": "15:00:00", + "start": "2026-02-26 15:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "17:00:00", + "startDate": "2026-02-26", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "17:00:00" + }, + { + "date": "2026-02-26", + "annotation": -1, + "endDate": "2026-02-26", + "start": "2026-02-26 17:00:00", + "soll": 0, + "ist": 0, + "pause": 60, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "17:00:00", + "end": "2026-02-26 18:00:00", + "endTime": "18:00:00", + "isDummy": 0, + "startDate": "2026-02-26" + }, + { + "date": "2026-02-26", + "surcharge": null, + "startOriginalDate": "2026-02-26", + "endDate": "2026-02-26", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 0, + "uuid": "702cad09-b149-4d82-99cc-fe874782cc57", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-26 18:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "18:00:00", + "end": "2026-02-26 18:00:00", + "department": "Operations", + "endOriginal": "2026-02-26 18:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-26", + "startOriginalTime": "18:00:00", + "start": "2026-02-26 18:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "18:00:00", + "startDate": "2026-02-26", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "18:00:00" + }, + { + "date": "2026-02-27", + "surcharge": 0.0, + "startOriginalDate": "2026-02-27", + "endDate": "2026-02-27", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 300, + "uuid": "cf27e36c-f6cf-413a-9c2d-8a4a0f6ead7d", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-27 07:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "07:00:00", + "end": "2026-02-27 12:00:00", + "department": "Operations", + "endOriginal": "2026-02-27 12:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-27", + "startOriginalTime": "07:00:00", + "start": "2026-02-27 07:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "12:00:00", + "startDate": "2026-02-27", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "12:00:00" + }, + { + "date": "2026-02-27", + "annotation": -1, + "endDate": "2026-02-27", + "start": "2026-02-27 12: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": "12:00:00", + "end": "2026-02-27 15:00:00", + "endTime": "15:00:00", + "isDummy": 0, + "startDate": "2026-02-27" + }, + { + "date": "2026-02-27", + "surcharge": 0.0, + "startOriginalDate": "2026-02-27", + "endDate": "2026-02-27", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 120, + "uuid": "fc61a852-26af-45f2-957d-be5835be36ab", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-27 15:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "15:00:00", + "end": "2026-02-27 17:00:00", + "department": "Operations", + "endOriginal": "2026-02-27 17:00:00", + "annotation": 8, + "endOriginalDate": "2026-02-27", + "startOriginalTime": "15:00:00", + "start": "2026-02-27 15:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "17:00:00", + "startDate": "2026-02-27", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "17:00:00" + }, + { + "date": "2026-02-27", + "surcharge": null, + "startOriginalDate": "2026-02-27", + "endDate": "2026-02-27", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 0, + "uuid": "81f4e389-a8c3-467c-8ed0-2d27f9ca1b21", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-27 17:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "17:00:00", + "end": "2026-02-27 17:00:00", + "department": "Operations", + "endOriginal": "2026-02-27 17:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-27", + "startOriginalTime": "17:00:00", + "start": "2026-02-27 17:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "17:00:00", + "startDate": "2026-02-27", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "17:00:00" + }, + { + "date": "2026-02-28", + "surcharge": 0.0, + "startOriginalDate": "2026-02-28", + "endDate": "2026-02-28", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 600, + "uuid": "deec7bc1-603e-4a99-bef5-df181afe5bba", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-28 07:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "07:00:00", + "end": "2026-02-28 17:00:00", + "department": "Operations", + "endOriginal": "2026-02-28 17:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-28", + "startOriginalTime": "07:00:00", + "start": "2026-02-28 07:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "17:00:00", + "startDate": "2026-02-28", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "17:00:00" + }, + { + "date": "2026-02-28", + "annotation": -1, + "endDate": "2026-02-28", + "start": "2026-02-28 17:00:00", + "soll": 0, + "ist": 0, + "pause": 60, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "17:00:00", + "end": "2026-02-28 18:00:00", + "endTime": "18:00:00", + "isDummy": 0, + "startDate": "2026-02-28" + }, + { + "date": "2026-02-28", + "surcharge": 0.0, + "startOriginalDate": "2026-02-28", + "endDate": "2026-02-28", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 60, + "uuid": "3d5cdff9-453d-4905-9e82-ec92fb712197", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-28 18:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 32, + "startTime": "18:00:00", + "end": "2026-02-28 19:00:00", + "department": "Operations", + "endOriginal": "2026-02-28 21:00:00", + "annotation": 8, + "endOriginalDate": "2026-02-28", + "startOriginalTime": "18:00:00", + "start": "2026-02-28 18:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "19:00:00", + "startDate": "2026-02-28", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "21:00:00" + }, + { + "date": "2026-02-28", + "surcharge": 0.0, + "startOriginalDate": "2026-02-28", + "endDate": "2026-02-28", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 120, + "uuid": "4e9aa02d-7a1b-4780-8e20-d9e11074d220", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-28 18:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "19:00:00", + "end": "2026-02-28 21:00:00", + "department": "Operations", + "endOriginal": "2026-02-28 21:00:00", + "annotation": 4104, + "endOriginalDate": "2026-02-28", + "startOriginalTime": "18:00:00", + "start": "2026-02-28 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-02-28", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "21:00:00" + }, + { + "date": "2026-02-29", + "surcharge": 0.0, + "startOriginalDate": "2026-02-29", + "endDate": "2026-02-29", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 210, + "uuid": "791f8b56-b301-41c7-9f66-088b92a5228d", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-29 06:30:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "06:30:00", + "end": "2026-02-29 10:00:00", + "department": "Operations", + "endOriginal": "2026-02-29 10:00:00", + "annotation": 8, + "endOriginalDate": "2026-02-29", + "startOriginalTime": "06:30:00", + "start": "2026-02-29 06:30:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "10:00:00", + "startDate": "2026-02-29", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "10:00:00" + }, + { + "date": "2026-02-29", + "annotation": -1, + "endDate": "2026-02-29", + "start": "2026-02-29 10:00:00", + "soll": 0, + "ist": 0, + "pause": 60, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "10:00:00", + "end": "2026-02-29 11:00:00", + "endTime": "11:00:00", + "isDummy": 0, + "startDate": "2026-02-29" + }, + { + "date": "2026-02-29", + "surcharge": 0.0, + "startOriginalDate": "2026-02-29", + "endDate": "2026-02-29", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 120, + "uuid": "399f34f6-8c50-4f5b-8033-925d88ab2217", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-29 11:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "11:00:00", + "end": "2026-02-29 13:00:00", + "department": "Operations", + "endOriginal": "2026-02-29 13:00:00", + "annotation": 8, + "endOriginalDate": "2026-02-29", + "startOriginalTime": "11:00:00", + "start": "2026-02-29 11:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "13:00:00", + "startDate": "2026-02-29", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "13:00:00" + }, + { + "date": "2026-02-29", + "surcharge": 0.0, + "startOriginalDate": "2026-02-29", + "endDate": "2026-02-29", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 20, + "uuid": "d6077dd7-41bf-42ce-a9c2-682a6ca84839", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-29 13:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "13:00:00", + "end": "2026-02-29 13:20:00", + "department": "Operations", + "endOriginal": "2026-02-29 13:20:00", + "annotation": 8, + "endOriginalDate": "2026-02-29", + "startOriginalTime": "13:00:00", + "start": "2026-02-29 13:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "13:20:00", + "startDate": "2026-02-29", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "13:20:00" + }, + { + "date": "2026-02-29", + "surcharge": 0.0, + "startOriginalDate": "2026-02-29", + "endDate": "2026-02-29", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 40, + "uuid": "d5a8db05-b8ce-44c6-aa8d-f11fd28793a3", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-29 13:20:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "13:20:00", + "end": "2026-02-29 14:00:00", + "department": "Operations", + "endOriginal": "2026-02-29 14:00:00", + "annotation": 8, + "endOriginalDate": "2026-02-29", + "startOriginalTime": "13:20:00", + "start": "2026-02-29 13:20:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "14:00:00", + "startDate": "2026-02-29", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "14:00:00" + }, + { + "date": "2026-02-29", + "annotation": -1, + "endDate": "2026-02-29", + "start": "2026-02-29 14:00:00", + "soll": 0, + "ist": 0, + "pause": 240, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "14:00:00", + "end": "2026-02-29 18:00:00", + "endTime": "18:00:00", + "isDummy": 0, + "startDate": "2026-02-29" + }, + { + "date": "2026-02-29", + "surcharge": 0.0, + "startOriginalDate": "2026-02-29", + "endDate": "2026-02-29", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 30, + "uuid": "f135b2c9-38e1-4ce8-b1bf-593be0847643", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-29 18:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "18:00:00", + "end": "2026-02-29 18:30:00", + "department": "Operations", + "endOriginal": "2026-02-29 18:30:00", + "annotation": 8, + "endOriginalDate": "2026-02-29", + "startOriginalTime": "18:00:00", + "start": "2026-02-29 18:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "18:30:00", + "startDate": "2026-02-29", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "18:30:00" + }, + { + "date": "2026-02-29", + "annotation": -1, + "endDate": "2026-02-29", + "start": "2026-02-29 18:30:00", + "soll": 0, + "ist": 0, + "pause": 150, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "18:30:00", + "end": "2026-02-29 21:00:00", + "endTime": "21:00:00", + "isDummy": 0, + "startDate": "2026-02-29" + }, + { + "date": "2026-02-29", + "surcharge": 0.0, + "startOriginalDate": "2026-02-29", + "endDate": "2026-02-29", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 30, + "uuid": "6e553a5a-1501-4b5f-872b-a11804caee0e", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-29 21:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "21:00:00", + "end": "2026-02-29 21:30:00", + "department": "Operations", + "endOriginal": "2026-02-29 21:30:00", + "annotation": 8, + "endOriginalDate": "2026-02-29", + "startOriginalTime": "21:00:00", + "start": "2026-02-29 21:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "21:30:00", + "startDate": "2026-02-29", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "21:30:00" + }, + { + "date": "2026-02-30", + "surcharge": 0.0, + "startOriginalDate": "2026-02-30", + "endDate": "2026-02-30", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 30, + "uuid": "073f924a-c191-4cab-9f2f-0a60bed338cf", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-30 08:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "08:00:00", + "end": "2026-02-30 08:30:00", + "department": "Operations", + "endOriginal": "2026-02-30 08:30:00", + "annotation": 128, + "endOriginalDate": "2026-02-30", + "startOriginalTime": "08:00:00", + "start": "2026-02-30 08:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "08:30:00", + "startDate": "2026-02-30", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "08:30:00" + }, + { + "date": "2026-02-30", + "surcharge": 0.0, + "startOriginalDate": "2026-02-30", + "endDate": "2026-02-30", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 390, + "uuid": "da14df01-f231-4ab3-95ae-f0f0824c77c9", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-30 08:30:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "08:30:00", + "end": "2026-02-30 15:00:00", + "department": "Operations", + "endOriginal": "2026-02-30 15:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-30", + "startOriginalTime": "08:30:00", + "start": "2026-02-30 08:30:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "15:00:00", + "startDate": "2026-02-30", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "15:00:00" + }, + { + "date": "2026-02-30", + "annotation": -1, + "endDate": "2026-02-30", + "start": "2026-02-30 15:00:00", + "soll": 0, + "ist": 0, + "pause": 60, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "15:00:00", + "end": "2026-02-30 16:00:00", + "endTime": "16:00:00", + "isDummy": 0, + "startDate": "2026-02-30" + }, + { + "date": "2026-02-30", + "surcharge": 0.0, + "startOriginalDate": "2026-02-30", + "endDate": "2026-02-30", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 240, + "uuid": "ae083d89-59ee-41e2-97a4-4829a31d5057", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-30 16:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "16:00:00", + "end": "2026-02-30 20:00:00", + "department": "Operations", + "endOriginal": "2026-02-30 20:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-30", + "startOriginalTime": "16:00:00", + "start": "2026-02-30 16:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "20:00:00", + "startDate": "2026-02-30", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "20:00:00" + }, + { + "date": "2026-02-31", + "surcharge": 0.0, + "startOriginalDate": "2026-02-31", + "endDate": "2026-02-31", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 30, + "uuid": "b8bd7f43-a44c-42b7-84de-ab0b3ef65c25", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-31 08:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "08:00:00", + "end": "2026-02-31 08:30:00", + "department": "Operations", + "endOriginal": "2026-02-31 08:30:00", + "annotation": 128, + "endOriginalDate": "2026-02-31", + "startOriginalTime": "08:00:00", + "start": "2026-02-31 08:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "08:30:00", + "startDate": "2026-02-31", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "08:30:00" + }, + { + "date": "2026-02-31", + "annotation": -1, + "endDate": "2026-02-31", + "start": "2026-02-31 08:30:00", + "soll": 0, + "ist": 0, + "pause": 30, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "08:30:00", + "end": "2026-02-31 09:00:00", + "endTime": "09:00:00", + "isDummy": 0, + "startDate": "2026-02-31" + }, + { + "date": "2026-02-31", + "surcharge": 0.0, + "startOriginalDate": "2026-02-31", + "endDate": "2026-02-31", + "platformStart": "Manuell", + "breakCalculationActive": true, + "ist": 180, + "uuid": "f10bdd77-7f47-404f-b543-f427d22b0ed6", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-31 09:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "09:00:00", + "end": "2026-02-31 12:00:00", + "department": "Operations", + "endOriginal": "2026-02-31 12:00:00", + "annotation": 8, + "endOriginalDate": "2026-02-31", + "startOriginalTime": "09:00:00", + "start": "2026-02-31 09:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "Manuell", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "12:00:00", + "startDate": "2026-02-31", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "12:00:00" + }, + { + "date": "2026-02-31", + "annotation": -1, + "endDate": "2026-02-31", + "start": "2026-02-31 12:00:00", + "soll": 0, + "ist": 0, + "pause": 90, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "12:00:00", + "end": "2026-02-31 13:30:00", + "endTime": "13:30:00", + "isDummy": 0, + "startDate": "2026-02-31" + }, + { + "date": "2026-02-31", + "surcharge": 0.0, + "startOriginalDate": "2026-02-31", + "endDate": "2026-02-31", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 30, + "uuid": "c81a9c8f-8aa2-4072-b2c7-05f5314c53ee", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-31 13:30:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "13:30:00", + "end": "2026-02-31 14:00:00", + "department": "Operations", + "endOriginal": "2026-02-31 17:00:00", + "annotation": 128, + "endOriginalDate": "2026-02-31", + "startOriginalTime": "13:30:00", + "start": "2026-02-31 13:30:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "14:00:00", + "startDate": "2026-02-31", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "17:00:00" + }, + { + "date": "2026-02-31", + "surcharge": 0.0, + "startOriginalDate": "2026-02-31", + "endDate": "2026-02-31", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 180, + "uuid": "579ded52-35e6-4203-a609-9ca93a9e0450", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-31 13:30:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "14:00:00", + "end": "2026-02-31 17:00:00", + "department": "Operations", + "endOriginal": null, + "annotation": 128, + "startOriginalTime": "13:30:00", + "start": "2026-02-31 14:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "17:00:00", + "startDate": "2026-02-31", + "isDummy": 0, + "status": "ACCEPTED" + }, + { + "date": "2026-02-31", + "annotation": -1, + "endDate": "2026-02-31", + "start": "2026-02-31 17:00:00", + "soll": 0, + "ist": 0, + "pause": 60, + "surchargeMinutes": 0, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "holidayType": null, + "sollType": "MONTH", + "pausemin": 0, + "warning": 0, + "startTime": "17:00:00", + "end": "2026-02-31 18:00:00", + "endTime": "18:00:00", + "isDummy": 0, + "startDate": "2026-02-31" + }, + { + "date": "2026-02-31", + "surcharge": 0.0, + "startOriginalDate": "2026-02-31", + "endDate": "2026-02-31", + "platformStart": "System", + "breakCalculationActive": true, + "ist": 30, + "uuid": "5cbea507-4b82-4424-9ba7-d8129ffb2ef3", + "hasSurcharge": false, + "employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91", + "startOriginal": "2026-02-31 18:00:00", + "responsibility": null, + "sollType": "MONTH", + "warning": 0, + "startTime": "18:00:00", + "end": "2026-02-31 18:30:00", + "department": "Operations", + "endOriginal": "2026-02-31 18:30:00", + "annotation": 128, + "endOriginalDate": "2026-02-31", + "startOriginalTime": "18:00:00", + "start": "2026-02-31 18:00:00", + "soll": 0, + "pause": 0, + "surchargeMinutes": 0, + "transfer": null, + "holidayType": null, + "platformEnd": "System", + "pausemin": 0, + "kilometer": 0.0, + "endTime": "18:30:00", + "startDate": "2026-02-31", + "isDummy": 0, + "status": "ACCEPTED", + "endOriginalTime": "18:30:00" + } + ], + "overtimeAccountMonth": 7771, + "saldo2": 1848, + "onWork": 31, + "unpaidOvertime": 0, + "month": 2, + "OVERTIMEACCOUNT_PAYOFF": 0, + "overtimeAccount": 5929 +} diff --git a/tests/Resources/saas_time_account_september.json b/tests/Resources/saas_time_account_september.json new file mode 100644 index 0000000..6abc008 --- /dev/null +++ b/tests/Resources/saas_time_account_september.json @@ -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 +} diff --git a/tests/Saas/ApiClientTest.php b/tests/Saas/ApiClientTest.php new file mode 100644 index 0000000..a18f7ae --- /dev/null +++ b/tests/Saas/ApiClientTest.php @@ -0,0 +1,131 @@ +createClient(function (string $method, string $url) use (&$requested) { + $requested = $url; + + return new MockResponse('{"ist": 0, "list": []}', ['response_headers' => ['content-type' => 'application/json']]); + }); + + $client->getTimeAccount('theo@example.com', 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 b+c@example.com', 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('theo@example.com', 2026, 9), + ); + } + + public function testAnErrorStatusBecomesAnApiClientException(): void + { + $client = $this->createClient(fn () => new MockResponse('nope', ['http_code' => 500])); + + $this->expectException(ApiClientException::class); + $this->expectExceptionMessageMatches('/theo@example.com \(2026-03\)/'); + + $client->getTimeAccount('theo@example.com', 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('theo@example.com', 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('theo@example.com', 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('theo@example.com', 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'], + ); + } +} diff --git a/tests/Saas/EmployeeDirectoryTest.php b/tests/Saas/EmployeeDirectoryTest.php new file mode 100644 index 0000000..fab77b0 --- /dev/null +++ b/tests/Saas/EmployeeDirectoryTest.php @@ -0,0 +1,128 @@ +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('theo.test@example.com'); + + $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('ex@example.com')->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('THEO.TEST@example.com')); + $this->assertTrue($directory->has(' theo.test@example.com ')); + $this->assertFalse($directory->has('nobody@example.com')); + $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('theo.test@example.com')->getDepartment()); + $this->assertTrue($restored->find('ex@example.com')->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' => 'rechnungen@example.com', + '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' => 'theo.test@example.com', + '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' => 'ex@example.com', + 'dateOfSeparation' => '2025-05-31', + 'uuid' => 'cccccccc-0000-0000-0000-000000000000', + 'apiKey' => 's3cr3t-ex', + ], + ]; + } +} diff --git a/tests/Saas/ResponseParserTest.php b/tests/Saas/ResponseParserTest.php new file mode 100644 index 0000000..1758078 --- /dev/null +++ b/tests/Saas/ResponseParserTest.php @@ -0,0 +1,336 @@ +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); + } +} diff --git a/tests/Service/Cron/WorkMonthReminderServiceTest.php b/tests/Service/Cron/WorkMonthReminderServiceTest.php new file mode 100644 index 0000000..3be3dc7 --- /dev/null +++ b/tests/Service/Cron/WorkMonthReminderServiceTest.php @@ -0,0 +1,127 @@ +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('theo@example.com', $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 = 'theo@example.com'): 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) + ; + } +} diff --git a/tests/Service/Pdf/WorkMonthRendererTest.php b/tests/Service/Pdf/WorkMonthRendererTest.php new file mode 100644 index 0000000..dc7ac52 --- /dev/null +++ b/tests/Service/Pdf/WorkMonthRendererTest.php @@ -0,0 +1,143 @@ +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'); + } +} diff --git a/tests/Service/Saas/WorkHoursSynchronizerTest.php b/tests/Service/Saas/WorkHoursSynchronizerTest.php new file mode 100644 index 0000000..edc13a7 --- /dev/null +++ b/tests/Service/Saas/WorkHoursSynchronizerTest.php @@ -0,0 +1,339 @@ +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('theo@example.com', '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('someone.else@example.com', '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 = 'theo@example.com'): 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) + ; + } +}