feat : Ajout du système de RTT sur la page employé avec le repport annuel des heures
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
This commit is contained in:
@@ -13,6 +13,7 @@ use App\Entity\User;
|
||||
use App\Enum\HalfDay;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use DateInterval;
|
||||
use DatePeriod;
|
||||
use DateTime;
|
||||
@@ -22,6 +23,7 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Throwable;
|
||||
|
||||
final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
{
|
||||
@@ -30,6 +32,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||
private WorkHourReadRepositoryInterface $workHourRepository,
|
||||
private Security $security,
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
@@ -132,10 +135,15 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
throw new UnprocessableEntityHttpException('La demi-journée de fin ne peut pas être avant la demi-journée de début.');
|
||||
}
|
||||
|
||||
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
|
||||
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
|
||||
$publicHolidays = $this->buildPublicHolidayMap($start, $end);
|
||||
|
||||
$segments = [];
|
||||
foreach ($days as $day) {
|
||||
if (isset($publicHolidays[$day->format('Y-m-d')])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isFirst = $day->format('Y-m-d') === $start->format('Y-m-d');
|
||||
$isLast = $day->format('Y-m-d') === $end->format('Y-m-d');
|
||||
$isSame = $isFirst && $isLast;
|
||||
@@ -246,4 +254,27 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
->setIsValid(false)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
{
|
||||
$map = [];
|
||||
$startYear = (int) $from->format('Y');
|
||||
$endYear = (int) $to->format('Y');
|
||||
|
||||
try {
|
||||
for ($year = $startYear; $year <= $endYear; ++$year) {
|
||||
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
|
||||
foreach ($holidays as $date => $label) {
|
||||
$map[(string) $date] = (string) $label;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
$summary->remainingSaturdays = $yearSummary['remainingSaturdays'];
|
||||
|
||||
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
|
||||
$summary->presenceDaysByMonth = $this->workHourRepository->countPresenceDaysByMonth($employee, $periodFrom, $periodTo);
|
||||
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
@@ -178,8 +178,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
|
||||
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee);
|
||||
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee);
|
||||
$suspensions = $this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to);
|
||||
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
|
||||
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
|
||||
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
|
||||
);
|
||||
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
|
||||
? $this->computeAccruedDaysFromStart(
|
||||
$leavePolicy['acquiredDays'],
|
||||
$leavePolicy['accrualPerMonth'],
|
||||
@@ -235,16 +237,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
$previousRemainingSaturdays = $remainingAcquiredSaturdays + $remainingGeneratedSaturdays;
|
||||
} else {
|
||||
// Forfait: no "en cours d'acquisition" counter, all rights are in acquired.
|
||||
$acquiredDays = $carryDays + $leavePolicy['acquiredDays'];
|
||||
$suspensions = $this->resolveSuspensionsForPeriod($employee, $from, $to);
|
||||
if ([] !== $suspensions) {
|
||||
$totalMonths = $this->countFractionalMonths($from, $to);
|
||||
$suspendedMonths = $this->countSuspendedFractionalMonths($from, $to, $suspensions);
|
||||
if ($totalMonths > 0) {
|
||||
$ratio = max(0.0, ($totalMonths - $suspendedMonths) / $totalMonths);
|
||||
$acquiredDays = $carryDays + $leavePolicy['acquiredDays'] * $ratio;
|
||||
}
|
||||
}
|
||||
// Suspensions do not impact forfait 218 leave calculation.
|
||||
$acquiredDays = $carryDays + $leavePolicy['acquiredDays'];
|
||||
$accruingDays = 0.0;
|
||||
$remainingDays = max(0.0, $acquiredDays - $takenDays);
|
||||
$acquiredSaturdays = 0.0;
|
||||
@@ -539,6 +533,66 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presence days = business days (Mon-Fri) - public holidays + weekend worked days - absence days.
|
||||
*
|
||||
* @return array<string, float> YYYY-MM => presence day count
|
||||
*/
|
||||
private function computePresenceDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
{
|
||||
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
|
||||
$weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to);
|
||||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
||||
|
||||
// Count absence days per month (0.5 for half-days).
|
||||
$absenceDaysByMonth = [];
|
||||
foreach ($absences as $absence) {
|
||||
$date = DateTimeImmutable::createFromInterface($absence->getStartDate());
|
||||
$monthKey = $date->format('Y-m');
|
||||
$days = 1.0;
|
||||
if ($absence->getStartHalf() === $absence->getEndHalf()) {
|
||||
$days = 0.5;
|
||||
}
|
||||
$absenceDaysByMonth[$monthKey] = ($absenceDaysByMonth[$monthKey] ?? 0.0) + $days;
|
||||
}
|
||||
|
||||
// Count business days and public holidays per month.
|
||||
$result = [];
|
||||
$cursor = $from->modify('first day of this month')->setTime(0, 0);
|
||||
while ($cursor <= $to) {
|
||||
$monthKey = $cursor->format('Y-m');
|
||||
$monthStart = $cursor < $from ? $from : $cursor;
|
||||
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
|
||||
if ($monthEnd > $to) {
|
||||
$monthEnd = $to;
|
||||
}
|
||||
|
||||
$businessDays = 0;
|
||||
for (
|
||||
$day = $monthStart;
|
||||
$day <= $monthEnd;
|
||||
$day = $day->modify('+1 day')
|
||||
) {
|
||||
$weekDay = (int) $day->format('N');
|
||||
if ($weekDay <= 5 && !isset($publicHolidays[$day->format('Y-m-d')])) {
|
||||
++$businessDays;
|
||||
}
|
||||
}
|
||||
|
||||
$weekend = $weekendWorkedDays[$monthKey] ?? 0.0;
|
||||
$absenced = $absenceDaysByMonth[$monthKey] ?? 0.0;
|
||||
|
||||
$presence = max(0.0, (float) $businessDays + $weekend - $absenced);
|
||||
if ($presence > 0.0) {
|
||||
$result[$monthKey] = $presence;
|
||||
}
|
||||
|
||||
$cursor = $cursor->modify('first day of next month');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||||
*/
|
||||
@@ -731,55 +785,6 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
return [$takenDays, $takenSaturdays];
|
||||
}
|
||||
|
||||
private function countFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to): float
|
||||
{
|
||||
$from = $this->normalizeDate($from);
|
||||
$to = $this->normalizeDate($to);
|
||||
$months = 0.0;
|
||||
$cursor = $from->modify('first day of this month')->setTime(0, 0);
|
||||
|
||||
while ($cursor <= $to) {
|
||||
$monthStart = $cursor > $from ? $cursor : $from;
|
||||
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
|
||||
if ($monthEnd > $to) {
|
||||
$monthEnd = $to;
|
||||
}
|
||||
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
|
||||
$daysInMonth = (int) $cursor->format('t');
|
||||
$months += $coveredDays / $daysInMonth;
|
||||
|
||||
$cursor = $cursor->modify('first day of next month');
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ContractSuspension> $suspensions
|
||||
*/
|
||||
private function countSuspendedFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to, array $suspensions): float
|
||||
{
|
||||
$from = $this->normalizeDate($from);
|
||||
$to = $this->normalizeDate($to);
|
||||
$months = 0.0;
|
||||
$cursor = $from->modify('first day of this month')->setTime(0, 0);
|
||||
|
||||
while ($cursor <= $to) {
|
||||
$monthStart = $cursor > $from ? $cursor : $from;
|
||||
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
|
||||
if ($monthEnd > $to) {
|
||||
$monthEnd = $to;
|
||||
}
|
||||
$daysInMonth = (int) $cursor->format('t');
|
||||
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
|
||||
$months += $suspendedDays / $daysInMonth;
|
||||
|
||||
$cursor = $cursor->modify('first day of next month');
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ContractSuspension>
|
||||
*/
|
||||
|
||||
@@ -16,8 +16,6 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
use function in_array;
|
||||
|
||||
final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
@@ -42,32 +40,27 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
||||
throw new NotFoundHttpException('Employee not found.');
|
||||
}
|
||||
|
||||
if (!in_array($data->rate, ['25', '50'], true)) {
|
||||
throw new UnprocessableEntityHttpException('rate must be "25" or "50".');
|
||||
}
|
||||
|
||||
if ($data->month < 1 || $data->month > 12) {
|
||||
throw new UnprocessableEntityHttpException('month must be between 1 and 12.');
|
||||
}
|
||||
|
||||
if ($data->minutes < 0) {
|
||||
throw new UnprocessableEntityHttpException('minutes must be >= 0.');
|
||||
}
|
||||
|
||||
$year = $data->year ?? $this->resolveCurrentExerciseYear();
|
||||
|
||||
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonthRate($employee, $year, $data->month, $data->rate);
|
||||
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
|
||||
|
||||
if (null === $payment) {
|
||||
$payment = new EmployeeRttPayment();
|
||||
$payment->setEmployee($employee);
|
||||
$payment->setYear($year);
|
||||
$payment->setMonth($data->month);
|
||||
$payment->setRate($data->rate);
|
||||
$this->entityManager->persist($payment);
|
||||
}
|
||||
|
||||
$payment->setMinutes($data->minutes);
|
||||
$payment->setBase25Minutes($data->base25Minutes);
|
||||
$payment->setBonus25Minutes($data->bonus25Minutes);
|
||||
$payment->setBase50Minutes($data->base50Minutes);
|
||||
$payment->setBonus50Minutes($data->bonus50Minutes);
|
||||
$payment->touch();
|
||||
$this->entityManager->flush();
|
||||
|
||||
$data->year = $year;
|
||||
|
||||
@@ -9,6 +9,7 @@ use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\EmployeeRttSummary;
|
||||
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
||||
use App\Dto\Rtt\RttMonthPayment;
|
||||
use App\Dto\Rtt\WeekRecoveryDetail;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use App\Repository\EmployeeRepository;
|
||||
@@ -76,22 +77,36 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
||||
$limitDate = $periodFrom->modify('-1 day');
|
||||
}
|
||||
|
||||
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
|
||||
$carryMinutes = $this->resolveCarryMinutes($employee, $year);
|
||||
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
|
||||
[$carry, $carryMonth] = $this->resolveCarry($employee, $year);
|
||||
|
||||
$summary = new EmployeeRttSummary();
|
||||
$summary->year = $year;
|
||||
$summary->carryFromPreviousYearMinutes = $carryMinutes;
|
||||
$summary->currentYearRecoveryMinutes = array_sum($currentByWeekStart);
|
||||
$summary->carryMonth = $carryMonth;
|
||||
$summary->carryFromPreviousYearMinutes = $carry->totalMinutes;
|
||||
$summary->carryBase25Minutes = $carry->base25Minutes;
|
||||
$summary->carryBonus25Minutes = $carry->bonus25Minutes;
|
||||
$summary->carryBase50Minutes = $carry->base50Minutes;
|
||||
$summary->carryBonus50Minutes = $carry->bonus50Minutes;
|
||||
$summary->currentYearRecoveryMinutes = array_sum(array_map(static fn ($d) => $d->totalMinutes, $currentByWeekStart));
|
||||
$summary->availableMinutes = $summary->carryFromPreviousYearMinutes + $summary->currentYearRecoveryMinutes;
|
||||
$summary->weeks = array_map(
|
||||
static fn (array $week) => new EmployeeRttWeekSummary(
|
||||
month: (int) $week['month'],
|
||||
weekNumber: (int) $week['weekNumber'],
|
||||
weekStart: $week['start']->format('Y-m-d'),
|
||||
weekEnd: $week['end']->format('Y-m-d'),
|
||||
recoveryMinutes: (int) ($currentByWeekStart[$week['start']->format('Y-m-d')] ?? 0),
|
||||
),
|
||||
static function (array $week) use ($currentByWeekStart) {
|
||||
$detail = $currentByWeekStart[$week['start']->format('Y-m-d')] ?? new WeekRecoveryDetail();
|
||||
|
||||
return new EmployeeRttWeekSummary(
|
||||
month: (int) $week['month'],
|
||||
weekNumber: (int) $week['weekNumber'],
|
||||
weekStart: $week['start']->format('Y-m-d'),
|
||||
weekEnd: $week['end']->format('Y-m-d'),
|
||||
overtimeMinutes: $detail->overtimeMinutes,
|
||||
base25Minutes: $detail->base25Minutes,
|
||||
bonus25Minutes: $detail->bonus25Minutes,
|
||||
base50Minutes: $detail->base50Minutes,
|
||||
bonus50Minutes: $detail->bonus50Minutes,
|
||||
totalMinutes: $detail->totalMinutes,
|
||||
);
|
||||
},
|
||||
$weekRanges
|
||||
);
|
||||
|
||||
@@ -101,21 +116,20 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
||||
foreach ($payments as $payment) {
|
||||
$m = $payment->getMonth();
|
||||
if (!isset($monthBuckets[$m])) {
|
||||
$monthBuckets[$m] = ['paidMinutes25' => 0, 'paidMinutes50' => 0];
|
||||
}
|
||||
if ('25' === $payment->getRate()) {
|
||||
$monthBuckets[$m]['paidMinutes25'] += $payment->getMinutes();
|
||||
} else {
|
||||
$monthBuckets[$m]['paidMinutes50'] += $payment->getMinutes();
|
||||
$monthBuckets[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0];
|
||||
}
|
||||
$monthBuckets[$m]['base25'] += $payment->getBase25Minutes();
|
||||
$monthBuckets[$m]['bonus25'] += $payment->getBonus25Minutes();
|
||||
$monthBuckets[$m]['base50'] += $payment->getBase50Minutes();
|
||||
$monthBuckets[$m]['bonus50'] += $payment->getBonus50Minutes();
|
||||
}
|
||||
|
||||
$monthPayments = [];
|
||||
$totalPaidMinutes = 0;
|
||||
|
||||
foreach ($monthBuckets as $m => $bucket) {
|
||||
$monthPayments[] = new RttMonthPayment($m, $bucket['paidMinutes25'], $bucket['paidMinutes50']);
|
||||
$totalPaidMinutes += $bucket['paidMinutes25'] + $bucket['paidMinutes50'];
|
||||
$monthPayments[] = new RttMonthPayment($m, $bucket['base25'], $bucket['bonus25'], $bucket['base50'], $bucket['bonus50']);
|
||||
$totalPaidMinutes += $bucket['base25'] + $bucket['bonus25'] + $bucket['base50'] + $bucket['bonus50'];
|
||||
}
|
||||
|
||||
$summary->totalPaidMinutes = $totalPaidMinutes;
|
||||
@@ -125,14 +139,29 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function resolveCarryMinutes(Employee $employee, int $year): int
|
||||
/**
|
||||
* @return array{WeekRecoveryDetail, int} [carry, month]
|
||||
*/
|
||||
private function resolveCarry(Employee $employee, int $year): array
|
||||
{
|
||||
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year);
|
||||
if (null !== $balance) {
|
||||
return $balance->getOpeningMinutes();
|
||||
return [
|
||||
new WeekRecoveryDetail(
|
||||
base25Minutes: $balance->getOpeningBase25Minutes(),
|
||||
bonus25Minutes: $balance->getOpeningBonus25Minutes(),
|
||||
base50Minutes: $balance->getOpeningBase50Minutes(),
|
||||
bonus50Minutes: $balance->getOpeningBonus50Minutes(),
|
||||
totalMinutes: $balance->getTotalOpeningMinutes(),
|
||||
),
|
||||
$balance->getMonth(),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1);
|
||||
return [
|
||||
$this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1),
|
||||
5,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveYear(): int
|
||||
|
||||
Reference in New Issue
Block a user