feat: import work hours from SaaS and exchange signed time sheet
This commit is contained in:
1 parent
7cf39850e6
commit
1a7c272414
73 files changed
+10488
-72
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Enum\WorkHoursSyncOutcome;
|
||||
use App\Model\WorkHoursSyncResult;
|
||||
use App\Service\Saas\WorkHoursSynchronizer;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:saas:work-hours',
|
||||
description: "Imports season teamers' working hours of one month from the SaaS",
|
||||
)]
|
||||
class SaasWorkHoursCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WorkHoursSynchronizer $synchronizer,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument(
|
||||
'month',
|
||||
InputArgument::OPTIONAL,
|
||||
'The month to import as YYYY-MM. Defaults to the previous month, which is what the schedule wants.',
|
||||
)
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report what would be written without writing it')
|
||||
->addOption(
|
||||
'teamer',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Limit the run to one teamer, by uuid or email address. Use this to try the API out.',
|
||||
)
|
||||
->addOption(
|
||||
'limit',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Stop after this many teamers, in the order the list comes back',
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled for the 10th of each month, importing the month before.
|
||||
*
|
||||
* A teamer that cannot be imported does not stop the others: only genuine failures
|
||||
* (the request or the payload) make the command fail, because the scheduler mails on a
|
||||
* non-zero exit and an incomplete profile would otherwise mail every month forever.
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
try {
|
||||
[$year, $month] = $this->resolveMonth($input->getArgument('month'));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$io->title(sprintf('Arbeitszeiten %04d-%02d', $year, $month));
|
||||
|
||||
if ($dryRun) {
|
||||
$io->note('Dry run - nothing is written.');
|
||||
}
|
||||
|
||||
$teamers = $this->synchronizer->findEligibleTeamers($year, $month);
|
||||
|
||||
if (null !== $only = $input->getOption('teamer')) {
|
||||
$teamers = $this->onlyThisTeamer($teamers, (string) $only);
|
||||
|
||||
if ([] === $teamers) {
|
||||
$io->error(sprintf('"%s" matches no season teamer placed in this month.', $only));
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $limit = $input->getOption('limit')) {
|
||||
$teamers = array_slice($teamers, 0, max(1, (int) $limit));
|
||||
}
|
||||
|
||||
if ([] === $teamers) {
|
||||
$io->warning('No season teamer is placed in this month.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->writeln(sprintf('%d teamer(s) to fetch, one request each.', count($teamers)));
|
||||
|
||||
$io->progressStart(count($teamers));
|
||||
|
||||
/** @var WorkHoursSyncResult[] $results */
|
||||
$results = [];
|
||||
|
||||
foreach ($teamers as $teamer) {
|
||||
$results[] = $this->synchronizer->syncTeamer($teamer, $year, $month, $dryRun);
|
||||
$io->progressAdvance();
|
||||
}
|
||||
|
||||
$io->progressFinish();
|
||||
|
||||
$this->report($io, $results);
|
||||
|
||||
return $this->hasFailures($results) ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param WorkHoursSyncResult[] $results
|
||||
*/
|
||||
private function report(SymfonyStyle $io, array $results): void
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($results as $result) {
|
||||
if (WorkHoursSyncOutcome::IMPORTED === $result->getOutcome() && [] === $result->getWarnings()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
$result->getTeamer()->getFullName(),
|
||||
$result->getOutcome()->value,
|
||||
$result->getMessage() ?? implode(' | ', $result->getWarnings()),
|
||||
];
|
||||
}
|
||||
|
||||
if ([] !== $rows) {
|
||||
$io->section('Needs a look');
|
||||
$io->table(['Teamer', 'Outcome', 'Detail'], $rows);
|
||||
}
|
||||
|
||||
$counts = array_count_values(array_map(
|
||||
static fn (WorkHoursSyncResult $result) => $result->getOutcome()->value,
|
||||
$results,
|
||||
));
|
||||
|
||||
$summary = [];
|
||||
|
||||
foreach (WorkHoursSyncOutcome::cases() as $outcome) {
|
||||
$summary[] = sprintf('%s: %d', $outcome->value, $counts[$outcome->value] ?? 0);
|
||||
}
|
||||
|
||||
$minutes = array_sum(array_map(
|
||||
static fn (WorkHoursSyncResult $result) => WorkHoursSyncOutcome::IMPORTED === $result->getOutcome()
|
||||
? (int) $result->getTotalMinutes()
|
||||
: 0,
|
||||
$results,
|
||||
));
|
||||
|
||||
$io->writeln(implode(' ', $summary));
|
||||
$io->writeln(sprintf('Imported %d minutes in total.', $minutes));
|
||||
|
||||
if ($this->hasFailures($results)) {
|
||||
$io->error('At least one teamer could not be imported.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d teamer(s) processed.', count($results)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Teamer[] $teamers
|
||||
*
|
||||
* @return Teamer[]
|
||||
*/
|
||||
private function onlyThisTeamer(array $teamers, string $needle): array
|
||||
{
|
||||
$needle = strtolower(trim($needle));
|
||||
|
||||
return array_values(array_filter($teamers, static function (Teamer $teamer) use ($needle) {
|
||||
return strtolower($teamer->getUuid()) === $needle
|
||||
|| strtolower((string) $teamer->getCommunication()?->getEmail()) === $needle;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param WorkHoursSyncResult[] $results
|
||||
*/
|
||||
private function hasFailures(array $results): bool
|
||||
{
|
||||
foreach ($results as $result) {
|
||||
if ($result->isFailure()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{int, int}
|
||||
*/
|
||||
private function resolveMonth(?string $argument): array
|
||||
{
|
||||
if (null === $argument) {
|
||||
$previous = (new \DateTimeImmutable('first day of this month'))->modify('-1 month');
|
||||
|
||||
return [(int) $previous->format('Y'), (int) $previous->format('n')];
|
||||
}
|
||||
|
||||
if (1 !== preg_match('/^(\d{4})-(\d{2})$/', $argument, $matches)) {
|
||||
throw new \InvalidArgumentException(sprintf('"%s" is not a month; expected YYYY-MM.', $argument));
|
||||
}
|
||||
|
||||
$month = (int) $matches[2];
|
||||
|
||||
if ($month < 1 || $month > 12) {
|
||||
throw new \InvalidArgumentException(sprintf('"%s" is not a month; expected YYYY-MM.', $argument));
|
||||
}
|
||||
|
||||
return [(int) $matches[1], $month];
|
||||
}
|
||||
}
|
||||
@@ -23,4 +23,6 @@ enum EmailTextKey: string
|
||||
case REMINDER_INVOICE_UPLOAD_FINAL = 'reminder_invoice_upload_final';
|
||||
case REMINDER_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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Traits\TimestampableEntity;
|
||||
use App\Enum\WorkMonthStatus;
|
||||
use App\Model\WorkMonthDays;
|
||||
use App\Repository\TeamerWorkMonthRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* One teamer's working time for one calendar month, imported from the SaaS time-tracking
|
||||
* service, and the state of the Arbeitszeitnachweis the teamer signs for it.
|
||||
*
|
||||
* The days live in a JSON column rather than in one row per day: every read is "one teamer,
|
||||
* one month", which a single row answers directly, and the signed document, its status and
|
||||
* the import's own metadata all need exactly one monthly row to hang off. The figures a list
|
||||
* sorts or filters on are denormalised into columns so nothing has to decode the blob to
|
||||
* answer a question. App\Model\WorkMonthDays is the only place that knows the blob's shape.
|
||||
*
|
||||
* Deliberately not soft-deletable, like SeasonAssignmentTask and Upload: rows are derived
|
||||
* from an import, never deleted by a user, and a soft-deleted row would silently shadow a
|
||||
* re-imported one against the unique constraint below. Not blameable either - the importer
|
||||
* writes these, not a person.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: TeamerWorkMonthRepository::class)]
|
||||
#[ORM\UniqueConstraint(name: 'teamer_work_month_unique', columns: ['teamer_id', 'year', 'month'])]
|
||||
class TeamerWorkMonth implements TimestampableEntityInterface
|
||||
{
|
||||
use TimestampableEntity;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'workMonths')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?Teamer $teamer = null;
|
||||
|
||||
#[ORM\Column(type: Types::SMALLINT)]
|
||||
private int $year;
|
||||
|
||||
/**
|
||||
* The calendar month, 1-12. The SaaS counts months from zero; that index never leaves
|
||||
* App\Saas.
|
||||
*/
|
||||
#[ORM\Column(type: Types::SMALLINT)]
|
||||
private int $month;
|
||||
|
||||
/**
|
||||
* @var array<string, array{minutes: int, absence?: string}>
|
||||
*/
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $days = [];
|
||||
|
||||
#[ORM\Column]
|
||||
private int $totalMinutes = 0;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $workedDays = 0;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $absenceDays = 0;
|
||||
|
||||
/**
|
||||
* The month total as the SaaS itself reported it. Kept alongside our own sum so a later
|
||||
* divergence is visible without fetching the month again.
|
||||
*/
|
||||
#[ORM\Column]
|
||||
private int $sourceIstMinutes = 0;
|
||||
|
||||
/**
|
||||
* The month's target. A monthly figure by nature: every booking carries soll 0 with
|
||||
* sollType MONTH, so there is no daily target to break it down into.
|
||||
*/
|
||||
#[ORM\Column]
|
||||
private int $targetMinutes = 0;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $breakMinutes = 0;
|
||||
|
||||
/**
|
||||
* The employee's department in the SaaS, shown in the head of the sheet. Nullable because
|
||||
* a month of nothing but holiday carries no booking to read it from.
|
||||
*/
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $department = null;
|
||||
|
||||
#[ORM\Column(enumType: WorkMonthStatus::class)]
|
||||
private WorkMonthStatus $status = WorkMonthStatus::OPEN;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $submittedAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $reviewedAt = null;
|
||||
|
||||
#[ORM\Column(length: 8, nullable: true)]
|
||||
private ?string $reviewedBy = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $reviewComment = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private \DateTimeImmutable $syncedAt;
|
||||
|
||||
/**
|
||||
* Anything the parser could not make sense of, kept so a past import stays explicable.
|
||||
*
|
||||
* @var string[]|null
|
||||
*/
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?array $warnings = null;
|
||||
|
||||
/**
|
||||
* Set when an import found different hours for a month whose figures are frozen because
|
||||
* the teamer already signed it. The stored hours stay as signed; this records that the
|
||||
* SaaS has since disagreed.
|
||||
*/
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $mismatchDetectedAt = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $mismatchTotalMinutes = null;
|
||||
|
||||
/**
|
||||
* Every signed copy the teamer has uploaded for this month, oldest first. They
|
||||
* accumulate rather than replace, as season documents do, so a rejection and the
|
||||
* re-upload that answered it both stay on the record.
|
||||
*
|
||||
* @var Collection<int, Upload>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'teamerWorkMonth', targetEntity: Upload::class)]
|
||||
#[ORM\OrderBy(['createdAt' => \SortDirection::Ascending])]
|
||||
private Collection $documents;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
$this->syncedAt = new \DateTimeImmutable();
|
||||
$this->documents = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUuid(): string
|
||||
{
|
||||
return $this->uuid;
|
||||
}
|
||||
|
||||
public function getTeamer(): ?Teamer
|
||||
{
|
||||
return $this->teamer;
|
||||
}
|
||||
|
||||
public function setTeamer(?Teamer $teamer): static
|
||||
{
|
||||
$this->teamer = $teamer;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getYear(): int
|
||||
{
|
||||
return $this->year;
|
||||
}
|
||||
|
||||
public function setYear(int $year): static
|
||||
{
|
||||
$this->year = $year;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMonth(): int
|
||||
{
|
||||
return $this->month;
|
||||
}
|
||||
|
||||
public function setMonth(int $month): static
|
||||
{
|
||||
$this->month = $month;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first of the month, for formatting and sorting in templates.
|
||||
*/
|
||||
public function getPeriod(): \DateTimeImmutable
|
||||
{
|
||||
return (new \DateTimeImmutable())->setDate($this->year, $this->month, 1)->setTime(0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* "März 2026" - the way the month is named in mails and on the sheet itself.
|
||||
*/
|
||||
public function getPeriodLabel(): string
|
||||
{
|
||||
return (string) \IntlDateFormatter::formatObject($this->getPeriod(), 'LLLL yyyy', 'de');
|
||||
}
|
||||
|
||||
public function getDays(): WorkMonthDays
|
||||
{
|
||||
return WorkMonthDays::fromArray($this->days);
|
||||
}
|
||||
|
||||
public function setDays(WorkMonthDays $days): static
|
||||
{
|
||||
$this->days = $days->toArray();
|
||||
$this->totalMinutes = $days->getTotalMinutes();
|
||||
$this->breakMinutes = $days->getTotalBreakMinutes();
|
||||
$this->workedDays = $days->countWorkedDays();
|
||||
$this->absenceDays = $days->countAbsenceDays();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTotalMinutes(): int
|
||||
{
|
||||
return $this->totalMinutes;
|
||||
}
|
||||
|
||||
public function getWorkedDays(): int
|
||||
{
|
||||
return $this->workedDays;
|
||||
}
|
||||
|
||||
public function getAbsenceDays(): int
|
||||
{
|
||||
return $this->absenceDays;
|
||||
}
|
||||
|
||||
public function getSourceIstMinutes(): int
|
||||
{
|
||||
return $this->sourceIstMinutes;
|
||||
}
|
||||
|
||||
public function setSourceIstMinutes(int $sourceIstMinutes): static
|
||||
{
|
||||
$this->sourceIstMinutes = $sourceIstMinutes;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTargetMinutes(): int
|
||||
{
|
||||
return $this->targetMinutes;
|
||||
}
|
||||
|
||||
public function setTargetMinutes(int $targetMinutes): static
|
||||
{
|
||||
$this->targetMinutes = $targetMinutes;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBreakMinutes(): int
|
||||
{
|
||||
return $this->breakMinutes;
|
||||
}
|
||||
|
||||
public function getDepartment(): ?string
|
||||
{
|
||||
return $this->department;
|
||||
}
|
||||
|
||||
public function setDepartment(?string $department): static
|
||||
{
|
||||
$this->department = $department;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Worked minus target: positive is time over the month's target, negative under it.
|
||||
*/
|
||||
public function getBalanceMinutes(): int
|
||||
{
|
||||
return $this->totalMinutes - $this->targetMinutes;
|
||||
}
|
||||
|
||||
public function getStatus(): WorkMonthStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(WorkMonthStatus $status): static
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSubmittedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->submittedAt;
|
||||
}
|
||||
|
||||
public function setSubmittedAt(?\DateTimeImmutable $submittedAt): static
|
||||
{
|
||||
$this->submittedAt = $submittedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReviewedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->reviewedAt;
|
||||
}
|
||||
|
||||
public function setReviewedAt(?\DateTimeImmutable $reviewedAt): static
|
||||
{
|
||||
$this->reviewedAt = $reviewedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReviewedBy(): ?string
|
||||
{
|
||||
return $this->reviewedBy;
|
||||
}
|
||||
|
||||
public function setReviewedBy(?string $reviewedBy): static
|
||||
{
|
||||
$this->reviewedBy = $reviewedBy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReviewComment(): ?string
|
||||
{
|
||||
return $this->reviewComment;
|
||||
}
|
||||
|
||||
public function setReviewComment(?string $reviewComment): static
|
||||
{
|
||||
$this->reviewComment = $reviewComment;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSyncedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->syncedAt;
|
||||
}
|
||||
|
||||
public function setSyncedAt(\DateTimeImmutable $syncedAt): static
|
||||
{
|
||||
$this->syncedAt = $syncedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getWarnings(): array
|
||||
{
|
||||
return $this->warnings ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $warnings
|
||||
*/
|
||||
public function setWarnings(array $warnings): static
|
||||
{
|
||||
$this->warnings = [] !== $warnings ? array_values($warnings) : null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMismatchDetectedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->mismatchDetectedAt;
|
||||
}
|
||||
|
||||
public function getMismatchTotalMinutes(): ?int
|
||||
{
|
||||
return $this->mismatchTotalMinutes;
|
||||
}
|
||||
|
||||
public function hasMismatch(): bool
|
||||
{
|
||||
return null !== $this->mismatchDetectedAt;
|
||||
}
|
||||
|
||||
public function recordMismatch(?int $totalMinutes): static
|
||||
{
|
||||
$this->mismatchDetectedAt = new \DateTimeImmutable();
|
||||
$this->mismatchTotalMinutes = $totalMinutes;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function clearMismatch(): static
|
||||
{
|
||||
$this->mismatchDetectedAt = null;
|
||||
$this->mismatchTotalMinutes = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Upload>
|
||||
*/
|
||||
public function getDocuments(): Collection
|
||||
{
|
||||
return $this->documents;
|
||||
}
|
||||
|
||||
public function addDocument(Upload $document): static
|
||||
{
|
||||
if (!$this->documents->contains($document)) {
|
||||
$this->documents->add($document);
|
||||
$document->setTeamerWorkMonth($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed copy that counts: the most recent one the teamer uploaded.
|
||||
*/
|
||||
public function getCurrentDocument(): ?Upload
|
||||
{
|
||||
return $this->documents->last() ?: null;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,13 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
*/
|
||||
public const TYPE_SEASON_DOCUMENT_IN = 'season_document_in';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum WorkHoursSyncOutcome: string
|
||||
{
|
||||
/** Hours were fetched and written. */
|
||||
case IMPORTED = 'imported';
|
||||
|
||||
/** The month is signed, so the hours were compared but left alone. */
|
||||
case LOCKED = 'locked';
|
||||
|
||||
/** Nothing to do: no address on file, or nothing to import. */
|
||||
case SKIPPED = 'skipped';
|
||||
|
||||
/** The request or the payload failed. Worth waking someone for. */
|
||||
case FAILED = 'failed';
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* Where one work month stands in the Arbeitszeitnachweis exchange.
|
||||
*
|
||||
* Deliberately not a Symfony workflow: the existing document state machine is keyed to
|
||||
* Disposition's own marking store and cannot carry a month. Four states and two
|
||||
* transitions do not need one.
|
||||
*/
|
||||
enum WorkMonthStatus: string
|
||||
{
|
||||
/** Imported, waiting for the teamer to download, sign and upload it. */
|
||||
case OPEN = 'open';
|
||||
|
||||
/** The teamer uploaded a signed copy; the office has not looked at it yet. */
|
||||
case SUBMITTED = 'submitted';
|
||||
|
||||
/** The office accepted the signed copy. */
|
||||
case APPROVED = 'approved';
|
||||
|
||||
/** The office rejected it. The teamer uploads again, which returns it to SUBMITTED. */
|
||||
case REJECTED = 'rejected';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::OPEN => 'Offen',
|
||||
self::SUBMITTED => 'Eingereicht',
|
||||
self::APPROVED => 'Geprüft',
|
||||
self::REJECTED => 'Abgelehnt',
|
||||
};
|
||||
}
|
||||
|
||||
public function badgeClass(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::OPEN => 'upload-status-badge--new',
|
||||
self::SUBMITTED => 'upload-status-badge--default',
|
||||
self::APPROVED => 'upload-status-badge--checked',
|
||||
self::REJECTED => 'upload-status-badge--error',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the imported hours are frozen.
|
||||
*
|
||||
* Once a teamer has signed a month, the stored hours have to keep matching the paper
|
||||
* they signed, so a later import may compare but not overwrite. A rejected month is
|
||||
* not locked: a rejection usually means the hours themselves were wrong.
|
||||
*/
|
||||
public function locksHours(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::SUBMITTED, self::APPROVED => true,
|
||||
self::OPEN, self::REJECTED => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Enum\WorkMonthStatus;
|
||||
use App\Model\WorkMonthFilterDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class WorkMonthFilterType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('teamerName', TextType::class, [
|
||||
'label' => 'Name',
|
||||
'required' => false,
|
||||
])
|
||||
// The day is irrelevant here; FirstDayOfMonthDateTransformer pins it to the 1st.
|
||||
->add('period', AbbreviatedDateType::class, [
|
||||
'label' => 'Monat',
|
||||
'required' => false,
|
||||
'years' => range((int) date('Y') + 1, (int) date('Y') - 5),
|
||||
])
|
||||
->add('statuses', MultiselectType::class, [
|
||||
'label' => 'Status',
|
||||
'required' => false,
|
||||
'empty_label' => 'nicht filtern',
|
||||
'choices' => WorkMonthStatus::cases(),
|
||||
'choice_label' => static fn (WorkMonthStatus $status) => $status->label(),
|
||||
'choice_value' => static fn (?WorkMonthStatus $status) => $status?->value,
|
||||
])
|
||||
->add('includeApproved', CheckboxType::class, [
|
||||
'label' => 'geprüfte Monate anzeigen',
|
||||
'required' => false,
|
||||
])
|
||||
->add('includeMismatchedOnly', CheckboxType::class, [
|
||||
'label' => 'nur Monate mit abweichenden Stunden',
|
||||
'required' => false,
|
||||
])
|
||||
// AbstractFilterHandler::handleRequest() branches on exactly these two names
|
||||
->add('apply', SubmitType::class, [
|
||||
'label' => 'filtern',
|
||||
])
|
||||
->add('reset', SubmitType::class, [
|
||||
'label' => 'reset',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => WorkMonthFilterDto::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Enum\WorkMonthStatus;
|
||||
use App\Model\WorkMonthReviewDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class WorkMonthReviewType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('status', ChoiceType::class, [
|
||||
'label' => 'Entscheidung',
|
||||
'expanded' => true,
|
||||
'placeholder' => false,
|
||||
// Only the two outcomes a review produces: "offen" and "eingereicht" are
|
||||
// states the teamer puts the month into, not the office.
|
||||
'choices' => [
|
||||
WorkMonthStatus::APPROVED->label() => WorkMonthStatus::APPROVED,
|
||||
WorkMonthStatus::REJECTED->label() => WorkMonthStatus::REJECTED,
|
||||
],
|
||||
'choice_value' => static fn (?WorkMonthStatus $status) => $status?->value,
|
||||
])
|
||||
->add('comment', TextareaType::class, [
|
||||
'label' => 'Kommentar/Begründung',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 3,
|
||||
],
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => WorkMonthReviewDto::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Logging;
|
||||
|
||||
use Monolog\LogRecord;
|
||||
|
||||
/**
|
||||
* Keeps SaaS api keys out of the log while leaving the requests themselves in it.
|
||||
*
|
||||
* The SaaS takes its api key as a path segment rather than in a header, so the url of every
|
||||
* call carries a credential. Symfony's http client logs a line per request, and an HTTP error
|
||||
* repeats the url inside the exception message, which then travels into whatever channel
|
||||
* catches it - so silencing one client would not have been enough.
|
||||
*
|
||||
* Registered without a channel, deliberately: a url can surface anywhere, and a processor
|
||||
* that only guarded the saas channel would miss the exception that matters most.
|
||||
*
|
||||
* Two passes, because the two failure modes are different. The configured key is redacted
|
||||
* wherever it appears, whole or url-encoded, which is exact. The path shapes are redacted by
|
||||
* pattern, which also covers the per-employee keys the roster hands out and any future
|
||||
* endpoint that follows the same convention.
|
||||
*/
|
||||
class RedactSaasCredentialsProcessor
|
||||
{
|
||||
private const PLACEHOLDER = '[redacted]';
|
||||
|
||||
/**
|
||||
* Anything sitting where an api key sits: /api/v2/<something>/<key>[/...].
|
||||
*/
|
||||
private const PATH_PATTERNS = [
|
||||
'#(/api/v[0-9]+/time/account/)[^/\s"\']+#i',
|
||||
'#(/api/v[0-9]+/employees/)[^/\s"\']+#i',
|
||||
'#(/api/v[0-9]+/employee/departments/)[^/\s"\']+#i',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
private readonly array $secrets;
|
||||
|
||||
public function __construct(string $apiKey)
|
||||
{
|
||||
$secrets = [];
|
||||
|
||||
// Short values would match far too much; a real key never is.
|
||||
if (8 <= strlen($apiKey)) {
|
||||
$secrets[$apiKey] = $apiKey;
|
||||
$secrets[rawurlencode($apiKey)] = rawurlencode($apiKey);
|
||||
}
|
||||
|
||||
$this->secrets = array_values($secrets);
|
||||
}
|
||||
|
||||
public function __invoke(LogRecord $record): LogRecord
|
||||
{
|
||||
$message = $this->redact($record->message);
|
||||
$context = $this->redactDeep($record->context);
|
||||
$extra = $this->redactDeep($record->extra);
|
||||
|
||||
if ($message === $record->message && $context === $record->context && $extra === $record->extra) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
// LogRecord only allows some of its fields to be assigned through array access;
|
||||
// message is not one of them, so a redacted record is a new one.
|
||||
return $record->with(message: $message, context: $context, extra: $extra);
|
||||
}
|
||||
|
||||
private function redactDeep(array $values, int $depth = 0): array
|
||||
{
|
||||
// Contexts hold arbitrary payloads; stop rather than walk something pathological.
|
||||
if (8 < $depth) {
|
||||
return $values;
|
||||
}
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$values[$key] = $this->redact($value);
|
||||
} elseif (is_array($value)) {
|
||||
$values[$key] = $this->redactDeep($value, $depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
private function redact(string $value): string
|
||||
{
|
||||
foreach ($this->secrets as $secret) {
|
||||
$value = str_replace($secret, self::PLACEHOLDER, $value);
|
||||
}
|
||||
|
||||
foreach (self::PATH_PATTERNS as $pattern) {
|
||||
$value = (string) preg_replace($pattern, '$1'.self::PLACEHOLDER, $value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,18 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
||||
],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Arbeitszeiten', [
|
||||
'route' => 'app_admin_work_month_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Arbeitszeiten',
|
||||
],
|
||||
'extras' => [
|
||||
'icon' => 'hourglass',
|
||||
'routes' => [
|
||||
['pattern' => '/^app_admin_work_month_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$menu->addChild('Verfügbarkeiten', [
|
||||
'route' => 'app_administrative_availability_index',
|
||||
'linkAttributes' => [
|
||||
|
||||
@@ -70,6 +70,22 @@ class TeamerMenuBuilder extends AbstractMenuBuilder
|
||||
]);
|
||||
}
|
||||
|
||||
// Season teamers only, and on the flag rather than on a current assignment: a month
|
||||
// still has to be reachable after the season it belongs to has ended.
|
||||
if (true === $this->isSeasonTeamer()) {
|
||||
$profileMenu->addChild('Arbeitszeiten', [
|
||||
'route' => 'app_teamer_work_month_index',
|
||||
'linkAttributes' => [
|
||||
'title' => 'Arbeitszeiten',
|
||||
],
|
||||
'extras' => [
|
||||
'routes' => [
|
||||
['pattern' => '/^app_teamer_work_month_/'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$profileMenu->addChild('Stammdaten & Foto', [
|
||||
'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();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Enum\WorkHoursSyncOutcome;
|
||||
|
||||
/**
|
||||
* What one teamer's import did, so the command and the admin action can report it without
|
||||
* the synchronizer having to know about either.
|
||||
*/
|
||||
class WorkHoursSyncResult
|
||||
{
|
||||
/**
|
||||
* @param string[] $warnings
|
||||
*/
|
||||
private function __construct(
|
||||
private readonly Teamer $teamer,
|
||||
private readonly WorkHoursSyncOutcome $outcome,
|
||||
private readonly ?string $message = null,
|
||||
private readonly ?int $totalMinutes = null,
|
||||
private readonly array $warnings = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $warnings
|
||||
*/
|
||||
public static function imported(Teamer $teamer, int $totalMinutes, array $warnings = []): self
|
||||
{
|
||||
return new self($teamer, WorkHoursSyncOutcome::IMPORTED, null, $totalMinutes, $warnings);
|
||||
}
|
||||
|
||||
public static function locked(Teamer $teamer, string $message, ?int $totalMinutes = null): self
|
||||
{
|
||||
return new self($teamer, WorkHoursSyncOutcome::LOCKED, $message, $totalMinutes);
|
||||
}
|
||||
|
||||
public static function skipped(Teamer $teamer, string $message): self
|
||||
{
|
||||
return new self($teamer, WorkHoursSyncOutcome::SKIPPED, $message);
|
||||
}
|
||||
|
||||
public static function failed(Teamer $teamer, string $message): self
|
||||
{
|
||||
return new self($teamer, WorkHoursSyncOutcome::FAILED, $message);
|
||||
}
|
||||
|
||||
public function getTeamer(): Teamer
|
||||
{
|
||||
return $this->teamer;
|
||||
}
|
||||
|
||||
public function getOutcome(): WorkHoursSyncOutcome
|
||||
{
|
||||
return $this->outcome;
|
||||
}
|
||||
|
||||
public function getMessage(): ?string
|
||||
{
|
||||
return $this->message;
|
||||
}
|
||||
|
||||
public function getTotalMinutes(): ?int
|
||||
{
|
||||
return $this->totalMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getWarnings(): array
|
||||
{
|
||||
return $this->warnings;
|
||||
}
|
||||
|
||||
public function isFailure(): bool
|
||||
{
|
||||
return WorkHoursSyncOutcome::FAILED === $this->outcome;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
/**
|
||||
* One calendar day as a work-month view renders it.
|
||||
*
|
||||
* "No minutes" and "zero minutes" are different things here: null means the SaaS reported
|
||||
* nothing at all for that day, 0 means it reported a day that happens to add up to zero -
|
||||
* a rest day, say. The view shows the first as a dash and the second as 0:00.
|
||||
*/
|
||||
class WorkMonthDay
|
||||
{
|
||||
public function __construct(
|
||||
private readonly \DateTimeImmutable $date,
|
||||
private readonly ?int $minutes,
|
||||
private readonly ?string $absence,
|
||||
private readonly ?string $startTime = null,
|
||||
private readonly ?string $endTime = null,
|
||||
private readonly int $breakMinutes = 0,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getDate(): \DateTimeImmutable
|
||||
{
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
public function getMinutes(): ?int
|
||||
{
|
||||
return $this->minutes;
|
||||
}
|
||||
|
||||
public function getAbsence(): ?string
|
||||
{
|
||||
return $this->absence;
|
||||
}
|
||||
|
||||
public function getStartTime(): ?string
|
||||
{
|
||||
return $this->startTime;
|
||||
}
|
||||
|
||||
public function getEndTime(): ?string
|
||||
{
|
||||
return $this->endTime;
|
||||
}
|
||||
|
||||
public function getBreakMinutes(): int
|
||||
{
|
||||
return $this->breakMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the day has a span to print. A rest day is booked but never worked in.
|
||||
*/
|
||||
public function hasSpan(): bool
|
||||
{
|
||||
return null !== $this->startTime && null !== $this->endTime;
|
||||
}
|
||||
|
||||
public function isAbsence(): bool
|
||||
{
|
||||
return null !== $this->absence;
|
||||
}
|
||||
|
||||
public function isReported(): bool
|
||||
{
|
||||
return null !== $this->minutes;
|
||||
}
|
||||
|
||||
public function isWeekend(): bool
|
||||
{
|
||||
return in_array((int) $this->date->format('N'), [6, 7], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Saas\Model\TimeAccount;
|
||||
use App\Saas\Model\TimeAccountDay;
|
||||
|
||||
/**
|
||||
* The days of one work month, and the only place that knows how they are stored.
|
||||
*
|
||||
* TeamerWorkMonth keeps its days in a JSON column rather than in rows: every read is
|
||||
* "one teamer, one month", which one row answers directly. The price is that the shape is
|
||||
* invisible to the schema, so it is kept small and passes through here alone - a change to
|
||||
* it is a change to this file and nothing else.
|
||||
*
|
||||
* Shape, with everything optional but "minutes" left out when it has nothing to say:
|
||||
* {"2026-03-01": {"minutes": 285, "break": 60, "start": "09:00", "end": "14:45"},
|
||||
* "2026-03-05": {"minutes": 0, "absence": "Ruhetag 24/25"}}
|
||||
* Days the SaaS did not report are simply absent; forCalendarMonth() fills the gaps.
|
||||
*/
|
||||
class WorkMonthDays
|
||||
{
|
||||
/**
|
||||
* @param array<string, array{minutes: int, break?: int, start?: string, end?: string, absence?: string}> $days keyed by Y-m-d
|
||||
*/
|
||||
private function __construct(private readonly array $days)
|
||||
{
|
||||
}
|
||||
|
||||
public static function fromArray(array $days): self
|
||||
{
|
||||
$clean = [];
|
||||
|
||||
foreach ($days as $key => $value) {
|
||||
if (!is_string($key) || !is_array($value) || 1 !== preg_match('/^\d{4}-\d{2}-\d{2}$/', $key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry = ['minutes' => (int) ($value['minutes'] ?? 0)];
|
||||
|
||||
if (0 < $break = (int) ($value['break'] ?? 0)) {
|
||||
$entry['break'] = $break;
|
||||
}
|
||||
|
||||
$start = self::cleanTime($value['start'] ?? null);
|
||||
$end = self::cleanTime($value['end'] ?? null);
|
||||
|
||||
// A span needs both ends; half of one says nothing.
|
||||
if (null !== $start && null !== $end) {
|
||||
$entry['start'] = $start;
|
||||
$entry['end'] = $end;
|
||||
}
|
||||
|
||||
$absence = $value['absence'] ?? null;
|
||||
|
||||
if (is_string($absence) && '' !== $absence) {
|
||||
$entry['absence'] = $absence;
|
||||
}
|
||||
|
||||
$clean[$key] = $entry;
|
||||
}
|
||||
|
||||
ksort($clean);
|
||||
|
||||
return new self($clean);
|
||||
}
|
||||
|
||||
public static function fromTimeAccount(TimeAccount $account): self
|
||||
{
|
||||
$days = [];
|
||||
|
||||
foreach ($account->getDays() as $key => $day) {
|
||||
/* @var TimeAccountDay $day */
|
||||
$days[$key] = ['minutes' => $day->getMinutes()];
|
||||
|
||||
if (0 < $day->getBreakMinutes()) {
|
||||
$days[$key]['break'] = $day->getBreakMinutes();
|
||||
}
|
||||
|
||||
if (null !== $day->getStartTime() && null !== $day->getEndTime()) {
|
||||
$days[$key]['start'] = $day->getStartTime();
|
||||
$days[$key]['end'] = $day->getEndTime();
|
||||
}
|
||||
|
||||
if (null !== $day->getHolidayType()) {
|
||||
$days[$key]['absence'] = $day->getHolidayType();
|
||||
}
|
||||
}
|
||||
|
||||
return new self($days);
|
||||
}
|
||||
|
||||
private static function cleanTime(mixed $raw): ?string
|
||||
{
|
||||
return is_string($raw) && 1 === preg_match('/^\d{2}:\d{2}$/', $raw) ? $raw : null;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->days;
|
||||
}
|
||||
|
||||
public function getTotalMinutes(): int
|
||||
{
|
||||
return array_sum(array_column($this->days, 'minutes'));
|
||||
}
|
||||
|
||||
public function countWorkedDays(): int
|
||||
{
|
||||
return count(array_filter($this->days, static fn (array $day) => ($day['minutes'] ?? 0) > 0));
|
||||
}
|
||||
|
||||
public function countAbsenceDays(): int
|
||||
{
|
||||
return count(array_filter($this->days, static fn (array $day) => isset($day['absence'])));
|
||||
}
|
||||
|
||||
public function getTotalBreakMinutes(): int
|
||||
{
|
||||
return array_sum(array_map(static fn (array $day) => $day['break'] ?? 0, $this->days));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every day of the month in order, reported or not, for tables and the PDF.
|
||||
*
|
||||
* @return list<WorkMonthDay>
|
||||
*/
|
||||
public function forCalendarMonth(int $year, int $month): array
|
||||
{
|
||||
$first = \DateTimeImmutable::createFromFormat('!Y-n-j', sprintf('%04d-%d-1', $year, $month));
|
||||
|
||||
if (false === $first) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$days = [];
|
||||
|
||||
for ($day = 1, $last = (int) $first->format('t'); $day <= $last; ++$day) {
|
||||
$date = $first->setDate($year, $month, $day);
|
||||
$entry = $this->days[$date->format('Y-m-d')] ?? null;
|
||||
|
||||
$days[] = new WorkMonthDay(
|
||||
$date,
|
||||
null !== $entry ? (int) $entry['minutes'] : null,
|
||||
$entry['absence'] ?? null,
|
||||
$entry['start'] ?? null,
|
||||
$entry['end'] ?? null,
|
||||
(int) ($entry['break'] ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
return $days;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Enum\WorkMonthStatus;
|
||||
|
||||
class WorkMonthFilterDto extends AbstractFilterDto
|
||||
{
|
||||
protected ?string $teamerName = null;
|
||||
|
||||
/**
|
||||
* Normalised to the first of the month by FirstDayOfMonthDateTransformer.
|
||||
*/
|
||||
protected ?\DateTimeImmutable $period = null;
|
||||
|
||||
/** @var WorkMonthStatus[] */
|
||||
protected array $statuses = [];
|
||||
|
||||
/**
|
||||
* Approved months are done with, so the list is about what still needs attention.
|
||||
*/
|
||||
protected bool $includeApproved = false;
|
||||
|
||||
/**
|
||||
* Months whose hours the SaaS has since disagreed with, which is a rare thing to go
|
||||
* looking for and a noisy thing to see by default.
|
||||
*/
|
||||
protected bool $includeMismatchedOnly = false;
|
||||
|
||||
public function getTeamerName(): ?string
|
||||
{
|
||||
return $this->teamerName;
|
||||
}
|
||||
|
||||
public function setTeamerName(?string $teamerName): static
|
||||
{
|
||||
$this->teamerName = $teamerName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPeriod(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->period;
|
||||
}
|
||||
|
||||
public function setPeriod(?\DateTimeImmutable $period): static
|
||||
{
|
||||
$this->period = $period;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return WorkMonthStatus[]
|
||||
*/
|
||||
public function getStatuses(): array
|
||||
{
|
||||
return $this->statuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param WorkMonthStatus[] $statuses
|
||||
*/
|
||||
public function setStatuses(array $statuses): static
|
||||
{
|
||||
$this->statuses = $statuses;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isIncludeApproved(): bool
|
||||
{
|
||||
return $this->includeApproved;
|
||||
}
|
||||
|
||||
public function setIncludeApproved(bool $includeApproved): static
|
||||
{
|
||||
$this->includeApproved = $includeApproved;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isIncludeMismatchedOnly(): bool
|
||||
{
|
||||
return $this->includeMismatchedOnly;
|
||||
}
|
||||
|
||||
public function setIncludeMismatchedOnly(bool $includeMismatchedOnly): static
|
||||
{
|
||||
$this->includeMismatchedOnly = $includeMismatchedOnly;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\TeamerWorkMonth;
|
||||
use App\Enum\WorkMonthStatus;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
class WorkMonthReviewDto
|
||||
{
|
||||
private TeamerWorkMonth $workMonth;
|
||||
|
||||
#[Assert\NotNull(message: 'Bitte eine Entscheidung treffen')]
|
||||
private ?WorkMonthStatus $status;
|
||||
|
||||
private ?string $comment;
|
||||
|
||||
public function __construct(TeamerWorkMonth $workMonth)
|
||||
{
|
||||
$this->workMonth = $workMonth;
|
||||
$this->status = null;
|
||||
$this->comment = $workMonth->getReviewComment();
|
||||
}
|
||||
|
||||
public function getWorkMonth(): TeamerWorkMonth
|
||||
{
|
||||
return $this->workMonth;
|
||||
}
|
||||
|
||||
public function getStatus(): ?WorkMonthStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(?WorkMonthStatus $status): static
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getComment(): ?string
|
||||
{
|
||||
return $this->comment;
|
||||
}
|
||||
|
||||
public function setComment(?string $comment): static
|
||||
{
|
||||
$this->comment = $comment;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* A rejection without a reason leaves the teamer guessing what to fix.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateComment(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (WorkMonthStatus::REJECTED === $this->status && '' === trim((string) $this->comment)) {
|
||||
$context
|
||||
->buildViolation('Bitte eine Begründung für die Ablehnung angeben')
|
||||
->atPath('comment')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,43 @@ class TeamerRepository extends ServiceEntityRepository
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Saas;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as HttpClientExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* Reads time accounts from the SaaS time-tracking service.
|
||||
*
|
||||
* The API counts months from zero, and so do the date strings it answers with. Both
|
||||
* conversions are confined to this class and to ResponseParser, so the rest of the
|
||||
* application only ever deals in calendar months.
|
||||
*/
|
||||
class ApiClient
|
||||
{
|
||||
private array $config;
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly LoggerInterface $logger,
|
||||
array $options,
|
||||
) {
|
||||
$this->config = $this->resolveOptions($options);
|
||||
}
|
||||
|
||||
/**
|
||||
* One employee's time account for one calendar month (1-12).
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getTimeAccount(string $email, int $year, int $month): array
|
||||
{
|
||||
if ($month < 1 || $month > 12) {
|
||||
throw new ApiClientException(sprintf('%d is not a calendar month.', $month));
|
||||
}
|
||||
|
||||
// The api key travels in the path, not in a header or query string - the vendor's
|
||||
// choice, not ours. Every url built here therefore carries a credential, which is what
|
||||
// App\Logging\RedactSaasCredentialsProcessor exists to strip back out of the log.
|
||||
//
|
||||
// The employee is addressed by email. The roster offers a per-employee key that would
|
||||
// serve instead, but swapping an address for a credential in the same URL is not an
|
||||
// improvement.
|
||||
$url = sprintf(
|
||||
'%s/api/v2/time/account/%s/%s/%d/%d',
|
||||
$this->baseUrl(),
|
||||
rawurlencode($this->config['api_key']),
|
||||
rawurlencode($email),
|
||||
$year,
|
||||
$month - 1,
|
||||
);
|
||||
|
||||
$this->logger->info('Requesting time account', [
|
||||
'email' => $email,
|
||||
'year' => $year,
|
||||
'month' => $month,
|
||||
]);
|
||||
|
||||
try {
|
||||
return $this->httpClient->request('GET', $url, [
|
||||
// One request per teamer, so a hanging host must not stall the whole run.
|
||||
'timeout' => 10,
|
||||
'max_duration' => 30,
|
||||
])->toArray();
|
||||
} catch (HttpClientExceptionInterface $e) {
|
||||
// Covers transport, redirect, 4xx/5xx and JSON decoding alike.
|
||||
throw new ApiClientException(sprintf('Time account request for %s (%04d-%02d) failed: %s', $email, $year, $month, $e->getMessage()), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole employee roster.
|
||||
*
|
||||
* Slow, and the same for every teamer, so it is fetched once per run behind
|
||||
* App\Saas\DataProvider\EmployeeDataProvider rather than per teamer.
|
||||
*
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
public function getEmployees(): array
|
||||
{
|
||||
$url = sprintf('%s/api/v2/employees/%s', $this->baseUrl(), rawurlencode($this->config['api_key']));
|
||||
|
||||
$this->logger->info('Requesting employee roster');
|
||||
|
||||
try {
|
||||
return $this->httpClient->request('GET', $url, [
|
||||
// Deliberately longer than the per-teamer call: this one is known to be slow.
|
||||
'timeout' => 30,
|
||||
'max_duration' => 60,
|
||||
])->toArray();
|
||||
} catch (HttpClientExceptionInterface $e) {
|
||||
throw new ApiClientException(sprintf('Employee roster request failed: %s', $e->getMessage()), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
private function baseUrl(): string
|
||||
{
|
||||
// Checked here rather than in the constructor, and the base url is deliberately not a
|
||||
// scoped client: an unconfigured host would then fail while the container is being
|
||||
// built, which takes the whole command down with it - "--help" included.
|
||||
if ('' === $baseUrl = rtrim((string) $this->config['base_url'], '/')) {
|
||||
throw new ApiClientException('APP_SAAS_BASE_URL is not configured.');
|
||||
}
|
||||
|
||||
return $baseUrl;
|
||||
}
|
||||
|
||||
private function resolveOptions(array $options): array
|
||||
{
|
||||
$optionsResolver = new OptionsResolver();
|
||||
$optionsResolver->setRequired(['base_url', 'api_key']);
|
||||
$optionsResolver->setAllowedTypes('base_url', 'string');
|
||||
$optionsResolver->setAllowedTypes('api_key', 'string');
|
||||
|
||||
return $optionsResolver->resolve($options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Saas;
|
||||
|
||||
class ApiClientException extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Saas\DataProvider;
|
||||
|
||||
use App\Saas\ApiClient;
|
||||
use App\Saas\ApiClientException;
|
||||
use App\Saas\Model\EmployeeDirectory;
|
||||
use App\Saas\ResponseParser;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
/**
|
||||
* The SaaS roster, fetched once and kept for a while.
|
||||
*
|
||||
* The endpoint is slow and answers the same thing for every teamer, so a monthly run that
|
||||
* touches forty people should ask for it once. Follows App\BusProNet\DataProvider.
|
||||
*
|
||||
* Only the reduced projection is cached, never the response: the raw entries carry a
|
||||
* per-employee api key, and the app cache writes to the filesystem.
|
||||
*/
|
||||
class EmployeeDataProvider
|
||||
{
|
||||
private const CACHE_KEY = 'saas_employees';
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly ResponseParser $responseParser,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly int $ttl = 21600,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getDirectory(): EmployeeDirectory
|
||||
{
|
||||
try {
|
||||
$rows = $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) {
|
||||
$item->expiresAfter($this->ttl);
|
||||
|
||||
$directory = $this->responseParser->parseEmployees($this->apiClient->getEmployees());
|
||||
|
||||
$this->logger->info('Fetched employee roster', ['employees' => $directory->count()]);
|
||||
|
||||
return $directory->toCacheable();
|
||||
});
|
||||
} catch (ApiClientException $e) {
|
||||
// An unreachable roster must not stop the import: it is a cross-check and a
|
||||
// source for the department, not the working time itself.
|
||||
$this->logger->error('Unable to fetch the employee roster: '.$e->getMessage());
|
||||
|
||||
return EmployeeDirectory::fromEmployees([]);
|
||||
}
|
||||
|
||||
return EmployeeDirectory::fromCacheable($rows);
|
||||
}
|
||||
|
||||
public function refresh(): EmployeeDirectory
|
||||
{
|
||||
$this->cache->delete(self::CACHE_KEY);
|
||||
|
||||
return $this->getDirectory();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Saas\Model;
|
||||
|
||||
/**
|
||||
* One employee of the SaaS, reduced to the four things this application has a use for.
|
||||
*
|
||||
* Everything else the roster reports is dropped on the way in, and deliberately: the raw
|
||||
* entry carries a per-employee "apiKey" - a credential - alongside a birthday, a personnel
|
||||
* number and an RFID token. None of that is needed to import working time, and the roster is
|
||||
* cached to the filesystem, so none of it may survive this class.
|
||||
*
|
||||
* The per-employee key would work in place of the email address when addressing a time
|
||||
* account, and is deliberately not used for it: that only trades one identifier in a URL for
|
||||
* a credential in a URL, and this is machine-to-machine traffic where the address is the
|
||||
* lesser exposure. Do not reintroduce it without deciding where the keys are to be stored.
|
||||
*/
|
||||
class Employee
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $email,
|
||||
private readonly ?string $uuid,
|
||||
private readonly ?string $department,
|
||||
private readonly bool $hasLeft,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getEmail(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function getUuid(): ?string
|
||||
{
|
||||
return $this->uuid;
|
||||
}
|
||||
|
||||
public function getDepartment(): ?string
|
||||
{
|
||||
return $this->department;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the employee has a date of separation, and so no longer books time.
|
||||
*/
|
||||
public function hasLeft(): bool
|
||||
{
|
||||
return $this->hasLeft;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Saas\Model;
|
||||
|
||||
/**
|
||||
* The SaaS roster, by email address.
|
||||
*
|
||||
* Lookups are case-insensitive: the address is typed by hand on both sides, and the API is
|
||||
* the one place where the two systems have to agree on it.
|
||||
*/
|
||||
class EmployeeDirectory
|
||||
{
|
||||
/**
|
||||
* @param array<string, Employee> $employees keyed by lower-cased email
|
||||
*/
|
||||
private function __construct(private readonly array $employees)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Employee[] $employees
|
||||
*/
|
||||
public static function fromEmployees(array $employees): self
|
||||
{
|
||||
$byEmail = [];
|
||||
|
||||
foreach ($employees as $employee) {
|
||||
$byEmail[mb_strtolower($employee->getEmail())] = $employee;
|
||||
}
|
||||
|
||||
return new self($byEmail);
|
||||
}
|
||||
|
||||
public function find(?string $email): ?Employee
|
||||
{
|
||||
if (null === $email || '' === trim($email)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->employees[mb_strtolower(trim($email))] ?? null;
|
||||
}
|
||||
|
||||
public function has(?string $email): bool
|
||||
{
|
||||
return null !== $this->find($email);
|
||||
}
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return [] === $this->employees;
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->employees);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{email: string, uuid: ?string, department: ?string, hasLeft: bool}>
|
||||
*/
|
||||
public function toCacheable(): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($this->employees as $key => $employee) {
|
||||
$rows[$key] = [
|
||||
'email' => $employee->getEmail(),
|
||||
'uuid' => $employee->getUuid(),
|
||||
'department' => $employee->getDepartment(),
|
||||
'hasLeft' => $employee->hasLeft(),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{email: string, uuid: ?string, department: ?string, hasLeft: bool}> $rows
|
||||
*/
|
||||
public static function fromCacheable(array $rows): self
|
||||
{
|
||||
return self::fromEmployees(array_map(
|
||||
static fn (array $row) => new Employee(
|
||||
(string) $row['email'],
|
||||
$row['uuid'] ?? null,
|
||||
$row['department'] ?? null,
|
||||
(bool) ($row['hasLeft'] ?? false),
|
||||
),
|
||||
array_values($rows),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Saas\Model;
|
||||
|
||||
/**
|
||||
* One employee's time account for one calendar month, reduced to what this application
|
||||
* stores: the days with their net minutes and absence labels.
|
||||
*
|
||||
* Everything the SaaS reports about overtime accounts and payroll transfers is deliberately
|
||||
* dropped. The header's own "ist" total survives as a cross-check against the days we summed
|
||||
* ourselves, and "soll" because the sheet shows target against actual.
|
||||
*/
|
||||
class TimeAccount
|
||||
{
|
||||
/**
|
||||
* @param array<string, TimeAccountDay> $days keyed by Y-m-d, ascending
|
||||
* @param string[] $warnings
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ?string $employeeUuid,
|
||||
private readonly int $year,
|
||||
private readonly int $month,
|
||||
private readonly int $istMinutes,
|
||||
private readonly int $targetMinutes,
|
||||
private readonly ?string $department,
|
||||
private readonly array $days,
|
||||
private readonly array $warnings,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getEmployeeUuid(): ?string
|
||||
{
|
||||
return $this->employeeUuid;
|
||||
}
|
||||
|
||||
public function getYear(): int
|
||||
{
|
||||
return $this->year;
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar month (1-12), never the API's zero-based index.
|
||||
*/
|
||||
public function getMonth(): int
|
||||
{
|
||||
return $this->month;
|
||||
}
|
||||
|
||||
/**
|
||||
* The total the SaaS itself reported for the month.
|
||||
*/
|
||||
public function getIstMinutes(): int
|
||||
{
|
||||
return $this->istMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The month's target, as reported. There is no daily target: every row carries soll 0
|
||||
* with sollType MONTH.
|
||||
*/
|
||||
public function getTargetMinutes(): int
|
||||
{
|
||||
return $this->targetMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The employee's department, as the bookings report it.
|
||||
*
|
||||
* Null when the month holds no booking at all - a month of nothing but holiday has
|
||||
* nowhere to read it from.
|
||||
*/
|
||||
public function getDepartment(): ?string
|
||||
{
|
||||
return $this->department;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, TimeAccountDay>
|
||||
*/
|
||||
public function getDays(): array
|
||||
{
|
||||
return $this->days;
|
||||
}
|
||||
|
||||
public function getTotalMinutes(): int
|
||||
{
|
||||
return array_sum(array_map(static fn (TimeAccountDay $day) => $day->getMinutes(), $this->days));
|
||||
}
|
||||
|
||||
public function countWorkedDays(): int
|
||||
{
|
||||
return count(array_filter($this->days, static fn (TimeAccountDay $day) => $day->getMinutes() > 0));
|
||||
}
|
||||
|
||||
public function countAbsenceDays(): int
|
||||
{
|
||||
return count(array_filter($this->days, static fn (TimeAccountDay $day) => $day->isAbsence()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getWarnings(): array
|
||||
{
|
||||
return $this->warnings;
|
||||
}
|
||||
|
||||
public function hasWarnings(): bool
|
||||
{
|
||||
return [] !== $this->warnings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Saas\Model;
|
||||
|
||||
/**
|
||||
* One calendar day of a time account: the span it was worked in, the net minutes inside that
|
||||
* span, the breaks taken, and - if the day was booked off - the absence label the SaaS uses
|
||||
* for it ("Ferien", "Ruhetag 24/25", ...).
|
||||
*
|
||||
* A day can be both: an absence label does not imply zero minutes.
|
||||
*/
|
||||
class TimeAccountDay
|
||||
{
|
||||
public function __construct(
|
||||
private readonly \DateTimeImmutable $date,
|
||||
private readonly int $minutes,
|
||||
private readonly ?string $holidayType,
|
||||
private readonly ?string $startTime = null,
|
||||
private readonly ?string $endTime = null,
|
||||
private readonly int $breakMinutes = 0,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getDate(): \DateTimeImmutable
|
||||
{
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
public function getMinutes(): int
|
||||
{
|
||||
return $this->minutes;
|
||||
}
|
||||
|
||||
public function getHolidayType(): ?string
|
||||
{
|
||||
return $this->holidayType;
|
||||
}
|
||||
|
||||
/**
|
||||
* First clock-in of the day as "HH:MM", or null when nothing with a duration was booked.
|
||||
*/
|
||||
public function getStartTime(): ?string
|
||||
{
|
||||
return $this->startTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last clock-out of the day as "HH:MM".
|
||||
*/
|
||||
public function getEndTime(): ?string
|
||||
{
|
||||
return $this->endTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything booked as a break that day. The worked minutes are already net of it; this is
|
||||
* the figure the sheet shows next to them.
|
||||
*/
|
||||
public function getBreakMinutes(): int
|
||||
{
|
||||
return $this->breakMinutes;
|
||||
}
|
||||
|
||||
public function isAbsence(): bool
|
||||
{
|
||||
return null !== $this->holidayType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
namespace App\Saas;
|
||||
|
||||
use App\Saas\Model\Employee;
|
||||
use App\Saas\Model\EmployeeDirectory;
|
||||
use App\Saas\Model\TimeAccount;
|
||||
use App\Saas\Model\TimeAccountDay;
|
||||
|
||||
/**
|
||||
* Turns a raw time-account payload into the handful of facts this application keeps.
|
||||
*
|
||||
* Two properties of the payload drive everything here:
|
||||
*
|
||||
* 1. Months are zero-based in the response as well as in the request, and the "date"
|
||||
* strings carry that index verbatim - a March booking arrives as "2026-02-31". Those
|
||||
* strings must never reach a date parser: PHP rolls "2026-02-31" over to 2026-03-03,
|
||||
* December arrives as "2026-11-31" and January as "2026-00-15". They are split and
|
||||
* rebuilt one month later instead, then validated.
|
||||
* 2. The row schema is not stable. Booking rows carry some 45 keys, the derived rows the
|
||||
* SaaS inserts for breaks and absences carry about 20, and header fields come and go
|
||||
* between months. Nothing is assumed to be present.
|
||||
*/
|
||||
class ResponseParser
|
||||
{
|
||||
/**
|
||||
* A break segment. Carries its length in "pause" and never any working time, so it
|
||||
* contributes nothing to a day's total - the minutes on booking rows are already net
|
||||
* of breaks.
|
||||
*/
|
||||
private const ANNOTATION_BREAK = -1;
|
||||
|
||||
/**
|
||||
* A full-day absence. This row, not a day summing to zero, is what marks a day off:
|
||||
* "holidayType" names it, and a day can carry both an absence and booked work.
|
||||
*/
|
||||
private const ANNOTATION_ABSENCE = -4;
|
||||
|
||||
/**
|
||||
* Reduces the employee roster to the four fields this application uses.
|
||||
*
|
||||
* The raw entries carry a per-employee "apiKey" - a credential - next to a birthday, a
|
||||
* personnel number and an RFID token. The roster is cached to the filesystem, so the
|
||||
* reduction happens here, before anything can hold on to the rest.
|
||||
*/
|
||||
public function parseEmployees(array $data): EmployeeDirectory
|
||||
{
|
||||
$employees = [];
|
||||
|
||||
foreach ($data as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$email = $row['email'] ?? null;
|
||||
|
||||
// The address is the only key this is useful by; an entry without one is not.
|
||||
if (!is_string($email) || '' === trim($email)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$uuid = $row['uuid'] ?? null;
|
||||
$department = $row['mainDepartment'] ?? null;
|
||||
$separation = $row['dateOfSeparation'] ?? null;
|
||||
|
||||
$employees[] = new Employee(
|
||||
trim($email),
|
||||
is_string($uuid) && '' !== $uuid ? $uuid : null,
|
||||
is_string($department) && '' !== trim($department) ? trim($department) : null,
|
||||
is_string($separation) && '' !== trim($separation),
|
||||
);
|
||||
}
|
||||
|
||||
return EmployeeDirectory::fromEmployees($employees);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $month the requested calendar month (1-12)
|
||||
*/
|
||||
public function parseTimeAccount(array $data, int $year, int $month): TimeAccount
|
||||
{
|
||||
$warnings = [];
|
||||
|
||||
$this->checkHeader($data, $year, $month, $warnings);
|
||||
|
||||
/** @var array<string, array{minutes: int, breakMinutes: int, holidayType: ?string, start: ?string, end: ?string}> $byDate */
|
||||
$byDate = [];
|
||||
|
||||
/** @var string[] $departments */
|
||||
$departments = [];
|
||||
$rows = $data['list'] ?? [];
|
||||
|
||||
if (!is_array($rows)) {
|
||||
$warnings[] = 'The payload carried no readable "list" of bookings.';
|
||||
$rows = [];
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = $this->rebaseDate($row['date'] ?? null, $year, $month, $warnings);
|
||||
|
||||
if (null === $date) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $date->format('Y-m-d');
|
||||
$byDate[$key] ??= [
|
||||
'minutes' => 0,
|
||||
'breakMinutes' => 0,
|
||||
'holidayType' => null,
|
||||
'start' => null,
|
||||
'end' => null,
|
||||
];
|
||||
|
||||
$annotation = (int) ($row['annotation'] ?? 0);
|
||||
|
||||
if ($annotation < 0
|
||||
&& self::ANNOTATION_BREAK !== $annotation
|
||||
&& self::ANNOTATION_ABSENCE !== $annotation
|
||||
) {
|
||||
// Positive annotations are a bitmask of booking attributes and need no
|
||||
// interpretation, but the negative ones are distinct row kinds. We only
|
||||
// know three of them; report the rest rather than quietly ignoring them.
|
||||
$warnings[] = sprintf('Unknown row kind %d on %s.', $annotation, $key);
|
||||
}
|
||||
|
||||
$byDate[$key]['minutes'] += (int) ($row['ist'] ?? 0);
|
||||
$byDate[$key]['breakMinutes'] += (int) ($row['pause'] ?? 0);
|
||||
|
||||
$department = $row['department'] ?? null;
|
||||
|
||||
if (is_string($department) && '' !== trim($department)) {
|
||||
$departments[trim($department)] = trim($department);
|
||||
}
|
||||
|
||||
if ($annotation >= 0) {
|
||||
// Only bookings define the working day's span. The absence marker carries a
|
||||
// placeholder 23:59:59 that would otherwise become every free day's start
|
||||
// time, and a break cannot extend the day past the last clock-out.
|
||||
$this->widenSpan($byDate[$key], $row);
|
||||
}
|
||||
|
||||
if (self::ANNOTATION_ABSENCE === $annotation) {
|
||||
$holidayType = $row['holidayType'] ?? null;
|
||||
|
||||
if (is_string($holidayType) && '' !== trim($holidayType)) {
|
||||
$byDate[$key]['holidayType'] = trim($holidayType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ksort($byDate);
|
||||
|
||||
$days = [];
|
||||
|
||||
foreach ($byDate as $key => $values) {
|
||||
// Nothing was booked, so the only rows were markers - a rest day, or an absence.
|
||||
// Those carry a time, but the day has no span worth showing.
|
||||
if (0 === $values['minutes'] && 0 === $values['breakMinutes']) {
|
||||
$values['start'] = null;
|
||||
$values['end'] = null;
|
||||
}
|
||||
|
||||
$days[$key] = new TimeAccountDay(
|
||||
new \DateTimeImmutable($key),
|
||||
$values['minutes'],
|
||||
$values['holidayType'],
|
||||
$this->shortTime($values['start']),
|
||||
$this->shortTime($values['end']),
|
||||
$values['breakMinutes'],
|
||||
);
|
||||
}
|
||||
|
||||
$istMinutes = (int) ($data['ist'] ?? 0);
|
||||
$summed = array_sum(array_column($byDate, 'minutes'));
|
||||
|
||||
if ($summed !== $istMinutes) {
|
||||
// Nothing is dropped on purpose, so these two must agree. A mismatch is the
|
||||
// first sign that the date convention or the row semantics have changed.
|
||||
$warnings[] = sprintf(
|
||||
'The days add up to %d minutes but the month reports %d.',
|
||||
$summed,
|
||||
$istMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
if (1 < count($departments)) {
|
||||
// One department per employee is what this is supposed to be; if a month ever
|
||||
// spans two, whoever reads the sheet should know rather than see an arbitrary one.
|
||||
$warnings[] = sprintf('The month reports more than one department: %s.', implode(', ', $departments));
|
||||
}
|
||||
|
||||
$employeeUuid = $data['employee'] ?? null;
|
||||
|
||||
return new TimeAccount(
|
||||
is_string($employeeUuid) && '' !== $employeeUuid ? $employeeUuid : null,
|
||||
$year,
|
||||
$month,
|
||||
$istMinutes,
|
||||
// The target is a monthly figure: every row carries soll 0 with sollType MONTH,
|
||||
// so there is no such thing as a daily target to show.
|
||||
(int) ($data['soll'] ?? 0),
|
||||
// Carried on every booking row, identically; the derived rows for breaks and
|
||||
// absences have none, so a month of pure holiday leaves this null.
|
||||
reset($departments) ?: null,
|
||||
$days,
|
||||
$warnings,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stretches a day's span to cover one more booking.
|
||||
*
|
||||
* Rows with no duration are kept: a day often opens with a zero-length row at the moment
|
||||
* the teamer clocked in, and skipping those moved the start of the day to the end of the
|
||||
* first break. Rest days carry such a row too, which is why a day with nothing booked
|
||||
* drops its span again once every row has been seen.
|
||||
*
|
||||
* @param array{start: ?string, end: ?string} $day
|
||||
*/
|
||||
private function widenSpan(array &$day, array $row): void
|
||||
{
|
||||
$start = $this->timeOf($row['startTime'] ?? null);
|
||||
$end = $this->timeOf($row['endTime'] ?? null);
|
||||
|
||||
if (null === $start || null === $end) {
|
||||
return;
|
||||
}
|
||||
|
||||
// "HH:MM:SS" sorts chronologically as a string, and no sample crosses midnight.
|
||||
$day['start'] = null === $day['start'] ? $start : min($day['start'], $start);
|
||||
$day['end'] = null === $day['end'] ? $end : max($day['end'], $end);
|
||||
}
|
||||
|
||||
private function timeOf(mixed $raw): ?string
|
||||
{
|
||||
return is_string($raw) && 1 === preg_match('/^\d{2}:\d{2}:\d{2}$/', $raw) ? $raw : null;
|
||||
}
|
||||
|
||||
private function shortTime(?string $time): ?string
|
||||
{
|
||||
return null !== $time ? substr($time, 0, 5) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $warnings
|
||||
*/
|
||||
private function checkHeader(array $data, int $year, int $month, array &$warnings): void
|
||||
{
|
||||
if (isset($data['year']) && (int) $data['year'] !== $year) {
|
||||
$warnings[] = sprintf('The payload is for year %d, not %d.', (int) $data['year'], $year);
|
||||
}
|
||||
|
||||
// The header reports the same zero-based index that was requested.
|
||||
if (isset($data['month']) && (int) $data['month'] !== $month - 1) {
|
||||
$warnings[] = sprintf(
|
||||
'The payload is for month index %d, not %d.',
|
||||
(int) $data['month'],
|
||||
$month - 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds a zero-based response date as a real one, or reports why it could not.
|
||||
*
|
||||
* @param string[] $warnings
|
||||
*/
|
||||
private function rebaseDate(mixed $raw, int $year, int $month, array &$warnings): ?\DateTimeImmutable
|
||||
{
|
||||
if (!is_string($raw) || 1 !== preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $raw, $matches)) {
|
||||
$warnings[] = sprintf(
|
||||
'Skipped a row with an unreadable date (%s).',
|
||||
is_scalar($raw) ? '"'.$raw.'"' : get_debug_type($raw),
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$rowYear = (int) $matches[1];
|
||||
$rowMonth = (int) $matches[2] + 1;
|
||||
$rowDay = (int) $matches[3];
|
||||
|
||||
if ($rowYear !== $year || $rowMonth !== $month || !checkdate($rowMonth, $rowDay, $rowYear)) {
|
||||
$warnings[] = sprintf('Skipped a row dated "%s": that is not a day of %04d-%02d.', $raw, $year, $month);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$date = \DateTimeImmutable::createFromFormat(
|
||||
'!Y-m-d',
|
||||
sprintf('%04d-%02d-%02d', $rowYear, $rowMonth, $rowDay),
|
||||
);
|
||||
|
||||
return false !== $date ? $date : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Common;
|
||||
|
||||
use App\Enum\WorkMonthStatus;
|
||||
use App\Model\AbstractFilterDto;
|
||||
use App\Model\WorkMonthFilterDto;
|
||||
|
||||
class WorkMonthFilterHandler extends AbstractFilterHandler
|
||||
{
|
||||
protected string $namespace = 'filter:work_month';
|
||||
protected string $modelClass = WorkMonthFilterDto::class;
|
||||
|
||||
public function getFilterSettings(): WorkMonthFilterDto
|
||||
{
|
||||
$filterDto = new $this->modelClass();
|
||||
|
||||
if (null === $data = $this->getSession()->get($this->namespace)) {
|
||||
return $filterDto;
|
||||
}
|
||||
|
||||
$filterDto
|
||||
->setTeamerName($data['teamer_name'] ?? null)
|
||||
->setIncludeApproved((bool) ($data['include_approved'] ?? false))
|
||||
->setIncludeMismatchedOnly((bool) ($data['include_mismatched_only'] ?? false))
|
||||
;
|
||||
|
||||
if (null !== ($period = $data['period'] ?? null)) {
|
||||
$filterDto->setPeriod(new \DateTimeImmutable($period));
|
||||
}
|
||||
|
||||
// tryFrom rather than from: a status dropped from the enum must not fatal a session
|
||||
// written before it went away.
|
||||
$statuses = array_filter(array_map(
|
||||
static fn (string $value) => WorkMonthStatus::tryFrom($value),
|
||||
$data['statuses'] ?? [],
|
||||
));
|
||||
|
||||
return $filterDto->setStatuses(array_values($statuses));
|
||||
}
|
||||
|
||||
protected function saveFilterSettings(AbstractFilterDto $filterDto): void
|
||||
{
|
||||
/* @var WorkMonthFilterDto $filterDto */
|
||||
$this->getSession()->set($this->namespace, [
|
||||
'teamer_name' => $filterDto->getTeamerName(),
|
||||
'period' => $filterDto->getPeriod()?->format('Y-m-d'),
|
||||
'statuses' => array_map(
|
||||
static fn (WorkMonthStatus $status) => $status->value,
|
||||
$filterDto->getStatuses(),
|
||||
),
|
||||
'include_approved' => $filterDto->isIncludeApproved(),
|
||||
'include_mismatched_only' => $filterDto->isIncludeMismatchedOnly(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Cron;
|
||||
|
||||
use App\Config\EmailTextKey;
|
||||
use App\Email\Mailer;
|
||||
use App\Repository\TeamerWorkMonthRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Chases the teamers who still owe us a signed Arbeitszeitnachweis.
|
||||
*
|
||||
* Idempotent without recording anything, like the other reminders here: it picks exactly one
|
||||
* weekday, so a month is chased once a week for as long as it stays open, and a second run on
|
||||
* the same day would simply send the same mail again.
|
||||
*
|
||||
* Only an upload stops it. An import that changes the hours does not, and neither does a
|
||||
* rejection - a rejected month is one the teamer still has to deal with.
|
||||
*/
|
||||
class WorkMonthReminderService
|
||||
{
|
||||
private const DIGEST_WEEKDAY = 1;
|
||||
|
||||
/**
|
||||
* Days between the import and the first reminder. The import runs on the 10th, so this
|
||||
* leaves the teamer the best part of a week before anyone nags.
|
||||
*/
|
||||
private const GRACE_DAYS = 5;
|
||||
|
||||
public function __construct(
|
||||
private readonly TeamerWorkMonthRepository $workMonthRepository,
|
||||
private readonly Mailer $mailer,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function sendReminders(?\DateTimeImmutable $today = null): string
|
||||
{
|
||||
$today = ($today ?? new \DateTimeImmutable('today'))->setTime(0, 0);
|
||||
|
||||
if (self::DIGEST_WEEKDAY !== (int) $today->format('N')) {
|
||||
$message = 'Work month reminders are only sent on mondays';
|
||||
$this->logger->info($message);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
$months = $this->workMonthRepository->findAwaitingDocument(
|
||||
$today->modify(sprintf('-%d days', self::GRACE_DAYS))
|
||||
);
|
||||
|
||||
$count = 0;
|
||||
|
||||
foreach ($months as $workMonth) {
|
||||
$email = $workMonth->getTeamer()?->getCommunication()?->getEmail();
|
||||
|
||||
if (null === $email || '' === trim($email)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->mailer->createAndSendText(EmailTextKey::REMINDER_WORK_MONTH_UPLOAD, [
|
||||
'workMonth' => $workMonth->getPeriodLabel(),
|
||||
], [
|
||||
'to' => $email,
|
||||
]);
|
||||
|
||||
++$count;
|
||||
}
|
||||
|
||||
$message = 'Sent '.$count.' work month reminders to teamers';
|
||||
$this->logger->info($message);
|
||||
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Pdf;
|
||||
|
||||
use App\Entity\TeamerWorkMonth;
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Renders the Arbeitszeitnachweis a teamer signs for one month.
|
||||
*
|
||||
* Deliberately a sibling of AbstractPdfRenderer rather than a subclass: that base exists for
|
||||
* the FPDI renderers, which stamp text at fixed millimetre coordinates onto a pre-made PDF.
|
||||
* That works for the one-page contract and invoice forms but not for a table whose length
|
||||
* depends on the month, so this one goes through Twig and Dompdf instead. Dompdf's bundled
|
||||
* DejaVu fonts also carry umlauts natively, where the FPDI path has to convert every string
|
||||
* to ISO-8859-1 first (see Pdf::encodeString()).
|
||||
*/
|
||||
class WorkMonthRenderer
|
||||
{
|
||||
private array $config;
|
||||
|
||||
public function __construct(
|
||||
private readonly Environment $twig,
|
||||
array $options,
|
||||
) {
|
||||
$this->config = $this->resolveOptions($options);
|
||||
}
|
||||
|
||||
public function render(TeamerWorkMonth $workMonth): string
|
||||
{
|
||||
$options = new Options();
|
||||
// Nothing in the document is fetched from anywhere: the stylesheet is inline and
|
||||
// there are no images, so the renderer never needs to leave the project directory.
|
||||
$options->setIsRemoteEnabled(false);
|
||||
$options->setChroot($this->config['project_dir']);
|
||||
$options->setDefaultFont('DejaVu Sans');
|
||||
$options->setDefaultPaperSize('A4');
|
||||
$options->setDefaultPaperOrientation('portrait');
|
||||
|
||||
$dompdf = new Dompdf($options);
|
||||
$dompdf->loadHtml($this->twig->render('pdf/work_month.html.twig', [
|
||||
'workMonth' => $workMonth,
|
||||
'teamer' => $workMonth->getTeamer(),
|
||||
'days' => $workMonth->getDays()->forCalendarMonth($workMonth->getYear(), $workMonth->getMonth()),
|
||||
'logo' => $this->logoPath(),
|
||||
]), 'UTF-8');
|
||||
$dompdf->render();
|
||||
|
||||
return (string) $dompdf->output();
|
||||
}
|
||||
|
||||
/**
|
||||
* The letterhead logo, as an absolute path inside the chroot.
|
||||
*
|
||||
* Read from the filesystem rather than embedded: remote loading is off and the chroot is
|
||||
* the project directory, so Dompdf can open this and nothing outside it. Null when the
|
||||
* file is missing, because a working-time record is worth printing without its letterhead.
|
||||
*/
|
||||
private function logoPath(): ?string
|
||||
{
|
||||
$path = $this->config['project_dir'].'/assets/images/logo.png';
|
||||
|
||||
return is_readable($path) ? $path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arbeitszeitnachweis_Test_2026-03.pdf.
|
||||
*/
|
||||
public function filename(TeamerWorkMonth $workMonth): string
|
||||
{
|
||||
$lastName = $workMonth->getTeamer()?->getLastName() ?? 'Teamer';
|
||||
|
||||
return sprintf(
|
||||
'Arbeitszeitnachweis_%s_%04d-%02d.pdf',
|
||||
preg_replace('/[^A-Za-z0-9]+/', '_', $lastName),
|
||||
$workMonth->getYear(),
|
||||
$workMonth->getMonth(),
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveOptions(array $options): array
|
||||
{
|
||||
$optionsResolver = new OptionsResolver();
|
||||
$optionsResolver->setRequired(['project_dir']);
|
||||
$optionsResolver->setAllowedTypes('project_dir', 'string');
|
||||
|
||||
return $optionsResolver->resolve($options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Saas;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\TeamerWorkMonth;
|
||||
use App\Enum\WorkMonthStatus;
|
||||
use App\Model\WorkHoursSyncResult;
|
||||
use App\Model\WorkMonthDays;
|
||||
use App\Repository\TeamerRepository;
|
||||
use App\Repository\TeamerWorkMonthRepository;
|
||||
use App\Saas\ApiClient;
|
||||
use App\Saas\ApiClientException;
|
||||
use App\Saas\DataProvider\EmployeeDataProvider;
|
||||
use App\Saas\ResponseParser;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Imports working time from the SaaS into TeamerWorkMonth records.
|
||||
*
|
||||
* Runs once a month for every season teamer, and on demand for a single one. Both go
|
||||
* through syncTeamer(), so the admin button and the scheduled command cannot drift apart.
|
||||
*/
|
||||
class WorkHoursSynchronizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly ResponseParser $responseParser,
|
||||
private readonly EmployeeDataProvider $employeeDataProvider,
|
||||
private readonly TeamerRepository $teamerRepository,
|
||||
private readonly TeamerWorkMonthRepository $workMonthRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The season teamers one month's import covers.
|
||||
*
|
||||
* @return Teamer[]
|
||||
*/
|
||||
public function findEligibleTeamers(int $year, int $month): array
|
||||
{
|
||||
$firstDay = $this->firstDayOf($year, $month);
|
||||
|
||||
return $this->teamerRepository->findSeasonTeamersForMonth(
|
||||
$firstDay,
|
||||
$firstDay->modify('last day of this month'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return WorkHoursSyncResult[]
|
||||
*/
|
||||
public function syncMonth(int $year, int $month, bool $dryRun = false, ?callable $onProgress = null): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($this->findEligibleTeamers($year, $month) as $teamer) {
|
||||
$result = $this->syncTeamer($teamer, $year, $month, $dryRun);
|
||||
$results[] = $result;
|
||||
|
||||
if (null !== $onProgress) {
|
||||
$onProgress($result);
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
public function syncTeamer(Teamer $teamer, int $year, int $month, bool $dryRun = false): WorkHoursSyncResult
|
||||
{
|
||||
$email = $teamer->getCommunication()?->getEmail();
|
||||
|
||||
if (null === $email || '' === trim($email)) {
|
||||
return WorkHoursSyncResult::skipped($teamer, 'Keine E-Mail-Adresse hinterlegt.');
|
||||
}
|
||||
|
||||
$directory = $this->employeeDataProvider->getDirectory();
|
||||
$employee = $directory->find($email);
|
||||
|
||||
// An empty roster means the roster call failed, not that nobody is registered, so it
|
||||
// must never be read as "skip everyone" - the import goes ahead without it.
|
||||
if (null === $employee && !$directory->isEmpty()) {
|
||||
return WorkHoursSyncResult::skipped(
|
||||
$teamer,
|
||||
sprintf('Nicht in der SaaS registriert (%s).', $email),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->apiClient->getTimeAccount($email, $year, $month);
|
||||
} catch (ApiClientException $e) {
|
||||
$this->logger->error('Work hours import failed', [
|
||||
'teamer' => $teamer->getFullName(),
|
||||
'year' => $year,
|
||||
'month' => $month,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return WorkHoursSyncResult::failed($teamer, $e->getMessage());
|
||||
}
|
||||
|
||||
$account = $this->responseParser->parseTimeAccount($payload, $year, $month);
|
||||
$days = WorkMonthDays::fromTimeAccount($account);
|
||||
$workMonth = $this->workMonthRepository->findOneForTeamerAndMonth($teamer, $year, $month);
|
||||
|
||||
if (null !== $workMonth && $workMonth->getStatus()->locksHours()) {
|
||||
return $this->compareLocked($workMonth, $days, $dryRun);
|
||||
}
|
||||
|
||||
if (null === $workMonth) {
|
||||
$workMonth = (new TeamerWorkMonth())
|
||||
->setTeamer($teamer)
|
||||
->setYear($year)
|
||||
->setMonth($month)
|
||||
;
|
||||
$this->entityManager->persist($workMonth);
|
||||
}
|
||||
|
||||
$workMonth
|
||||
->setDays($days)
|
||||
->setSourceIstMinutes($account->getIstMinutes())
|
||||
->setTargetMinutes($account->getTargetMinutes())
|
||||
// Keep the last known one: a month of pure holiday reports none, and blanking it
|
||||
// would lose what earlier months already established.
|
||||
// The bookings first, the roster second: a month made up entirely of holiday has
|
||||
// no booking to read a department from, and the roster always does.
|
||||
->setDepartment($account->getDepartment() ?? $employee?->getDepartment() ?? $workMonth->getDepartment())
|
||||
->setWarnings($account->getWarnings())
|
||||
->setSyncedAt(new \DateTimeImmutable())
|
||||
->clearMismatch()
|
||||
;
|
||||
|
||||
$employeeUuid = $account->getEmployeeUuid() ?? $employee?->getUuid();
|
||||
|
||||
if (null !== $employeeUuid && $teamer->getSaasEmployeeUuid() !== $employeeUuid) {
|
||||
$teamer->setSaasEmployeeUuid($employeeUuid);
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
$this->logger->info('Work hours imported', [
|
||||
'teamer' => $teamer->getFullName(),
|
||||
'year' => $year,
|
||||
'month' => $month,
|
||||
'minutes' => $days->getTotalMinutes(),
|
||||
'days' => count($days->toArray()),
|
||||
'warnings' => count($account->getWarnings()),
|
||||
'dryRun' => $dryRun,
|
||||
]);
|
||||
|
||||
return WorkHoursSyncResult::imported($teamer, $days->getTotalMinutes(), $account->getWarnings());
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases a signed month back to the importer, for when a recorded discrepancy turns
|
||||
* out to be a genuine correction the teamer has to sign again.
|
||||
*/
|
||||
public function unlock(TeamerWorkMonth $workMonth): void
|
||||
{
|
||||
$workMonth
|
||||
->setStatus(WorkMonthStatus::OPEN)
|
||||
->setSubmittedAt(null)
|
||||
->setReviewedAt(null)
|
||||
->setReviewedBy(null)
|
||||
->setReviewComment(null)
|
||||
->clearMismatch()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* A signed month keeps the hours it was signed with. The fresh figures are only
|
||||
* compared, so the office can see that the SaaS has since changed its mind.
|
||||
*/
|
||||
private function compareLocked(TeamerWorkMonth $workMonth, WorkMonthDays $days, bool $dryRun): WorkHoursSyncResult
|
||||
{
|
||||
$teamer = $workMonth->getTeamer();
|
||||
$fresh = $days->getTotalMinutes();
|
||||
|
||||
if ($workMonth->getTotalMinutes() === $fresh) {
|
||||
$workMonth->clearMismatch();
|
||||
|
||||
if (!$dryRun) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
return WorkHoursSyncResult::locked(
|
||||
$teamer,
|
||||
'Bereits eingereicht, Stunden unverändert.',
|
||||
$workMonth->getTotalMinutes(),
|
||||
);
|
||||
}
|
||||
|
||||
$workMonth->recordMismatch($fresh);
|
||||
|
||||
if (!$dryRun) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
$this->logger->warning('Work hours changed after submission', [
|
||||
'teamer' => $teamer->getFullName(),
|
||||
'year' => $workMonth->getYear(),
|
||||
'month' => $workMonth->getMonth(),
|
||||
'signed' => $workMonth->getTotalMinutes(),
|
||||
'reported' => $fresh,
|
||||
]);
|
||||
|
||||
return WorkHoursSyncResult::locked(
|
||||
$teamer,
|
||||
sprintf(
|
||||
'Bereits eingereicht; die SaaS meldet jetzt %d statt %d Minuten.',
|
||||
$fresh,
|
||||
$workMonth->getTotalMinutes(),
|
||||
),
|
||||
$workMonth->getTotalMinutes(),
|
||||
);
|
||||
}
|
||||
|
||||
private function firstDayOf(int $year, int $month): \DateTimeImmutable
|
||||
{
|
||||
$first = \DateTimeImmutable::createFromFormat('!Y-n-j', sprintf('%04d-%d-1', $year, $month));
|
||||
|
||||
if (false === $first) {
|
||||
throw new \InvalidArgumentException(sprintf('%04d-%02d is not a month.', $year, $month));
|
||||
}
|
||||
|
||||
return $first;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ class AppExtension extends AbstractExtension
|
||||
new TwigFilter('bpn_pickup_label', [AppRuntime::class, 'bpnPickupLabel']),
|
||||
new TwigFilter('upload_status_badge', [AppRuntime::class, 'renderUploadStatusBadge'], ['is_safe' => ['html']]),
|
||||
new TwigFilter('upload_filename_full', [AppRuntime::class, 'getFilenameWithFolder']),
|
||||
new TwigFilter('duration_hm', [AppRuntime::class, 'formatDurationHoursMinutes']),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,24 @@ class AppRuntime implements RuntimeExtensionInterface
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes as hours and minutes, the way a timesheet reads them: 510 becomes "8:30".
|
||||
*
|
||||
* Hours are not padded and not capped at 24 - a month total is a perfectly ordinary
|
||||
* "195:45". Null is a day the SaaS never reported, which is not the same as zero.
|
||||
*/
|
||||
public function formatDurationHoursMinutes(?int $minutes, string $empty = '–'): string
|
||||
{
|
||||
if (null === $minutes) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$sign = $minutes < 0 ? '-' : '';
|
||||
$minutes = abs($minutes);
|
||||
|
||||
return sprintf('%s%d:%02d', $sign, intdiv($minutes, 60), $minutes % 60);
|
||||
}
|
||||
|
||||
public function seasonAssignmentLabel(SeasonAssignment $seasonAssignment): string
|
||||
{
|
||||
return $this->seasonAssignmentLabeler->label($seasonAssignment);
|
||||
|
||||
Reference in new issue
Block a user