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