Compare commits

..

3 Commits

Author SHA1 Message Date
gitea-actions
bf3f7b35a5 chore: bump version to v0.1.27
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-03-12 09:37:13 +00:00
5c251800fa Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-03-12 10:37:03 +01:00
e34e928264 fix : calcule des congés en cours d'acquisition au prorata (date début contrat) 2026-03-12 10:36:49 +01:00
6 changed files with 223 additions and 29 deletions

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.26' app.version: '0.1.27'

View File

@@ -169,11 +169,13 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- acquis annuel samedi: `5` - acquis annuel samedi: `5`
- en cours d'acquisition jours: `25/12 = 2,08` jours/mois - en cours d'acquisition jours: `25/12 = 2,08` jours/mois
- en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI) - en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI)
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
- samedis acquis affiches: uniquement `opening_saturdays` (report N-1) - samedis acquis affiches: uniquement `opening_saturdays` (report N-1)
- contrat `4h`: - contrat `4h`:
- acquis annuel CP: `10` - acquis annuel CP: `10`
- acquis annuel samedi: `0` - acquis annuel samedi: `0`
- en cours d'acquisition: `0.83` jour/mois - en cours d'acquisition: `0.83` jour/mois
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
- contrat `FORFAIT`: - contrat `FORFAIT`:
- base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218` - base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
- prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année - prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année

View File

@@ -117,14 +117,14 @@ final readonly class LeaveBalanceComputationService
{ {
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) { if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
return [ return [
new DateTimeImmutable(sprintf('%d-01-01', $year)), new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year)),
new DateTimeImmutable(sprintf('%d-12-31', $year)), new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year)),
]; ];
} }
return [ return [
new DateTimeImmutable(sprintf('%d-06-01', $year - 1)), new DateTimeImmutable(sprintf('%d-06-01 00:00:00', $year - 1)),
new DateTimeImmutable(sprintf('%d-05-31', $year)), new DateTimeImmutable(sprintf('%d-05-31 00:00:00', $year)),
]; ];
} }
@@ -145,7 +145,7 @@ final readonly class LeaveBalanceComputationService
$oldestStartDate = null; $oldestStartDate = null;
foreach ($history as $item) { foreach ($history as $item) {
$start = DateTimeImmutable::createFromFormat('Y-m-d', $item->startDate); $start = $this->parseYmdDate($item->startDate);
if (!$start) { if (!$start) {
continue; continue;
} }
@@ -197,14 +197,14 @@ final readonly class LeaveBalanceComputationService
): ?DateTimeImmutable { ): ?DateTimeImmutable {
$earliest = null; $earliest = null;
foreach ($employee->getContractHistory() as $period) { foreach ($employee->getContractHistory() as $period) {
$start = DateTimeImmutable::createFromFormat('Y-m-d', $period->startDate); $start = $this->parseYmdDate($period->startDate);
if (!$start) { if (!$start) {
continue; continue;
} }
$end = null; $end = null;
if (null !== $period->endDate && '' !== trim($period->endDate)) { if (null !== $period->endDate && '' !== trim($period->endDate)) {
$end = DateTimeImmutable::createFromFormat('Y-m-d', $period->endDate); $end = $this->parseYmdDate($period->endDate);
} }
if ($start > $to) { if ($start > $to) {
@@ -268,11 +268,37 @@ final readonly class LeaveBalanceComputationService
return 0.0; return 0.0;
} }
$monthsElapsed = ((int) $periodEnd->format('Y') - (int) $periodStart->format('Y')) * 12 $periodStart = $this->normalizeDate($periodStart);
+ ((int) $periodEnd->format('n') - (int) $periodStart->format('n')) $periodEnd = $this->normalizeDate($periodEnd);
+ 1; $coveredMonths = 0.0;
$cursor = $periodStart->modify('first day of this month')->setTime(0, 0);
while ($cursor <= $periodEnd) {
$monthStart = $cursor > $periodStart ? $cursor : $periodStart;
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
if ($monthEnd > $periodEnd) {
$monthEnd = $periodEnd;
}
return min($annualCap, $monthsElapsed * $accrualPerMonth); $coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
$daysInMonth = (int) $cursor->format('t');
$coveredMonths += $coveredDays / $daysInMonth;
$cursor = $cursor->modify('first day of next month');
}
return min($annualCap, $coveredMonths * $accrualPerMonth);
}
private function parseYmdDate(string $value): ?DateTimeImmutable
{
$date = DateTimeImmutable::createFromFormat('!Y-m-d', trim($value));
return $date instanceof DateTimeImmutable ? $date : null;
}
private function normalizeDate(DateTimeImmutable $date): DateTimeImmutable
{
return $date->setTime(0, 0);
} }
private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): int private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): int

View File

@@ -280,14 +280,14 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
): ?DateTimeImmutable { ): ?DateTimeImmutable {
$earliest = null; $earliest = null;
foreach ($employee->getContractHistory() as $period) { foreach ($employee->getContractHistory() as $period) {
$start = DateTimeImmutable::createFromFormat('Y-m-d', $period->startDate); $start = $this->parseYmdDate($period->startDate);
if (!$start instanceof DateTimeImmutable) { if (!$start instanceof DateTimeImmutable) {
continue; continue;
} }
$end = null; $end = null;
if (null !== $period->endDate && '' !== trim($period->endDate)) { if (null !== $period->endDate && '' !== trim($period->endDate)) {
$end = DateTimeImmutable::createFromFormat('Y-m-d', $period->endDate); $end = $this->parseYmdDate($period->endDate);
} }
if ($start > $to) { if ($start > $to) {
@@ -344,14 +344,25 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return 0.0; return 0.0;
} }
$monthsElapsed = ((int) $periodEnd->format('Y') - (int) $periodStart->format('Y')) * 12 $periodStart = $this->normalizeDate($periodStart);
+ ((int) $periodEnd->format('n') - (int) $periodStart->format('n')) $periodEnd = $this->normalizeDate($periodEnd);
+ 1; $coveredMonths = 0.0;
if ($monthsElapsed < 0) { $cursor = $periodStart->modify('first day of this month')->setTime(0, 0);
return 0.0; while ($cursor <= $periodEnd) {
$monthStart = $cursor > $periodStart ? $cursor : $periodStart;
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
if ($monthEnd > $periodEnd) {
$monthEnd = $periodEnd;
}
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
$daysInMonth = (int) $cursor->format('t');
$coveredMonths += $coveredDays / $daysInMonth;
$cursor = $cursor->modify('first day of next month');
} }
return min($acquiredDays, $monthsElapsed * $accrualPerMonth); return min($acquiredDays, $coveredMonths * $accrualPerMonth);
} }
private function resolveAccrualCalculationEndDate( private function resolveAccrualCalculationEndDate(
@@ -373,6 +384,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$lastDayPreviousMonth = $today $lastDayPreviousMonth = $today
->modify('first day of this month') ->modify('first day of this month')
->modify('-1 day') ->modify('-1 day')
->setTime(0, 0)
; ;
$end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd; $end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd;
} }
@@ -380,7 +392,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
// Cap at contract end date if the employee has left. // Cap at contract end date if the employee has left.
$contractEndRaw = $employee->getCurrentContractEndDate(); $contractEndRaw = $employee->getCurrentContractEndDate();
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) { if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
$contractEnd = DateTimeImmutable::createFromFormat('Y-m-d', $contractEndRaw); $contractEnd = $this->parseYmdDate($contractEndRaw);
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) { if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
$end = $contractEnd; $end = $contractEnd;
} }
@@ -398,7 +410,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
// Cap at contract end date if the employee has left. // Cap at contract end date if the employee has left.
$contractEndRaw = $employee->getCurrentContractEndDate(); $contractEndRaw = $employee->getCurrentContractEndDate();
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) { if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
$contractEnd = DateTimeImmutable::createFromFormat('Y-m-d', $contractEndRaw); $contractEnd = $this->parseYmdDate($contractEndRaw);
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) { if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
$end = $contractEnd; $end = $contractEnd;
} }
@@ -520,8 +532,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
private function resolveLeavePeriodBounds(int $leaveYear): array private function resolveLeavePeriodBounds(int $leaveYear): array
{ {
// Exercice CP "2026" = du 1er juin 2025 au 31 mai 2026. // Exercice CP "2026" = du 1er juin 2025 au 31 mai 2026.
$from = new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1)); $from = new DateTimeImmutable(sprintf('%d-06-01 00:00:00', $leaveYear - 1));
$to = new DateTimeImmutable(sprintf('%d-05-31', $leaveYear)); $to = new DateTimeImmutable(sprintf('%d-05-31 00:00:00', $leaveYear));
return [$from, $to]; return [$from, $to];
} }
@@ -531,12 +543,12 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
*/ */
private function resolveForfaitYearBounds(Employee $employee, int $year): array private function resolveForfaitYearBounds(Employee $employee, int $year): array
{ {
$from = new DateTimeImmutable(sprintf('%d-01-01', $year)); $from = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
$to = new DateTimeImmutable(sprintf('%d-12-31', $year)); $to = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
$contractStartRaw = $employee->getCurrentContractStartDate(); $contractStartRaw = $employee->getCurrentContractStartDate();
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) { if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
$contractStart = DateTimeImmutable::createFromFormat('Y-m-d', $contractStartRaw); $contractStart = $this->parseYmdDate($contractStartRaw);
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) { if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
$from = $contractStart; $from = $contractStart;
} }
@@ -544,7 +556,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$contractEndRaw = $employee->getCurrentContractEndDate(); $contractEndRaw = $employee->getCurrentContractEndDate();
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) { if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
$contractEnd = DateTimeImmutable::createFromFormat('Y-m-d', $contractEndRaw); $contractEnd = $this->parseYmdDate($contractEndRaw);
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) { if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
$to = $contractEnd; $to = $contractEnd;
} }
@@ -582,7 +594,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$oldestStartDate = null; $oldestStartDate = null;
foreach ($history as $item) { foreach ($history as $item) {
$start = DateTimeImmutable::createFromFormat('Y-m-d', $item->startDate); $start = $this->parseYmdDate($item->startDate);
if (!$start) { if (!$start) {
continue; continue;
} }
@@ -611,6 +623,18 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return $firstYear; return $firstYear;
} }
private function parseYmdDate(string $value): ?DateTimeImmutable
{
$date = DateTimeImmutable::createFromFormat('!Y-m-d', trim($value));
return $date instanceof DateTimeImmutable ? $date : null;
}
private function normalizeDate(DateTimeImmutable $date): DateTimeImmutable
{
return $date->setTime(0, 0);
}
/** /**
* @param list<Absence> $absences * @param list<Absence> $absences
* *

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Leave;
use App\Service\Leave\LeaveBalanceComputationService;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
/**
* @internal
*/
final class LeaveBalanceComputationServiceTest extends TestCase
{
public function testComputeAccruedDaysProratesPartialFirstMonth(): void
{
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
$result = $method->invoke(
$service,
25.0,
25.0 / 12.0,
new DateTimeImmutable('2025-06-10'),
new DateTimeImmutable('2026-02-28')
);
self::assertEqualsWithDelta(18.125, $result, 0.0001);
}
public function testComputeAccruedDaysTotalMatchesAlainCase(): void
{
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
$days = $method->invoke(
$service,
25.0,
25.0 / 12.0,
new DateTimeImmutable('2025-06-10'),
new DateTimeImmutable('2026-02-28')
);
$saturdays = $method->invoke(
$service,
5.0,
5.0 / 12.0,
new DateTimeImmutable('2025-06-10'),
new DateTimeImmutable('2026-02-28')
);
self::assertEqualsWithDelta(21.75, $days + $saturdays, 0.0001);
}
public function testComputeAccruedDaysIncludesLastDayOfMonthDespiteTimeComponents(): void
{
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
$result = $method->invoke(
$service,
25.0,
25.0 / 12.0,
new DateTimeImmutable('2026-02-01 12:50:18'),
new DateTimeImmutable('2026-02-28 00:00:00')
);
self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use App\State\EmployeeLeaveSummaryProvider;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
/**
* @internal
*/
final class EmployeeLeaveSummaryProviderTest extends TestCase
{
public function testComputeAccruedDaysFromStartProratesPartialFirstMonth(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
$method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart');
$result = $method->invoke(
$provider,
25.0,
25.0 / 12.0,
new DateTimeImmutable('2025-06-10'),
new DateTimeImmutable('2026-02-28')
);
self::assertEqualsWithDelta(18.125, $result, 0.0001);
}
public function testComputeAccruingDaysTotalMatchesAlainCase(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
$method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart');
$days = $method->invoke(
$provider,
25.0,
25.0 / 12.0,
new DateTimeImmutable('2025-06-10'),
new DateTimeImmutable('2026-02-28')
);
$saturdays = $method->invoke(
$provider,
5.0,
5.0 / 12.0,
new DateTimeImmutable('2025-06-10'),
new DateTimeImmutable('2026-02-28')
);
self::assertEqualsWithDelta(21.75, $days + $saturdays, 0.0001);
}
public function testComputeAccruedDaysFromStartIncludesLastDayOfMonth(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
$method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart');
$result = $method->invoke(
$provider,
25.0,
25.0 / 12.0,
new DateTimeImmutable('2026-02-01 12:50:18'),
new DateTimeImmutable('2026-02-28 00:00:00')
);
self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
}
}