Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49a1c07ed1 | ||
| 9fe2397386 | |||
|
|
bf3f7b35a5 | ||
| 5c251800fa | |||
| e34e928264 | |||
|
|
f7dc9b6988 | ||
| b0de877b27 | |||
| 59f05717bf | |||
|
|
f96fd64767 | ||
| 523d4f296b | |||
|
|
3994be6556 | ||
| f46eeaa893 | |||
|
|
eb703272c7 | ||
| 6629eb98cb |
@@ -7,7 +7,18 @@
|
|||||||
"Bash(docker compose:*)",
|
"Bash(docker compose:*)",
|
||||||
"Bash(make test:*)",
|
"Bash(make test:*)",
|
||||||
"Bash(grep:*)",
|
"Bash(grep:*)",
|
||||||
"Bash(docker exec:*)"
|
"Bash(docker exec:*)",
|
||||||
|
"Bash(php8.3 bin/phpunit tests/State/EmployeeWriteProcessorTest.php --filter=testSetsEntryDateOnNewEmployee 2>&1)",
|
||||||
|
"Read(//usr/bin/**)",
|
||||||
|
"Read(//usr/local/bin/**)",
|
||||||
|
"Bash(command -v php8.2)",
|
||||||
|
"Bash(command -v php8.1)",
|
||||||
|
"Bash(ls /usr/bin/php*)",
|
||||||
|
"Read(//opt/**)",
|
||||||
|
"Read(//home/m-tristan/.nix-profile/**)",
|
||||||
|
"Read(//home/m-tristan/.local/bin/**)",
|
||||||
|
"Bash(env)",
|
||||||
|
"Bash(ls /home/m-tristan/workspace/SIRH/docker* /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null; cat /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null | grep -E \"\\(phpunit|test|php\\)\" | head -20)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.22'
|
app.version: '0.1.28'
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -198,13 +200,16 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
|||||||
- lecture des compteurs:
|
- lecture des compteurs:
|
||||||
- `acquis` = droits reportés de l'exercice N-1 (après application des règles de soldé)
|
- `acquis` = droits reportés de l'exercice N-1 (après application des règles de soldé)
|
||||||
- `en cours d'acquisition` = total droits générés sur l'exercice N (jours + samedis en cours), sans detail séparé en UI
|
- `en cours d'acquisition` = total droits générés sur l'exercice N (jours + samedis en cours), sans detail séparé en UI
|
||||||
|
- `en cours d'acquisition` est arrêté au dernier jour du mois précédent
|
||||||
- règle de consommation:
|
- règle de consommation:
|
||||||
- les absences s'imputent d'abord sur `acquis`, puis sur `en cours d'acquisition`
|
- les absences s'imputent d'abord sur `acquis`, puis sur `en cours d'acquisition`
|
||||||
- la prise sur `en cours d'acquisition` est autorisée (usage anticipé)
|
- la prise sur `en cours d'acquisition` est autorisée (usage anticipé)
|
||||||
- `en cours d'acquisition` peut devenir négatif si la prise dépasse le généré (ex: `2.08 - 3 = -0.92`), puis se reconstitue avec les acquisitions suivantes
|
- `en cours d'acquisition` peut devenir négatif si la prise dépasse le généré (ex: `2.08 - 3 = -0.92`), puis se reconstitue avec les acquisitions suivantes
|
||||||
- date d'arret de calcul:
|
- date d'arret de calcul:
|
||||||
- les compteurs sont calculés jusqu'au dernier jour du mois précédent (le mois en cours est exclu)
|
- `reste à prendre` est calculé en prévisionnel jusqu'à la fin de l'exercice
|
||||||
- exemple: au `04/03/2026`, l'arret de calcul est le `28/02/2026` (ou `29/02` en année bissextile)
|
- les absences futures déjà posées sur l'exercice sont déduites du `reste à prendre`
|
||||||
|
- `en cours d'acquisition` reste calculé jusqu'au dernier jour du mois précédent
|
||||||
|
- exemple: au `11/03/2026`, l'exercice `2026` déduit les absences posées jusqu'au `31/05/2026`, mais l'acquisition reste arrêtée au `28/02/2026`
|
||||||
- hors périmètre phase 1: `INTERIM` (retour non supporté)
|
- hors périmètre phase 1: `INTERIM` (retour non supporté)
|
||||||
- onglet `RTT`:
|
- onglet `RTT`:
|
||||||
- endpoint de synthèse: `GET /api/employees/{id}/rtt-summary?year=YYYY`
|
- endpoint de synthèse: `GET /api/employees/{id}/rtt-summary?year=YYYY`
|
||||||
|
|||||||
@@ -12,9 +12,12 @@
|
|||||||
|
|
||||||
<div v-else class="flex min-h-0 flex-1 flex-col">
|
<div v-else class="flex min-h-0 flex-1 flex-col">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-4xl font-bold text-primary-500">{{ employee.firstName }} {{ employee.lastName }}</h1>
|
<div>
|
||||||
|
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
|
||||||
|
<p>Date d'entrée : {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
|
||||||
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="font-bold text-[20px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p>
|
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p>
|
||||||
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
<p><strong>Type:</strong> {{ contractNatureLabel(employee.currentContractNature) }}</p>
|
<p><strong>Type:</strong> {{ contractNatureLabel(employee.currentContractNature) }}</p>
|
||||||
<p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p>
|
<p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p>
|
||||||
<p><strong>Site:</strong> {{ employee.site?.name ?? '-' }}</p>
|
<p><strong>Site:</strong> {{ employee.site?.name ?? '-' }}</p>
|
||||||
|
<p><strong>Date d'entrée :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ export type Employee = {
|
|||||||
currentContractEndDate?: string | null
|
currentContractEndDate?: string | null
|
||||||
contractHistory?: ContractHistoryItem[]
|
contractHistory?: ContractHistoryItem[]
|
||||||
displayOrder?: number
|
displayOrder?: number
|
||||||
|
entryDate?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
26
migrations/Version20260312120000.php
Normal file
26
migrations/Version20260312120000.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260312120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add entry_date column to employees table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employees ADD entry_date DATE DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employees DROP entry_date');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@ use DateTimeImmutable;
|
|||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Context;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
normalizationContext: ['groups' => ['employee:read', 'site:read']],
|
normalizationContext: ['groups' => ['employee:read', 'site:read']],
|
||||||
@@ -57,6 +59,11 @@ class Employee
|
|||||||
#[Groups(['employee:read', 'employee:write'])]
|
#[Groups(['employee:read', 'employee:write'])]
|
||||||
private int $displayOrder = 0;
|
private int $displayOrder = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||||
|
private ?DateTimeImmutable $entryDate = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
private DateTimeImmutable $createdAt;
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
@@ -166,6 +173,18 @@ class Employee
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getEntryDate(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->entryDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEntryDate(?DateTimeImmutable $entryDate): self
|
||||||
|
{
|
||||||
|
$this->entryDate = $entryDate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getContractNature(): ?string
|
public function getContractNature(): ?string
|
||||||
{
|
{
|
||||||
return $this->contractNature;
|
return $this->contractNature;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -349,14 +375,25 @@ final readonly class LeaveBalanceComputationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
for ($cursor = $rangeStart; $cursor <= $rangeEnd; $cursor = $cursor->modify('+1 day')) {
|
for ($cursor = $rangeStart; $cursor <= $rangeEnd; $cursor = $cursor->modify('+1 day')) {
|
||||||
|
$dayOfWeek = (int) $cursor->format('N');
|
||||||
|
|
||||||
|
if ($splitSaturdays) {
|
||||||
|
if (7 === $dayOfWeek) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ($dayOfWeek >= 6) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[$am, $pm] = $this->resolveSegmentsForDate($absence, $cursor->format('Y-m-d'));
|
[$am, $pm] = $this->resolveSegmentsForDate($absence, $cursor->format('Y-m-d'));
|
||||||
$dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
|
$dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
|
||||||
if ($dayAmount <= 0.0) {
|
if ($dayAmount <= 0.0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$isSaturday = $splitSaturdays && '6' === $cursor->format('N');
|
if ($splitSaturdays && 6 === $dayOfWeek) {
|
||||||
if ($isSaturday) {
|
|
||||||
$takenSaturdays += $dayAmount;
|
$takenSaturdays += $dayAmount;
|
||||||
} else {
|
} else {
|
||||||
$takenDays += $dayAmount;
|
$takenDays += $dayAmount;
|
||||||
|
|||||||
@@ -163,18 +163,19 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
$effectiveFrom = $this->resolveEffectivePeriodStart($employee, $from, $to);
|
$effectiveFrom = $this->resolveEffectivePeriodStart($employee, $from, $to);
|
||||||
$hasShiftedStart = $effectiveFrom > $from;
|
$hasShiftedStart = $effectiveFrom > $from;
|
||||||
if ($hasShiftedStart) {
|
if ($hasShiftedStart && null === $openingBalance) {
|
||||||
$carryDays = 0.0;
|
$carryDays = 0.0;
|
||||||
$carrySaturdays = 0.0;
|
$carrySaturdays = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$calculationEnd = $this->resolveCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee);
|
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee);
|
||||||
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
|
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee);
|
||||||
|
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
|
||||||
? $this->computeAccruedDaysFromStart(
|
? $this->computeAccruedDaysFromStart(
|
||||||
$leavePolicy['acquiredDays'],
|
$leavePolicy['acquiredDays'],
|
||||||
$leavePolicy['accrualPerMonth'],
|
$leavePolicy['accrualPerMonth'],
|
||||||
$effectiveFrom,
|
$effectiveFrom,
|
||||||
$calculationEnd
|
$accrualCalculationEnd
|
||||||
)
|
)
|
||||||
: 0.0;
|
: 0.0;
|
||||||
$generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
|
$generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
|
||||||
@@ -182,14 +183,14 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$leavePolicy['acquiredSaturdays'],
|
$leavePolicy['acquiredSaturdays'],
|
||||||
$leavePolicy['saturdayAccrualPerMonth'],
|
$leavePolicy['saturdayAccrualPerMonth'],
|
||||||
$effectiveFrom,
|
$effectiveFrom,
|
||||||
$calculationEnd
|
$accrualCalculationEnd
|
||||||
)
|
)
|
||||||
: 0.0;
|
: 0.0;
|
||||||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
||||||
[$takenDays, $takenSaturdays] = $this->computeTakenAbsences(
|
[$takenDays, $takenSaturdays] = $this->computeTakenAbsences(
|
||||||
$absences,
|
$absences,
|
||||||
$effectiveFrom,
|
$effectiveFrom,
|
||||||
$calculationEnd,
|
$takenCalculationEnd,
|
||||||
$leavePolicy['countOnlyCp'],
|
$leavePolicy['countOnlyCp'],
|
||||||
$leavePolicy['splitSaturdays']
|
$leavePolicy['splitSaturdays']
|
||||||
);
|
);
|
||||||
@@ -279,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) {
|
||||||
@@ -343,17 +344,28 @@ 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 resolveCalculationEndDate(
|
private function resolveAccrualCalculationEndDate(
|
||||||
string $ruleCode,
|
string $ruleCode,
|
||||||
int $year,
|
int $year,
|
||||||
DateTimeImmutable $periodEnd,
|
DateTimeImmutable $periodEnd,
|
||||||
@@ -372,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;
|
||||||
}
|
}
|
||||||
@@ -379,7 +392,25 @@ 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) {
|
||||||
|
$end = $contractEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $end;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTakenCalculationEndDate(
|
||||||
|
DateTimeImmutable $periodEnd,
|
||||||
|
Employee $employee
|
||||||
|
): ?DateTimeImmutable {
|
||||||
|
$end = $periodEnd;
|
||||||
|
|
||||||
|
// Cap at contract end date if the employee has left.
|
||||||
|
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||||
|
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||||
|
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
||||||
$end = $contractEnd;
|
$end = $contractEnd;
|
||||||
}
|
}
|
||||||
@@ -501,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];
|
||||||
}
|
}
|
||||||
@@ -512,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;
|
||||||
}
|
}
|
||||||
@@ -525,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;
|
||||||
}
|
}
|
||||||
@@ -563,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;
|
||||||
}
|
}
|
||||||
@@ -592,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
|
||||||
*
|
*
|
||||||
@@ -632,14 +675,27 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
for ($cursor = $rangeStart; $cursor <= $rangeEnd; $cursor = $cursor->modify('+1 day')) {
|
for ($cursor = $rangeStart; $cursor <= $rangeEnd; $cursor = $cursor->modify('+1 day')) {
|
||||||
|
$dayOfWeek = (int) $cursor->format('N');
|
||||||
|
|
||||||
|
if ($splitSaturdays) {
|
||||||
|
// Mode CDI/CDD : dimanche ignoré, samedi compté séparément.
|
||||||
|
if (7 === $dayOfWeek) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Mode forfait : seuls les jours ouvrés (lun-ven) comptent.
|
||||||
|
if ($dayOfWeek >= 6) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[$am, $pm] = $this->resolveSegmentsForDate($absence, $cursor->format('Y-m-d'));
|
[$am, $pm] = $this->resolveSegmentsForDate($absence, $cursor->format('Y-m-d'));
|
||||||
$dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
|
$dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
|
||||||
if ($dayAmount <= 0.0) {
|
if ($dayAmount <= 0.0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$isSaturday = $splitSaturdays && '6' === $cursor->format('N');
|
if ($splitSaturdays && 6 === $dayOfWeek) {
|
||||||
if ($isSaturday && $splitSaturdays) {
|
|
||||||
$takenSaturdays += $dayAmount;
|
$takenSaturdays += $dayAmount;
|
||||||
} else {
|
} else {
|
||||||
$takenDays += $dayAmount;
|
$takenDays += $dayAmount;
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
|||||||
nature: $nature
|
nature: $nature
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$data->setEntryDate($startDate);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
71
tests/Service/Leave/LeaveBalanceComputationServiceTest.php
Normal file
71
tests/Service/Leave/LeaveBalanceComputationServiceTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
tests/State/EmployeeLeaveSummaryProviderTest.php
Normal file
71
tests/State/EmployeeLeaveSummaryProviderTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ namespace App\Tests\State;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\Delete;
|
use ApiPlatform\Metadata\Delete;
|
||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\Entity\Contract;
|
use App\Entity\Contract;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
@@ -194,6 +195,54 @@ final class EmployeeWriteProcessorTest extends TestCase
|
|||||||
self::assertSame($employee, $result);
|
self::assertSame($employee, $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testSetsEntryDateOnNewEmployee(): void
|
||||||
|
{
|
||||||
|
$employee = new Employee();
|
||||||
|
$employee->setFirstName('Jane');
|
||||||
|
$employee->setLastName('Doe');
|
||||||
|
$employee->setContractStartDate('2026-04-01');
|
||||||
|
$employee->setContractNature('CDI');
|
||||||
|
|
||||||
|
$contract = new Contract()
|
||||||
|
->setName('35h')
|
||||||
|
->setTrackingMode(Contract::TRACKING_TIME)
|
||||||
|
->setWeeklyHours(35)
|
||||||
|
;
|
||||||
|
$employee->setContract($contract);
|
||||||
|
|
||||||
|
$persistProcessor = $this->createMock(ProcessorInterface::class);
|
||||||
|
$removeProcessor = $this->createStub(ProcessorInterface::class);
|
||||||
|
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||||
|
$periodRepository = $this->createStub(EmployeeContractPeriodReadRepositoryInterface::class);
|
||||||
|
$changeRequestFactory = new EmployeeContractChangeRequestFactory();
|
||||||
|
$periodManager = $this->createMock(EmployeeContractPeriodManagerInterface::class);
|
||||||
|
|
||||||
|
$persistProcessor
|
||||||
|
->expects(self::once())
|
||||||
|
->method('process')
|
||||||
|
->willReturn($employee)
|
||||||
|
;
|
||||||
|
|
||||||
|
$periodManager
|
||||||
|
->expects(self::once())
|
||||||
|
->method('ensureContractPeriodExists')
|
||||||
|
;
|
||||||
|
|
||||||
|
$processor = new EmployeeWriteProcessor(
|
||||||
|
$persistProcessor,
|
||||||
|
$removeProcessor,
|
||||||
|
$entityManager,
|
||||||
|
$periodRepository,
|
||||||
|
$changeRequestFactory,
|
||||||
|
$periodManager
|
||||||
|
);
|
||||||
|
|
||||||
|
$processor->process($employee, new Post());
|
||||||
|
|
||||||
|
self::assertNotNull($employee->getEntryDate());
|
||||||
|
self::assertSame('2026-04-01', $employee->getEntryDate()->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
public function testDeleteOperationDelegatesToRemoveProcessor(): void
|
public function testDeleteOperationDelegatesToRemoveProcessor(): void
|
||||||
{
|
{
|
||||||
$employee = $this->buildEmployeeWithId(45);
|
$employee = $this->buildEmployeeWithId(45);
|
||||||
|
|||||||
Reference in New Issue
Block a user