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

+154
View File
@@ -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;
}
}