65df36dd1a
- 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>
146 lines
4.8 KiB
PHP
146 lines
4.8 KiB
PHP
<?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();
|
||
}
|
||
}
|