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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user