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

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

No files matched your search

@@ -0,0 +1,56 @@
<?php
namespace App\Service\Common;
use App\Enum\WorkMonthStatus;
use App\Model\AbstractFilterDto;
use App\Model\WorkMonthFilterDto;
class WorkMonthFilterHandler extends AbstractFilterHandler
{
protected string $namespace = 'filter:work_month';
protected string $modelClass = WorkMonthFilterDto::class;
public function getFilterSettings(): WorkMonthFilterDto
{
$filterDto = new $this->modelClass();
if (null === $data = $this->getSession()->get($this->namespace)) {
return $filterDto;
}
$filterDto
->setTeamerName($data['teamer_name'] ?? null)
->setIncludeApproved((bool) ($data['include_approved'] ?? false))
->setIncludeMismatchedOnly((bool) ($data['include_mismatched_only'] ?? false))
;
if (null !== ($period = $data['period'] ?? null)) {
$filterDto->setPeriod(new \DateTimeImmutable($period));
}
// tryFrom rather than from: a status dropped from the enum must not fatal a session
// written before it went away.
$statuses = array_filter(array_map(
static fn (string $value) => WorkMonthStatus::tryFrom($value),
$data['statuses'] ?? [],
));
return $filterDto->setStatuses(array_values($statuses));
}
protected function saveFilterSettings(AbstractFilterDto $filterDto): void
{
/* @var WorkMonthFilterDto $filterDto */
$this->getSession()->set($this->namespace, [
'teamer_name' => $filterDto->getTeamerName(),
'period' => $filterDto->getPeriod()?->format('Y-m-d'),
'statuses' => array_map(
static fn (WorkMonthStatus $status) => $status->value,
$filterDto->getStatuses(),
),
'include_approved' => $filterDto->isIncludeApproved(),
'include_mismatched_only' => $filterDto->isIncludeMismatchedOnly(),
]);
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Service\Cron;
use App\Config\EmailTextKey;
use App\Email\Mailer;
use App\Repository\TeamerWorkMonthRepository;
use Psr\Log\LoggerInterface;
/**
* Chases the teamers who still owe us a signed Arbeitszeitnachweis.
*
* Idempotent without recording anything, like the other reminders here: it picks exactly one
* weekday, so a month is chased once a week for as long as it stays open, and a second run on
* the same day would simply send the same mail again.
*
* Only an upload stops it. An import that changes the hours does not, and neither does a
* rejection - a rejected month is one the teamer still has to deal with.
*/
class WorkMonthReminderService
{
private const DIGEST_WEEKDAY = 1;
/**
* Days between the import and the first reminder. The import runs on the 10th, so this
* leaves the teamer the best part of a week before anyone nags.
*/
private const GRACE_DAYS = 5;
public function __construct(
private readonly TeamerWorkMonthRepository $workMonthRepository,
private readonly Mailer $mailer,
private readonly LoggerInterface $logger,
) {
}
public function sendReminders(?\DateTimeImmutable $today = null): string
{
$today = ($today ?? new \DateTimeImmutable('today'))->setTime(0, 0);
if (self::DIGEST_WEEKDAY !== (int) $today->format('N')) {
$message = 'Work month reminders are only sent on mondays';
$this->logger->info($message);
return $message;
}
$months = $this->workMonthRepository->findAwaitingDocument(
$today->modify(sprintf('-%d days', self::GRACE_DAYS))
);
$count = 0;
foreach ($months as $workMonth) {
$email = $workMonth->getTeamer()?->getCommunication()?->getEmail();
if (null === $email || '' === trim($email)) {
continue;
}
$this->mailer->createAndSendText(EmailTextKey::REMINDER_WORK_MONTH_UPLOAD, [
'workMonth' => $workMonth->getPeriodLabel(),
], [
'to' => $email,
]);
++$count;
}
$message = 'Sent '.$count.' work month reminders to teamers';
$this->logger->info($message);
return $message;
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Service\Pdf;
use App\Entity\TeamerWorkMonth;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Twig\Environment;
/**
* Renders the Arbeitszeitnachweis a teamer signs for one month.
*
* Deliberately a sibling of AbstractPdfRenderer rather than a subclass: that base exists for
* the FPDI renderers, which stamp text at fixed millimetre coordinates onto a pre-made PDF.
* That works for the one-page contract and invoice forms but not for a table whose length
* depends on the month, so this one goes through Twig and Dompdf instead. Dompdf's bundled
* DejaVu fonts also carry umlauts natively, where the FPDI path has to convert every string
* to ISO-8859-1 first (see Pdf::encodeString()).
*/
class WorkMonthRenderer
{
private array $config;
public function __construct(
private readonly Environment $twig,
array $options,
) {
$this->config = $this->resolveOptions($options);
}
public function render(TeamerWorkMonth $workMonth): string
{
$options = new Options();
// Nothing in the document is fetched from anywhere: the stylesheet is inline and
// there are no images, so the renderer never needs to leave the project directory.
$options->setIsRemoteEnabled(false);
$options->setChroot($this->config['project_dir']);
$options->setDefaultFont('DejaVu Sans');
$options->setDefaultPaperSize('A4');
$options->setDefaultPaperOrientation('portrait');
$dompdf = new Dompdf($options);
$dompdf->loadHtml($this->twig->render('pdf/work_month.html.twig', [
'workMonth' => $workMonth,
'teamer' => $workMonth->getTeamer(),
'days' => $workMonth->getDays()->forCalendarMonth($workMonth->getYear(), $workMonth->getMonth()),
'logo' => $this->logoPath(),
]), 'UTF-8');
$dompdf->render();
return (string) $dompdf->output();
}
/**
* The letterhead logo, as an absolute path inside the chroot.
*
* Read from the filesystem rather than embedded: remote loading is off and the chroot is
* the project directory, so Dompdf can open this and nothing outside it. Null when the
* file is missing, because a working-time record is worth printing without its letterhead.
*/
private function logoPath(): ?string
{
$path = $this->config['project_dir'].'/assets/images/logo.png';
return is_readable($path) ? $path : null;
}
/**
* Arbeitszeitnachweis_Test_2026-03.pdf.
*/
public function filename(TeamerWorkMonth $workMonth): string
{
$lastName = $workMonth->getTeamer()?->getLastName() ?? 'Teamer';
return sprintf(
'Arbeitszeitnachweis_%s_%04d-%02d.pdf',
preg_replace('/[^A-Za-z0-9]+/', '_', $lastName),
$workMonth->getYear(),
$workMonth->getMonth(),
);
}
private function resolveOptions(array $options): array
{
$optionsResolver = new OptionsResolver();
$optionsResolver->setRequired(['project_dir']);
$optionsResolver->setAllowedTypes('project_dir', 'string');
return $optionsResolver->resolve($options);
}
}
+233
View File
@@ -0,0 +1,233 @@
<?php
namespace App\Service\Saas;
use App\Entity\Teamer;
use App\Entity\TeamerWorkMonth;
use App\Enum\WorkMonthStatus;
use App\Model\WorkHoursSyncResult;
use App\Model\WorkMonthDays;
use App\Repository\TeamerRepository;
use App\Repository\TeamerWorkMonthRepository;
use App\Saas\ApiClient;
use App\Saas\ApiClientException;
use App\Saas\DataProvider\EmployeeDataProvider;
use App\Saas\ResponseParser;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Imports working time from the SaaS into TeamerWorkMonth records.
*
* Runs once a month for every season teamer, and on demand for a single one. Both go
* through syncTeamer(), so the admin button and the scheduled command cannot drift apart.
*/
class WorkHoursSynchronizer
{
public function __construct(
private readonly ApiClient $apiClient,
private readonly ResponseParser $responseParser,
private readonly EmployeeDataProvider $employeeDataProvider,
private readonly TeamerRepository $teamerRepository,
private readonly TeamerWorkMonthRepository $workMonthRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
/**
* The season teamers one month's import covers.
*
* @return Teamer[]
*/
public function findEligibleTeamers(int $year, int $month): array
{
$firstDay = $this->firstDayOf($year, $month);
return $this->teamerRepository->findSeasonTeamersForMonth(
$firstDay,
$firstDay->modify('last day of this month'),
);
}
/**
* @return WorkHoursSyncResult[]
*/
public function syncMonth(int $year, int $month, bool $dryRun = false, ?callable $onProgress = null): array
{
$results = [];
foreach ($this->findEligibleTeamers($year, $month) as $teamer) {
$result = $this->syncTeamer($teamer, $year, $month, $dryRun);
$results[] = $result;
if (null !== $onProgress) {
$onProgress($result);
}
}
return $results;
}
public function syncTeamer(Teamer $teamer, int $year, int $month, bool $dryRun = false): WorkHoursSyncResult
{
$email = $teamer->getCommunication()?->getEmail();
if (null === $email || '' === trim($email)) {
return WorkHoursSyncResult::skipped($teamer, 'Keine E-Mail-Adresse hinterlegt.');
}
$directory = $this->employeeDataProvider->getDirectory();
$employee = $directory->find($email);
// An empty roster means the roster call failed, not that nobody is registered, so it
// must never be read as "skip everyone" - the import goes ahead without it.
if (null === $employee && !$directory->isEmpty()) {
return WorkHoursSyncResult::skipped(
$teamer,
sprintf('Nicht in der SaaS registriert (%s).', $email),
);
}
try {
$payload = $this->apiClient->getTimeAccount($email, $year, $month);
} catch (ApiClientException $e) {
$this->logger->error('Work hours import failed', [
'teamer' => $teamer->getFullName(),
'year' => $year,
'month' => $month,
'error' => $e->getMessage(),
]);
return WorkHoursSyncResult::failed($teamer, $e->getMessage());
}
$account = $this->responseParser->parseTimeAccount($payload, $year, $month);
$days = WorkMonthDays::fromTimeAccount($account);
$workMonth = $this->workMonthRepository->findOneForTeamerAndMonth($teamer, $year, $month);
if (null !== $workMonth && $workMonth->getStatus()->locksHours()) {
return $this->compareLocked($workMonth, $days, $dryRun);
}
if (null === $workMonth) {
$workMonth = (new TeamerWorkMonth())
->setTeamer($teamer)
->setYear($year)
->setMonth($month)
;
$this->entityManager->persist($workMonth);
}
$workMonth
->setDays($days)
->setSourceIstMinutes($account->getIstMinutes())
->setTargetMinutes($account->getTargetMinutes())
// Keep the last known one: a month of pure holiday reports none, and blanking it
// would lose what earlier months already established.
// The bookings first, the roster second: a month made up entirely of holiday has
// no booking to read a department from, and the roster always does.
->setDepartment($account->getDepartment() ?? $employee?->getDepartment() ?? $workMonth->getDepartment())
->setWarnings($account->getWarnings())
->setSyncedAt(new \DateTimeImmutable())
->clearMismatch()
;
$employeeUuid = $account->getEmployeeUuid() ?? $employee?->getUuid();
if (null !== $employeeUuid && $teamer->getSaasEmployeeUuid() !== $employeeUuid) {
$teamer->setSaasEmployeeUuid($employeeUuid);
}
if (!$dryRun) {
$this->entityManager->flush();
}
$this->logger->info('Work hours imported', [
'teamer' => $teamer->getFullName(),
'year' => $year,
'month' => $month,
'minutes' => $days->getTotalMinutes(),
'days' => count($days->toArray()),
'warnings' => count($account->getWarnings()),
'dryRun' => $dryRun,
]);
return WorkHoursSyncResult::imported($teamer, $days->getTotalMinutes(), $account->getWarnings());
}
/**
* Releases a signed month back to the importer, for when a recorded discrepancy turns
* out to be a genuine correction the teamer has to sign again.
*/
public function unlock(TeamerWorkMonth $workMonth): void
{
$workMonth
->setStatus(WorkMonthStatus::OPEN)
->setSubmittedAt(null)
->setReviewedAt(null)
->setReviewedBy(null)
->setReviewComment(null)
->clearMismatch()
;
}
/**
* A signed month keeps the hours it was signed with. The fresh figures are only
* compared, so the office can see that the SaaS has since changed its mind.
*/
private function compareLocked(TeamerWorkMonth $workMonth, WorkMonthDays $days, bool $dryRun): WorkHoursSyncResult
{
$teamer = $workMonth->getTeamer();
$fresh = $days->getTotalMinutes();
if ($workMonth->getTotalMinutes() === $fresh) {
$workMonth->clearMismatch();
if (!$dryRun) {
$this->entityManager->flush();
}
return WorkHoursSyncResult::locked(
$teamer,
'Bereits eingereicht, Stunden unverändert.',
$workMonth->getTotalMinutes(),
);
}
$workMonth->recordMismatch($fresh);
if (!$dryRun) {
$this->entityManager->flush();
}
$this->logger->warning('Work hours changed after submission', [
'teamer' => $teamer->getFullName(),
'year' => $workMonth->getYear(),
'month' => $workMonth->getMonth(),
'signed' => $workMonth->getTotalMinutes(),
'reported' => $fresh,
]);
return WorkHoursSyncResult::locked(
$teamer,
sprintf(
'Bereits eingereicht; die SaaS meldet jetzt %d statt %d Minuten.',
$fresh,
$workMonth->getTotalMinutes(),
),
$workMonth->getTotalMinutes(),
);
}
private function firstDayOf(int $year, int $month): \DateTimeImmutable
{
$first = \DateTimeImmutable::createFromFormat('!Y-n-j', sprintf('%04d-%d-1', $year, $month));
if (false === $first) {
throw new \InvalidArgumentException(sprintf('%04d-%02d is not a month.', $year, $month));
}
return $first;
}
}