Merge pull request 'fix(absence) : déduire les jours pris du report CP au changement de période' (#22) from fix/absence-cp-carryover into develop
Auto Tag Develop / tag (push) Successful in 9s

Reviewed-on: #22
This commit was merged in pull request #22.
This commit is contained in:
2026-06-24 08:57:24 +00:00
2 changed files with 132 additions and 3 deletions
@@ -111,9 +111,18 @@ class AccrueLeaveCommand extends Command
$previousBalance = null !== $previousPeriod
? $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $previousPeriod)
: null;
$balance->setAcquired(
null !== $previousBalance ? $previousBalance->getAcquiring() : $profile->getInitialLeaveBalance(),
);
if (null !== $previousBalance) {
// Only the days *not yet taken* carry over. Leave is charged
// oldest-first: it first consumes the previous "acquired"
// (N-2) bucket — which expires at roll-over anyway — so only
// days taken beyond that bucket eat into the carry-over.
$carryOver = $previousBalance->getAcquiring()
- max(0.0, $previousBalance->getTaken() - $previousBalance->getAcquired());
$balance->setAcquired(max(0.0, $carryOver));
} else {
$balance->setAcquired($profile->getInitialLeaveBalance());
}
}
if ($monthKey === $balance->getLastAccruedMonth()) {
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Command;
use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Infrastructure\Command\AccrueLeaveCommand;
use App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceBalanceRepository;
use App\Module\Core\Domain\Entity\User;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
/**
* Covers the period roll-over: when a new reference period opens, the previous
* period's "en cours d'acquisition" (N) becomes the new "acquired" (N-1), but
* only for the days that were not already taken.
*
* @internal
*/
class AccrueLeaveCommandTest extends KernelTestCase
{
private EntityManagerInterface $em;
private DoctrineAbsenceBalanceRepository $balances;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->balances = self::getContainer()->get(DoctrineAbsenceBalanceRepository::class);
}
/**
* Tristan's real case: 9.75 accruing, 1 day taken, nothing previously
* acquired → the day taken eats into the carry-over, so 8.75 rolls over
* (not 9.75).
*/
public function testCarryOverDeductsTakenDays(): void
{
$user = $this->createEmployee();
$this->seedPreviousBalance($user, acquired: 0.0, acquiring: 9.75, taken: 1.0);
$this->runForJune2026();
$rolled = $this->balances->findOneForPeriod($user, AbsenceType::PaidLeave, '2026-2027');
self::assertNotNull($rolled);
self::assertEqualsWithDelta(8.75, $rolled->getAcquired(), 0.0001);
}
/**
* Leave is charged oldest-first: the 3 days taken come out of the expiring
* N-2 "acquired" bucket (5), so the full 10 accruing days carry over intact.
*/
public function testCarryOverChargesOldestBucketFirst(): void
{
$user = $this->createEmployee();
$this->seedPreviousBalance($user, acquired: 5.0, acquiring: 10.0, taken: 3.0);
$this->runForJune2026();
$rolled = $this->balances->findOneForPeriod($user, AbsenceType::PaidLeave, '2026-2027');
self::assertNotNull($rolled);
self::assertEqualsWithDelta(10.0, $rolled->getAcquired(), 0.0001);
}
/** No day taken → the whole accruing bucket carries over. */
public function testFullCarryOverWhenNothingTaken(): void
{
$user = $this->createEmployee();
$this->seedPreviousBalance($user, acquired: 0.0, acquiring: 10.0, taken: 0.0);
$this->runForJune2026();
$rolled = $this->balances->findOneForPeriod($user, AbsenceType::PaidLeave, '2026-2027');
self::assertNotNull($rolled);
self::assertEqualsWithDelta(10.0, $rolled->getAcquired(), 0.0001);
}
private function createEmployee(): User
{
$user = new User();
$user->setUsername('accrue-test-'.uniqid());
$user->setPassword('x');
$user->setRoles(['ROLE_USER']);
$user->setIsEmployee(true);
$user->setHireDate(new DateTimeImmutable('2024-01-01'));
$user->setReferencePeriodStart('06-01');
$user->setAnnualLeaveDays(25.0);
$user->setWorkTimeRatio(1.0);
$user->setInitialLeaveBalance(0.0);
$this->em->persist($user);
$this->em->flush();
return $user;
}
private function seedPreviousBalance(User $user, float $acquired, float $acquiring, float $taken): void
{
$balance = new AbsenceBalance();
$balance->setUser($user);
$balance->setType(AbsenceType::PaidLeave);
$balance->setPeriod('2025-2026');
$balance->setAcquired($acquired);
$balance->setAcquiring($acquiring);
$balance->setTaken($taken);
$this->em->persist($balance);
$this->em->flush();
}
private function runForJune2026(): void
{
$command = self::getContainer()->get(AccrueLeaveCommand::class);
$tester = new CommandTester($command);
$tester->execute(['--month' => '2026-06']);
self::assertSame(0, $tester->getStatusCode());
}
}