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

@@ -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);
}
}