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,127 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Cron;
use App\Config\EmailTextKey;
use App\Email\Mailer;
use App\Entity\Embeddable\Communication;
use App\Entity\Teamer;
use App\Entity\TeamerWorkMonth;
use App\Repository\TeamerWorkMonthRepository;
use App\Service\Cron\WorkMonthReminderService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
/**
* Pins the rules the reminder applies: which day it runs on, how long it waits after an
* import, and who it can actually reach. The query itself is the repository's business.
*/
class WorkMonthReminderServiceTest extends TestCase
{
private TeamerWorkMonthRepository&MockObject $repository;
private Mailer&MockObject $mailer;
private WorkMonthReminderService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(TeamerWorkMonthRepository::class);
$this->mailer = $this->createMock(Mailer::class);
$this->service = new WorkMonthReminderService(
$this->repository,
$this->mailer,
new NullLogger(),
);
}
/**
* app:cron runs daily, so the weekday gate is the only thing stopping a teamer being
* chased every morning.
*/
public function testItOnlyRunsOnMondays(): void
{
$this->repository->expects($this->never())->method('findAwaitingDocument');
$this->mailer->expects($this->never())->method('createAndSendText');
$this->assertSame(
'Work month reminders are only sent on mondays',
$this->service->sendReminders(new \DateTimeImmutable('2026-03-10')),
);
}
public function testItLeavesTheTeamerAGracePeriodAfterTheImport(): void
{
$this->repository
->expects($this->once())
->method('findAwaitingDocument')
->with($this->callback(
static fn (\DateTimeImmutable $cutoff) => '2026-03-04' === $cutoff->format('Y-m-d')
))
->willReturn([])
;
// 9 March 2026 is a Monday; the import ran on the 10th of the month before.
$this->service->sendReminders(new \DateTimeImmutable('2026-03-09'));
}
public function testItMailsOneReminderPerOpenMonth(): void
{
$this->repository->method('findAwaitingDocument')->willReturn([
$this->createWorkMonth(2026, 1),
$this->createWorkMonth(2026, 2),
]);
$sent = [];
$this->mailer
->expects($this->exactly(2))
->method('createAndSendText')
->willReturnCallback(function (EmailTextKey $key, array $placeholders, array $options) use (&$sent) {
$sent[] = [$key, $placeholders, $options];
})
;
$this->assertSame(
'Sent 2 work month reminders to teamers',
$this->service->sendReminders(new \DateTimeImmutable('2026-03-09')),
);
$this->assertSame(EmailTextKey::REMINDER_WORK_MONTH_UPLOAD, $sent[0][0]);
$this->assertSame('Januar 2026', $sent[0][1]['workMonth']);
$this->assertSame('Februar 2026', $sent[1][1]['workMonth']);
$this->assertSame('[email protected]', $sent[0][2]['to']);
}
public function testATeamerWithoutAnAddressIsSkippedSilently(): void
{
$this->repository->method('findAwaitingDocument')->willReturn([
$this->createWorkMonth(2026, 1, null),
$this->createWorkMonth(2026, 2, ' '),
$this->createWorkMonth(2026, 3),
]);
$this->mailer->expects($this->once())->method('createAndSendText');
$this->assertSame(
'Sent 1 work month reminders to teamers',
$this->service->sendReminders(new \DateTimeImmutable('2026-03-09')),
);
}
private function createWorkMonth(int $year, int $month, ?string $email = '[email protected]'): TeamerWorkMonth
{
$communication = new Communication();
$communication->setEmail($email);
$teamer = new Teamer();
$teamer->setFirstName('Theo')->setLastName('Test')->setCommunication($communication);
return (new TeamerWorkMonth())
->setTeamer($teamer)
->setYear($year)
->setMonth($month)
;
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
namespace App\Tests\Service\Pdf;
use App\Entity\Teamer;
use App\Entity\TeamerWorkMonth;
use App\Model\WorkMonthDays;
use App\Service\Pdf\WorkMonthRenderer;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Twig\Environment;
/**
* Renders a real document rather than mocking Twig: the point of the test is that the
* template, the duration filter and Dompdf agree with each other.
*/
class WorkMonthRendererTest extends WebTestCase
{
public function testItRendersAPdf(): void
{
$pdf = $this->render($this->createWorkMonth());
$this->assertStringStartsWith('%PDF', $pdf);
$this->assertGreaterThan(2000, strlen($pdf), 'A month of rows should not fit in a stub.');
}
/**
* A month whose days the SaaS never reported still has to produce a full sheet, so the
* teamer can write on the empty lines.
*/
public function testItPlacesTheLogoInTheTopRightCorner(): void
{
$pdf = $this->render($this->createWorkMonth());
// A4 is 595.28 x 841.89pt and the page margins are 15mm at the side, 18mm at the top,
// so the content corner sits at 552.76 / 790.87. Asserting the placement rather than
// the markup is the only way to know the logo actually landed there.
$this->assertSame(1, preg_match(
'#([\d.]+) 0 0 ([\d.]+) ([\d.]+) ([\d.]+) cm\s*/\w+ Do#',
$this->contentStreams($pdf),
$placement
), 'The logo should be drawn exactly once.');
[, $width, $height, $x, $y] = array_map('floatval', $placement);
$this->assertEqualsWithDelta(552.76, $x + $width, 1.0, 'Flush with the right margin.');
$this->assertEqualsWithDelta(790.87, $y + $height, 1.0, 'Flush with the top margin.');
$this->assertEqualsWithDelta(230 / 70, $width / $height, 0.05, 'The logo must not be stretched.');
}
/**
* A working-time record is worth printing without its letterhead.
*/
public function testItRendersWithoutALogo(): void
{
$renderer = new WorkMonthRenderer(
static::getContainer()->get(Environment::class),
['project_dir' => sys_get_temp_dir()],
);
$this->assertStringStartsWith('%PDF', $renderer->render($this->createWorkMonth()));
}
private function contentStreams(string $pdf): string
{
preg_match_all('#stream\r?\n(.*?)endstream#s', $pdf, $matches);
$decoded = '';
foreach ($matches[1] as $stream) {
$inflated = @gzuncompress(ltrim($stream, "\r\n"));
if (false !== $inflated) {
$decoded .= $inflated;
}
}
return $decoded;
}
public function testItRendersAMonthWithoutAnyBookings(): void
{
$workMonth = (new TeamerWorkMonth())
->setTeamer($this->createTeamer())
->setYear(2026)
->setMonth(2)
->setDays(WorkMonthDays::fromArray([]))
;
$this->assertStringStartsWith('%PDF', $this->render($workMonth));
}
public function testTheFilenameCarriesTheTeamerAndTheMonth(): void
{
$this->assertSame(
'Arbeitszeitnachweis_Test_2026-03.pdf',
$this->createRenderer()->filename($this->createWorkMonth()),
);
}
public function testTheFilenameSurvivesAnAwkwardName(): void
{
$renderer = $this->createRenderer();
$workMonth = $this->createWorkMonth();
$workMonth->getTeamer()->setLastName('Müller-Lüdenscheidt');
$this->assertSame('Arbeitszeitnachweis_M_ller_L_denscheidt_2026-03.pdf', $renderer->filename($workMonth));
}
private function render(TeamerWorkMonth $workMonth): string
{
return $this->createRenderer()->render($workMonth);
}
private function createRenderer(): WorkMonthRenderer
{
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
/** @var Environment $twig */
$twig = static::getContainer()->get(Environment::class);
return new WorkMonthRenderer($twig, ['project_dir' => $projectDir]);
}
private function createWorkMonth(): TeamerWorkMonth
{
return (new TeamerWorkMonth())
->setTeamer($this->createTeamer())
->setYear(2026)
->setMonth(3)
->setDays(WorkMonthDays::fromArray([
'2026-03-01' => ['minutes' => 285],
'2026-03-05' => ['minutes' => 0, 'absence' => 'Ruhetag 24/25'],
'2026-03-12' => ['minutes' => 510],
]))
->setSourceIstMinutes(795)
;
}
private function createTeamer(): Teamer
{
return (new Teamer())->setFirstName('Theo')->setLastName('Test');
}
}
@@ -0,0 +1,339 @@
<?php
namespace App\Tests\Service\Saas;
use App\Entity\Embeddable\Communication;
use App\Entity\Teamer;
use App\Entity\TeamerWorkMonth;
use App\Enum\WorkHoursSyncOutcome;
use App\Enum\WorkMonthStatus;
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\Model\Employee;
use App\Saas\Model\EmployeeDirectory;
use App\Saas\ResponseParser;
use App\Service\Saas\WorkHoursSynchronizer;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
class WorkHoursSynchronizerTest extends TestCase
{
private ApiClient&MockObject $apiClient;
private EmployeeDataProvider&MockObject $employeeDataProvider;
private TeamerWorkMonthRepository&MockObject $workMonthRepository;
private EntityManagerInterface&MockObject $entityManager;
private WorkHoursSynchronizer $synchronizer;
protected function setUp(): void
{
$this->apiClient = $this->createMock(ApiClient::class);
$this->workMonthRepository = $this->createMock(TeamerWorkMonthRepository::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->employeeDataProvider = $this->createMock(EmployeeDataProvider::class);
// Registered by default; the tests that care say otherwise.
$this->employeeDataProvider->method('getDirectory')->willReturn(
EmployeeDirectory::fromEmployees([
new Employee('[email protected]', 'e-1', 'Operations', false),
])
);
$this->synchronizer = new WorkHoursSynchronizer(
$this->apiClient,
new ResponseParser(),
$this->employeeDataProvider,
$this->createMock(TeamerRepository::class),
$this->workMonthRepository,
$this->entityManager,
new NullLogger(),
);
}
public function testATeamerWithoutAnAddressIsSkippedRatherThanFailed(): void
{
$this->apiClient->expects($this->never())->method('getTimeAccount');
$result = $this->synchronizer->syncTeamer($this->createTeamer(null), 2026, 3);
$this->assertSame(WorkHoursSyncOutcome::SKIPPED, $result->getOutcome());
$this->assertFalse($result->isFailure());
}
/**
* One unreachable teamer must not take the rest of the month's import down with it.
*/
public function testARequestFailureIsReportedWithoutWriting(): void
{
$this->apiClient->method('getTimeAccount')->willThrowException(new ApiClientException('gateway said no'));
$this->entityManager->expects($this->never())->method('flush');
$result = $this->synchronizer->syncTeamer($this->createTeamer(), 2026, 3);
$this->assertSame(WorkHoursSyncOutcome::FAILED, $result->getOutcome());
$this->assertTrue($result->isFailure());
$this->assertSame('gateway said no', $result->getMessage());
}
/**
* Catching this up front turns N failed requests into one legible line.
*/
public function testATeamerMissingFromTheRosterIsSkippedWithoutAnyRequest(): void
{
$provider = $this->createMock(EmployeeDataProvider::class);
$provider->method('getDirectory')->willReturn(
EmployeeDirectory::fromEmployees([new Employee('[email protected]', 'e-9', 'Service', false)])
);
$apiClient = $this->createMock(ApiClient::class);
$apiClient->expects($this->never())->method('getTimeAccount');
$synchronizer = new WorkHoursSynchronizer(
$apiClient,
new ResponseParser(),
$provider,
$this->createMock(TeamerRepository::class),
$this->workMonthRepository,
$this->entityManager,
new NullLogger(),
);
$result = $synchronizer->syncTeamer($this->createTeamer(), 2026, 3);
$this->assertSame(WorkHoursSyncOutcome::SKIPPED, $result->getOutcome());
$this->assertStringContainsString('Nicht in der SaaS registriert', $result->getMessage());
}
/**
* An unreachable roster comes back empty. Reading that as "nobody is registered" would
* silently skip the entire month.
*/
public function testAnUnreachableRosterDoesNotSkipEveryone(): void
{
$provider = $this->createMock(EmployeeDataProvider::class);
$provider->method('getDirectory')->willReturn(EmployeeDirectory::fromEmployees([]));
$this->apiClient->method('getTimeAccount')->willReturn($this->payload());
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null);
$synchronizer = new WorkHoursSynchronizer(
$this->apiClient,
new ResponseParser(),
$provider,
$this->createMock(TeamerRepository::class),
$this->workMonthRepository,
$this->entityManager,
new NullLogger(),
);
$this->assertSame(
WorkHoursSyncOutcome::IMPORTED,
$synchronizer->syncTeamer($this->createTeamer(), 2026, 3)->getOutcome(),
);
}
/**
* A month of nothing but holiday carries no booking to read a department from; the roster
* is the fallback that keeps the sheet's header complete.
*/
public function testTheRosterSuppliesTheDepartmentWhenTheBookingsCannot(): void
{
$teamer = $this->createTeamer();
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null);
$this->apiClient->method('getTimeAccount')->willReturn([
'employee' => 'e-1',
'year' => 2026,
'month' => 2,
'ist' => 0,
'list' => [
['date' => '2026-02-02', 'annotation' => -4, 'ist' => 0, 'holidayType' => 'Ferien'],
],
]);
$persisted = null;
$this->entityManager->method('persist')->willReturnCallback(function (object $entity) use (&$persisted) {
$persisted = $entity;
});
$this->synchronizer->syncTeamer($teamer, 2026, 3);
$this->assertSame('Operations', $persisted->getDepartment());
}
public function testANewMonthIsCreatedAndLearnsTheEmployeeId(): void
{
$teamer = $this->createTeamer();
$this->apiClient->method('getTimeAccount')->willReturn($this->payload());
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null);
$persisted = null;
$this->entityManager
->expects($this->once())
->method('persist')
->willReturnCallback(function (object $entity) use (&$persisted) {
$persisted = $entity;
})
;
$this->entityManager->expects($this->once())->method('flush');
$result = $this->synchronizer->syncTeamer($teamer, 2026, 3);
$this->assertSame(WorkHoursSyncOutcome::IMPORTED, $result->getOutcome());
$this->assertInstanceOf(TeamerWorkMonth::class, $persisted);
$this->assertSame(2026, $persisted->getYear());
$this->assertSame(3, $persisted->getMonth());
$this->assertSame(345, $persisted->getTotalMinutes());
$this->assertSame(345, $persisted->getSourceIstMinutes());
$this->assertSame(2, $persisted->getWorkedDays());
$this->assertSame(1, $persisted->getAbsenceDays());
$this->assertSame(WorkMonthStatus::OPEN, $persisted->getStatus());
$this->assertSame('e-1', $teamer->getSaasEmployeeUuid());
}
public function testImportingTwiceLeavesOneRecordWithTheSameFigures(): void
{
$teamer = $this->createTeamer();
$existing = $this->createWorkMonth($teamer, WorkMonthStatus::OPEN);
$this->apiClient->method('getTimeAccount')->willReturn($this->payload());
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn($existing);
$this->entityManager->expects($this->never())->method('persist');
$this->synchronizer->syncTeamer($teamer, 2026, 3);
$this->synchronizer->syncTeamer($teamer, 2026, 3);
$this->assertSame(345, $existing->getTotalMinutes());
}
/**
* The heart of the lock: a signed month keeps the hours that were signed, and the fresh
* figures only leave a trace that the SaaS has changed its mind.
*/
public function testASubmittedMonthIsComparedButNeverOverwritten(): void
{
$teamer = $this->createTeamer();
$existing = $this->createWorkMonth($teamer, WorkMonthStatus::SUBMITTED);
$this->apiClient->method('getTimeAccount')->willReturn($this->payload(420, 60));
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn($existing);
$result = $this->synchronizer->syncTeamer($teamer, 2026, 3);
$this->assertSame(WorkHoursSyncOutcome::LOCKED, $result->getOutcome());
$this->assertSame(120, $existing->getTotalMinutes(), 'The signed hours must survive.');
$this->assertTrue($existing->hasMismatch());
$this->assertSame(480, $existing->getMismatchTotalMinutes());
$this->assertStringContainsString('480', $result->getMessage());
}
public function testAnApprovedMonthThatStillAgreesClearsAnEarlierMismatch(): void
{
$teamer = $this->createTeamer();
$existing = $this->createWorkMonth($teamer, WorkMonthStatus::APPROVED);
$existing->recordMismatch(999);
$this->apiClient->method('getTimeAccount')->willReturn($this->payload(120, 0));
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn($existing);
$result = $this->synchronizer->syncTeamer($teamer, 2026, 3);
$this->assertSame(WorkHoursSyncOutcome::LOCKED, $result->getOutcome());
$this->assertFalse($existing->hasMismatch());
}
/**
* A rejection usually means the hours themselves were wrong, so that month is not
* frozen - the next import has to be able to correct it.
*/
public function testARejectedMonthIsStillImported(): void
{
$teamer = $this->createTeamer();
$existing = $this->createWorkMonth($teamer, WorkMonthStatus::REJECTED);
$this->apiClient->method('getTimeAccount')->willReturn($this->payload());
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn($existing);
$result = $this->synchronizer->syncTeamer($teamer, 2026, 3);
$this->assertSame(WorkHoursSyncOutcome::IMPORTED, $result->getOutcome());
$this->assertSame(345, $existing->getTotalMinutes());
}
public function testADryRunWritesNothing(): void
{
$this->apiClient->method('getTimeAccount')->willReturn($this->payload());
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null);
$this->entityManager->expects($this->never())->method('flush');
$result = $this->synchronizer->syncTeamer($this->createTeamer(), 2026, 3, true);
$this->assertSame(WorkHoursSyncOutcome::IMPORTED, $result->getOutcome());
}
public function testParserWarningsTravelOntoTheRecord(): void
{
$teamer = $this->createTeamer();
$payload = $this->payload();
$payload['list'][] = ['date' => '2026-02-11', 'annotation' => -2, 'ist' => 0];
$this->apiClient->method('getTimeAccount')->willReturn($payload);
$this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null);
$persisted = null;
$this->entityManager->method('persist')->willReturnCallback(function (object $entity) use (&$persisted) {
$persisted = $entity;
});
$result = $this->synchronizer->syncTeamer($teamer, 2026, 3);
$this->assertSame(['Unknown row kind -2 on 2026-03-11.'], $result->getWarnings());
$this->assertSame(['Unknown row kind -2 on 2026-03-11.'], $persisted->getWarnings());
}
/**
* March, requested as index 2, so the payload's dates read "2026-02-xx".
*/
private function payload(int $firstDay = 285, int $secondDay = 60): array
{
return [
'employee' => 'e-1',
'year' => 2026,
'month' => 2,
'ist' => $firstDay + $secondDay,
'list' => [
['date' => '2026-02-02', 'annotation' => 8, 'ist' => $firstDay],
['date' => '2026-02-03', 'annotation' => 8, 'ist' => $secondDay],
['date' => '2026-02-05', 'annotation' => -4, 'ist' => 0, 'holidayType' => 'Ferien'],
],
];
}
private function createTeamer(?string $email = '[email protected]'): Teamer
{
$teamer = new Teamer();
$teamer->setFirstName('Theo')->setLastName('Test');
$communication = new Communication();
$communication->setEmail($email);
$teamer->setCommunication($communication);
return $teamer;
}
private function createWorkMonth(Teamer $teamer, WorkMonthStatus $status): TeamerWorkMonth
{
return (new TeamerWorkMonth())
->setTeamer($teamer)
->setYear(2026)
->setMonth(3)
->setDays(WorkMonthDays::fromArray(['2026-03-02' => ['minutes' => 120]]))
->setSourceIstMinutes(120)
->setStatus($status)
;
}
}