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

+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);