feat(absences) : fondation backend du module de gestion des absences
Module type Payfit (étapes 1+2 de la spec V1) : demande d'absence, validation admin, soldes à jour. - Enums : AbsenceType, AbsenceStatus, HalfDay, ContractType, FamilySituation - Entités : AbsencePolicy, AbsenceBalance, AbsenceRequest + champs RH sur User - Services : PublicHolidayProvider (fériés FR métropole en PHP pur, Computus), AbsenceDayCalculator (décompte jours ouvrés/ouvrables + demi-journées, TDD), AbsenceBalanceService (périodes + pending/taken/recrédit) - API Platform : providers/processors (création, approve/reject/cancel) + RBAC me/admin, contrôleurs preview (dry-run), upload/download justificatif, calendrier - Migrations : une par table + colonnes RH user (DEFAULT puis DROP DEFAULT) - Fixtures : 5 policies par défaut, salariés démo, soldes et demandes - Tests unitaires : PublicHolidayProvider, AbsenceDayCalculator (12 tests) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\AbsenceBalance;
|
||||
use App\Entity\AbsenceRequest;
|
||||
use App\Entity\User;
|
||||
use App\Enum\AbsenceType;
|
||||
use App\Repository\AbsenceBalanceRepository;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* Maintains per-employee leave balances as absence requests move through their
|
||||
* lifecycle: a PENDING request reserves days in `pending`, an APPROVED one
|
||||
* moves them to `taken`, and a cancellation gives them back.
|
||||
*/
|
||||
final readonly class AbsenceBalanceService
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private AbsenceBalanceRepository $balanceRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Reference period string for a request: paid leave follows the employee's
|
||||
* reference period (e.g. "2025-2026"), other types are tracked yearly.
|
||||
*/
|
||||
public function periodFor(User $user, AbsenceType $type, DateTimeInterface $date): string
|
||||
{
|
||||
if (AbsenceType::PaidLeave !== $type) {
|
||||
return $date->format('Y');
|
||||
}
|
||||
|
||||
$year = (int) $date->format('Y');
|
||||
$startMonthDay = $user->getReferencePeriodStart(); // e.g. "06-01"
|
||||
$currentMonthDay = $date->format('m-d');
|
||||
|
||||
$startYear = $currentMonthDay >= $startMonthDay ? $year : $year - 1;
|
||||
|
||||
return sprintf('%d-%d', $startYear, $startYear + 1);
|
||||
}
|
||||
|
||||
public function getOrCreateBalance(User $user, AbsenceType $type, string $period): AbsenceBalance
|
||||
{
|
||||
$balance = $this->balanceRepository->findOneForPeriod($user, $type, $period);
|
||||
|
||||
if (null === $balance) {
|
||||
$balance = new AbsenceBalance()
|
||||
->setUser($user)
|
||||
->setType($type)
|
||||
->setPeriod($period)
|
||||
;
|
||||
$this->entityManager->persist($balance);
|
||||
}
|
||||
|
||||
return $balance;
|
||||
}
|
||||
|
||||
/** Reserve the requested days in the PENDING bucket. */
|
||||
public function reservePending(AbsenceRequest $request): void
|
||||
{
|
||||
if (!$this->shouldTrack($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$balance = $this->balanceForRequest($request);
|
||||
$balance->setPending($balance->getPending() + $request->getCountedDays());
|
||||
}
|
||||
|
||||
/** Move reserved days from PENDING to TAKEN on approval. */
|
||||
public function applyApproval(AbsenceRequest $request): void
|
||||
{
|
||||
if (!$this->shouldTrack($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$balance = $this->balanceForRequest($request);
|
||||
$balance->setPending(max(0.0, $balance->getPending() - $request->getCountedDays()));
|
||||
$balance->setTaken($balance->getTaken() + $request->getCountedDays());
|
||||
}
|
||||
|
||||
/**
|
||||
* Give days back when a request is cancelled or rejected.
|
||||
*
|
||||
* @param bool $wasApproved true if the request had already been approved
|
||||
* (days were in TAKEN), false if still PENDING
|
||||
*/
|
||||
public function release(AbsenceRequest $request, bool $wasApproved): void
|
||||
{
|
||||
if (!$this->shouldTrack($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$balance = $this->balanceForRequest($request);
|
||||
|
||||
if ($wasApproved) {
|
||||
$balance->setTaken(max(0.0, $balance->getTaken() - $request->getCountedDays()));
|
||||
} else {
|
||||
$balance->setPending(max(0.0, $balance->getPending() - $request->getCountedDays()));
|
||||
}
|
||||
}
|
||||
|
||||
private function balanceForRequest(AbsenceRequest $request): AbsenceBalance
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->getUser();
|
||||
$type = $request->getType();
|
||||
$period = $this->periodFor($user, $type, $request->getStartDate());
|
||||
|
||||
return $this->getOrCreateBalance($user, $type, $period);
|
||||
}
|
||||
|
||||
private function shouldTrack(AbsenceRequest $request): bool
|
||||
{
|
||||
$type = $request->getType();
|
||||
|
||||
return null !== $type && $type->decrementsBalance() && null !== $request->getUser();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enum\HalfDay;
|
||||
use DateInterval;
|
||||
use DatePeriod;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Computes the number of days deducted for an absence request, following the
|
||||
* business rules of the spec (§5.1): weekends and public holidays are skipped,
|
||||
* and half-days on the boundaries subtract 0.5 each.
|
||||
*/
|
||||
final readonly class AbsenceDayCalculator
|
||||
{
|
||||
public function __construct(
|
||||
private PublicHolidayProvider $holidayProvider,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param bool $workingDaysOnly true => "jours ouvrés" (Mon-Fri),
|
||||
* false => "jours ouvrables" (Mon-Sat, Sunday excluded)
|
||||
*/
|
||||
public function countWorkingDays(
|
||||
DateTimeImmutable $start,
|
||||
DateTimeImmutable $end,
|
||||
?HalfDay $startHalfDay = null,
|
||||
?HalfDay $endHalfDay = null,
|
||||
bool $workingDaysOnly = true,
|
||||
): float {
|
||||
$start = $start->setTime(0, 0);
|
||||
$end = $end->setTime(0, 0);
|
||||
|
||||
if ($end < $start) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$days = 0.0;
|
||||
$period = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
|
||||
|
||||
foreach ($period as $day) {
|
||||
$weekday = (int) $day->format('N'); // 1 (Mon) .. 7 (Sun)
|
||||
|
||||
if (7 === $weekday) {
|
||||
continue; // Sunday: never counted
|
||||
}
|
||||
if (6 === $weekday && $workingDaysOnly) {
|
||||
continue; // Saturday: only counted for "jours ouvrables"
|
||||
}
|
||||
if ($this->holidayProvider->isHoliday($day)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
++$days;
|
||||
}
|
||||
|
||||
if ($days <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
if (null !== $startHalfDay) {
|
||||
$days -= 0.5;
|
||||
}
|
||||
if (null !== $endHalfDay) {
|
||||
$days -= 0.5;
|
||||
}
|
||||
|
||||
return max(0.0, $days);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
* Provides French métropole public holidays.
|
||||
*
|
||||
* Dates are computed in pure PHP: fixed-date holidays are hardcoded and
|
||||
* Easter-based ones are derived from the Computus (Meeus/Jones/Butcher
|
||||
* Gregorian algorithm), so the provider has no runtime dependency and is
|
||||
* fully deterministic. Alsace-Moselle / DOM specifics are out of scope.
|
||||
*/
|
||||
final class PublicHolidayProvider
|
||||
{
|
||||
/** @var array<int, array<string, string>> cache of holidays per year */
|
||||
private array $cache = [];
|
||||
|
||||
/**
|
||||
* @return array<string, string> map of 'Y-m-d' => label, sorted by date
|
||||
*/
|
||||
public function getHolidays(int $year): array
|
||||
{
|
||||
if (isset($this->cache[$year])) {
|
||||
return $this->cache[$year];
|
||||
}
|
||||
|
||||
$easter = $this->easterSunday($year);
|
||||
$easterMonday = $easter->modify('+1 day');
|
||||
$ascension = $easter->modify('+39 days');
|
||||
$whitMonday = $easter->modify('+50 days');
|
||||
|
||||
$holidays = [
|
||||
sprintf('%d-01-01', $year) => 'Jour de l\'an',
|
||||
$easterMonday->format('Y-m-d') => 'Lundi de Pâques',
|
||||
sprintf('%d-05-01', $year) => 'Fête du Travail',
|
||||
sprintf('%d-05-08', $year) => 'Victoire 1945',
|
||||
$ascension->format('Y-m-d') => 'Ascension',
|
||||
$whitMonday->format('Y-m-d') => 'Lundi de Pentecôte',
|
||||
sprintf('%d-07-14', $year) => 'Fête nationale',
|
||||
sprintf('%d-08-15', $year) => 'Assomption',
|
||||
sprintf('%d-11-01', $year) => 'Toussaint',
|
||||
sprintf('%d-11-11', $year) => 'Armistice 1918',
|
||||
sprintf('%d-12-25', $year) => 'Noël',
|
||||
];
|
||||
|
||||
ksort($holidays);
|
||||
|
||||
return $this->cache[$year] = $holidays;
|
||||
}
|
||||
|
||||
public function isHoliday(DateTimeInterface $date): bool
|
||||
{
|
||||
$holidays = $this->getHolidays((int) $date->format('Y'));
|
||||
|
||||
return isset($holidays[$date->format('Y-m-d')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Easter Sunday date for the given year (Gregorian Computus).
|
||||
*/
|
||||
private function easterSunday(int $year): DateTimeImmutable
|
||||
{
|
||||
$a = $year % 19;
|
||||
$b = intdiv($year, 100);
|
||||
$c = $year % 100;
|
||||
$d = intdiv($b, 4);
|
||||
$e = $b % 4;
|
||||
$f = intdiv($b + 8, 25);
|
||||
$g = intdiv($b - $f + 1, 3);
|
||||
$h = (19 * $a + $b - $d - $g + 15) % 30;
|
||||
$i = intdiv($c, 4);
|
||||
$k = $c % 4;
|
||||
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
|
||||
$m = intdiv($a + 11 * $h + 22 * $l, 451);
|
||||
|
||||
$month = intdiv($h + $l - 7 * $m + 114, 31);
|
||||
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
|
||||
|
||||
return new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user