Files
Lesstime/src/Service/AbsenceBalanceService.php
T
Matthieu 65df36dd1a fix(absences) : garde-fou solde négatif à l'approbation + cohérence fixture
- AbsenceBalanceService::availableForRequest() : jours disponibles (acquis N-1
  + en cours N − pris) pour la période de la demande, null si type non suivi.
- Blocage de l'approbation si countedDays > disponible, dans les deux chemins
  (REST AbsenceReviewProcessor + MCP ReviewAbsenceRequestTool), comme le motif
  décès. Les CP en cours d'acquisition restent posables, mais pas au-delà du
  droit total (plus de solde négatif silencieux à l'approbation).
- Fixture : demande pending CP d'alice replacée dans sa période de référence
  2025-2026 (26→29/05/2026, 4 j ouvrés) et solde pending aligné (5 → 4) ;
  plus de "en attente" orphelin non lié à une demande.
- Test fonctionnel testApproveBeyondAvailableBalanceIsBlocked + employé de test
  doté d'un droit pour que les approbations existantes passent le garde-fou.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:48:49 +02:00

146 lines
4.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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());
}
/**
* Days still available to take in the request's balance period
* (acquired N-1 + acquiring N already taken), or null when the type is
* not balance-tracked (per-event leaves such as bereavement or marriage).
*
* Days currently reserved in PENDING are intentionally not subtracted: the
* request being reviewed already sits in that pending bucket, and approval
* only moves it to TAKEN.
*/
public function availableForRequest(AbsenceRequest $request): ?float
{
if (!$this->shouldTrack($request)) {
return null;
}
/** @var User $user */
$user = $request->getUser();
$period = $this->periodFor($user, $request->getType(), $request->getStartDate());
$balance = $this->balanceRepository->findOneForPeriod($user, $request->getType(), $period);
return $balance?->getAvailable() ?? 0.0;
}
/** 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();
}
}