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>
This commit is contained in:
@@ -686,8 +686,9 @@ class AppFixtures extends Fixture
|
|||||||
$cpPeriod = '2025-2026';
|
$cpPeriod = '2025-2026';
|
||||||
$balanceData = [
|
$balanceData = [
|
||||||
// [user, acquired (N-1), acquiring (N, en cours), taken, pending]
|
// [user, acquired (N-1), acquiring (N, en cours), taken, pending]
|
||||||
|
// Alice's `pending` matches her pending CP request below (same 2025-2026 period).
|
||||||
[$admin, 10.0, 22.5, 5.0, 0.0],
|
[$admin, 10.0, 22.5, 5.0, 0.0],
|
||||||
[$userAlice, 8.0, 18.0, 2.0, 5.0],
|
[$userAlice, 8.0, 18.0, 2.0, 4.0],
|
||||||
[$userBob, 0.0, 14.0, 0.0, 0.0],
|
[$userBob, 0.0, 14.0, 0.0, 0.0],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -717,12 +718,15 @@ class AppFixtures extends Fixture
|
|||||||
$approvedCp->setReviewedBy($admin);
|
$approvedCp->setReviewedBy($admin);
|
||||||
$manager->persist($approvedCp);
|
$manager->persist($approvedCp);
|
||||||
|
|
||||||
|
// Pending CP within Alice's current reference period (2025-2026), so the
|
||||||
|
// reserved days line up with her balance's `pending` bucket above.
|
||||||
|
// Tue 26 → Fri 29 May 2026 = 4 working days (Pentecost Mon 25/05 excluded).
|
||||||
$pendingCp = new AbsenceRequest();
|
$pendingCp = new AbsenceRequest();
|
||||||
$pendingCp->setUser($userAlice);
|
$pendingCp->setUser($userAlice);
|
||||||
$pendingCp->setType(AbsenceType::PaidLeave);
|
$pendingCp->setType(AbsenceType::PaidLeave);
|
||||||
$pendingCp->setStartDate(new DateTimeImmutable('2026-06-15'));
|
$pendingCp->setStartDate(new DateTimeImmutable('2026-05-26'));
|
||||||
$pendingCp->setEndDate(new DateTimeImmutable('2026-06-19'));
|
$pendingCp->setEndDate(new DateTimeImmutable('2026-05-29'));
|
||||||
$pendingCp->setCountedDays(5.0);
|
$pendingCp->setCountedDays(4.0);
|
||||||
$pendingCp->setStatus(AbsenceStatus::Pending);
|
$pendingCp->setStatus(AbsenceStatus::Pending);
|
||||||
$pendingCp->setCreatedAt(new DateTimeImmutable('-2 days'));
|
$pendingCp->setCreatedAt(new DateTimeImmutable('-2 days'));
|
||||||
$manager->persist($pendingCp);
|
$manager->persist($pendingCp);
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ class ReviewAbsenceRequestTool
|
|||||||
assert($admin instanceof User);
|
assert($admin instanceof User);
|
||||||
|
|
||||||
if ('approve' === $decision) {
|
if ('approve' === $decision) {
|
||||||
|
// Never let an approval push the balance below zero (CP only).
|
||||||
|
$available = $this->balanceService->availableForRequest($request);
|
||||||
|
if (null !== $available && $request->getCountedDays() > $available + 1e-9) {
|
||||||
|
throw new InvalidArgumentException(sprintf(
|
||||||
|
'Approving this request would put the balance below zero: %g day(s) requested but only %g available.',
|
||||||
|
$request->getCountedDays(),
|
||||||
|
$available,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
$request->setStatus(AbsenceStatus::Approved);
|
$request->setStatus(AbsenceStatus::Approved);
|
||||||
$request->setRejectionReason(null);
|
$request->setRejectionReason(null);
|
||||||
$this->balanceService->applyApproval($request);
|
$this->balanceService->applyApproval($request);
|
||||||
|
|||||||
@@ -70,6 +70,29 @@ final readonly class AbsenceBalanceService
|
|||||||
$balance->setPending($balance->getPending() + $request->getCountedDays());
|
$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. */
|
/** Move reserved days from PENDING to TAKEN on approval. */
|
||||||
public function applyApproval(AbsenceRequest $request): void
|
public function applyApproval(AbsenceRequest $request): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -58,6 +58,17 @@ final readonly class AbsenceReviewProcessor implements ProcessorInterface
|
|||||||
assert($admin instanceof User);
|
assert($admin instanceof User);
|
||||||
|
|
||||||
if ($isApprove) {
|
if ($isApprove) {
|
||||||
|
// Never let an approval push the balance below zero (CP only): the
|
||||||
|
// days being accrued (N) are posable, but not beyond the entitlement.
|
||||||
|
$available = $this->balanceService->availableForRequest($data);
|
||||||
|
if (null !== $available && $data->getCountedDays() > $available + 1e-9) {
|
||||||
|
throw new UnprocessableEntityHttpException(sprintf(
|
||||||
|
'Approving this request would put the balance below zero: %g day(s) requested but only %g available.',
|
||||||
|
$data->getCountedDays(),
|
||||||
|
$available,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
$data->setStatus(AbsenceStatus::Approved);
|
$data->setStatus(AbsenceStatus::Approved);
|
||||||
$data->setRejectionReason(null);
|
$data->setRejectionReason(null);
|
||||||
$this->balanceService->applyApproval($data);
|
$this->balanceService->applyApproval($data);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Functional\Mcp;
|
namespace App\Tests\Functional\Mcp;
|
||||||
|
|
||||||
|
use App\Entity\AbsenceBalance;
|
||||||
use App\Entity\AbsencePolicy;
|
use App\Entity\AbsencePolicy;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Enum\AbsenceType;
|
use App\Enum\AbsenceType;
|
||||||
@@ -62,6 +63,15 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
|
|||||||
$policy->setActive(true);
|
$policy->setActive(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Entitlement so CP requests can be approved without breaching the
|
||||||
|
// no-negative-balance guard (period of the June 2026 test requests).
|
||||||
|
$balance = new AbsenceBalance();
|
||||||
|
$balance->setUser($this->employee);
|
||||||
|
$balance->setType(AbsenceType::PaidLeave);
|
||||||
|
$balance->setPeriod('2026-2027');
|
||||||
|
$balance->setAcquired(25.0);
|
||||||
|
$this->em->persist($balance);
|
||||||
|
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +120,33 @@ class AbsenceRequestLifecycleTest extends KernelTestCase
|
|||||||
self::assertSame(5.0, $balance->getTaken());
|
self::assertSame(5.0, $balance->getTaken());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testApproveBeyondAvailableBalanceIsBlocked(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(
|
||||||
|
($this->createTool($this->admin))($this->employee->getId(), 'cp', '2026-06-01', '2026-06-05'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shrink the entitlement below the 5 requested days.
|
||||||
|
$balance = self::getContainer()->get(AbsenceBalanceRepository::class)
|
||||||
|
->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027')
|
||||||
|
;
|
||||||
|
$balance->setAcquired(2.0);
|
||||||
|
$balance->setAcquiring(0.0);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
try {
|
||||||
|
($this->reviewTool($this->admin))($created['id'], 'approve');
|
||||||
|
self::fail('Expected approval to be blocked when it would breach the balance.');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('below zero', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approval bailed out before mutating: nothing moved to taken, days stay reserved.
|
||||||
|
self::assertSame(0.0, $balance->getTaken());
|
||||||
|
self::assertSame(5.0, $balance->getPending());
|
||||||
|
}
|
||||||
|
|
||||||
public function testAdminCancelApprovedReleasesTaken(): void
|
public function testAdminCancelApprovedReleasesTaken(): void
|
||||||
{
|
{
|
||||||
$created = json_decode(
|
$created = json_decode(
|
||||||
|
|||||||
Reference in New Issue
Block a user