feat: import work hours from SaaS and exchange signed time sheet

This commit is contained in:
fromme committed 2026-09-26 12:13:59 +02:00
1 parent 7cf39850e6
commit 1a7c272414
73 files changed
+10488 -72

No files matched your search

+6
View File
@@ -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,
+1
View File
@@ -19,6 +19,7 @@
"doctrine/doctrine-bundle": "^2.15",
"doctrine/doctrine-migrations-bundle": "^3.2",
"doctrine/orm": "^3.6",
"dompdf/dompdf": "^3.1",
"flagception/flagception-bundle": "^6.0",
"knplabs/knp-menu-bundle": "^3.2",
"knplabs/knp-paginator-bundle": "^6.2",
Generated
+301 -68
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "e4ae4ad86c4c01e26d770c2ab3e0fda4",
"content-hash": "3c22c1470cd1883d7c10f2e9800431e0",
"packages": [
{
"name": "beberlei/doctrineextensions",
@@ -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": "[email protected]"
},
{
"name": "Matt Farina",
"email": "[email protected]"
},
{
"name": "Asmir Mustafic",
"email": "[email protected]"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.11.0"
},
"time": "2026-08-18T06:18:41+00:00"
},
{
"name": "monolog/monolog",
"version": "3.12.0",
@@ -4920,6 +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": "[email protected]"
},
{
"name": "Jake Hotson",
"email": "[email protected]"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.5.0"
},
"time": "2026-09-20T15:02:00+00:00"
},
{
"name": "scienta/doctrine-json-functions",
"version": "6.5.0",
@@ -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": "[email protected]"
},
{
"name": "Matt Farina",
"email": "[email protected]"
},
{
"name": "Asmir Mustafic",
"email": "[email protected]"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.11.0"
},
"time": "2026-08-18T06:18:41+00:00"
},
{
"name": "myclabs/deep-copy",
"version": "1.14.0",
+27
View File
@@ -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"'
+17 -4
View File
@@ -2,6 +2,7 @@ monolog:
channels:
- deprecation
- bpn
- saas
- audit
- api
@@ -12,7 +13,7 @@ monolog:
handler: exceptions
excluded_http_codes: [404, 405]
buffer_size: 50
channels: ["!bpn", "!audit", "!api"]
channels: ["!bpn", "!saas", "!audit", "!api"]
exceptions:
type: rotating_file
path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
@@ -24,6 +25,12 @@ monolog:
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
max_files: 5
level: info
saas:
channels: ["saas"]
type: rotating_file
path: '%kernel.logs_dir%/saas.%kernel.environment%.log'
max_files: 5
level: info
audit:
channels: [ "audit" ]
type: rotating_file
@@ -45,7 +52,7 @@ monolog:
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!bpn", "!api", "!doctrine", "!console"]
channels: ["!event", "!bpn", "!saas", "!api", "!doctrine", "!console"]
when@dev:
monolog:
@@ -82,7 +89,7 @@ when@prod:
handler: exceptions
excluded_http_codes: [404, 405]
buffer_size: 50
channels: ["!bpn", "!audit", "!api"]
channels: ["!bpn", "!saas", "!audit", "!api"]
exceptions:
type: rotating_file
path: "%kernel.logs_dir%/framework.%kernel.environment%.log"
@@ -94,6 +101,12 @@ when@prod:
path: '%kernel.logs_dir%/bpn.%kernel.environment%.log'
max_files: 5
level: info
saas:
channels: ["saas"]
type: rotating_file
path: '%kernel.logs_dir%/saas.%kernel.environment%.log'
max_files: 5
level: info
audit:
channels: [ "audit" ]
type: rotating_file
@@ -113,4 +126,4 @@ when@prod:
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!bpn", "!audit", "!api"]
channels: ["!event", "!doctrine", "!bpn", "!saas", "!audit", "!api"]
+8
View File
@@ -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:
+8
View File
@@ -27,6 +27,10 @@ when@prod:
frequency: '30 1 * * *'
description: "Updates status of teamers depending on their disposition"
- task: app:saas:work-hours
frequency: '0 4 10 * *'
description: "Imports last month's working hours of season teamers from SaaS"
- task: oneup:uploader:clear-orphans
frequency: '30 * * * *'
description: "Removes orphaned uploads"
@@ -53,6 +57,10 @@ when@staging:
frequency: '30 1 * * *'
description: "Updates status of teamers depending on their disposition"
- task: app:saas:work-hours
frequency: '0 4 10 * *'
description: "Imports last month's working hours of season teamers from SaaS"
- task: oneup:uploader:clear-orphans
frequency: '30 * * * *'
description: "Removes orphaned uploads"
+28
View File
@@ -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:
+1
View File
@@ -46,6 +46,7 @@ $rsyncOptions = [
'package.json',
'package-lock.json',
'*.http',
'http-client*.json',
'postcss.config.js',
'phpunit.xml.dist',
'public/.htaccess',
+5
View File
@@ -0,0 +1,5 @@
{
"prod": {
"saas_base_url": "https://desktop.saas.de/rest"
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Work months imported from the SaaS time tracking, and the signed Arbeitszeitnachweis that
* belongs to each of them.
*
* Hand-trimmed: doctrine:migrations:diff also produced a long tail of datetime, JSON and
* TINYINT redeclarations across almost every table. That is the long-standing drift between
* the dev database and the mapping, not anything this feature changed, so none of it is here.
*/
final class Version20260921122743 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds teamer_work_month, the SaaS employee id on teamer and the work month relation on upload';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE teamer_work_month (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, year SMALLINT NOT NULL, month SMALLINT NOT NULL, days JSON NOT NULL, total_minutes INT NOT NULL, worked_days INT NOT NULL, absence_days INT NOT NULL, source_ist_minutes INT NOT NULL, status VARCHAR(255) NOT NULL, submitted_at DATETIME DEFAULT NULL, reviewed_at DATETIME DEFAULT NULL, reviewed_by VARCHAR(8) DEFAULT NULL, review_comment LONGTEXT DEFAULT NULL, synced_at DATETIME NOT NULL, warnings JSON DEFAULT NULL, mismatch_detected_at DATETIME DEFAULT NULL, mismatch_total_minutes INT DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, teamer_id INT NOT NULL, UNIQUE INDEX UNIQ_28857572D17F50A6 (uuid), UNIQUE INDEX teamer_work_month_unique (teamer_id, year, month), INDEX IDX_288575724302FF75 (teamer_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE teamer_work_month ADD CONSTRAINT FK_288575724302FF75 FOREIGN KEY (teamer_id) REFERENCES teamer (id)');
$this->addSql('ALTER TABLE teamer ADD saas_employee_uuid VARCHAR(36) DEFAULT NULL');
$this->addSql('ALTER TABLE upload ADD teamer_work_month_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE upload ADD CONSTRAINT FK_17BDE61FFEAE833A FOREIGN KEY (teamer_work_month_id) REFERENCES teamer_work_month (id)');
$this->addSql('CREATE INDEX IDX_17BDE61FFEAE833A ON upload (teamer_work_month_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE upload DROP FOREIGN KEY FK_17BDE61FFEAE833A');
$this->addSql('DROP INDEX IDX_17BDE61FFEAE833A ON upload');
$this->addSql('ALTER TABLE upload DROP teamer_work_month_id');
$this->addSql('ALTER TABLE teamer DROP saas_employee_uuid');
$this->addSql('ALTER TABLE teamer_work_month DROP FOREIGN KEY FK_288575724302FF75');
$this->addSql('DROP TABLE teamer_work_month');
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The monthly target and the month's total break time, both shown in the head of the
* Arbeitszeitnachweis next to the hours actually worked.
*
* Hand-trimmed, like Version20260921122743: doctrine:migrations:diff sweeps in the
* long-standing datetime and JSON drift of the dev database, none of which belongs here.
*
* The per-day start, end and break times that go with these live in the existing "days" JSON
* column, so they need no schema change. Rows imported before this migration simply carry
* neither, and are refreshed by the next import of a month that is not yet signed.
*/
final class Version20260921160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds the monthly target and total break time to teamer_work_month';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE teamer_work_month ADD target_minutes INT DEFAULT 0 NOT NULL, ADD break_minutes INT DEFAULT 0 NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE teamer_work_month DROP target_minutes, DROP break_minutes');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The employee's department, shown in the head of the Arbeitszeitnachweis.
*
* Hand-trimmed like its two predecessors: doctrine:migrations:diff sweeps in the long-standing
* datetime and JSON drift of the dev database, none of which belongs here.
*
* Nullable, and not only because existing rows have none: a month made up entirely of holiday
* carries no booking to read a department from.
*/
final class Version20260921170000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds the department to teamer_work_month';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE teamer_work_month ADD department VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE teamer_work_month DROP department');
}
}
+9
View File
@@ -0,0 +1,9 @@
### GET all employees
GET {{saas_base_url}}/api/v2/employees/{{saas_api_key}}
### GET all departments
GET {{saas_base_url}}/api/v2/employee/departments/{{saas_api_key}}
### GET single time account
GET {{saas_base_url}}/api/v2/time/account/{{saas_api_key}}/{{saas_employee_email}}/2026/2
+5
View File
@@ -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;
}
}
+228
View File
@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Teamer;
use App\Enum\WorkHoursSyncOutcome;
use App\Model\WorkHoursSyncResult;
use App\Service\Saas\WorkHoursSynchronizer;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:saas:work-hours',
description: "Imports season teamers' working hours of one month from the SaaS",
)]
class SaasWorkHoursCommand extends Command
{
public function __construct(
private readonly WorkHoursSynchronizer $synchronizer,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument(
'month',
InputArgument::OPTIONAL,
'The month to import as YYYY-MM. Defaults to the previous month, which is what the schedule wants.',
)
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report what would be written without writing it')
->addOption(
'teamer',
null,
InputOption::VALUE_REQUIRED,
'Limit the run to one teamer, by uuid or email address. Use this to try the API out.',
)
->addOption(
'limit',
null,
InputOption::VALUE_REQUIRED,
'Stop after this many teamers, in the order the list comes back',
)
;
}
/**
* Scheduled for the 10th of each month, importing the month before.
*
* A teamer that cannot be imported does not stop the others: only genuine failures
* (the request or the payload) make the command fail, because the scheduler mails on a
* non-zero exit and an incomplete profile would otherwise mail every month forever.
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
try {
[$year, $month] = $this->resolveMonth($input->getArgument('month'));
} catch (\InvalidArgumentException $e) {
$io->error($e->getMessage());
return Command::INVALID;
}
$io->title(sprintf('Arbeitszeiten %04d-%02d', $year, $month));
if ($dryRun) {
$io->note('Dry run - nothing is written.');
}
$teamers = $this->synchronizer->findEligibleTeamers($year, $month);
if (null !== $only = $input->getOption('teamer')) {
$teamers = $this->onlyThisTeamer($teamers, (string) $only);
if ([] === $teamers) {
$io->error(sprintf('"%s" matches no season teamer placed in this month.', $only));
return Command::INVALID;
}
}
if (null !== $limit = $input->getOption('limit')) {
$teamers = array_slice($teamers, 0, max(1, (int) $limit));
}
if ([] === $teamers) {
$io->warning('No season teamer is placed in this month.');
return Command::SUCCESS;
}
$io->writeln(sprintf('%d teamer(s) to fetch, one request each.', count($teamers)));
$io->progressStart(count($teamers));
/** @var WorkHoursSyncResult[] $results */
$results = [];
foreach ($teamers as $teamer) {
$results[] = $this->synchronizer->syncTeamer($teamer, $year, $month, $dryRun);
$io->progressAdvance();
}
$io->progressFinish();
$this->report($io, $results);
return $this->hasFailures($results) ? Command::FAILURE : Command::SUCCESS;
}
/**
* @param WorkHoursSyncResult[] $results
*/
private function report(SymfonyStyle $io, array $results): void
{
$rows = [];
foreach ($results as $result) {
if (WorkHoursSyncOutcome::IMPORTED === $result->getOutcome() && [] === $result->getWarnings()) {
continue;
}
$rows[] = [
$result->getTeamer()->getFullName(),
$result->getOutcome()->value,
$result->getMessage() ?? implode(' | ', $result->getWarnings()),
];
}
if ([] !== $rows) {
$io->section('Needs a look');
$io->table(['Teamer', 'Outcome', 'Detail'], $rows);
}
$counts = array_count_values(array_map(
static fn (WorkHoursSyncResult $result) => $result->getOutcome()->value,
$results,
));
$summary = [];
foreach (WorkHoursSyncOutcome::cases() as $outcome) {
$summary[] = sprintf('%s: %d', $outcome->value, $counts[$outcome->value] ?? 0);
}
$minutes = array_sum(array_map(
static fn (WorkHoursSyncResult $result) => WorkHoursSyncOutcome::IMPORTED === $result->getOutcome()
? (int) $result->getTotalMinutes()
: 0,
$results,
));
$io->writeln(implode(' ', $summary));
$io->writeln(sprintf('Imported %d minutes in total.', $minutes));
if ($this->hasFailures($results)) {
$io->error('At least one teamer could not be imported.');
return;
}
$io->success(sprintf('%d teamer(s) processed.', count($results)));
}
/**
* @param Teamer[] $teamers
*
* @return Teamer[]
*/
private function onlyThisTeamer(array $teamers, string $needle): array
{
$needle = strtolower(trim($needle));
return array_values(array_filter($teamers, static function (Teamer $teamer) use ($needle) {
return strtolower($teamer->getUuid()) === $needle
|| strtolower((string) $teamer->getCommunication()?->getEmail()) === $needle;
}));
}
/**
* @param WorkHoursSyncResult[] $results
*/
private function hasFailures(array $results): bool
{
foreach ($results as $result) {
if ($result->isFailure()) {
return true;
}
}
return false;
}
/**
* @return array{int, int}
*/
private function resolveMonth(?string $argument): array
{
if (null === $argument) {
$previous = (new \DateTimeImmutable('first day of this month'))->modify('-1 month');
return [(int) $previous->format('Y'), (int) $previous->format('n')];
}
if (1 !== preg_match('/^(\d{4})-(\d{2})$/', $argument, $matches)) {
throw new \InvalidArgumentException(sprintf('"%s" is not a month; expected YYYY-MM.', $argument));
}
$month = (int) $matches[2];
if ($month < 1 || $month > 12) {
throw new \InvalidArgumentException(sprintf('"%s" is not a month; expected YYYY-MM.', $argument));
}
return [(int) $matches[1], $month];
}
}
+2
View File
@@ -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';
}
@@ -0,0 +1,47 @@
<?php
namespace App\Controller\Admin\WorkMonth;
use App\Repository\TeamerWorkMonthRepository;
use App\Service\Common\WorkMonthFilterHandler;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Every imported work month with the state of its Arbeitszeitnachweis.
*
* Deliberately not the existing document list: UploadRepository::getUploadListQuery() inner
* joins a disposition, so a document hanging off a month can never appear there, and it is
* the month rather than the file that carries the status anyway.
*/
class IndexController extends AbstractController
{
public function __construct(
private readonly TeamerWorkMonthRepository $workMonthRepository,
private readonly WorkMonthFilterHandler $filterHandler,
private readonly PaginatorInterface $paginator,
) {
}
#[Route('/admin/work-month', name: 'app_admin_work_month_index')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$filterDto = $this->filterHandler->getFilterSettings();
$pagination = $this->paginator->paginate(
$this->workMonthRepository->getListQuery($filterDto),
$request->query->getInt('page', 1),
50,
);
return $this->render('admin/work_month/index.html.twig', [
'pagination' => $pagination,
'filterDto' => $filterDto,
]);
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Controller\Admin\WorkMonth;
use App\Entity\TeamerWorkMonth;
use App\Service\Pdf\WorkMonthRenderer;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The same sheet the teamer gets, for the office.
*
* This is what a month's hours look like in full: there is no separate day-by-day screen,
* because it would only restate the document that actually gets signed.
*/
class PdfController extends AbstractController
{
public function __construct(
private readonly WorkMonthRenderer $renderer,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/work-month/pdf/{uuid}', name: 'app_admin_work_month_pdf')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(TeamerWorkMonth $workMonth): Response
{
$this->logger->info('Download work month pdf', [
'work_month' => $workMonth->getUuid(),
'teamer' => $workMonth->getTeamer()?->getFullName(),
]);
$response = new Response($this->renderer->render($workMonth));
$response->headers->set('Content-Type', 'application/pdf');
$response->headers->set('Content-Disposition', $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
$this->renderer->filename($workMonth),
));
return $response;
}
}
@@ -0,0 +1,117 @@
<?php
namespace App\Controller\Admin\WorkMonth;
use App\Config\EmailTextKey;
use App\Controller\Traits\ReturnUrlTrait;
use App\Email\Mailer;
use App\Entity\TeamerWorkMonth;
use App\Entity\Upload;
use App\Entity\User;
use App\Enum\WorkMonthStatus;
use App\Form\WorkMonthReviewType;
use App\Htmx\HxRedirectResponse;
use App\Model\WorkMonthReviewDto;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The office's verdict on a signed Arbeitszeitnachweis.
*
* A rejection does not undo the upload: the file stays on the month and the teamer uploads a
* new one over it, which puts the month back into "eingereicht".
*/
class ReviewController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly Mailer $mailer,
) {
}
#[Route('/admin/work-month/review/{uuid}', name: 'app_admin_work_month_review')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(TeamerWorkMonth $workMonth, Request $request): Response
{
if (WorkMonthStatus::SUBMITTED !== $workMonth->getStatus()) {
throw $this->createNotFoundException('This work month is not waiting for a review');
}
$returnUrl = $this->getReturnUrl($request, 'app_admin_work_month_index');
$formData = new WorkMonthReviewDto($workMonth);
$form = $this->createForm(WorkMonthReviewType::class, $formData);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var User $user */
$user = $this->getUser();
$status = $formData->getStatus();
$workMonth
->setStatus($status)
->setReviewedAt(new \DateTimeImmutable())
->setReviewedBy($user->getInitials())
->setReviewComment($formData->getComment())
;
// Keep the file's own status in step, so the existing upload badge and the
// UploadVoter's CHECK rule stay meaningful for it.
$workMonth->getCurrentDocument()?->setStatus(
WorkMonthStatus::APPROVED === $status ? Upload::STATUS_CHECKED : Upload::STATUS_REJECTED
);
$this->entityManager->flush();
if (WorkMonthStatus::REJECTED === $status) {
$this->notifyRejection($workMonth, (string) $formData->getComment());
}
$this->addFlash(
'success',
WorkMonthStatus::APPROVED === $status
? 'Der Arbeitszeitnachweis wurde bestätigt'
: 'Der Arbeitszeitnachweis wurde abgelehnt'
);
$this->logger->info('Review work month', [
'work_month' => $workMonth->getUuid(),
'teamer' => $workMonth->getTeamer()?->getFullName(),
'period' => sprintf('%04d-%02d', $workMonth->getYear(), $workMonth->getMonth()),
'status' => $status->value,
'by' => $user->getInitials(),
]);
return new HxRedirectResponse($returnUrl);
}
return $this->render('admin/work_month/modal_review.html.twig', [
'workMonth' => $workMonth,
'form' => $form->createView(),
'returnUrl' => $returnUrl,
]);
}
private function notifyRejection(TeamerWorkMonth $workMonth, string $comment): void
{
$email = $workMonth->getTeamer()?->getCommunication()?->getEmail();
if (null === $email || '' === trim($email)) {
return;
}
$this->mailer->createAndSendText(EmailTextKey::WORK_MONTH_REJECTED, [
'workMonth' => $workMonth->getPeriodLabel(),
'comment' => $comment,
], [
'to' => $email,
]);
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Controller\Admin\WorkMonth;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\TeamerWorkMonth;
use App\Enum\WorkHoursSyncOutcome;
use App\Service\Saas\WorkHoursSynchronizer;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Fetching one month again by hand, for a correction the schedule has already passed by.
*
* A month the teamer has signed is frozen, so re-fetching it only records that the SaaS now
* disagrees. Releasing it is a separate, deliberate step: it throws away the signature and
* sends the teamer back to the start.
*/
class SyncController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly WorkHoursSynchronizer $synchronizer,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/work-month/sync/{uuid}', name: 'app_admin_work_month_sync', methods: ['POST'])]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function sync(TeamerWorkMonth $workMonth, Request $request): Response
{
if (null === $teamer = $workMonth->getTeamer()) {
throw $this->createNotFoundException('No teamer on this work month');
}
$result = $this->synchronizer->syncTeamer($teamer, $workMonth->getYear(), $workMonth->getMonth());
match ($result->getOutcome()) {
WorkHoursSyncOutcome::IMPORTED => $this->addFlash('success', 'Die Arbeitszeiten wurden aktualisiert'),
WorkHoursSyncOutcome::LOCKED => $this->addFlash('success', $result->getMessage() ?? 'Der Monat ist bereits eingereicht'),
WorkHoursSyncOutcome::SKIPPED => $this->addFlash('error', $result->getMessage() ?? 'Der Abruf war nicht möglich'),
WorkHoursSyncOutcome::FAILED => $this->addFlash('error', 'Der Abruf ist fehlgeschlagen: '.$result->getMessage()),
};
return $this->redirect($this->getReturnUrl($request, 'app_admin_work_month_index'));
}
#[Route('/admin/work-month/unlock/{uuid}', name: 'app_admin_work_month_unlock', methods: ['POST'])]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function unlock(TeamerWorkMonth $workMonth, Request $request): Response
{
$this->synchronizer->unlock($workMonth);
$this->entityManager->flush();
$this->addFlash('success', 'Der Monat wurde wieder geöffnet und muss neu unterschrieben werden');
$this->logger->info('Unlock work month', [
'work_month' => $workMonth->getUuid(),
'teamer' => $workMonth->getTeamer()?->getFullName(),
'period' => sprintf('%04d-%02d', $workMonth->getYear(), $workMonth->getMonth()),
]);
return $this->redirect($this->getReturnUrl($request, 'app_admin_work_month_index'));
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Controller\Common;
use App\Controller\Traits\ReturnUrlTrait;
use App\Form\WorkMonthFilterType;
use App\Htmx\HxRedirectResponse;
use App\Service\Common\WorkMonthFilterHandler;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class WorkMonthFilterController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(private readonly WorkMonthFilterHandler $filterHandler)
{
}
#[Route('/common/work-month/filter', name: 'app_common_work_month_filter')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$formData = $this->filterHandler->getFilterSettings();
$form = $this->createForm(WorkMonthFilterType::class, $formData);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->filterHandler->handleRequest($form);
$returnUrl = $this->getReturnUrl($request, 'app_admin_work_month_index');
return new HxRedirectResponse($returnUrl);
}
return $this->render('common/modal_work_month_filter.html.twig', [
'filterForm' => $form->createView(),
'filterDto' => $form->getData(),
]);
}
#[Route('/common/work-month/filter/reset', name: 'app_common_work_month_filter_reset')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
$returnUrl = $this->getReturnUrl($request, 'app_admin_work_month_index');
return $this->redirect($returnUrl);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Controller\Teamer\WorkMonth;
use App\Entity\User;
use App\Repository\TeamerWorkMonthRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The months a teamer has to sign off, newest first.
*
* Only months the import has actually produced appear here; the current month is not among
* them until the 10th of the month after it.
*/
class IndexController extends AbstractController
{
public function __construct(
private readonly TeamerWorkMonthRepository $workMonthRepository,
) {
}
#[Route('/teamer/arbeitszeiten', name: 'app_teamer_work_month_index')]
#[IsGranted('ROLE_TEAMER')]
public function index(): Response
{
/** @var User $user */
$user = $this->getUser();
if (null === $teamer = $user->getTeamer()) {
throw $this->createNotFoundException('No teamer for this user');
}
return $this->render('teamer/work_month/index.html.twig', [
'workMonths' => $this->workMonthRepository->findForTeamer($teamer),
]);
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Controller\Teamer\WorkMonth;
use App\Entity\TeamerWorkMonth;
use App\Entity\User;
use App\Service\Pdf\WorkMonthRenderer;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The Arbeitszeitnachweis to print and sign.
*
* Built on demand and never stored, exactly as the contract and the invoice are. What keeps
* a regenerated sheet identical to the signed one is the lock in WorkHoursSynchronizer: once
* a month is submitted, its hours stop moving.
*/
class PdfController extends AbstractController
{
public function __construct(
private readonly WorkMonthRenderer $renderer,
private readonly LoggerInterface $logger,
) {
}
#[Route('/teamer/arbeitszeiten/pdf/{uuid}', name: 'app_teamer_work_month_pdf')]
#[IsGranted('ROLE_TEAMER')]
public function index(TeamerWorkMonth $workMonth): Response
{
/** @var User $user */
$user = $this->getUser();
// the month is addressed by uuid, so this is what keeps one teamer out of another's
if ($user->getTeamer() !== $workMonth->getTeamer()) {
throw $this->createNotFoundException('Not this teamer\'s work month');
}
$this->logger->info('Download work month pdf', [
'user' => $user->getUserIdentifier(),
'work_month' => $workMonth->getUuid(),
]);
$response = new Response($this->renderer->render($workMonth));
$response->headers->set('Content-Type', 'application/pdf');
$response->headers->set('Content-Disposition', $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$this->renderer->filename($workMonth),
));
return $response;
}
}
@@ -0,0 +1,133 @@
<?php
namespace App\Controller\Teamer\WorkMonth;
use App\Entity\TeamerWorkMonth;
use App\Entity\Upload;
use App\Entity\User;
use App\Enum\WorkMonthStatus;
use App\Htmx\HxRedirectResponse;
use App\Model\UploadDto;
use App\Service\Upload\UploadHandler;
use Doctrine\ORM\EntityManagerInterface;
use Flagception\Manager\FeatureManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The signed Arbeitszeitnachweis coming back for one month.
*
* Uploads accumulate rather than replace - Upload is not soft-deletable, and a rejection and
* the re-upload answering it both belong on the record. TeamerWorkMonth::getCurrentDocument()
* is what decides which one counts.
*/
class UploadController extends AbstractController
{
public function __construct(
private readonly UploadHandler $uploadHandler,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly FeatureManagerInterface $featureManager,
) {
}
#[Route('/teamer/arbeitszeiten/upload/{uuid}', name: 'app_teamer_work_month_upload')]
#[IsGranted('ROLE_TEAMER')]
public function index(TeamerWorkMonth $workMonth, Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
// the month is addressed by uuid, so this is what keeps one teamer out of another's
if ($user->getTeamer() !== $workMonth->getTeamer()) {
throw $this->createNotFoundException('Not this teamer\'s work month');
}
if (WorkMonthStatus::APPROVED === $workMonth->getStatus()) {
throw $this->createNotFoundException('This work month is already approved');
}
// Dummy form without fields, as elsewhere: the files travel in the upload session.
$form = $this->createFormBuilder()->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$count = $this->processUpload($workMonth, $user);
if (0 === $count) {
$this->addFlash('error', 'Es wurde keine Datei hochgeladen');
} else {
$this->addFlash('success', 'Der Arbeitszeitnachweis wurde hochgeladen');
}
return new HxRedirectResponse($this->generateUrl('app_teamer_work_month_index'));
}
return $this->render('teamer/work_month/modal_upload.html.twig', [
'workMonth' => $workMonth,
'form' => $form->createView(),
]);
}
private function processUpload(TeamerWorkMonth $workMonth, User $user): int
{
$uploadSession = $this->uploadHandler->getUploadSession();
// Only this dropzone's files: the session is shared, so it must not be destroyed whole.
$uploadDtos = $uploadSession->getUploadsByType(Upload::TYPE_WORK_MONTH);
$uploads = [];
/** @var UploadDto $uploadDto */
foreach ($uploadDtos as $uploadDto) {
$upload = Upload::fromUploadDto($uploadDto, $user, Upload::TYPE_WORK_MONTH);
// Unlike a season document this one is reviewed, so it enters the checking states
// the UploadVoter lets an administrative user act on.
$upload->setStatus(Upload::STATUS_NEW);
$workMonth->addDocument($upload);
$this->entityManager->persist($upload);
$uploads[] = $upload;
}
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_WORK_MONTH, $uploadSession);
$this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_WORK_MONTH);
if ([] === $uploads) {
return 0;
}
if (true === $this->featureManager->isActive('sanitize_uploads')) {
// Teamers photograph signed sheets, so what arrives is not necessarily a PDF.
foreach ($uploads as $upload) {
$this->uploadHandler->ensurePdf($upload);
}
}
$workMonth
->setStatus(WorkMonthStatus::SUBMITTED)
->setSubmittedAt(new \DateTimeImmutable())
// A new submission answers an earlier rejection; the old verdict must not stand.
->setReviewedAt(null)
->setReviewedBy(null)
->setReviewComment(null)
;
$this->entityManager->flush();
$this->logger->info('Upload work month document', [
'user' => $user->getUserIdentifier(),
'work_month' => $workMonth->getUuid(),
'period' => sprintf('%04d-%02d', $workMonth->getYear(), $workMonth->getMonth()),
'count' => count($uploads),
]);
return count($uploads);
}
}
+36
View File
@@ -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<int, TeamerWorkMonth>
*/
#[ORM\OneToMany(mappedBy: 'teamer', targetEntity: TeamerWorkMonth::class, cascade: ['remove'])]
#[ORM\OrderBy(['year' => \SortDirection::Descending, 'month' => \SortDirection::Descending])]
private Collection $workMonths;
public function __construct()
{
$this->uuid = Uuid::v4();
@@ -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<int, TeamerWorkMonth>
*/
public function getWorkMonths(): Collection
{
return $this->workMonths;
}
}
+440
View File
@@ -0,0 +1,440 @@
<?php
namespace App\Entity;
use App\Entity\Traits\TimestampableEntity;
use App\Enum\WorkMonthStatus;
use App\Model\WorkMonthDays;
use App\Repository\TeamerWorkMonthRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* One teamer's working time for one calendar month, imported from the SaaS time-tracking
* service, and the state of the Arbeitszeitnachweis the teamer signs for it.
*
* The days live in a JSON column rather than in one row per day: every read is "one teamer,
* one month", which a single row answers directly, and the signed document, its status and
* the import's own metadata all need exactly one monthly row to hang off. The figures a list
* sorts or filters on are denormalised into columns so nothing has to decode the blob to
* answer a question. App\Model\WorkMonthDays is the only place that knows the blob's shape.
*
* Deliberately not soft-deletable, like SeasonAssignmentTask and Upload: rows are derived
* from an import, never deleted by a user, and a soft-deleted row would silently shadow a
* re-imported one against the unique constraint below. Not blameable either - the importer
* writes these, not a person.
*/
#[ORM\Entity(repositoryClass: TeamerWorkMonthRepository::class)]
#[ORM\UniqueConstraint(name: 'teamer_work_month_unique', columns: ['teamer_id', 'year', 'month'])]
class TeamerWorkMonth implements TimestampableEntityInterface
{
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(inversedBy: 'workMonths')]
#[ORM\JoinColumn(nullable: false)]
private ?Teamer $teamer = null;
#[ORM\Column(type: Types::SMALLINT)]
private int $year;
/**
* The calendar month, 1-12. The SaaS counts months from zero; that index never leaves
* App\Saas.
*/
#[ORM\Column(type: Types::SMALLINT)]
private int $month;
/**
* @var array<string, array{minutes: int, absence?: string}>
*/
#[ORM\Column(type: Types::JSON)]
private array $days = [];
#[ORM\Column]
private int $totalMinutes = 0;
#[ORM\Column]
private int $workedDays = 0;
#[ORM\Column]
private int $absenceDays = 0;
/**
* The month total as the SaaS itself reported it. Kept alongside our own sum so a later
* divergence is visible without fetching the month again.
*/
#[ORM\Column]
private int $sourceIstMinutes = 0;
/**
* The month's target. A monthly figure by nature: every booking carries soll 0 with
* sollType MONTH, so there is no daily target to break it down into.
*/
#[ORM\Column]
private int $targetMinutes = 0;
#[ORM\Column]
private int $breakMinutes = 0;
/**
* The employee's department in the SaaS, shown in the head of the sheet. Nullable because
* a month of nothing but holiday carries no booking to read it from.
*/
#[ORM\Column(length: 255, nullable: true)]
private ?string $department = null;
#[ORM\Column(enumType: WorkMonthStatus::class)]
private WorkMonthStatus $status = WorkMonthStatus::OPEN;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $submittedAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $reviewedAt = null;
#[ORM\Column(length: 8, nullable: true)]
private ?string $reviewedBy = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $reviewComment = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $syncedAt;
/**
* Anything the parser could not make sense of, kept so a past import stays explicable.
*
* @var string[]|null
*/
#[ORM\Column(nullable: true)]
private ?array $warnings = null;
/**
* Set when an import found different hours for a month whose figures are frozen because
* the teamer already signed it. The stored hours stay as signed; this records that the
* SaaS has since disagreed.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $mismatchDetectedAt = null;
#[ORM\Column(nullable: true)]
private ?int $mismatchTotalMinutes = null;
/**
* Every signed copy the teamer has uploaded for this month, oldest first. They
* accumulate rather than replace, as season documents do, so a rejection and the
* re-upload that answered it both stay on the record.
*
* @var Collection<int, Upload>
*/
#[ORM\OneToMany(mappedBy: 'teamerWorkMonth', targetEntity: Upload::class)]
#[ORM\OrderBy(['createdAt' => \SortDirection::Ascending])]
private Collection $documents;
public function __construct()
{
$this->uuid = Uuid::v4();
$this->syncedAt = new \DateTimeImmutable();
$this->documents = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getTeamer(): ?Teamer
{
return $this->teamer;
}
public function setTeamer(?Teamer $teamer): static
{
$this->teamer = $teamer;
return $this;
}
public function getYear(): int
{
return $this->year;
}
public function setYear(int $year): static
{
$this->year = $year;
return $this;
}
public function getMonth(): int
{
return $this->month;
}
public function setMonth(int $month): static
{
$this->month = $month;
return $this;
}
/**
* The first of the month, for formatting and sorting in templates.
*/
public function getPeriod(): \DateTimeImmutable
{
return (new \DateTimeImmutable())->setDate($this->year, $this->month, 1)->setTime(0, 0);
}
/**
* "März 2026" - the way the month is named in mails and on the sheet itself.
*/
public function getPeriodLabel(): string
{
return (string) \IntlDateFormatter::formatObject($this->getPeriod(), 'LLLL yyyy', 'de');
}
public function getDays(): WorkMonthDays
{
return WorkMonthDays::fromArray($this->days);
}
public function setDays(WorkMonthDays $days): static
{
$this->days = $days->toArray();
$this->totalMinutes = $days->getTotalMinutes();
$this->breakMinutes = $days->getTotalBreakMinutes();
$this->workedDays = $days->countWorkedDays();
$this->absenceDays = $days->countAbsenceDays();
return $this;
}
public function getTotalMinutes(): int
{
return $this->totalMinutes;
}
public function getWorkedDays(): int
{
return $this->workedDays;
}
public function getAbsenceDays(): int
{
return $this->absenceDays;
}
public function getSourceIstMinutes(): int
{
return $this->sourceIstMinutes;
}
public function setSourceIstMinutes(int $sourceIstMinutes): static
{
$this->sourceIstMinutes = $sourceIstMinutes;
return $this;
}
public function getTargetMinutes(): int
{
return $this->targetMinutes;
}
public function setTargetMinutes(int $targetMinutes): static
{
$this->targetMinutes = $targetMinutes;
return $this;
}
public function getBreakMinutes(): int
{
return $this->breakMinutes;
}
public function getDepartment(): ?string
{
return $this->department;
}
public function setDepartment(?string $department): static
{
$this->department = $department;
return $this;
}
/**
* Worked minus target: positive is time over the month's target, negative under it.
*/
public function getBalanceMinutes(): int
{
return $this->totalMinutes - $this->targetMinutes;
}
public function getStatus(): WorkMonthStatus
{
return $this->status;
}
public function setStatus(WorkMonthStatus $status): static
{
$this->status = $status;
return $this;
}
public function getSubmittedAt(): ?\DateTimeImmutable
{
return $this->submittedAt;
}
public function setSubmittedAt(?\DateTimeImmutable $submittedAt): static
{
$this->submittedAt = $submittedAt;
return $this;
}
public function getReviewedAt(): ?\DateTimeImmutable
{
return $this->reviewedAt;
}
public function setReviewedAt(?\DateTimeImmutable $reviewedAt): static
{
$this->reviewedAt = $reviewedAt;
return $this;
}
public function getReviewedBy(): ?string
{
return $this->reviewedBy;
}
public function setReviewedBy(?string $reviewedBy): static
{
$this->reviewedBy = $reviewedBy;
return $this;
}
public function getReviewComment(): ?string
{
return $this->reviewComment;
}
public function setReviewComment(?string $reviewComment): static
{
$this->reviewComment = $reviewComment;
return $this;
}
public function getSyncedAt(): \DateTimeImmutable
{
return $this->syncedAt;
}
public function setSyncedAt(\DateTimeImmutable $syncedAt): static
{
$this->syncedAt = $syncedAt;
return $this;
}
/**
* @return string[]
*/
public function getWarnings(): array
{
return $this->warnings ?? [];
}
/**
* @param string[] $warnings
*/
public function setWarnings(array $warnings): static
{
$this->warnings = [] !== $warnings ? array_values($warnings) : null;
return $this;
}
public function getMismatchDetectedAt(): ?\DateTimeImmutable
{
return $this->mismatchDetectedAt;
}
public function getMismatchTotalMinutes(): ?int
{
return $this->mismatchTotalMinutes;
}
public function hasMismatch(): bool
{
return null !== $this->mismatchDetectedAt;
}
public function recordMismatch(?int $totalMinutes): static
{
$this->mismatchDetectedAt = new \DateTimeImmutable();
$this->mismatchTotalMinutes = $totalMinutes;
return $this;
}
public function clearMismatch(): static
{
$this->mismatchDetectedAt = null;
$this->mismatchTotalMinutes = null;
return $this;
}
/**
* @return Collection<int, Upload>
*/
public function getDocuments(): Collection
{
return $this->documents;
}
public function addDocument(Upload $document): static
{
if (!$this->documents->contains($document)) {
$this->documents->add($document);
$document->setTeamerWorkMonth($this);
}
return $this;
}
/**
* The signed copy that counts: the most recent one the teamer uploaded.
*/
public function getCurrentDocument(): ?Upload
{
return $this->documents->last() ?: null;
}
}
+23
View File
@@ -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;
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Enum;
enum WorkHoursSyncOutcome: string
{
/** Hours were fetched and written. */
case IMPORTED = 'imported';
/** The month is signed, so the hours were compared but left alone. */
case LOCKED = 'locked';
/** Nothing to do: no address on file, or nothing to import. */
case SKIPPED = 'skipped';
/** The request or the payload failed. Worth waking someone for. */
case FAILED = 'failed';
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Enum;
/**
* Where one work month stands in the Arbeitszeitnachweis exchange.
*
* Deliberately not a Symfony workflow: the existing document state machine is keyed to
* Disposition's own marking store and cannot carry a month. Four states and two
* transitions do not need one.
*/
enum WorkMonthStatus: string
{
/** Imported, waiting for the teamer to download, sign and upload it. */
case OPEN = 'open';
/** The teamer uploaded a signed copy; the office has not looked at it yet. */
case SUBMITTED = 'submitted';
/** The office accepted the signed copy. */
case APPROVED = 'approved';
/** The office rejected it. The teamer uploads again, which returns it to SUBMITTED. */
case REJECTED = 'rejected';
public function label(): string
{
return match ($this) {
self::OPEN => 'Offen',
self::SUBMITTED => 'Eingereicht',
self::APPROVED => 'Geprüft',
self::REJECTED => 'Abgelehnt',
};
}
public function badgeClass(): string
{
return match ($this) {
self::OPEN => 'upload-status-badge--new',
self::SUBMITTED => 'upload-status-badge--default',
self::APPROVED => 'upload-status-badge--checked',
self::REJECTED => 'upload-status-badge--error',
};
}
/**
* Whether the imported hours are frozen.
*
* Once a teamer has signed a month, the stored hours have to keep matching the paper
* they signed, so a later import may compare but not overwrite. A rejected month is
* not locked: a rejection usually means the hours themselves were wrong.
*/
public function locksHours(): bool
{
return match ($this) {
self::SUBMITTED, self::APPROVED => true,
self::OPEN, self::REJECTED => false,
};
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Form;
use App\Enum\WorkMonthStatus;
use App\Model\WorkMonthFilterDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class WorkMonthFilterType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('teamerName', TextType::class, [
'label' => 'Name',
'required' => false,
])
// The day is irrelevant here; FirstDayOfMonthDateTransformer pins it to the 1st.
->add('period', AbbreviatedDateType::class, [
'label' => 'Monat',
'required' => false,
'years' => range((int) date('Y') + 1, (int) date('Y') - 5),
])
->add('statuses', MultiselectType::class, [
'label' => 'Status',
'required' => false,
'empty_label' => 'nicht filtern',
'choices' => WorkMonthStatus::cases(),
'choice_label' => static fn (WorkMonthStatus $status) => $status->label(),
'choice_value' => static fn (?WorkMonthStatus $status) => $status?->value,
])
->add('includeApproved', CheckboxType::class, [
'label' => 'geprüfte Monate anzeigen',
'required' => false,
])
->add('includeMismatchedOnly', CheckboxType::class, [
'label' => 'nur Monate mit abweichenden Stunden',
'required' => false,
])
// AbstractFilterHandler::handleRequest() branches on exactly these two names
->add('apply', SubmitType::class, [
'label' => 'filtern',
])
->add('reset', SubmitType::class, [
'label' => 'reset',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => WorkMonthFilterDto::class,
]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Form;
use App\Enum\WorkMonthStatus;
use App\Model\WorkMonthReviewDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class WorkMonthReviewType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('status', ChoiceType::class, [
'label' => 'Entscheidung',
'expanded' => true,
'placeholder' => false,
// Only the two outcomes a review produces: "offen" and "eingereicht" are
// states the teamer puts the month into, not the office.
'choices' => [
WorkMonthStatus::APPROVED->label() => WorkMonthStatus::APPROVED,
WorkMonthStatus::REJECTED->label() => WorkMonthStatus::REJECTED,
],
'choice_value' => static fn (?WorkMonthStatus $status) => $status?->value,
])
->add('comment', TextareaType::class, [
'label' => 'Kommentar/Begründung',
'required' => false,
'attr' => [
'rows' => 3,
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => WorkMonthReviewDto::class,
]);
}
}
@@ -0,0 +1,99 @@
<?php
namespace App\Logging;
use Monolog\LogRecord;
/**
* Keeps SaaS api keys out of the log while leaving the requests themselves in it.
*
* The SaaS takes its api key as a path segment rather than in a header, so the url of every
* call carries a credential. Symfony's http client logs a line per request, and an HTTP error
* repeats the url inside the exception message, which then travels into whatever channel
* catches it - so silencing one client would not have been enough.
*
* Registered without a channel, deliberately: a url can surface anywhere, and a processor
* that only guarded the saas channel would miss the exception that matters most.
*
* Two passes, because the two failure modes are different. The configured key is redacted
* wherever it appears, whole or url-encoded, which is exact. The path shapes are redacted by
* pattern, which also covers the per-employee keys the roster hands out and any future
* endpoint that follows the same convention.
*/
class RedactSaasCredentialsProcessor
{
private const PLACEHOLDER = '[redacted]';
/**
* Anything sitting where an api key sits: /api/v2/<something>/<key>[/...].
*/
private const PATH_PATTERNS = [
'#(/api/v[0-9]+/time/account/)[^/\s"\']+#i',
'#(/api/v[0-9]+/employees/)[^/\s"\']+#i',
'#(/api/v[0-9]+/employee/departments/)[^/\s"\']+#i',
];
/**
* @var string[]
*/
private readonly array $secrets;
public function __construct(string $apiKey)
{
$secrets = [];
// Short values would match far too much; a real key never is.
if (8 <= strlen($apiKey)) {
$secrets[$apiKey] = $apiKey;
$secrets[rawurlencode($apiKey)] = rawurlencode($apiKey);
}
$this->secrets = array_values($secrets);
}
public function __invoke(LogRecord $record): LogRecord
{
$message = $this->redact($record->message);
$context = $this->redactDeep($record->context);
$extra = $this->redactDeep($record->extra);
if ($message === $record->message && $context === $record->context && $extra === $record->extra) {
return $record;
}
// LogRecord only allows some of its fields to be assigned through array access;
// message is not one of them, so a redacted record is a new one.
return $record->with(message: $message, context: $context, extra: $extra);
}
private function redactDeep(array $values, int $depth = 0): array
{
// Contexts hold arbitrary payloads; stop rather than walk something pathological.
if (8 < $depth) {
return $values;
}
foreach ($values as $key => $value) {
if (is_string($value)) {
$values[$key] = $this->redact($value);
} elseif (is_array($value)) {
$values[$key] = $this->redactDeep($value, $depth + 1);
}
}
return $values;
}
private function redact(string $value): string
{
foreach ($this->secrets as $secret) {
$value = str_replace($secret, self::PLACEHOLDER, $value);
}
foreach (self::PATH_PATTERNS as $pattern) {
$value = (string) preg_replace($pattern, '$1'.self::PLACEHOLDER, $value);
}
return $value;
}
}
+12
View File
@@ -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' => [
+23
View File
@@ -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 &amp; 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();
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace App\Model;
use App\Entity\Teamer;
use App\Enum\WorkHoursSyncOutcome;
/**
* What one teamer's import did, so the command and the admin action can report it without
* the synchronizer having to know about either.
*/
class WorkHoursSyncResult
{
/**
* @param string[] $warnings
*/
private function __construct(
private readonly Teamer $teamer,
private readonly WorkHoursSyncOutcome $outcome,
private readonly ?string $message = null,
private readonly ?int $totalMinutes = null,
private readonly array $warnings = [],
) {
}
/**
* @param string[] $warnings
*/
public static function imported(Teamer $teamer, int $totalMinutes, array $warnings = []): self
{
return new self($teamer, WorkHoursSyncOutcome::IMPORTED, null, $totalMinutes, $warnings);
}
public static function locked(Teamer $teamer, string $message, ?int $totalMinutes = null): self
{
return new self($teamer, WorkHoursSyncOutcome::LOCKED, $message, $totalMinutes);
}
public static function skipped(Teamer $teamer, string $message): self
{
return new self($teamer, WorkHoursSyncOutcome::SKIPPED, $message);
}
public static function failed(Teamer $teamer, string $message): self
{
return new self($teamer, WorkHoursSyncOutcome::FAILED, $message);
}
public function getTeamer(): Teamer
{
return $this->teamer;
}
public function getOutcome(): WorkHoursSyncOutcome
{
return $this->outcome;
}
public function getMessage(): ?string
{
return $this->message;
}
public function getTotalMinutes(): ?int
{
return $this->totalMinutes;
}
/**
* @return string[]
*/
public function getWarnings(): array
{
return $this->warnings;
}
public function isFailure(): bool
{
return WorkHoursSyncOutcome::FAILED === $this->outcome;
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App\Model;
/**
* One calendar day as a work-month view renders it.
*
* "No minutes" and "zero minutes" are different things here: null means the SaaS reported
* nothing at all for that day, 0 means it reported a day that happens to add up to zero -
* a rest day, say. The view shows the first as a dash and the second as 0:00.
*/
class WorkMonthDay
{
public function __construct(
private readonly \DateTimeImmutable $date,
private readonly ?int $minutes,
private readonly ?string $absence,
private readonly ?string $startTime = null,
private readonly ?string $endTime = null,
private readonly int $breakMinutes = 0,
) {
}
public function getDate(): \DateTimeImmutable
{
return $this->date;
}
public function getMinutes(): ?int
{
return $this->minutes;
}
public function getAbsence(): ?string
{
return $this->absence;
}
public function getStartTime(): ?string
{
return $this->startTime;
}
public function getEndTime(): ?string
{
return $this->endTime;
}
public function getBreakMinutes(): int
{
return $this->breakMinutes;
}
/**
* Whether the day has a span to print. A rest day is booked but never worked in.
*/
public function hasSpan(): bool
{
return null !== $this->startTime && null !== $this->endTime;
}
public function isAbsence(): bool
{
return null !== $this->absence;
}
public function isReported(): bool
{
return null !== $this->minutes;
}
public function isWeekend(): bool
{
return in_array((int) $this->date->format('N'), [6, 7], true);
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
namespace App\Model;
use App\Saas\Model\TimeAccount;
use App\Saas\Model\TimeAccountDay;
/**
* The days of one work month, and the only place that knows how they are stored.
*
* TeamerWorkMonth keeps its days in a JSON column rather than in rows: every read is
* "one teamer, one month", which one row answers directly. The price is that the shape is
* invisible to the schema, so it is kept small and passes through here alone - a change to
* it is a change to this file and nothing else.
*
* Shape, with everything optional but "minutes" left out when it has nothing to say:
* {"2026-03-01": {"minutes": 285, "break": 60, "start": "09:00", "end": "14:45"},
* "2026-03-05": {"minutes": 0, "absence": "Ruhetag 24/25"}}
* Days the SaaS did not report are simply absent; forCalendarMonth() fills the gaps.
*/
class WorkMonthDays
{
/**
* @param array<string, array{minutes: int, break?: int, start?: string, end?: string, absence?: string}> $days keyed by Y-m-d
*/
private function __construct(private readonly array $days)
{
}
public static function fromArray(array $days): self
{
$clean = [];
foreach ($days as $key => $value) {
if (!is_string($key) || !is_array($value) || 1 !== preg_match('/^\d{4}-\d{2}-\d{2}$/', $key)) {
continue;
}
$entry = ['minutes' => (int) ($value['minutes'] ?? 0)];
if (0 < $break = (int) ($value['break'] ?? 0)) {
$entry['break'] = $break;
}
$start = self::cleanTime($value['start'] ?? null);
$end = self::cleanTime($value['end'] ?? null);
// A span needs both ends; half of one says nothing.
if (null !== $start && null !== $end) {
$entry['start'] = $start;
$entry['end'] = $end;
}
$absence = $value['absence'] ?? null;
if (is_string($absence) && '' !== $absence) {
$entry['absence'] = $absence;
}
$clean[$key] = $entry;
}
ksort($clean);
return new self($clean);
}
public static function fromTimeAccount(TimeAccount $account): self
{
$days = [];
foreach ($account->getDays() as $key => $day) {
/* @var TimeAccountDay $day */
$days[$key] = ['minutes' => $day->getMinutes()];
if (0 < $day->getBreakMinutes()) {
$days[$key]['break'] = $day->getBreakMinutes();
}
if (null !== $day->getStartTime() && null !== $day->getEndTime()) {
$days[$key]['start'] = $day->getStartTime();
$days[$key]['end'] = $day->getEndTime();
}
if (null !== $day->getHolidayType()) {
$days[$key]['absence'] = $day->getHolidayType();
}
}
return new self($days);
}
private static function cleanTime(mixed $raw): ?string
{
return is_string($raw) && 1 === preg_match('/^\d{2}:\d{2}$/', $raw) ? $raw : null;
}
public function toArray(): array
{
return $this->days;
}
public function getTotalMinutes(): int
{
return array_sum(array_column($this->days, 'minutes'));
}
public function countWorkedDays(): int
{
return count(array_filter($this->days, static fn (array $day) => ($day['minutes'] ?? 0) > 0));
}
public function countAbsenceDays(): int
{
return count(array_filter($this->days, static fn (array $day) => isset($day['absence'])));
}
public function getTotalBreakMinutes(): int
{
return array_sum(array_map(static fn (array $day) => $day['break'] ?? 0, $this->days));
}
/**
* Every day of the month in order, reported or not, for tables and the PDF.
*
* @return list<WorkMonthDay>
*/
public function forCalendarMonth(int $year, int $month): array
{
$first = \DateTimeImmutable::createFromFormat('!Y-n-j', sprintf('%04d-%d-1', $year, $month));
if (false === $first) {
return [];
}
$days = [];
for ($day = 1, $last = (int) $first->format('t'); $day <= $last; ++$day) {
$date = $first->setDate($year, $month, $day);
$entry = $this->days[$date->format('Y-m-d')] ?? null;
$days[] = new WorkMonthDay(
$date,
null !== $entry ? (int) $entry['minutes'] : null,
$entry['absence'] ?? null,
$entry['start'] ?? null,
$entry['end'] ?? null,
(int) ($entry['break'] ?? 0),
);
}
return $days;
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace App\Model;
use App\Enum\WorkMonthStatus;
class WorkMonthFilterDto extends AbstractFilterDto
{
protected ?string $teamerName = null;
/**
* Normalised to the first of the month by FirstDayOfMonthDateTransformer.
*/
protected ?\DateTimeImmutable $period = null;
/** @var WorkMonthStatus[] */
protected array $statuses = [];
/**
* Approved months are done with, so the list is about what still needs attention.
*/
protected bool $includeApproved = false;
/**
* Months whose hours the SaaS has since disagreed with, which is a rare thing to go
* looking for and a noisy thing to see by default.
*/
protected bool $includeMismatchedOnly = false;
public function getTeamerName(): ?string
{
return $this->teamerName;
}
public function setTeamerName(?string $teamerName): static
{
$this->teamerName = $teamerName;
return $this;
}
public function getPeriod(): ?\DateTimeImmutable
{
return $this->period;
}
public function setPeriod(?\DateTimeImmutable $period): static
{
$this->period = $period;
return $this;
}
/**
* @return WorkMonthStatus[]
*/
public function getStatuses(): array
{
return $this->statuses;
}
/**
* @param WorkMonthStatus[] $statuses
*/
public function setStatuses(array $statuses): static
{
$this->statuses = $statuses;
return $this;
}
public function isIncludeApproved(): bool
{
return $this->includeApproved;
}
public function setIncludeApproved(bool $includeApproved): static
{
$this->includeApproved = $includeApproved;
return $this;
}
public function isIncludeMismatchedOnly(): bool
{
return $this->includeMismatchedOnly;
}
public function setIncludeMismatchedOnly(bool $includeMismatchedOnly): static
{
$this->includeMismatchedOnly = $includeMismatchedOnly;
return $this;
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Model;
use App\Entity\TeamerWorkMonth;
use App\Enum\WorkMonthStatus;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
class WorkMonthReviewDto
{
private TeamerWorkMonth $workMonth;
#[Assert\NotNull(message: 'Bitte eine Entscheidung treffen')]
private ?WorkMonthStatus $status;
private ?string $comment;
public function __construct(TeamerWorkMonth $workMonth)
{
$this->workMonth = $workMonth;
$this->status = null;
$this->comment = $workMonth->getReviewComment();
}
public function getWorkMonth(): TeamerWorkMonth
{
return $this->workMonth;
}
public function getStatus(): ?WorkMonthStatus
{
return $this->status;
}
public function setStatus(?WorkMonthStatus $status): static
{
$this->status = $status;
return $this;
}
public function getComment(): ?string
{
return $this->comment;
}
public function setComment(?string $comment): static
{
$this->comment = $comment;
return $this;
}
/**
* A rejection without a reason leaves the teamer guessing what to fix.
*/
#[Assert\Callback]
public function validateComment(ExecutionContextInterface $context): void
{
if (WorkMonthStatus::REJECTED === $this->status && '' === trim((string) $this->comment)) {
$context
->buildViolation('Bitte eine Begründung für die Ablehnung angeben')
->atPath('comment')
->addViolation()
;
}
}
}
+37
View File
@@ -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
@@ -0,0 +1,134 @@
<?php
namespace App\Repository;
use App\Entity\Teamer;
use App\Entity\TeamerWorkMonth;
use App\Enum\WorkMonthStatus;
use App\Model\WorkMonthFilterDto;
use App\Repository\Traits\QueryHelperTrait;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TeamerWorkMonth>
*
* @method TeamerWorkMonth|null find($id, $lockMode = null, $lockVersion = null)
* @method TeamerWorkMonth|null findOneBy(array $criteria, array $orderBy = null)
* @method TeamerWorkMonth[] findAll()
* @method TeamerWorkMonth[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class TeamerWorkMonthRepository extends ServiceEntityRepository
{
use QueryHelperTrait;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TeamerWorkMonth::class);
}
public function findOneForTeamerAndMonth(Teamer $teamer, int $year, int $month): ?TeamerWorkMonth
{
return $this->findOneBy(['teamer' => $teamer, 'year' => $year, 'month' => $month]);
}
/**
* @return TeamerWorkMonth[]
*/
public function findForTeamer(Teamer $teamer): array
{
return $this->findBy(
['teamer' => $teamer],
['year' => \SortDirection::Descending, 'month' => \SortDirection::Descending],
);
}
/**
* The admin list.
*
* Deliberately over TeamerWorkMonth rather than over Upload: UploadRepository's list
* queries inner-join a disposition, which structurally excludes every document hanging
* off anything else, and it is the month that carries the status anyway.
*/
public function getListQuery(WorkMonthFilterDto $filterDto): Query
{
$qb = $this->createQueryBuilder('work_month');
$qb
->select('work_month', 'teamer')
->innerJoin('work_month.teamer', 'teamer')
;
$this->applyFilterSettings($filterDto, $qb);
return $qb
->addOrderBy('work_month.year', \SortDirection::Descending)
->addOrderBy('work_month.month', \SortDirection::Descending)
->addOrderBy('teamer.lastName', \SortDirection::Ascending)
->getQuery()
;
}
/**
* Months still waiting for a signed copy, for the reminder.
*
* @return TeamerWorkMonth[]
*/
public function findAwaitingDocument(\DateTimeImmutable $importedBefore): array
{
return $this
->createQueryBuilder('work_month')
->select('work_month', 'teamer')
->innerJoin('work_month.teamer', 'teamer')
->andWhere('work_month.status IN (:awaiting)')
->andWhere('work_month.syncedAt <= :importedBefore')
->andWhere('teamer.deletedAt IS NULL')
->setParameter('awaiting', [WorkMonthStatus::OPEN, WorkMonthStatus::REJECTED])
->setParameter('importedBefore', $importedBefore)
->addOrderBy('teamer.lastName', \SortDirection::Ascending)
->getQuery()
->getResult()
;
}
private function applyFilterSettings(WorkMonthFilterDto $filterDto, QueryBuilder $qb): void
{
if (null !== $filterDto->getTeamerName()) {
$qb
->andWhere($qb->expr()->orX(
$qb->expr()->like('teamer.firstName', ':teamerName'),
$qb->expr()->like('teamer.lastName', ':teamerName')
))
->setParameter('teamerName', '%'.$this->escapeLikeWildcards($filterDto->getTeamerName()).'%')
;
}
if (null !== $period = $filterDto->getPeriod()) {
$qb
->andWhere('work_month.year = :year')
->andWhere('work_month.month = :month')
->setParameter('year', (int) $period->format('Y'))
->setParameter('month', (int) $period->format('n'))
;
}
if (0 < count($filterDto->getStatuses())) {
$qb
->andWhere('work_month.status IN (:statuses)')
->setParameter('statuses', $filterDto->getStatuses())
;
} elseif (!$filterDto->isIncludeApproved()) {
// Only a default, so an explicit status choice still wins.
$qb
->andWhere('work_month.status != :approved')
->setParameter('approved', WorkMonthStatus::APPROVED)
;
}
if ($filterDto->isIncludeMismatchedOnly()) {
$qb->andWhere('work_month.mismatchDetectedAt IS NOT NULL');
}
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Saas;
use Psr\Log\LoggerInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as HttpClientExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Reads time accounts from the SaaS time-tracking service.
*
* The API counts months from zero, and so do the date strings it answers with. Both
* conversions are confined to this class and to ResponseParser, so the rest of the
* application only ever deals in calendar months.
*/
class ApiClient
{
private array $config;
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
array $options,
) {
$this->config = $this->resolveOptions($options);
}
/**
* One employee's time account for one calendar month (1-12).
*
* @throws ApiClientException
*/
public function getTimeAccount(string $email, int $year, int $month): array
{
if ($month < 1 || $month > 12) {
throw new ApiClientException(sprintf('%d is not a calendar month.', $month));
}
// The api key travels in the path, not in a header or query string - the vendor's
// choice, not ours. Every url built here therefore carries a credential, which is what
// App\Logging\RedactSaasCredentialsProcessor exists to strip back out of the log.
//
// The employee is addressed by email. The roster offers a per-employee key that would
// serve instead, but swapping an address for a credential in the same URL is not an
// improvement.
$url = sprintf(
'%s/api/v2/time/account/%s/%s/%d/%d',
$this->baseUrl(),
rawurlencode($this->config['api_key']),
rawurlencode($email),
$year,
$month - 1,
);
$this->logger->info('Requesting time account', [
'email' => $email,
'year' => $year,
'month' => $month,
]);
try {
return $this->httpClient->request('GET', $url, [
// One request per teamer, so a hanging host must not stall the whole run.
'timeout' => 10,
'max_duration' => 30,
])->toArray();
} catch (HttpClientExceptionInterface $e) {
// Covers transport, redirect, 4xx/5xx and JSON decoding alike.
throw new ApiClientException(sprintf('Time account request for %s (%04d-%02d) failed: %s', $email, $year, $month, $e->getMessage()), 0, $e);
}
}
/**
* The whole employee roster.
*
* Slow, and the same for every teamer, so it is fetched once per run behind
* App\Saas\DataProvider\EmployeeDataProvider rather than per teamer.
*
* @throws ApiClientException
*/
public function getEmployees(): array
{
$url = sprintf('%s/api/v2/employees/%s', $this->baseUrl(), rawurlencode($this->config['api_key']));
$this->logger->info('Requesting employee roster');
try {
return $this->httpClient->request('GET', $url, [
// Deliberately longer than the per-teamer call: this one is known to be slow.
'timeout' => 30,
'max_duration' => 60,
])->toArray();
} catch (HttpClientExceptionInterface $e) {
throw new ApiClientException(sprintf('Employee roster request failed: %s', $e->getMessage()), 0, $e);
}
}
/**
* @throws ApiClientException
*/
private function baseUrl(): string
{
// Checked here rather than in the constructor, and the base url is deliberately not a
// scoped client: an unconfigured host would then fail while the container is being
// built, which takes the whole command down with it - "--help" included.
if ('' === $baseUrl = rtrim((string) $this->config['base_url'], '/')) {
throw new ApiClientException('APP_SAAS_BASE_URL is not configured.');
}
return $baseUrl;
}
private function resolveOptions(array $options): array
{
$optionsResolver = new OptionsResolver();
$optionsResolver->setRequired(['base_url', 'api_key']);
$optionsResolver->setAllowedTypes('base_url', 'string');
$optionsResolver->setAllowedTypes('api_key', 'string');
return $optionsResolver->resolve($options);
}
}
+7
View File
@@ -0,0 +1,7 @@
<?php
namespace App\Saas;
class ApiClientException extends \Exception
{
}
@@ -0,0 +1,64 @@
<?php
namespace App\Saas\DataProvider;
use App\Saas\ApiClient;
use App\Saas\ApiClientException;
use App\Saas\Model\EmployeeDirectory;
use App\Saas\ResponseParser;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* The SaaS roster, fetched once and kept for a while.
*
* The endpoint is slow and answers the same thing for every teamer, so a monthly run that
* touches forty people should ask for it once. Follows App\BusProNet\DataProvider.
*
* Only the reduced projection is cached, never the response: the raw entries carry a
* per-employee api key, and the app cache writes to the filesystem.
*/
class EmployeeDataProvider
{
private const CACHE_KEY = 'saas_employees';
public function __construct(
private readonly ApiClient $apiClient,
private readonly ResponseParser $responseParser,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
private readonly int $ttl = 21600,
) {
}
public function getDirectory(): EmployeeDirectory
{
try {
$rows = $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) {
$item->expiresAfter($this->ttl);
$directory = $this->responseParser->parseEmployees($this->apiClient->getEmployees());
$this->logger->info('Fetched employee roster', ['employees' => $directory->count()]);
return $directory->toCacheable();
});
} catch (ApiClientException $e) {
// An unreachable roster must not stop the import: it is a cross-check and a
// source for the department, not the working time itself.
$this->logger->error('Unable to fetch the employee roster: '.$e->getMessage());
return EmployeeDirectory::fromEmployees([]);
}
return EmployeeDirectory::fromCacheable($rows);
}
public function refresh(): EmployeeDirectory
{
$this->cache->delete(self::CACHE_KEY);
return $this->getDirectory();
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Saas\Model;
/**
* One employee of the SaaS, reduced to the four things this application has a use for.
*
* Everything else the roster reports is dropped on the way in, and deliberately: the raw
* entry carries a per-employee "apiKey" - a credential - alongside a birthday, a personnel
* number and an RFID token. None of that is needed to import working time, and the roster is
* cached to the filesystem, so none of it may survive this class.
*
* The per-employee key would work in place of the email address when addressing a time
* account, and is deliberately not used for it: that only trades one identifier in a URL for
* a credential in a URL, and this is machine-to-machine traffic where the address is the
* lesser exposure. Do not reintroduce it without deciding where the keys are to be stored.
*/
class Employee
{
public function __construct(
private readonly string $email,
private readonly ?string $uuid,
private readonly ?string $department,
private readonly bool $hasLeft,
) {
}
public function getEmail(): string
{
return $this->email;
}
public function getUuid(): ?string
{
return $this->uuid;
}
public function getDepartment(): ?string
{
return $this->department;
}
/**
* Whether the employee has a date of separation, and so no longer books time.
*/
public function hasLeft(): bool
{
return $this->hasLeft;
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Saas\Model;
/**
* The SaaS roster, by email address.
*
* Lookups are case-insensitive: the address is typed by hand on both sides, and the API is
* the one place where the two systems have to agree on it.
*/
class EmployeeDirectory
{
/**
* @param array<string, Employee> $employees keyed by lower-cased email
*/
private function __construct(private readonly array $employees)
{
}
/**
* @param Employee[] $employees
*/
public static function fromEmployees(array $employees): self
{
$byEmail = [];
foreach ($employees as $employee) {
$byEmail[mb_strtolower($employee->getEmail())] = $employee;
}
return new self($byEmail);
}
public function find(?string $email): ?Employee
{
if (null === $email || '' === trim($email)) {
return null;
}
return $this->employees[mb_strtolower(trim($email))] ?? null;
}
public function has(?string $email): bool
{
return null !== $this->find($email);
}
public function isEmpty(): bool
{
return [] === $this->employees;
}
public function count(): int
{
return count($this->employees);
}
/**
* @return array<string, array{email: string, uuid: ?string, department: ?string, hasLeft: bool}>
*/
public function toCacheable(): array
{
$rows = [];
foreach ($this->employees as $key => $employee) {
$rows[$key] = [
'email' => $employee->getEmail(),
'uuid' => $employee->getUuid(),
'department' => $employee->getDepartment(),
'hasLeft' => $employee->hasLeft(),
];
}
return $rows;
}
/**
* @param array<string, array{email: string, uuid: ?string, department: ?string, hasLeft: bool}> $rows
*/
public static function fromCacheable(array $rows): self
{
return self::fromEmployees(array_map(
static fn (array $row) => new Employee(
(string) $row['email'],
$row['uuid'] ?? null,
$row['department'] ?? null,
(bool) ($row['hasLeft'] ?? false),
),
array_values($rows),
));
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Saas\Model;
/**
* One employee's time account for one calendar month, reduced to what this application
* stores: the days with their net minutes and absence labels.
*
* Everything the SaaS reports about overtime accounts and payroll transfers is deliberately
* dropped. The header's own "ist" total survives as a cross-check against the days we summed
* ourselves, and "soll" because the sheet shows target against actual.
*/
class TimeAccount
{
/**
* @param array<string, TimeAccountDay> $days keyed by Y-m-d, ascending
* @param string[] $warnings
*/
public function __construct(
private readonly ?string $employeeUuid,
private readonly int $year,
private readonly int $month,
private readonly int $istMinutes,
private readonly int $targetMinutes,
private readonly ?string $department,
private readonly array $days,
private readonly array $warnings,
) {
}
public function getEmployeeUuid(): ?string
{
return $this->employeeUuid;
}
public function getYear(): int
{
return $this->year;
}
/**
* The calendar month (1-12), never the API's zero-based index.
*/
public function getMonth(): int
{
return $this->month;
}
/**
* The total the SaaS itself reported for the month.
*/
public function getIstMinutes(): int
{
return $this->istMinutes;
}
/**
* The month's target, as reported. There is no daily target: every row carries soll 0
* with sollType MONTH.
*/
public function getTargetMinutes(): int
{
return $this->targetMinutes;
}
/**
* The employee's department, as the bookings report it.
*
* Null when the month holds no booking at all - a month of nothing but holiday has
* nowhere to read it from.
*/
public function getDepartment(): ?string
{
return $this->department;
}
/**
* @return array<string, TimeAccountDay>
*/
public function getDays(): array
{
return $this->days;
}
public function getTotalMinutes(): int
{
return array_sum(array_map(static fn (TimeAccountDay $day) => $day->getMinutes(), $this->days));
}
public function countWorkedDays(): int
{
return count(array_filter($this->days, static fn (TimeAccountDay $day) => $day->getMinutes() > 0));
}
public function countAbsenceDays(): int
{
return count(array_filter($this->days, static fn (TimeAccountDay $day) => $day->isAbsence()));
}
/**
* @return string[]
*/
public function getWarnings(): array
{
return $this->warnings;
}
public function hasWarnings(): bool
{
return [] !== $this->warnings;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Saas\Model;
/**
* One calendar day of a time account: the span it was worked in, the net minutes inside that
* span, the breaks taken, and - if the day was booked off - the absence label the SaaS uses
* for it ("Ferien", "Ruhetag 24/25", ...).
*
* A day can be both: an absence label does not imply zero minutes.
*/
class TimeAccountDay
{
public function __construct(
private readonly \DateTimeImmutable $date,
private readonly int $minutes,
private readonly ?string $holidayType,
private readonly ?string $startTime = null,
private readonly ?string $endTime = null,
private readonly int $breakMinutes = 0,
) {
}
public function getDate(): \DateTimeImmutable
{
return $this->date;
}
public function getMinutes(): int
{
return $this->minutes;
}
public function getHolidayType(): ?string
{
return $this->holidayType;
}
/**
* First clock-in of the day as "HH:MM", or null when nothing with a duration was booked.
*/
public function getStartTime(): ?string
{
return $this->startTime;
}
/**
* Last clock-out of the day as "HH:MM".
*/
public function getEndTime(): ?string
{
return $this->endTime;
}
/**
* Everything booked as a break that day. The worked minutes are already net of it; this is
* the figure the sheet shows next to them.
*/
public function getBreakMinutes(): int
{
return $this->breakMinutes;
}
public function isAbsence(): bool
{
return null !== $this->holidayType;
}
}
+300
View File
@@ -0,0 +1,300 @@
<?php
namespace App\Saas;
use App\Saas\Model\Employee;
use App\Saas\Model\EmployeeDirectory;
use App\Saas\Model\TimeAccount;
use App\Saas\Model\TimeAccountDay;
/**
* Turns a raw time-account payload into the handful of facts this application keeps.
*
* Two properties of the payload drive everything here:
*
* 1. Months are zero-based in the response as well as in the request, and the "date"
* strings carry that index verbatim - a March booking arrives as "2026-02-31". Those
* strings must never reach a date parser: PHP rolls "2026-02-31" over to 2026-03-03,
* December arrives as "2026-11-31" and January as "2026-00-15". They are split and
* rebuilt one month later instead, then validated.
* 2. The row schema is not stable. Booking rows carry some 45 keys, the derived rows the
* SaaS inserts for breaks and absences carry about 20, and header fields come and go
* between months. Nothing is assumed to be present.
*/
class ResponseParser
{
/**
* A break segment. Carries its length in "pause" and never any working time, so it
* contributes nothing to a day's total - the minutes on booking rows are already net
* of breaks.
*/
private const ANNOTATION_BREAK = -1;
/**
* A full-day absence. This row, not a day summing to zero, is what marks a day off:
* "holidayType" names it, and a day can carry both an absence and booked work.
*/
private const ANNOTATION_ABSENCE = -4;
/**
* Reduces the employee roster to the four fields this application uses.
*
* The raw entries carry a per-employee "apiKey" - a credential - next to a birthday, a
* personnel number and an RFID token. The roster is cached to the filesystem, so the
* reduction happens here, before anything can hold on to the rest.
*/
public function parseEmployees(array $data): EmployeeDirectory
{
$employees = [];
foreach ($data as $row) {
if (!is_array($row)) {
continue;
}
$email = $row['email'] ?? null;
// The address is the only key this is useful by; an entry without one is not.
if (!is_string($email) || '' === trim($email)) {
continue;
}
$uuid = $row['uuid'] ?? null;
$department = $row['mainDepartment'] ?? null;
$separation = $row['dateOfSeparation'] ?? null;
$employees[] = new Employee(
trim($email),
is_string($uuid) && '' !== $uuid ? $uuid : null,
is_string($department) && '' !== trim($department) ? trim($department) : null,
is_string($separation) && '' !== trim($separation),
);
}
return EmployeeDirectory::fromEmployees($employees);
}
/**
* @param int $month the requested calendar month (1-12)
*/
public function parseTimeAccount(array $data, int $year, int $month): TimeAccount
{
$warnings = [];
$this->checkHeader($data, $year, $month, $warnings);
/** @var array<string, array{minutes: int, breakMinutes: int, holidayType: ?string, start: ?string, end: ?string}> $byDate */
$byDate = [];
/** @var string[] $departments */
$departments = [];
$rows = $data['list'] ?? [];
if (!is_array($rows)) {
$warnings[] = 'The payload carried no readable "list" of bookings.';
$rows = [];
}
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$date = $this->rebaseDate($row['date'] ?? null, $year, $month, $warnings);
if (null === $date) {
continue;
}
$key = $date->format('Y-m-d');
$byDate[$key] ??= [
'minutes' => 0,
'breakMinutes' => 0,
'holidayType' => null,
'start' => null,
'end' => null,
];
$annotation = (int) ($row['annotation'] ?? 0);
if ($annotation < 0
&& self::ANNOTATION_BREAK !== $annotation
&& self::ANNOTATION_ABSENCE !== $annotation
) {
// Positive annotations are a bitmask of booking attributes and need no
// interpretation, but the negative ones are distinct row kinds. We only
// know three of them; report the rest rather than quietly ignoring them.
$warnings[] = sprintf('Unknown row kind %d on %s.', $annotation, $key);
}
$byDate[$key]['minutes'] += (int) ($row['ist'] ?? 0);
$byDate[$key]['breakMinutes'] += (int) ($row['pause'] ?? 0);
$department = $row['department'] ?? null;
if (is_string($department) && '' !== trim($department)) {
$departments[trim($department)] = trim($department);
}
if ($annotation >= 0) {
// Only bookings define the working day's span. The absence marker carries a
// placeholder 23:59:59 that would otherwise become every free day's start
// time, and a break cannot extend the day past the last clock-out.
$this->widenSpan($byDate[$key], $row);
}
if (self::ANNOTATION_ABSENCE === $annotation) {
$holidayType = $row['holidayType'] ?? null;
if (is_string($holidayType) && '' !== trim($holidayType)) {
$byDate[$key]['holidayType'] = trim($holidayType);
}
}
}
ksort($byDate);
$days = [];
foreach ($byDate as $key => $values) {
// Nothing was booked, so the only rows were markers - a rest day, or an absence.
// Those carry a time, but the day has no span worth showing.
if (0 === $values['minutes'] && 0 === $values['breakMinutes']) {
$values['start'] = null;
$values['end'] = null;
}
$days[$key] = new TimeAccountDay(
new \DateTimeImmutable($key),
$values['minutes'],
$values['holidayType'],
$this->shortTime($values['start']),
$this->shortTime($values['end']),
$values['breakMinutes'],
);
}
$istMinutes = (int) ($data['ist'] ?? 0);
$summed = array_sum(array_column($byDate, 'minutes'));
if ($summed !== $istMinutes) {
// Nothing is dropped on purpose, so these two must agree. A mismatch is the
// first sign that the date convention or the row semantics have changed.
$warnings[] = sprintf(
'The days add up to %d minutes but the month reports %d.',
$summed,
$istMinutes,
);
}
if (1 < count($departments)) {
// One department per employee is what this is supposed to be; if a month ever
// spans two, whoever reads the sheet should know rather than see an arbitrary one.
$warnings[] = sprintf('The month reports more than one department: %s.', implode(', ', $departments));
}
$employeeUuid = $data['employee'] ?? null;
return new TimeAccount(
is_string($employeeUuid) && '' !== $employeeUuid ? $employeeUuid : null,
$year,
$month,
$istMinutes,
// The target is a monthly figure: every row carries soll 0 with sollType MONTH,
// so there is no such thing as a daily target to show.
(int) ($data['soll'] ?? 0),
// Carried on every booking row, identically; the derived rows for breaks and
// absences have none, so a month of pure holiday leaves this null.
reset($departments) ?: null,
$days,
$warnings,
);
}
/**
* Stretches a day's span to cover one more booking.
*
* Rows with no duration are kept: a day often opens with a zero-length row at the moment
* the teamer clocked in, and skipping those moved the start of the day to the end of the
* first break. Rest days carry such a row too, which is why a day with nothing booked
* drops its span again once every row has been seen.
*
* @param array{start: ?string, end: ?string} $day
*/
private function widenSpan(array &$day, array $row): void
{
$start = $this->timeOf($row['startTime'] ?? null);
$end = $this->timeOf($row['endTime'] ?? null);
if (null === $start || null === $end) {
return;
}
// "HH:MM:SS" sorts chronologically as a string, and no sample crosses midnight.
$day['start'] = null === $day['start'] ? $start : min($day['start'], $start);
$day['end'] = null === $day['end'] ? $end : max($day['end'], $end);
}
private function timeOf(mixed $raw): ?string
{
return is_string($raw) && 1 === preg_match('/^\d{2}:\d{2}:\d{2}$/', $raw) ? $raw : null;
}
private function shortTime(?string $time): ?string
{
return null !== $time ? substr($time, 0, 5) : null;
}
/**
* @param string[] $warnings
*/
private function checkHeader(array $data, int $year, int $month, array &$warnings): void
{
if (isset($data['year']) && (int) $data['year'] !== $year) {
$warnings[] = sprintf('The payload is for year %d, not %d.', (int) $data['year'], $year);
}
// The header reports the same zero-based index that was requested.
if (isset($data['month']) && (int) $data['month'] !== $month - 1) {
$warnings[] = sprintf(
'The payload is for month index %d, not %d.',
(int) $data['month'],
$month - 1,
);
}
}
/**
* Rebuilds a zero-based response date as a real one, or reports why it could not.
*
* @param string[] $warnings
*/
private function rebaseDate(mixed $raw, int $year, int $month, array &$warnings): ?\DateTimeImmutable
{
if (!is_string($raw) || 1 !== preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $raw, $matches)) {
$warnings[] = sprintf(
'Skipped a row with an unreadable date (%s).',
is_scalar($raw) ? '"'.$raw.'"' : get_debug_type($raw),
);
return null;
}
$rowYear = (int) $matches[1];
$rowMonth = (int) $matches[2] + 1;
$rowDay = (int) $matches[3];
if ($rowYear !== $year || $rowMonth !== $month || !checkdate($rowMonth, $rowDay, $rowYear)) {
$warnings[] = sprintf('Skipped a row dated "%s": that is not a day of %04d-%02d.', $raw, $year, $month);
return null;
}
$date = \DateTimeImmutable::createFromFormat(
'!Y-m-d',
sprintf('%04d-%02d-%02d', $rowYear, $rowMonth, $rowDay),
);
return false !== $date ? $date : null;
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Service\Common;
use App\Enum\WorkMonthStatus;
use App\Model\AbstractFilterDto;
use App\Model\WorkMonthFilterDto;
class WorkMonthFilterHandler extends AbstractFilterHandler
{
protected string $namespace = 'filter:work_month';
protected string $modelClass = WorkMonthFilterDto::class;
public function getFilterSettings(): WorkMonthFilterDto
{
$filterDto = new $this->modelClass();
if (null === $data = $this->getSession()->get($this->namespace)) {
return $filterDto;
}
$filterDto
->setTeamerName($data['teamer_name'] ?? null)
->setIncludeApproved((bool) ($data['include_approved'] ?? false))
->setIncludeMismatchedOnly((bool) ($data['include_mismatched_only'] ?? false))
;
if (null !== ($period = $data['period'] ?? null)) {
$filterDto->setPeriod(new \DateTimeImmutable($period));
}
// tryFrom rather than from: a status dropped from the enum must not fatal a session
// written before it went away.
$statuses = array_filter(array_map(
static fn (string $value) => WorkMonthStatus::tryFrom($value),
$data['statuses'] ?? [],
));
return $filterDto->setStatuses(array_values($statuses));
}
protected function saveFilterSettings(AbstractFilterDto $filterDto): void
{
/* @var WorkMonthFilterDto $filterDto */
$this->getSession()->set($this->namespace, [
'teamer_name' => $filterDto->getTeamerName(),
'period' => $filterDto->getPeriod()?->format('Y-m-d'),
'statuses' => array_map(
static fn (WorkMonthStatus $status) => $status->value,
$filterDto->getStatuses(),
),
'include_approved' => $filterDto->isIncludeApproved(),
'include_mismatched_only' => $filterDto->isIncludeMismatchedOnly(),
]);
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Service\Cron;
use App\Config\EmailTextKey;
use App\Email\Mailer;
use App\Repository\TeamerWorkMonthRepository;
use Psr\Log\LoggerInterface;
/**
* Chases the teamers who still owe us a signed Arbeitszeitnachweis.
*
* Idempotent without recording anything, like the other reminders here: it picks exactly one
* weekday, so a month is chased once a week for as long as it stays open, and a second run on
* the same day would simply send the same mail again.
*
* Only an upload stops it. An import that changes the hours does not, and neither does a
* rejection - a rejected month is one the teamer still has to deal with.
*/
class WorkMonthReminderService
{
private const DIGEST_WEEKDAY = 1;
/**
* Days between the import and the first reminder. The import runs on the 10th, so this
* leaves the teamer the best part of a week before anyone nags.
*/
private const GRACE_DAYS = 5;
public function __construct(
private readonly TeamerWorkMonthRepository $workMonthRepository,
private readonly Mailer $mailer,
private readonly LoggerInterface $logger,
) {
}
public function sendReminders(?\DateTimeImmutable $today = null): string
{
$today = ($today ?? new \DateTimeImmutable('today'))->setTime(0, 0);
if (self::DIGEST_WEEKDAY !== (int) $today->format('N')) {
$message = 'Work month reminders are only sent on mondays';
$this->logger->info($message);
return $message;
}
$months = $this->workMonthRepository->findAwaitingDocument(
$today->modify(sprintf('-%d days', self::GRACE_DAYS))
);
$count = 0;
foreach ($months as $workMonth) {
$email = $workMonth->getTeamer()?->getCommunication()?->getEmail();
if (null === $email || '' === trim($email)) {
continue;
}
$this->mailer->createAndSendText(EmailTextKey::REMINDER_WORK_MONTH_UPLOAD, [
'workMonth' => $workMonth->getPeriodLabel(),
], [
'to' => $email,
]);
++$count;
}
$message = 'Sent '.$count.' work month reminders to teamers';
$this->logger->info($message);
return $message;
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Service\Pdf;
use App\Entity\TeamerWorkMonth;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Twig\Environment;
/**
* Renders the Arbeitszeitnachweis a teamer signs for one month.
*
* Deliberately a sibling of AbstractPdfRenderer rather than a subclass: that base exists for
* the FPDI renderers, which stamp text at fixed millimetre coordinates onto a pre-made PDF.
* That works for the one-page contract and invoice forms but not for a table whose length
* depends on the month, so this one goes through Twig and Dompdf instead. Dompdf's bundled
* DejaVu fonts also carry umlauts natively, where the FPDI path has to convert every string
* to ISO-8859-1 first (see Pdf::encodeString()).
*/
class WorkMonthRenderer
{
private array $config;
public function __construct(
private readonly Environment $twig,
array $options,
) {
$this->config = $this->resolveOptions($options);
}
public function render(TeamerWorkMonth $workMonth): string
{
$options = new Options();
// Nothing in the document is fetched from anywhere: the stylesheet is inline and
// there are no images, so the renderer never needs to leave the project directory.
$options->setIsRemoteEnabled(false);
$options->setChroot($this->config['project_dir']);
$options->setDefaultFont('DejaVu Sans');
$options->setDefaultPaperSize('A4');
$options->setDefaultPaperOrientation('portrait');
$dompdf = new Dompdf($options);
$dompdf->loadHtml($this->twig->render('pdf/work_month.html.twig', [
'workMonth' => $workMonth,
'teamer' => $workMonth->getTeamer(),
'days' => $workMonth->getDays()->forCalendarMonth($workMonth->getYear(), $workMonth->getMonth()),
'logo' => $this->logoPath(),
]), 'UTF-8');
$dompdf->render();
return (string) $dompdf->output();
}
/**
* The letterhead logo, as an absolute path inside the chroot.
*
* Read from the filesystem rather than embedded: remote loading is off and the chroot is
* the project directory, so Dompdf can open this and nothing outside it. Null when the
* file is missing, because a working-time record is worth printing without its letterhead.
*/
private function logoPath(): ?string
{
$path = $this->config['project_dir'].'/assets/images/logo.png';
return is_readable($path) ? $path : null;
}
/**
* Arbeitszeitnachweis_Test_2026-03.pdf.
*/
public function filename(TeamerWorkMonth $workMonth): string
{
$lastName = $workMonth->getTeamer()?->getLastName() ?? 'Teamer';
return sprintf(
'Arbeitszeitnachweis_%s_%04d-%02d.pdf',
preg_replace('/[^A-Za-z0-9]+/', '_', $lastName),
$workMonth->getYear(),
$workMonth->getMonth(),
);
}
private function resolveOptions(array $options): array
{
$optionsResolver = new OptionsResolver();
$optionsResolver->setRequired(['project_dir']);
$optionsResolver->setAllowedTypes('project_dir', 'string');
return $optionsResolver->resolve($options);
}
}
+233
View File
@@ -0,0 +1,233 @@
<?php
namespace App\Service\Saas;
use App\Entity\Teamer;
use App\Entity\TeamerWorkMonth;
use App\Enum\WorkMonthStatus;
use App\Model\WorkHoursSyncResult;
use App\Model\WorkMonthDays;
use App\Repository\TeamerRepository;
use App\Repository\TeamerWorkMonthRepository;
use App\Saas\ApiClient;
use App\Saas\ApiClientException;
use App\Saas\DataProvider\EmployeeDataProvider;
use App\Saas\ResponseParser;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Imports working time from the SaaS into TeamerWorkMonth records.
*
* Runs once a month for every season teamer, and on demand for a single one. Both go
* through syncTeamer(), so the admin button and the scheduled command cannot drift apart.
*/
class WorkHoursSynchronizer
{
public function __construct(
private readonly ApiClient $apiClient,
private readonly ResponseParser $responseParser,
private readonly EmployeeDataProvider $employeeDataProvider,
private readonly TeamerRepository $teamerRepository,
private readonly TeamerWorkMonthRepository $workMonthRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
/**
* The season teamers one month's import covers.
*
* @return Teamer[]
*/
public function findEligibleTeamers(int $year, int $month): array
{
$firstDay = $this->firstDayOf($year, $month);
return $this->teamerRepository->findSeasonTeamersForMonth(
$firstDay,
$firstDay->modify('last day of this month'),
);
}
/**
* @return WorkHoursSyncResult[]
*/
public function syncMonth(int $year, int $month, bool $dryRun = false, ?callable $onProgress = null): array
{
$results = [];
foreach ($this->findEligibleTeamers($year, $month) as $teamer) {
$result = $this->syncTeamer($teamer, $year, $month, $dryRun);
$results[] = $result;
if (null !== $onProgress) {
$onProgress($result);
}
}
return $results;
}
public function syncTeamer(Teamer $teamer, int $year, int $month, bool $dryRun = false): WorkHoursSyncResult
{
$email = $teamer->getCommunication()?->getEmail();
if (null === $email || '' === trim($email)) {
return WorkHoursSyncResult::skipped($teamer, 'Keine E-Mail-Adresse hinterlegt.');
}
$directory = $this->employeeDataProvider->getDirectory();
$employee = $directory->find($email);
// An empty roster means the roster call failed, not that nobody is registered, so it
// must never be read as "skip everyone" - the import goes ahead without it.
if (null === $employee && !$directory->isEmpty()) {
return WorkHoursSyncResult::skipped(
$teamer,
sprintf('Nicht in der SaaS registriert (%s).', $email),
);
}
try {
$payload = $this->apiClient->getTimeAccount($email, $year, $month);
} catch (ApiClientException $e) {
$this->logger->error('Work hours import failed', [
'teamer' => $teamer->getFullName(),
'year' => $year,
'month' => $month,
'error' => $e->getMessage(),
]);
return WorkHoursSyncResult::failed($teamer, $e->getMessage());
}
$account = $this->responseParser->parseTimeAccount($payload, $year, $month);
$days = WorkMonthDays::fromTimeAccount($account);
$workMonth = $this->workMonthRepository->findOneForTeamerAndMonth($teamer, $year, $month);
if (null !== $workMonth && $workMonth->getStatus()->locksHours()) {
return $this->compareLocked($workMonth, $days, $dryRun);
}
if (null === $workMonth) {
$workMonth = (new TeamerWorkMonth())
->setTeamer($teamer)
->setYear($year)
->setMonth($month)
;
$this->entityManager->persist($workMonth);
}
$workMonth
->setDays($days)
->setSourceIstMinutes($account->getIstMinutes())
->setTargetMinutes($account->getTargetMinutes())
// Keep the last known one: a month of pure holiday reports none, and blanking it
// would lose what earlier months already established.
// The bookings first, the roster second: a month made up entirely of holiday has
// no booking to read a department from, and the roster always does.
->setDepartment($account->getDepartment() ?? $employee?->getDepartment() ?? $workMonth->getDepartment())
->setWarnings($account->getWarnings())
->setSyncedAt(new \DateTimeImmutable())
->clearMismatch()
;
$employeeUuid = $account->getEmployeeUuid() ?? $employee?->getUuid();
if (null !== $employeeUuid && $teamer->getSaasEmployeeUuid() !== $employeeUuid) {
$teamer->setSaasEmployeeUuid($employeeUuid);
}
if (!$dryRun) {
$this->entityManager->flush();
}
$this->logger->info('Work hours imported', [
'teamer' => $teamer->getFullName(),
'year' => $year,
'month' => $month,
'minutes' => $days->getTotalMinutes(),
'days' => count($days->toArray()),
'warnings' => count($account->getWarnings()),
'dryRun' => $dryRun,
]);
return WorkHoursSyncResult::imported($teamer, $days->getTotalMinutes(), $account->getWarnings());
}
/**
* Releases a signed month back to the importer, for when a recorded discrepancy turns
* out to be a genuine correction the teamer has to sign again.
*/
public function unlock(TeamerWorkMonth $workMonth): void
{
$workMonth
->setStatus(WorkMonthStatus::OPEN)
->setSubmittedAt(null)
->setReviewedAt(null)
->setReviewedBy(null)
->setReviewComment(null)
->clearMismatch()
;
}
/**
* A signed month keeps the hours it was signed with. The fresh figures are only
* compared, so the office can see that the SaaS has since changed its mind.
*/
private function compareLocked(TeamerWorkMonth $workMonth, WorkMonthDays $days, bool $dryRun): WorkHoursSyncResult
{
$teamer = $workMonth->getTeamer();
$fresh = $days->getTotalMinutes();
if ($workMonth->getTotalMinutes() === $fresh) {
$workMonth->clearMismatch();
if (!$dryRun) {
$this->entityManager->flush();
}
return WorkHoursSyncResult::locked(
$teamer,
'Bereits eingereicht, Stunden unverändert.',
$workMonth->getTotalMinutes(),
);
}
$workMonth->recordMismatch($fresh);
if (!$dryRun) {
$this->entityManager->flush();
}
$this->logger->warning('Work hours changed after submission', [
'teamer' => $teamer->getFullName(),
'year' => $workMonth->getYear(),
'month' => $workMonth->getMonth(),
'signed' => $workMonth->getTotalMinutes(),
'reported' => $fresh,
]);
return WorkHoursSyncResult::locked(
$teamer,
sprintf(
'Bereits eingereicht; die SaaS meldet jetzt %d statt %d Minuten.',
$fresh,
$workMonth->getTotalMinutes(),
),
$workMonth->getTotalMinutes(),
);
}
private function firstDayOf(int $year, int $month): \DateTimeImmutable
{
$first = \DateTimeImmutable::createFromFormat('!Y-n-j', sprintf('%04d-%d-1', $year, $month));
if (false === $first) {
throw new \InvalidArgumentException(sprintf('%04d-%02d is not a month.', $year, $month));
}
return $first;
}
}
+1
View File
@@ -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']),
];
}
+18
View File
@@ -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);
+146
View File
@@ -0,0 +1,146 @@
{% extends 'admin/layout.html.twig' %}
{% block title %}Arbeitszeiten{% endblock %}
{% block content %}
<div class="flex items-start justify-between pb-4">
<h1 class="text-2xl font-bold">
Arbeitszeiten
</h1>
<div class="flex flex-col items-end space-y-2 md:flex-row md:items-center md:space-x-2 md:space-y-0">
<button type="button"
class="{{ html_classes('btn btn--small', { 'btn--secondary': filterDto.active }) }}"
title="Arbeitszeiten filtern"
hx-get="{{ path('app_common_work_month_filter', { 'r': return_url() }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('filter', 'w-4 h-4 shrink-0') }}
{% if filterDto.active %}
<span class="whitespace-nowrap">{{ filterDto.activeFiltersCount }} Filter aktiv</span>
{% else %}
<span>Filter</span>
{% endif %}
</button>
{% if filterDto.active %}
<a href="{{ path('app_common_work_month_filter_reset', { 'r': return_url() }) }}" class="btn btn--small btn--secondary">
Reset
</a>
{% endif %}
</div>
</div>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner">
<table class="data-table">
<thead>
<tr>
<th>{{ knp_pagination_sortable(pagination, 'Name', 'teamer.lastName') }}</th>
<th>Vorname</th>
<th>{{ knp_pagination_sortable(pagination, 'Monat', 'work_month.year') }}</th>
<th class="text-right">Stunden</th>
<th>Status</th>
<th>Nachweis</th>
<th></th>
</tr>
</thead>
<tbody>
{% for workMonth in pagination %}
<tr>
<td>
<a href="{{ path('app_administrative_teamer_profile', { 'uuid': workMonth.teamer.uuid, 'r': return_url() }) }}">
<span class="whitespace-nowrap">{{ workMonth.teamer.lastName }}</span>
</a>
</td>
<td>
<span class="whitespace-nowrap">{{ workMonth.teamer.firstName }}</span>
</td>
<td>
<span class="whitespace-nowrap">{{ workMonth.periodLabel }}</span>
</td>
<td class="text-right">
<span class="whitespace-nowrap">{{ workMonth.totalMinutes|duration_hm }}</span>
{% if workMonth.hasMismatch %}
<div class="text-xs text-red-600 whitespace-nowrap"
title="Die SaaS meldet inzwischen andere Stunden für diesen bereits eingereichten Monat.">
jetzt {{ workMonth.mismatchTotalMinutes|duration_hm }}
</div>
{% endif %}
{% if workMonth.warnings is not empty %}
<div class="text-xs text-amber-700 whitespace-nowrap"
title="{{ workMonth.warnings|join(' | ') }}">
{{ workMonth.warnings|length }} Hinweis(e)
</div>
{% endif %}
</td>
<td>
<span class="upload-status-badge {{ workMonth.status.badgeClass }}">
{{ workMonth.status.label }}
</span>
</td>
<td>
{% if workMonth.currentDocument %}
<a href="{{ path('app_common_download', { 'uuid': workMonth.currentDocument.uuid }) }}"
class="flex items-center space-x-1">
{{ icon('download', 'w-4 h-4 shrink-0') }}
<span class="whitespace-nowrap">{{ workMonth.submittedAt|date('d.m.Y') }}</span>
</a>
{% else %}
<span class="text-gray-400">–</span>
{% endif %}
</td>
<td>
<twig:DropDown>
<a href="{{ path('app_admin_work_month_pdf', { 'uuid': workMonth.uuid }) }}"
class="text-gray-700 hover:text-primary block px-4 py-2 text-sm"
role="menuitem"
tabindex="-1"
target="_blank">
Nachweis ansehen
</a>
{% if workMonth.status.value == 'submitted' %}
<a href="{{ path('app_admin_work_month_review', { 'uuid': workMonth.uuid, 'r': return_url() }) }}"
class="text-gray-700 hover:text-primary block px-4 py-2 text-sm"
role="menuitem"
tabindex="-1"
hx-get="{{ path('app_admin_work_month_review', { 'uuid': workMonth.uuid, 'r': return_url() }) }}"
hx-target="body"
hx-swap="beforeend">
Prüfen
</a>
{% endif %}
<form method="post" action="{{ path('app_admin_work_month_sync', { 'uuid': workMonth.uuid, 'r': return_url() }) }}">
<button type="submit"
class="w-full text-left text-gray-700 hover:text-primary block px-4 py-2 text-sm"
role="menuitem"
tabindex="-1">
Erneut abrufen
</button>
</form>
{% if workMonth.status.value in ['submitted', 'approved'] %}
<form method="post" action="{{ path('app_admin_work_month_unlock', { 'uuid': workMonth.uuid, 'r': return_url() }) }}">
<button type="submit"
class="w-full text-left text-gray-700 hover:text-primary block px-4 py-2 text-sm"
role="menuitem"
tabindex="-1"
onclick="return confirm('Der Monat wird wieder geöffnet und muss neu unterschrieben werden. Fortfahren?')">
Wieder öffnen
</button>
</form>
{% endif %}
</twig:DropDown>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="text-gray-500">
Keine Arbeitszeiten gefunden.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{{ knp_pagination_render(pagination) }}
{% endblock %}
@@ -0,0 +1,49 @@
{% extends 'htmx_modal.html.twig' %}
{% block title %}Arbeitszeitnachweis prüfen{% endblock %}
{% block content %}
{{ form_start(form, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML' } }) }}
<div class="pb-4">
<div class="font-bold">
{{ workMonth.teamer }} &middot; {{ workMonth.periodLabel }}
</div>
<div class="text-sm text-gray-500">
{{ workMonth.totalMinutes|duration_hm }} Stunden an {{ workMonth.workedDays }} Tagen,
eingereicht am {{ workMonth.submittedAt|date('d.m.Y') }}
</div>
{% if workMonth.hasMismatch %}
<div class="text-sm text-red-600 pt-2">
Achtung: die SaaS meldet für diesen Monat inzwischen
{{ workMonth.mismatchTotalMinutes|duration_hm }} Stunden.
</div>
{% endif %}
</div>
<div class="flex flex-col space-y-1 pb-4">
{% if workMonth.currentDocument %}
<a href="{{ path('app_common_download', { 'uuid': workMonth.currentDocument.uuid }) }}"
class="flex items-center space-x-1">
{{ icon('download') }}
<span class="flex-shrink-0">Hochgeladenen Nachweis ansehen</span>
</a>
{% endif %}
<a href="{{ path('app_admin_work_month_pdf', { 'uuid': workMonth.uuid }) }}"
class="flex items-center space-x-1"
target="_blank">
{{ icon('pdf') }}
<span class="flex-shrink-0">Nachweis aus den Daten erzeugen</span>
</a>
</div>
<div class="flex flex-col space-y-2 pb-4">
{{ form_row(form.status) }}
{{ form_row(form.comment) }}
</div>
<button type="submit" class="btn">
Speichern
</button>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
@@ -0,0 +1,20 @@
{% extends 'htmx_modal.html.twig' %}
{% block title %}Arbeitszeiten filtern{% endblock %}
{% block content %}
{{ form_start(filterForm, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML'} }) }}
<div class="flex flex-col space-y-2 pb-2">
{{ form_row(filterForm.teamerName) }}
{{ form_row(filterForm.period) }}
{{ form_row(filterForm.statuses) }}
{{ form_row(filterForm.includeApproved) }}
{{ form_row(filterForm.includeMismatchedOnly) }}
</div>
<div class="flex items-center space-x-2">
{{ form_widget(filterForm.apply, { 'attr': { 'class': 'btn' } }) }}
{{ form_widget(filterForm.reset, { 'attr': { 'class': 'btn btn--secondary' } }) }}
</div>
{{ form_rest(filterForm) }}
{{ form_end(filterForm) }}
{% endblock %}
+139
View File
@@ -0,0 +1,139 @@
{#
The Arbeitszeitnachweis: one month of working time for one teamer, to be printed,
signed and uploaded again.
Rendered by App\Service\Pdf\WorkMonthRenderer through Dompdf, not by the FPDI renderers
the contract and the invoice use - a table of 28 to 31 rows is exactly what coordinate
stamping cannot do. Styles have to be inline and simple: Dompdf supports little beyond
CSS 2.1, and nothing may be loaded from outside the document.
#}
<style>
@page { margin: 18mm 15mm 20mm 15mm; }
body { font-family: "DejaVu Sans", sans-serif; font-size: 9pt; color: #000; }
h1 { font-size: 14pt; margin: 0 0 2mm 0; }
/* A table rather than a float: Dompdf lays tables out reliably, floats less so. */
table.letterhead { width: 100%; border-collapse: collapse; margin-bottom: 4mm; }
table.letterhead td { padding: 0; vertical-align: top; }
table.letterhead td.mark { text-align: right; width: 42mm; }
table.letterhead img { width: 38mm; height: 11.6mm; }
.meta { width: 100%; margin-bottom: 6mm; font-size: 9pt; }
.meta td { padding: 0.6mm 0; vertical-align: top; }
.meta .label { color: #444; width: 32mm; }
table.days { width: 100%; border-collapse: collapse; }
table.days th,
table.days td { border: 0.3pt solid #999; padding: 1.2mm 1.6mm; }
table.days th { background-color: #eee; font-size: 8.5pt; text-align: left; }
table.days td.num { text-align: right; }
table.days tr.weekend td { background-color: #f6f6f6; }
table.days tr.total td { font-weight: bold; background-color: #eee; }
.signature { margin-top: 14mm; }
.signature td { padding-top: 12mm; font-size: 8pt; color: #444; }
.signature .line { border-top: 0.4pt solid #000; width: 70mm; }
.note { margin-top: 6mm; font-size: 7.5pt; color: #555; }
</style>
<table class="letterhead">
<tr>
<td><h1>Arbeitszeitnachweis</h1></td>
<td class="mark">
{% if logo|default(null) %}
<img src="{{ logo }}" alt="E&amp;P Reisen Events">
{% endif %}
</td>
</tr>
</table>
<table class="meta">
<tr>
<td class="label">Teamer:in</td>
<td>{{ teamer.fullName }}</td>
</tr>
{% if workMonth.department %}
<tr>
<td class="label">Abteilung</td>
<td>{{ workMonth.department }}</td>
</tr>
{% endif %}
<tr>
<td class="label">Monat</td>
<td>{{ workMonth.period|format_datetime(pattern='LLLL yyyy', locale='de') }}</td>
</tr>
<tr>
<td class="label">Sollstunden</td>
<td>{{ workMonth.targetMinutes|duration_hm }}</td>
</tr>
<tr>
<td class="label">Iststunden</td>
<td><strong>{{ workMonth.totalMinutes|duration_hm }}</strong></td>
</tr>
<tr>
<td class="label">Saldo</td>
<td>{{ workMonth.balanceMinutes > 0 ? '+' : '' }}{{ workMonth.balanceMinutes|duration_hm }}</td>
</tr>
<tr>
<td class="label">Pausen gesamt</td>
<td>{{ workMonth.breakMinutes|duration_hm }}</td>
</tr>
<tr>
<td class="label">Arbeitstage</td>
<td>{{ workMonth.workedDays }}{% if workMonth.absenceDays > 0 %}, freie Tage: {{ workMonth.absenceDays }}{% endif %}</td>
</tr>
</table>
<table class="days">
<thead>
<tr>
<th style="width: 21mm;">Datum</th>
<th style="width: 13mm;">Tag</th>
<th style="width: 15mm;">Beginn</th>
<th style="width: 15mm;">Ende</th>
<th style="width: 15mm;">Pause</th>
<th style="width: 18mm;">Stunden</th>
<th>Abwesenheit</th>
</tr>
</thead>
<tbody>
{% for day in days %}
<tr{% if day.weekend %} class="weekend"{% endif %}>
<td>{{ day.date|date('d.m.Y') }}</td>
<td>{{ day.date|format_datetime(pattern='EEE', locale='de') }}</td>
<td class="num">{{ day.startTime ?? '' }}</td>
<td class="num">{{ day.endTime ?? '' }}</td>
<td class="num">{{ day.breakMinutes > 0 ? day.breakMinutes|duration_hm : '' }}</td>
<td class="num">{{ day.minutes|duration_hm }}</td>
<td>{{ day.absence ?? '' }}</td>
</tr>
{% endfor %}
<tr class="total">
<td colspan="4">Gesamt</td>
<td class="num">{{ workMonth.breakMinutes|duration_hm }}</td>
<td class="num">{{ workMonth.totalMinutes|duration_hm }}</td>
<td></td>
</tr>
</tbody>
</table>
<table class="signature">
<tr>
<td style="width: 50%;">
<div class="line"></div>
Ort, Datum
</td>
<td style="width: 50%;">
<div class="line"></div>
Unterschrift {{ teamer.fullName }}
</td>
</tr>
</table>
<p class="note">
Bitte den Nachweis unterschrieben wieder im Team-Portal hochladen.
Stand der Daten: {{ workMonth.syncedAt|date('d.m.Y H:i') }} Uhr.
</p>
@@ -0,0 +1,80 @@
{% extends 'teamer/layout.html.twig' %}
{% block title %}Arbeitszeiten{% endblock %}
{% block content %}
<div class="max-w-screen-md mx-auto">
<h1 class="text-2xl font-bold pb-2">
Arbeitszeiten
</h1>
<p class="pb-8 text-gray-700">
Hier findest du für jeden abgerechneten Monat deinen Arbeitszeitnachweis.
Bitte lade ihn herunter, unterschreibe ihn und lade ihn anschließend wieder hoch.
</p>
{% if workMonths is empty %}
<div class="text-gray-500">
Es liegen noch keine Arbeitszeiten vor. Ein Monat erscheint hier, sobald er
abgerechnet wurde.
</div>
{% else %}
<dl class="divide-y divide-gray-100">
{% for workMonth in workMonths %}
<div class="py-6 first:pt-0 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="font-bold leading-6 text-gray-900">
{{ workMonth.period|format_datetime(pattern='LLLL yyyy', locale='de') }}
<div class="text-sm font-normal text-gray-500">
{{ workMonth.totalMinutes|duration_hm }} Stunden
an {{ workMonth.workedDays }} Tagen
</div>
</dt>
<dd class="mt-1 text-sm leading-6 sm:mt-0 sm:col-span-2">
<div class="pb-2">
<span class="upload-status-badge {{ workMonth.status.badgeClass }}">
{{ workMonth.status.label }}
</span>
</div>
{% if workMonth.status.value == 'rejected' and workMonth.reviewComment %}
<div class="pb-2 text-red-600">
{{ workMonth.reviewComment }}
</div>
{% endif %}
<div class="flex flex-col space-y-1">
<a href="{{ path('app_teamer_work_month_pdf', { 'uuid': workMonth.uuid }) }}"
class="flex items-center space-x-1">
{{ icon('download') }}
<span class="flex-shrink-0">Nachweis herunterladen</span>
</a>
{% if workMonth.status.value != 'approved' %}
<a href="{{ path('app_teamer_work_month_upload', { 'uuid': workMonth.uuid }) }}"
class="flex items-center space-x-1"
hx-get="{{ path('app_teamer_work_month_upload', { 'uuid': workMonth.uuid }) }}"
hx-target="#htmx-modal"
hx-swap="outerHTML">
{{ icon('upload') }}
<span class="flex-shrink-0">
{{ workMonth.currentDocument ? 'Erneut hochladen' : 'Unterschrieben hochladen' }}
</span>
</a>
{% endif %}
{% if workMonth.currentDocument %}
<a href="{{ path('app_common_download', { 'uuid': workMonth.currentDocument.uuid }) }}"
class="flex items-center space-x-1 text-gray-500">
{{ icon('document') }}
<span class="flex-shrink-0">
Hochgeladen am {{ workMonth.submittedAt|date('d.m.Y') }}
</span>
</a>
{% endif %}
</div>
</dd>
</div>
{% endfor %}
</dl>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,26 @@
{% extends 'htmx_modal.html.twig' %}
{% block title %}Arbeitszeitnachweis hochladen{% endblock %}
{% block content %}
{% set formAttr = stimulus_controller('form-upload-guard')|stimulus_action('form-upload-guard', 'guard', 'submit') %}
{{ form_start(form, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML' }|merge(formAttr.toArray()) }) }}
<div class="pb-4">
Lade hier deinen unterschriebenen Arbeitszeitnachweis für
{{ workMonth.period|format_datetime(pattern='LLLL yyyy', locale='de') }} hoch.
Ein Foto genügt, solange alles lesbar ist.
</div>
<div class="pb-4">
{% include '_partials/_upload_collection_form.html.twig' with {
'endpoint_upload': path('_uploader_upload_work_month'),
'max_filesize': 5,
'max_files': 2,
'accepted_files': 'image/jpg,image/jpeg,application/pdf',
} %}
</div>
<button type="submit" class="btn" {{ stimulus_target('form-upload-guard', 'submit') }}>
Nachweis speichern
</button>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
@@ -0,0 +1,103 @@
<?php
namespace App\Tests\Logging;
use App\Logging\RedactSaasCredentialsProcessor;
use Monolog\Level;
use Monolog\LogRecord;
use PHPUnit\Framework\TestCase;
class RedactSaasCredentialsProcessorTest extends TestCase
{
private const KEY = 'sk-live-0123456789abcdef';
/**
* The line Symfony's http client writes for every request it makes.
*/
public function testItRedactsTheKeyOutOfARequestLine(): void
{
$record = $this->process(sprintf(
'Request: "GET https://saas.example/api/v2/time/account/%s/theo%%40example.com/2026/2"',
self::KEY,
));
$this->assertStringNotContainsString(self::KEY, $record->message);
$this->assertStringContainsString('/time/account/[redacted]', $record->message);
$this->assertStringContainsString('theo%40example.com/2026/2', $record->message, 'Only the key goes.');
}
/**
* The one a silenced client would have missed: an HTTP error repeats the url inside the
* exception message, which then travels into whichever channel catches it.
*/
public function testItRedactsTheKeyOutOfAnExceptionMessage(): void
{
$record = $this->process(
'Work hours import failed',
['error' => sprintf('HTTP 500 returned for "https://saas.example/api/v2/time/account/%s/[email protected]/2026/2".', self::KEY)],
);
$this->assertStringNotContainsString(self::KEY, json_encode($record->context));
$this->assertStringContainsString('[redacted]', $record->context['error']);
}
public function testItRedactsTheRosterAndDepartmentEndpointsToo(): void
{
$record = $this->process(sprintf(
'GET https://saas.example/api/v2/employees/%s and /api/v2/employee/departments/%s',
self::KEY,
self::KEY,
));
$this->assertStringNotContainsString(self::KEY, $record->message);
$this->assertStringContainsString('/employees/[redacted]', $record->message);
$this->assertStringContainsString('/employee/departments/[redacted]', $record->message);
}
/**
* The roster hands out a key per employee. None is configured here, so only the pattern
* can catch those - which is why the pattern pass exists alongside the exact one.
*/
public function testAnUnknownKeyInAKeyPositionIsStillRedacted(): void
{
$record = $this->process('Request: "GET https://saas.example/api/v2/time/account/some-other-key/[email protected]/2026/2"');
$this->assertStringNotContainsString('some-other-key', $record->message);
}
public function testItReachesIntoNestedContext(): void
{
$record = $this->process('failed', [
'request' => ['url' => 'https://saas.example/api/v2/employees/'.self::KEY],
]);
$this->assertStringNotContainsString(self::KEY, json_encode($record->context));
}
public function testItLeavesOrdinaryRecordsAlone(): void
{
$record = $this->process('Work hours imported', ['teamer' => 'Test, Theo', 'minutes' => 480]);
$this->assertSame('Work hours imported', $record->message);
$this->assertSame(['teamer' => 'Test, Theo', 'minutes' => 480], $record->context);
}
/**
* An unconfigured key is an empty string, which must never be treated as a secret to
* replace - that would rewrite every character of every message.
*/
public function testAnUnconfiguredKeyRedactsNothingByValue(): void
{
$processor = new RedactSaasCredentialsProcessor('');
$record = $processor(new LogRecord(new \DateTimeImmutable(), 'app', Level::Info, 'plain message'));
$this->assertSame('plain message', $record->message);
}
private function process(string $message, array $context = []): LogRecord
{
$processor = new RedactSaasCredentialsProcessor(self::KEY);
return $processor(new LogRecord(new \DateTimeImmutable(), 'app', Level::Info, $message, $context));
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace App\Tests\Model;
use App\Model\WorkMonthDays;
use App\Saas\Model\TimeAccount;
use App\Saas\Model\TimeAccountDay;
use PHPUnit\Framework\TestCase;
class WorkMonthDaysTest extends TestCase
{
public function testItRoundTripsThroughTheStoredShape(): void
{
$stored = [
'2026-03-01' => ['minutes' => 285],
'2026-03-05' => ['minutes' => 0, 'absence' => 'Ruhetag 24/25'],
];
$this->assertSame($stored, WorkMonthDays::fromArray($stored)->toArray());
}
public function testItSortsAndDropsEntriesItCannotRead(): void
{
$days = WorkMonthDays::fromArray([
'2026-03-05' => ['minutes' => 60],
'not-a-date' => ['minutes' => 120],
'2026-03-01' => ['minutes' => 30, 'absence' => ''],
'2026-03-09' => 'not an entry',
]);
$this->assertSame(['2026-03-01', '2026-03-05'], array_keys($days->toArray()));
$this->assertArrayNotHasKey('absence', $days->toArray()['2026-03-01'], 'An empty label is no label.');
}
public function testAMissingMinutesKeyCountsAsZero(): void
{
$days = WorkMonthDays::fromArray(['2026-03-01' => ['absence' => 'Ferien']]);
$this->assertSame(0, $days->getTotalMinutes());
$this->assertSame(1, $days->countAbsenceDays());
$this->assertSame(0, $days->countWorkedDays());
}
public function testItSummarisesTheMonth(): void
{
$days = WorkMonthDays::fromArray([
'2026-03-01' => ['minutes' => 285],
'2026-03-05' => ['minutes' => 0, 'absence' => 'Ruhetag 24/25'],
'2026-03-06' => ['minutes' => 600],
]);
$this->assertSame(885, $days->getTotalMinutes());
$this->assertSame(2, $days->countWorkedDays());
$this->assertSame(1, $days->countAbsenceDays());
}
public function testItBuildsFromATimeAccount(): void
{
$account = new TimeAccount('e-1', 2026, 3, 345, 9900, 'Operations', [
'2026-03-01' => new TimeAccountDay(new \DateTimeImmutable('2026-03-01'), 285, null, '09:00', '14:45', 60),
'2026-03-05' => new TimeAccountDay(new \DateTimeImmutable('2026-03-05'), 60, 'Ferien'),
], []);
$this->assertSame([
'2026-03-01' => ['minutes' => 285, 'break' => 60, 'start' => '09:00', 'end' => '14:45'],
'2026-03-05' => ['minutes' => 60, 'absence' => 'Ferien'],
], WorkMonthDays::fromTimeAccount($account)->toArray());
}
public function testASpanNeedsBothEnds(): void
{
$days = WorkMonthDays::fromArray([
'2026-03-01' => ['minutes' => 60, 'start' => '09:00'],
'2026-03-02' => ['minutes' => 60, 'start' => '09:00', 'end' => 'noon'],
'2026-03-03' => ['minutes' => 60, 'start' => '09:00', 'end' => '10:00'],
])->forCalendarMonth(2026, 3);
$this->assertFalse($days[0]->hasSpan());
$this->assertFalse($days[1]->hasSpan());
$this->assertTrue($days[2]->hasSpan());
}
public function testItTotalsTheBreaks(): void
{
$days = WorkMonthDays::fromArray([
'2026-03-01' => ['minutes' => 285, 'break' => 60],
'2026-03-02' => ['minutes' => 405, 'break' => 150],
'2026-03-03' => ['minutes' => 180],
]);
$this->assertSame(210, $days->getTotalBreakMinutes());
$this->assertSame(0, $days->forCalendarMonth(2026, 3)[2]->getBreakMinutes());
}
/**
* The calendar, not the payload, decides how many rows a month has: days the SaaS never
* reported still need a line in the table and in the PDF.
*/
public function testItFillsEveryCalendarDayOfALongMonth(): void
{
$days = WorkMonthDays::fromArray(['2026-03-05' => ['minutes' => 0, 'absence' => 'Ruhetag 24/25']]);
$calendar = $days->forCalendarMonth(2026, 3);
$this->assertCount(31, $calendar);
$this->assertSame('2026-03-01', $calendar[0]->getDate()->format('Y-m-d'));
$this->assertSame('2026-03-31', $calendar[30]->getDate()->format('Y-m-d'));
}
public function testItFillsEveryCalendarDayOfAShortMonth(): void
{
$this->assertCount(28, WorkMonthDays::fromArray([])->forCalendarMonth(2026, 2));
$this->assertCount(29, WorkMonthDays::fromArray([])->forCalendarMonth(2024, 2));
}
/**
* "Not reported" and "reported as zero" are different things: a rest day is a real
* booking of nothing, an unreported day is a gap.
*/
public function testItSeparatesUnreportedDaysFromZeroDays(): void
{
$calendar = WorkMonthDays::fromArray([
'2026-03-05' => ['minutes' => 0, 'absence' => 'Ruhetag 24/25'],
])->forCalendarMonth(2026, 3);
$restDay = $calendar[4];
$this->assertSame('2026-03-05', $restDay->getDate()->format('Y-m-d'));
$this->assertTrue($restDay->isReported());
$this->assertSame(0, $restDay->getMinutes());
$this->assertSame('Ruhetag 24/25', $restDay->getAbsence());
$gap = $calendar[0];
$this->assertFalse($gap->isReported());
$this->assertNull($gap->getMinutes());
$this->assertFalse($gap->isAbsence());
}
public function testItKnowsWeekends(): void
{
$calendar = WorkMonthDays::fromArray([])->forCalendarMonth(2026, 3);
$this->assertTrue($calendar[0]->isWeekend(), '1 March 2026 is a Sunday.');
$this->assertFalse($calendar[1]->isWeekend());
}
}
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository;
use App\Entity\TeamerWorkMonth;
use App\Enum\WorkMonthStatus;
use App\Model\WorkMonthFilterDto;
use App\Repository\TeamerWorkMonthRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* The list's defaults are the part worth pinning: what an admin sees when they have chosen
* no filter at all is decided in DQL, not in the template.
*/
class TeamerWorkMonthRepositoryTest extends KernelTestCase
{
public function testTheListHidesApprovedMonthsUntilAskedFor(): void
{
$query = $this->createListQuery(new WorkMonthFilterDto());
$this->assertStringContainsString('work_month.status != :approved', $query->getDQL());
$this->assertSame(WorkMonthStatus::APPROVED, $query->getParameter('approved')->getValue());
}
public function testApprovedMonthsComeBackWhenTheFlagIsSet(): void
{
$query = $this->createListQuery((new WorkMonthFilterDto())->setIncludeApproved(true));
$this->assertStringNotContainsString(':approved', $query->getDQL());
}
/**
* An explicit status choice has to win over the "hide approved" default, or picking
* "Geprüft" from the filter would return nothing at all.
*/
public function testAnExplicitStatusChoiceOverridesTheDefault(): void
{
$query = $this->createListQuery(
(new WorkMonthFilterDto())->setStatuses([WorkMonthStatus::APPROVED])
);
$dql = $query->getDQL();
$this->assertStringContainsString('work_month.status IN (:statuses)', $dql);
$this->assertStringNotContainsString(':approved', $dql);
$this->assertSame([WorkMonthStatus::APPROVED], $query->getParameter('statuses')->getValue());
}
public function testFilteringByMonthSplitsThePeriodIntoItsColumns(): void
{
$query = $this->createListQuery(
(new WorkMonthFilterDto())->setPeriod(new \DateTimeImmutable('2026-03-01'))
);
$this->assertStringContainsString('work_month.year = :year', $query->getDQL());
$this->assertStringContainsString('work_month.month = :month', $query->getDQL());
$this->assertSame(2026, $query->getParameter('year')->getValue());
$this->assertSame(3, $query->getParameter('month')->getValue(), 'The calendar month, not the API index.');
}
/**
* The reminder must not chase someone who has already sent the sheet in, nor a deleted
* teamer.
*/
public function testTheReminderQueryOnlyLooksAtMonthsStillWaiting(): void
{
$repository = $this->createRepository();
$months = $repository->findAwaitingDocument(new \DateTimeImmutable('2026-03-04'));
// db_test starts empty, so this asserts the query runs at all - the semantics above
// are what the DQL assertions cover.
$this->assertSame([], $months);
}
private function createListQuery(WorkMonthFilterDto $filterDto): Query
{
return $this->createRepository()->getListQuery($filterDto);
}
private function createRepository(): TeamerWorkMonthRepository
{
self::bootKernel();
/** @var EntityManagerInterface $entityManager */
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
/** @var TeamerWorkMonthRepository $repository */
$repository = $entityManager->getRepository(TeamerWorkMonth::class);
return $repository;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,510 @@
{
"surcharge": 0,
"PAYOFF_TIMEACCOUNT": 0,
"year": 2026,
"istOnWeekend": 0,
"TIMEACCOUNT_PAYOFF": 0,
"lastMonth": 0,
"outOfWork": 19.0,
"ist": 480,
"autoOvertime": 0,
"employee": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"hasTime": true,
"current": null,
"isReadOnly": false,
"inactive": false,
"PAYOFF_OVERTIMEACCOUNT": 0,
"today": {
"soll": 0,
"ist": 0
},
"OVERTIMEACCOUNT_TIMEACCOUNT": 0,
"end": null,
"traffic": {
"active": false
},
"TIMEACCOUNT_OVERTIMEACCOUNT": 0,
"start": "2021-10-22",
"soll": 2661,
"hasProject": false,
"saldo": -2181,
"list": [
{
"date": "2026-08-01",
"surcharge": 0.0,
"startOriginalDate": "2026-08-01",
"endDate": "2026-08-01",
"platformStart": "Manuell",
"breakCalculationActive": true,
"ist": 360,
"uuid": "cb0aaf79-d41d-4001-9ace-bc95f60dd856",
"hasSurcharge": false,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"startOriginal": "2026-08-01 10:00:00",
"responsibility": null,
"sollType": "MONTH",
"warning": 0,
"startTime": "10:00:00",
"end": "2026-08-01 16:00:00",
"department": "Operations",
"endOriginal": "2026-08-01 16:00:00",
"annotation": 8,
"endOriginalDate": "2026-08-01",
"startOriginalTime": "10:00:00",
"start": "2026-08-01 10:00:00",
"soll": 0,
"pause": 0,
"surchargeMinutes": 0,
"transfer": null,
"holidayType": null,
"platformEnd": "Manuell",
"pausemin": 0,
"kilometer": 0.0,
"endTime": "16:00:00",
"startDate": "2026-08-01",
"isDummy": 0,
"status": "ACCEPTED",
"endOriginalTime": "16:00:00"
},
{
"date": "2026-08-01",
"annotation": -1,
"endDate": "2026-08-01",
"start": "2026-08-01 16:00:00",
"soll": 0,
"ist": 0,
"pause": 180,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": null,
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "16:00:00",
"end": "2026-08-01 19:00:00",
"endTime": "19:00:00",
"isDummy": 0,
"startDate": "2026-08-01"
},
{
"date": "2026-08-01",
"surcharge": 0.0,
"endDate": "2026-08-01",
"platformStart": "Manuell",
"breakCalculationActive": true,
"ist": 120,
"uuid": "9652275f-0b76-40b5-b67a-21ef8b7f0f5f",
"hasSurcharge": false,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"startOriginal": null,
"responsibility": null,
"sollType": "MONTH",
"warning": 0,
"startTime": "19:00:00",
"end": "2026-08-01 21:00:00",
"department": "Operations",
"endOriginal": null,
"annotation": 8,
"start": "2026-08-01 19:00:00",
"soll": 0,
"pause": 0,
"surchargeMinutes": 0,
"transfer": null,
"holidayType": null,
"platformEnd": "Manuell",
"pausemin": 0,
"kilometer": 0.0,
"endTime": "21:00:00",
"startDate": "2026-08-01",
"isDummy": 0,
"status": "ACCEPTED"
},
{
"date": "2026-08-02",
"annotation": -4,
"endDate": null,
"start": "2026-08-02 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-02"
},
{
"date": "2026-08-03",
"annotation": -4,
"endDate": null,
"start": "2026-08-03 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-03"
},
{
"date": "2026-08-04",
"annotation": -4,
"endDate": null,
"start": "2026-08-04 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-04"
},
{
"date": "2026-08-05",
"annotation": -4,
"endDate": null,
"start": "2026-08-05 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-05"
},
{
"date": "2026-08-06",
"annotation": -4,
"endDate": null,
"start": "2026-08-06 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-06"
},
{
"date": "2026-08-07",
"annotation": -4,
"endDate": null,
"start": "2026-08-07 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-07"
},
{
"date": "2026-08-08",
"annotation": -4,
"endDate": null,
"start": "2026-08-08 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-08"
},
{
"date": "2026-08-09",
"annotation": -4,
"endDate": null,
"start": "2026-08-09 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-09"
},
{
"date": "2026-08-10",
"annotation": -4,
"endDate": null,
"start": "2026-08-10 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-10"
},
{
"date": "2026-08-11",
"annotation": -4,
"endDate": null,
"start": "2026-08-11 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-11"
},
{
"date": "2026-08-12",
"annotation": -4,
"endDate": null,
"start": "2026-08-12 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-12"
},
{
"date": "2026-08-13",
"annotation": -4,
"endDate": null,
"start": "2026-08-13 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-13"
},
{
"date": "2026-08-14",
"annotation": -4,
"endDate": null,
"start": "2026-08-14 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-14"
},
{
"date": "2026-08-15",
"annotation": -4,
"endDate": null,
"start": "2026-08-15 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-15"
},
{
"date": "2026-08-16",
"annotation": -4,
"endDate": null,
"start": "2026-08-16 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-16"
},
{
"date": "2026-08-17",
"annotation": -4,
"endDate": null,
"start": "2026-08-17 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-17"
},
{
"date": "2026-08-18",
"annotation": -4,
"endDate": null,
"start": "2026-08-18 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-18"
},
{
"date": "2026-08-19",
"annotation": -4,
"endDate": null,
"start": "2026-08-19 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-19"
},
{
"date": "2026-08-20",
"annotation": -4,
"endDate": null,
"start": "2026-08-20 12:40:01",
"breakCalculationActive": true,
"soll": 0,
"ist": 0,
"pause": 0,
"surchargeMinutes": 0,
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
"holidayType": "Ferien",
"sollType": "MONTH",
"pausemin": 0,
"warning": 0,
"startTime": "23:59:59",
"endTime": null,
"isDummy": 0,
"startDate": "2026-08-20"
}
],
"overtimeAccountMonth": 5929,
"saldo2": -2181,
"onWork": 1,
"unpaidOvertime": 0,
"month": 8,
"OVERTIMEACCOUNT_PAYOFF": 0,
"overtimeAccount": 5929
}
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace App\Tests\Saas;
use App\Saas\ApiClient;
use App\Saas\ApiClientException;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
class ApiClientTest extends TestCase
{
/**
* The whole point of the calendar-month argument: the app never has to know that the
* API counts from zero.
*
* @dataProvider provideMonths
*/
public function testItTranslatesTheCalendarMonthIntoTheApiIndex(int $month, string $expectedPath): void
{
$requested = null;
$client = $this->createClient(function (string $method, string $url) use (&$requested) {
$requested = $url;
return new MockResponse('{"ist": 0, "list": []}', ['response_headers' => ['content-type' => 'application/json']]);
});
$client->getTimeAccount('[email protected]', 2026, $month);
$this->assertSame('https://saas.example'.$expectedPath, $requested);
}
public static function provideMonths(): iterable
{
yield 'January is index 0' => [1, '/api/v2/time/account/secret%2Fkey/theo%40example.com/2026/0'];
yield 'March is index 2' => [3, '/api/v2/time/account/secret%2Fkey/theo%40example.com/2026/2'];
yield 'December is index 11' => [12, '/api/v2/time/account/secret%2Fkey/theo%40example.com/2026/11'];
}
/**
* Both the key and the address sit in the path, so both have to survive a slash or an
* "@" without changing the route.
*/
public function testItEscapesTheKeyAndTheAddress(): void
{
$requested = null;
$client = $this->createClient(function (string $method, string $url) use (&$requested) {
$requested = $url;
return new MockResponse('{"ist": 0, "list": []}', ['response_headers' => ['content-type' => 'application/json']]);
});
$client->getTimeAccount('a [email protected]', 2026, 3);
$this->assertStringContainsString('/secret%2Fkey/', $requested);
$this->assertStringContainsString('/a%20b%2Bc%40example.com/', $requested);
}
public function testItReturnsTheDecodedPayload(): void
{
$client = $this->createClient(fn () => new MockResponse(
'{"ist": 480, "employee": "e-1", "list": []}',
['response_headers' => ['content-type' => 'application/json']],
));
$this->assertSame(
['ist' => 480, 'employee' => 'e-1', 'list' => []],
$client->getTimeAccount('[email protected]', 2026, 9),
);
}
public function testAnErrorStatusBecomesAnApiClientException(): void
{
$client = $this->createClient(fn () => new MockResponse('nope', ['http_code' => 500]));
$this->expectException(ApiClientException::class);
$this->expectExceptionMessageMatches('/[email protected] \(2026-03\)/');
$client->getTimeAccount('[email protected]', 2026, 3);
}
public function testUnreadableJsonBecomesAnApiClientException(): void
{
$client = $this->createClient(fn () => new MockResponse(
'not json',
['response_headers' => ['content-type' => 'application/json']],
));
$this->expectException(ApiClientException::class);
$client->getTimeAccount('[email protected]', 2026, 3);
}
public function testItRefusesAMonthThatIsNotACalendarMonth(): void
{
$client = $this->createClient(fn () => new MockResponse('{}'));
$this->expectException(ApiClientException::class);
$this->expectExceptionMessage('0 is not a calendar month.');
$client->getTimeAccount('[email protected]', 2026, 0);
}
public function testAnUnconfiguredBaseUrlFailsAtCallTimeNotAtBoot(): void
{
// The command has to stay usable - "--help" included - on a machine that has no
// credentials, so this cannot be a container-time failure.
$client = new ApiClient(
new MockHttpClient(fn () => new MockResponse('{}')),
new NullLogger(),
['base_url' => '', 'api_key' => 'k'],
);
$this->expectException(ApiClientException::class);
$this->expectExceptionMessage('APP_SAAS_BASE_URL is not configured.');
$client->getTimeAccount('[email protected]', 2026, 3);
}
private function createClient(callable $handler): ApiClient
{
return new ApiClient(
new MockHttpClient($handler),
new NullLogger(),
['base_url' => 'https://saas.example', 'api_key' => 'secret/key'],
);
}
}
+128
View File
@@ -0,0 +1,128 @@
<?php
namespace App\Tests\Saas;
use App\Saas\Model\EmployeeDirectory;
use App\Saas\ResponseParser;
use PHPUnit\Framework\TestCase;
class EmployeeDirectoryTest extends TestCase
{
/**
* The point of the projection: the roster answers with a per-employee api key next to a
* birthday, a personnel number and an RFID token, and the result of this is cached to the
* filesystem. None of it may get that far.
*/
public function testTheProjectionKeepsNoCredentialsOrPersonalData(): void
{
$directory = (new ResponseParser())->parseEmployees($this->roster());
$serialised = json_encode($directory->toCacheable(), JSON_THROW_ON_ERROR);
$this->assertStringNotContainsString('s3cr3t', $serialised, 'The api key must not survive.');
$this->assertStringNotContainsString('apiKey', $serialised);
$this->assertStringNotContainsString('1990', $serialised, 'The birthday must not survive.');
$this->assertStringNotContainsString('RFID', $serialised);
$this->assertStringNotContainsString('personnelNumber', $serialised);
}
public function testItReadsTheFieldsWeActuallyUse(): void
{
$directory = (new ResponseParser())->parseEmployees($this->roster());
$theo = $directory->find('[email protected]');
$this->assertNotNull($theo);
$this->assertSame('Operations', $theo->getDepartment());
$this->assertSame('aaaaaaaa-894a-4e63-9c8d-5777e6afdc91', $theo->getUuid());
$this->assertFalse($theo->hasLeft());
}
public function testADateOfSeparationMarksSomeoneAsGone(): void
{
$directory = (new ResponseParser())->parseEmployees($this->roster());
$this->assertTrue($directory->find('[email protected]')->hasLeft());
}
/**
* Both systems have the address typed in by hand, so the one place they have to agree on
* it cannot be case-sensitive.
*/
public function testLookupIgnoresCaseAndSurroundingSpace(): void
{
$directory = (new ResponseParser())->parseEmployees($this->roster());
$this->assertTrue($directory->has('[email protected]'));
$this->assertTrue($directory->has(' [email protected] '));
$this->assertFalse($directory->has('[email protected]'));
$this->assertFalse($directory->has(null));
}
public function testAnEntryWithoutAnAddressIsDropped(): void
{
$directory = (new ResponseParser())->parseEmployees([
['mainDepartment' => 'Operations', 'email' => ''],
['mainDepartment' => 'Operations'],
'not an entry',
]);
$this->assertTrue($directory->isEmpty());
}
public function testItSurvivesTheCacheRoundTrip(): void
{
$directory = (new ResponseParser())->parseEmployees($this->roster());
$restored = EmployeeDirectory::fromCacheable($directory->toCacheable());
$this->assertSame($directory->count(), $restored->count());
$this->assertSame('Operations', $restored->find('[email protected]')->getDepartment());
$this->assertTrue($restored->find('[email protected]')->hasLeft());
}
/**
* An unreachable roster produces an empty one, and the importer reads that as "no roster"
* rather than "nobody is registered".
*/
public function testAnEmptyDirectoryIsRecognisable(): void
{
$this->assertTrue(EmployeeDirectory::fromEmployees([])->isEmpty());
}
private function roster(): array
{
return [
[
'personnelNumber' => '4711',
'mainDepartment' => 'Standard-Abteilung',
'email' => '[email protected]',
'givenName' => 'Admin',
'lastName' => 'Hausmanagement',
'birthday' => '1990-05-01',
'joinDate' => '2021-00-01',
'dateOfSeparation' => null,
'RFID' => 'abc',
'uuid' => 'bbbbbbbb-0000-0000-0000-000000000000',
'apiKey' => 's3cr3t-admin',
],
[
'personnelNumber' => '',
'mainDepartment' => 'Operations',
'email' => '[email protected]',
'givenName' => 'Theo',
'lastName' => 'Test',
'birthday' => null,
'joinDate' => '2021-10-22',
'dateOfSeparation' => null,
'RFID' => '',
'uuid' => 'aaaaaaaa-894a-4e63-9c8d-5777e6afdc91',
'apiKey' => 's3cr3t-theo',
],
[
'mainDepartment' => 'Operations',
'email' => '[email protected]',
'dateOfSeparation' => '2025-05-31',
'uuid' => 'cccccccc-0000-0000-0000-000000000000',
'apiKey' => 's3cr3t-ex',
],
];
}
}
+336
View File
@@ -0,0 +1,336 @@
<?php
namespace App\Tests\Saas;
use App\Saas\Model\TimeAccount;
use App\Saas\ResponseParser;
use PHPUnit\Framework\TestCase;
/**
* The fixtures are real responses with the free-text comments, IP addresses, coordinates,
* device names and the employee name removed - none of which this application stores.
*
* Both were requested with a zero-based month, so their date strings are one month behind
* the days they describe: the "september" fixture was asked for month 8 and the "march" one
* for month 2.
*/
class ResponseParserTest extends TestCase
{
public function testSeptemberFixtureAddsUpToTheReportedTotal(): void
{
$account = $this->parse('saas_time_account_september.json', 2026, 9);
$this->assertSame('aaaaaaaa-894a-4e63-9c8d-5777e6afdc91', $account->getEmployeeUuid());
$this->assertSame(480, $account->getIstMinutes());
$this->assertSame(480, $account->getTotalMinutes(), 'Nothing is dropped, so the days must match the header.');
$this->assertSame([], $account->getWarnings());
}
public function testSeptemberFixtureIsRebasedOutOfAugust(): void
{
$account = $this->parse('saas_time_account_september.json', 2026, 9);
$keys = array_keys($account->getDays());
$this->assertCount(20, $keys);
$this->assertSame('2026-09-01', reset($keys));
$this->assertSame('2026-09-20', end($keys));
}
public function testSeptemberFixtureMarksHolidays(): void
{
$account = $this->parse('saas_time_account_september.json', 2026, 9);
$this->assertSame(19, $account->countAbsenceDays());
$this->assertSame(1, $account->countWorkedDays());
$this->assertSame('Ferien', $account->getDays()['2026-09-02']->getHolidayType());
$this->assertSame(480, $account->getDays()['2026-09-01']->getMinutes());
}
public function testMarchFixtureAddsUpToTheReportedTotal(): void
{
$account = $this->parse('saas_time_account_march.json', 2026, 3);
$this->assertSame(11748, $account->getIstMinutes());
$this->assertSame(11748, $account->getTotalMinutes());
}
/**
* The decisive case: the payload's last day is "2026-02-31". February has 28 days, so a
* date parser would have rolled it into March 3rd and collided with a real row.
*/
public function testMarchFixtureKeepsAllThirtyOneDays(): void
{
$account = $this->parse('saas_time_account_march.json', 2026, 3);
$keys = array_keys($account->getDays());
$this->assertCount(31, $keys);
$this->assertSame('2026-03-01', reset($keys));
$this->assertSame('2026-03-31', end($keys));
$this->assertSame(450, $account->getDays()['2026-03-31']->getMinutes());
}
/**
* A rest day arrives as two rows on the same date - a zero-length booking and the
* absence marker - and must still be one day worth zero minutes.
*/
public function testRestDaysAreCountedOnce(): void
{
$account = $this->parse('saas_time_account_march.json', 2026, 3);
$this->assertSame(4, $account->countAbsenceDays());
foreach (['2026-03-05', '2026-03-09', '2026-03-16', '2026-03-24'] as $key) {
$this->assertSame('Ruhetag 24/25', $account->getDays()[$key]->getHolidayType());
$this->assertSame(0, $account->getDays()[$key]->getMinutes());
}
}
/**
* 2026-03-12 has no manually booked row at all and still carries a full day of work,
* which is why zero minutes can never stand in for "day off".
*/
public function testAWorkedDayWithoutManualRowsStillCounts(): void
{
$account = $this->parse('saas_time_account_march.json', 2026, 3);
$day = $account->getDays()['2026-03-12'];
$this->assertSame(510, $day->getMinutes());
$this->assertFalse($day->isAbsence());
}
public function testUnknownRowKindIsReported(): void
{
$account = $this->parse('saas_time_account_march.json', 2026, 3);
$this->assertSame(['Unknown row kind -2 on 2026-03-11.'], $account->getWarnings());
}
/**
* January is index 0 and December index 11, so their date strings are "2026-00-xx" and
* "2026-11-31" - the two the fixtures cannot show and the two that break naive parsing.
*/
public function testJanuaryIsRebasedFromIndexZero(): void
{
$account = (new ResponseParser())->parseTimeAccount([
'employee' => 'e-1',
'year' => 2026,
'month' => 0,
'ist' => 120,
'list' => [
['date' => '2026-00-15', 'annotation' => 8, 'ist' => 120],
],
], 2026, 1);
$this->assertSame(['2026-01-15'], array_keys($account->getDays()));
$this->assertSame([], $account->getWarnings());
}
public function testDecemberIsRebasedPastAShorterNovember(): void
{
$account = (new ResponseParser())->parseTimeAccount([
'employee' => 'e-1',
'year' => 2026,
'month' => 11,
'ist' => 60,
'list' => [
['date' => '2026-11-31', 'annotation' => 128, 'ist' => 60],
],
], 2026, 12);
$this->assertSame(['2026-12-31'], array_keys($account->getDays()));
$this->assertSame([], $account->getWarnings());
}
public function testDaysOutsideTheRequestedMonthAreSkippedAndReported(): void
{
$account = (new ResponseParser())->parseTimeAccount([
'year' => 2026,
'month' => 2,
'ist' => 0,
'list' => [
['date' => '2026-03-01', 'annotation' => 8, 'ist' => 90],
['date' => 'nonsense', 'annotation' => 8, 'ist' => 30],
],
], 2026, 3);
$this->assertSame([], $account->getDays());
$this->assertCount(2, $account->getWarnings());
$this->assertStringContainsString('not a day of 2026-03', $account->getWarnings()[0]);
$this->assertStringContainsString('unreadable date', $account->getWarnings()[1]);
}
/**
* If the days and the header ever stop agreeing, the date convention or the row
* semantics have moved. That has to be loud rather than quietly wrong.
*/
public function testATotalThatDisagreesWithTheHeaderIsReported(): void
{
$account = (new ResponseParser())->parseTimeAccount([
'year' => 2026,
'month' => 2,
'ist' => 999,
'list' => [
['date' => '2026-02-01', 'annotation' => 8, 'ist' => 90],
],
], 2026, 3);
$this->assertSame(90, $account->getTotalMinutes());
$this->assertSame(['The days add up to 90 minutes but the month reports 999.'], $account->getWarnings());
}
public function testAnEmptyPayloadIsTolerated(): void
{
$account = (new ResponseParser())->parseTimeAccount([], 2026, 3);
$this->assertNull($account->getEmployeeUuid());
$this->assertSame([], $account->getDays());
$this->assertSame(0, $account->getTotalMinutes());
$this->assertSame([], $account->getWarnings());
}
public function testItReadsTheMonthlyTarget(): void
{
$this->assertSame(9900, $this->parse('saas_time_account_march.json', 2026, 3)->getTargetMinutes());
$this->assertSame(2661, $this->parse('saas_time_account_september.json', 2026, 9)->getTargetMinutes());
}
public function testItReadsTheDepartment(): void
{
$this->assertSame('Operations', $this->parse('saas_time_account_march.json', 2026, 3)->getDepartment());
$this->assertSame('Operations', $this->parse('saas_time_account_september.json', 2026, 9)->getDepartment());
}
/**
* Only the full booking rows carry it, so a month of nothing but holiday has nowhere to
* read it from. That is a missing value, not an error.
*/
public function testAMonthWithoutBookingsHasNoDepartment(): void
{
$account = (new ResponseParser())->parseTimeAccount([
'year' => 2026,
'month' => 2,
'ist' => 0,
'list' => [
['date' => '2026-02-02', 'annotation' => -4, 'ist' => 0, 'holidayType' => 'Ferien'],
],
], 2026, 3);
$this->assertNull($account->getDepartment());
$this->assertSame([], $account->getWarnings());
}
public function testTwoDepartmentsInOneMonthAreReported(): void
{
$account = (new ResponseParser())->parseTimeAccount([
'year' => 2026,
'month' => 2,
'ist' => 120,
'list' => [
['date' => '2026-02-02', 'annotation' => 8, 'ist' => 60, 'department' => 'Operations'],
['date' => '2026-02-03', 'annotation' => 8, 'ist' => 60, 'department' => 'Service'],
],
], 2026, 3);
$this->assertSame('Operations', $account->getDepartment(), 'The first one wins.');
$this->assertSame(
['The month reports more than one department: Operations, Service.'],
$account->getWarnings()
);
}
public function testItSpansTheDayFromFirstClockInToLastClockOut(): void
{
$day = $this->parse('saas_time_account_september.json', 2026, 9)->getDays()['2026-09-01'];
// Booked 10:00-16:00, 16:00-19:00 as a break, then 19:00-21:00.
$this->assertSame('10:00', $day->getStartTime());
$this->assertSame('21:00', $day->getEndTime());
$this->assertSame(180, $day->getBreakMinutes());
$this->assertSame(480, $day->getMinutes());
}
/**
* A day often opens with a zero-length row at the moment the teamer clocked in. Skipping
* those moved the start of 3 March from 08:00 to 09:00, the end of its first break.
*/
public function testAZeroLengthClockInStillOpensTheDay(): void
{
$day = $this->parse('saas_time_account_march.json', 2026, 3)->getDays()['2026-03-03'];
$this->assertSame('08:00', $day->getStartTime());
$this->assertSame('19:30', $day->getEndTime());
}
/**
* Rest days carry a zero-length marker too, and absences a placeholder 23:59:59. Neither
* is a working day, so neither gets a span.
*/
public function testADayWithNothingBookedHasNoSpan(): void
{
$restDay = $this->parse('saas_time_account_march.json', 2026, 3)->getDays()['2026-03-05'];
$holiday = $this->parse('saas_time_account_september.json', 2026, 9)->getDays()['2026-09-02'];
$this->assertNull($restDay->getStartTime());
$this->assertNull($restDay->getEndTime());
$this->assertNull($holiday->getStartTime());
$this->assertSame(0, $holiday->getBreakMinutes());
}
/**
* The invariant behind the whole sheet: the span a day was worked in equals the hours on
* it plus the breaks inside it.
*
* 11 March is the one exception in this data, and deliberately excluded: it carries the
* unknown -2 row, five and a half hours the SaaS counts as neither work nor break. That
* row is already reported as a warning.
*/
public function testEverySpanAccountsForItsHoursAndBreaks(): void
{
$account = $this->parse('saas_time_account_march.json', 2026, 3);
$checked = 0;
foreach ($account->getDays() as $key => $day) {
if (null === $day->getStartTime() || '2026-03-11' === $key) {
continue;
}
[$startHour, $startMinute] = explode(':', $day->getStartTime());
[$endHour, $endMinute] = explode(':', $day->getEndTime());
$span = ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute);
$this->assertSame(
$day->getMinutes() + $day->getBreakMinutes(),
$span,
sprintf('%s does not add up.', $key)
);
++$checked;
}
$this->assertSame(26, $checked);
}
public function testBreakMinutesAddUpToTheMonth(): void
{
$account = $this->parse('saas_time_account_march.json', 2026, 3);
$total = 0;
foreach ($account->getDays() as $day) {
$total += $day->getBreakMinutes();
}
$this->assertSame(3265, $total);
}
private function parse(string $filename, int $year, int $month): TimeAccount
{
$payload = json_decode(
file_get_contents(__DIR__.'/../Resources/'.$filename),
true,
512,
JSON_THROW_ON_ERROR,
);
return (new ResponseParser())->parseTimeAccount($payload, $year, $month);
}
}
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Cron;
use App\Config\EmailTextKey;
use App\Email\Mailer;
use App\Entity\Embeddable\Communication;
use App\Entity\Teamer;
use App\Entity\TeamerWorkMonth;
use App\Repository\TeamerWorkMonthRepository;
use App\Service\Cron\WorkMonthReminderService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
/**
* Pins the rules the reminder applies: which day it runs on, how long it waits after an
* import, and who it can actually reach. The query itself is the repository's business.
*/
class WorkMonthReminderServiceTest extends TestCase
{
private TeamerWorkMonthRepository&MockObject $repository;
private Mailer&MockObject $mailer;
private WorkMonthReminderService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(TeamerWorkMonthRepository::class);
$this->mailer = $this->createMock(Mailer::class);
$this->service = new WorkMonthReminderService(
$this->repository,
$this->mailer,
new NullLogger(),
);
}
/**
* app:cron runs daily, so the weekday gate is the only thing stopping a teamer being
* chased every morning.
*/
public function testItOnlyRunsOnMondays(): void
{
$this->repository->expects($this->never())->method('findAwaitingDocument');
$this->mailer->expects($this->never())->method('createAndSendText');
$this->assertSame(
'Work month reminders are only sent on mondays',
$this->service->sendReminders(new \DateTimeImmutable('2026-03-10')),
);
}
public function testItLeavesTheTeamerAGracePeriodAfterTheImport(): void
{
$this->repository
->expects($this->once())
->method('findAwaitingDocument')
->with($this->callback(
static fn (\DateTimeImmutable $cutoff) => '2026-03-04' === $cutoff->format('Y-m-d')
))
->willReturn([])
;
// 9 March 2026 is a Monday; the import ran on the 10th of the month before.
$this->service->sendReminders(new \DateTimeImmutable('2026-03-09'));
}
public function testItMailsOneReminderPerOpenMonth(): void
{
$this->repository->method('findAwaitingDocument')->willReturn([
$this->createWorkMonth(2026, 1),
$this->createWorkMonth(2026, 2),
]);
$sent = [];
$this->mailer
->expects($this->exactly(2))
->method('createAndSendText')
->willReturnCallback(function (EmailTextKey $key, array $placeholders, array $options) use (&$sent) {
$sent[] = [$key, $placeholders, $options];
})
;
$this->assertSame(
'Sent 2 work month reminders to teamers',
$this->service->sendReminders(new \DateTimeImmutable('2026-03-09')),
);
$this->assertSame(EmailTextKey::REMINDER_WORK_MONTH_UPLOAD, $sent[0][0]);
$this->assertSame('Januar 2026', $sent[0][1]['workMonth']);
$this->assertSame('Februar 2026', $sent[1][1]['workMonth']);
$this->assertSame('[email protected]', $sent[0][2]['to']);
}
public function testATeamerWithoutAnAddressIsSkippedSilently(): void
{
$this->repository->method('findAwaitingDocument')->willReturn([
$this->createWorkMonth(2026, 1, null),
$this->createWorkMonth(2026, 2, ' '),
$this->createWorkMonth(2026, 3),
]);
$this->mailer->expects($this->once())->method('createAndSendText');
$this->assertSame(
'Sent 1 work month reminders to teamers',
$this->service->sendReminders(new \DateTimeImmutable('2026-03-09')),
);
}
private function createWorkMonth(int $year, int $month, ?string $email = '[email protected]'): TeamerWorkMonth
{
$communication = new Communication();
$communication->setEmail($email);
$teamer = new Teamer();
$teamer->setFirstName('Theo')->setLastName('Test')->setCommunication($communication);
return (new TeamerWorkMonth())
->setTeamer($teamer)
->setYear($year)
->setMonth($month)
;
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
namespace App\Tests\Service\Pdf;
use App\Entity\Teamer;
use App\Entity\TeamerWorkMonth;
use App\Model\WorkMonthDays;
use App\Service\Pdf\WorkMonthRenderer;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Twig\Environment;
/**
* Renders a real document rather than mocking Twig: the point of the test is that the
* template, the duration filter and Dompdf agree with each other.
*/
class WorkMonthRendererTest extends WebTestCase
{
public function testItRendersAPdf(): void
{
$pdf = $this->render($this->createWorkMonth());
$this->assertStringStartsWith('%PDF', $pdf);
$this->assertGreaterThan(2000, strlen($pdf), 'A month of rows should not fit in a stub.');
}
/**
* A month whose days the SaaS never reported still has to produce a full sheet, so the
* teamer can write on the empty lines.
*/
public function testItPlacesTheLogoInTheTopRightCorner(): void
{
$pdf = $this->render($this->createWorkMonth());
// A4 is 595.28 x 841.89pt and the page margins are 15mm at the side, 18mm at the top,
// so the content corner sits at 552.76 / 790.87. Asserting the placement rather than
// the markup is the only way to know the logo actually landed there.
$this->assertSame(1, preg_match(
'#([\d.]+) 0 0 ([\d.]+) ([\d.]+) ([\d.]+) cm\s*/\w+ Do#',
$this->contentStreams($pdf),
$placement
), 'The logo should be drawn exactly once.');
[, $width, $height, $x, $y] = array_map('floatval', $placement);
$this->assertEqualsWithDelta(552.76, $x + $width, 1.0, 'Flush with the right margin.');
$this->assertEqualsWithDelta(790.87, $y + $height, 1.0, 'Flush with the top margin.');
$this->assertEqualsWithDelta(230 / 70, $width / $height, 0.05, 'The logo must not be stretched.');
}
/**
* A working-time record is worth printing without its letterhead.
*/
public function testItRendersWithoutALogo(): void
{
$renderer = new WorkMonthRenderer(
static::getContainer()->get(Environment::class),
['project_dir' => sys_get_temp_dir()],
);
$this->assertStringStartsWith('%PDF', $renderer->render($this->createWorkMonth()));
}
private function contentStreams(string $pdf): string
{
preg_match_all('#stream\r?\n(.*?)endstream#s', $pdf, $matches);
$decoded = '';
foreach ($matches[1] as $stream) {
$inflated = @gzuncompress(ltrim($stream, "\r\n"));
if (false !== $inflated) {
$decoded .= $inflated;
}
}
return $decoded;
}
public function testItRendersAMonthWithoutAnyBookings(): void
{
$workMonth = (new TeamerWorkMonth())
->setTeamer($this->createTeamer())
->setYear(2026)
->setMonth(2)
->setDays(WorkMonthDays::fromArray([]))
;
$this->assertStringStartsWith('%PDF', $this->render($workMonth));
}
public function testTheFilenameCarriesTheTeamerAndTheMonth(): void
{
$this->assertSame(
'Arbeitszeitnachweis_Test_2026-03.pdf',
$this->createRenderer()->filename($this->createWorkMonth()),
);
}
public function testTheFilenameSurvivesAnAwkwardName(): void
{
$renderer = $this->createRenderer();
$workMonth = $this->createWorkMonth();
$workMonth->getTeamer()->setLastName('Müller-Lüdenscheidt');
$this->assertSame('Arbeitszeitnachweis_M_ller_L_denscheidt_2026-03.pdf', $renderer->filename($workMonth));
}
private function render(TeamerWorkMonth $workMonth): string
{
return $this->createRenderer()->render($workMonth);
}
private function createRenderer(): WorkMonthRenderer
{
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
/** @var Environment $twig */
$twig = static::getContainer()->get(Environment::class);
return new WorkMonthRenderer($twig, ['project_dir' => $projectDir]);
}
private function createWorkMonth(): TeamerWorkMonth
{
return (new TeamerWorkMonth())
->setTeamer($this->createTeamer())
->setYear(2026)
->setMonth(3)
->setDays(WorkMonthDays::fromArray([
'2026-03-01' => ['minutes' => 285],
'2026-03-05' => ['minutes' => 0, 'absence' => 'Ruhetag 24/25'],
'2026-03-12' => ['minutes' => 510],
]))
->setSourceIstMinutes(795)
;
}
private function createTeamer(): Teamer
{
return (new Teamer())->setFirstName('Theo')->setLastName('Test');
}
}
@@ -0,0 +1,339 @@
<?php
namespace App\Tests\Service\Saas;
use App\Entity\Embeddable\Communication;
use App\Entity\Teamer;
use App\Entity\TeamerWorkMonth;
use App\Enum\WorkHoursSyncOutcome;
use App\Enum\WorkMonthStatus;
use App\Model\WorkMonthDays;
use App\Repository\TeamerRepository;
use App\Repository\TeamerWorkMonthRepository;
use App\Saas\ApiClient;
use App\Saas\ApiClientException;
use App\Saas\DataProvider\EmployeeDataProvider;
use App\Saas\Model\Employee;
use App\Saas\Model\EmployeeDirectory;
use App\Saas\ResponseParser;
use App\Service\Saas\WorkHoursSynchronizer;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
class WorkHoursSynchronizerTest extends TestCase
{
private ApiClient&MockObject $apiClient;
private EmployeeDataProvider&MockObject $employeeDataProvider;
private TeamerWorkMonthRepository&MockObject $workMonthRepository;
private EntityManagerInterface&MockObject $entityManager;
private WorkHoursSynchronizer $synchronizer;
protected function setUp(): void
{
$this->apiClient = $this->createMock(ApiClient::class);
$this->workMonthRepository = $this->createMock(TeamerWorkMonthRepository::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->employeeDataProvider = $this->createMock(EmployeeDataProvider::class);
// Registered by default; the tests that care say otherwise.
$this->employeeDataProvider->method('getDirectory')->willReturn(
EmployeeDirectory::fromEmployees([
new Employee('[email protected]', 'e-1', 'Operations', false),
])
);
$this->synchronizer = new WorkHoursSynchronizer(
$this->apiClient,
new ResponseParser(),
$this->employeeDataProvider,
$this->createMock(TeamerRepository::class),
$this->workMonthRepository,
$this->entityManager,
new NullLogger(),
);
}
public function testATeamerWithoutAnAddressIsSkippedRatherThanFailed(): void
{
$this->apiClient->expects($this->never())->method('getTimeAccount');
$result = $this->synchronizer->syncTeamer($this->createTeamer(null), 2026, 3);
$this->assertSame(WorkHoursSyncOutcome::SKIPPED, $result->getOutcome());
$this->assertFalse($result->isFailure());
}
/**
* One unreachable teamer must not take the rest of the month's import down with it.
*/
public function testARequestFailureIsReportedWithoutWriting(): void
{
$this->apiClient->method('getTimeAccount')->willThrowException(new ApiClientException('gateway said no'));
$this->entityManager->expects($this->never())->method('flush');
$result = $this->synchronizer->syncTeamer($this->createTeamer(), 2026, 3);
$this->assertSame(WorkHoursSyncOutcome::FAILED, $result->getOutcome());
$this->assertTrue($result->isFailure());
$this->assertSame('gateway said no', $result->getMessage());
}
/**
* Catching this up front turns N failed requests into one legible line.
*/
public function testATeamerMissingFromTheRosterIsSkippedWithoutAnyRequest(): void
{
$provider = $this->createMock(EmployeeDataProvider::class);
$provider->method('getDirectory')->willReturn(
EmployeeDirectory::fromEmployees([new Employee('[email protected]', 'e-9', 'Service', false)])
);
$apiClient = $this->createMock(ApiClient::class);
$apiClient->expects($this->never())->method('getTimeAccount');
$synchronizer = new WorkHoursSynchronizer(
$apiClient,
new ResponseParser(),
$provider,
$this->createMock(TeamerRepository::class),
$this->workMonthRepository,
$this->entityManager,
new NullLogger(),
);
$result = $synchronizer->syncTeamer($this->createTeamer(), 2026, 3);
$this->assertSame(WorkHoursSyncOutcome::SKIPPED, $result->getOutcome());
$this->assertStringContainsString('Nicht in der SaaS registriert', $result->getMessage());
}
/**
* An unreachable roster comes back empty. Reading that as "nobody is registered" would
* silently skip the entire month.
*/
public function testAnUnreachableRosterDoesNotSkipEveryone(): void
{
$provider = $this->createMock(EmployeeDataProvider::class);
$provider->method('getDirectory')->willReturn(EmployeeDirectory::fromEmployees([]));
$this->apiClient->method('getTimeAccount')->willReturn($this->payload());
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null);
$synchronizer = new WorkHoursSynchronizer(
$this->apiClient,
new ResponseParser(),
$provider,
$this->createMock(TeamerRepository::class),
$this->workMonthRepository,
$this->entityManager,
new NullLogger(),
);
$this->assertSame(
WorkHoursSyncOutcome::IMPORTED,
$synchronizer->syncTeamer($this->createTeamer(), 2026, 3)->getOutcome(),
);
}
/**
* A month of nothing but holiday carries no booking to read a department from; the roster
* is the fallback that keeps the sheet's header complete.
*/
public function testTheRosterSuppliesTheDepartmentWhenTheBookingsCannot(): void
{
$teamer = $this->createTeamer();
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null);
$this->apiClient->method('getTimeAccount')->willReturn([
'employee' => 'e-1',
'year' => 2026,
'month' => 2,
'ist' => 0,
'list' => [
['date' => '2026-02-02', 'annotation' => -4, 'ist' => 0, 'holidayType' => 'Ferien'],
],
]);
$persisted = null;
$this->entityManager->method('persist')->willReturnCallback(function (object $entity) use (&$persisted) {
$persisted = $entity;
});
$this->synchronizer->syncTeamer($teamer, 2026, 3);
$this->assertSame('Operations', $persisted->getDepartment());
}
public function testANewMonthIsCreatedAndLearnsTheEmployeeId(): void
{
$teamer = $this->createTeamer();
$this->apiClient->method('getTimeAccount')->willReturn($this->payload());
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null);
$persisted = null;
$this->entityManager
->expects($this->once())
->method('persist')
->willReturnCallback(function (object $entity) use (&$persisted) {
$persisted = $entity;
})
;
$this->entityManager->expects($this->once())->method('flush');
$result = $this->synchronizer->syncTeamer($teamer, 2026, 3);
$this->assertSame(WorkHoursSyncOutcome::IMPORTED, $result->getOutcome());
$this->assertInstanceOf(TeamerWorkMonth::class, $persisted);
$this->assertSame(2026, $persisted->getYear());
$this->assertSame(3, $persisted->getMonth());
$this->assertSame(345, $persisted->getTotalMinutes());
$this->assertSame(345, $persisted->getSourceIstMinutes());
$this->assertSame(2, $persisted->getWorkedDays());
$this->assertSame(1, $persisted->getAbsenceDays());
$this->assertSame(WorkMonthStatus::OPEN, $persisted->getStatus());
$this->assertSame('e-1', $teamer->getSaasEmployeeUuid());
}
public function testImportingTwiceLeavesOneRecordWithTheSameFigures(): void
{
$teamer = $this->createTeamer();
$existing = $this->createWorkMonth($teamer, WorkMonthStatus::OPEN);
$this->apiClient->method('getTimeAccount')->willReturn($this->payload());
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn($existing);
$this->entityManager->expects($this->never())->method('persist');
$this->synchronizer->syncTeamer($teamer, 2026, 3);
$this->synchronizer->syncTeamer($teamer, 2026, 3);
$this->assertSame(345, $existing->getTotalMinutes());
}
/**
* The heart of the lock: a signed month keeps the hours that were signed, and the fresh
* figures only leave a trace that the SaaS has changed its mind.
*/
public function testASubmittedMonthIsComparedButNeverOverwritten(): void
{
$teamer = $this->createTeamer();
$existing = $this->createWorkMonth($teamer, WorkMonthStatus::SUBMITTED);
$this->apiClient->method('getTimeAccount')->willReturn($this->payload(420, 60));
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn($existing);
$result = $this->synchronizer->syncTeamer($teamer, 2026, 3);
$this->assertSame(WorkHoursSyncOutcome::LOCKED, $result->getOutcome());
$this->assertSame(120, $existing->getTotalMinutes(), 'The signed hours must survive.');
$this->assertTrue($existing->hasMismatch());
$this->assertSame(480, $existing->getMismatchTotalMinutes());
$this->assertStringContainsString('480', $result->getMessage());
}
public function testAnApprovedMonthThatStillAgreesClearsAnEarlierMismatch(): void
{
$teamer = $this->createTeamer();
$existing = $this->createWorkMonth($teamer, WorkMonthStatus::APPROVED);
$existing->recordMismatch(999);
$this->apiClient->method('getTimeAccount')->willReturn($this->payload(120, 0));
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn($existing);
$result = $this->synchronizer->syncTeamer($teamer, 2026, 3);
$this->assertSame(WorkHoursSyncOutcome::LOCKED, $result->getOutcome());
$this->assertFalse($existing->hasMismatch());
}
/**
* A rejection usually means the hours themselves were wrong, so that month is not
* frozen - the next import has to be able to correct it.
*/
public function testARejectedMonthIsStillImported(): void
{
$teamer = $this->createTeamer();
$existing = $this->createWorkMonth($teamer, WorkMonthStatus::REJECTED);
$this->apiClient->method('getTimeAccount')->willReturn($this->payload());
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn($existing);
$result = $this->synchronizer->syncTeamer($teamer, 2026, 3);
$this->assertSame(WorkHoursSyncOutcome::IMPORTED, $result->getOutcome());
$this->assertSame(345, $existing->getTotalMinutes());
}
public function testADryRunWritesNothing(): void
{
$this->apiClient->method('getTimeAccount')->willReturn($this->payload());
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null);
$this->entityManager->expects($this->never())->method('flush');
$result = $this->synchronizer->syncTeamer($this->createTeamer(), 2026, 3, true);
$this->assertSame(WorkHoursSyncOutcome::IMPORTED, $result->getOutcome());
}
public function testParserWarningsTravelOntoTheRecord(): void
{
$teamer = $this->createTeamer();
$payload = $this->payload();
$payload['list'][] = ['date' => '2026-02-11', 'annotation' => -2, 'ist' => 0];
$this->apiClient->method('getTimeAccount')->willReturn($payload);
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null);
$persisted = null;
$this->entityManager->method('persist')->willReturnCallback(function (object $entity) use (&$persisted) {
$persisted = $entity;
});
$result = $this->synchronizer->syncTeamer($teamer, 2026, 3);
$this->assertSame(['Unknown row kind -2 on 2026-03-11.'], $result->getWarnings());
$this->assertSame(['Unknown row kind -2 on 2026-03-11.'], $persisted->getWarnings());
}
/**
* March, requested as index 2, so the payload's dates read "2026-02-xx".
*/
private function payload(int $firstDay = 285, int $secondDay = 60): array
{
return [
'employee' => 'e-1',
'year' => 2026,
'month' => 2,
'ist' => $firstDay + $secondDay,
'list' => [
['date' => '2026-02-02', 'annotation' => 8, 'ist' => $firstDay],
['date' => '2026-02-03', 'annotation' => 8, 'ist' => $secondDay],
['date' => '2026-02-05', 'annotation' => -4, 'ist' => 0, 'holidayType' => 'Ferien'],
],
];
}
private function createTeamer(?string $email = '[email protected]'): Teamer
{
$teamer = new Teamer();
$teamer->setFirstName('Theo')->setLastName('Test');
$communication = new Communication();
$communication->setEmail($email);
$teamer->setCommunication($communication);
return $teamer;
}
private function createWorkMonth(Teamer $teamer, WorkMonthStatus $status): TeamerWorkMonth
{
return (new TeamerWorkMonth())
->setTeamer($teamer)
->setYear(2026)
->setMonth(3)
->setDays(WorkMonthDays::fromArray(['2026-03-02' => ['minutes' => 120]]))
->setSourceIstMinutes(120)
->setStatus($status)
;
}
}