Retour RH: vue jour par date, RTT mi-semaine, récap salaire & exports, panier de nuit (#21)
Auto Tag Develop / tag (push) Successful in 11s
Auto Tag Develop / tag (push) Successful in 11s
## Correctifs RH (branche fix/retour-rh) ### Vue Jour (Heures) - Mode saisie/présence, libellé de contrat et sauvegarde résolus **à la date affichée** (et non au contrat courant). Corrige les salariés passés 39h/35h → Forfait. ### RTT — heures supplémentaires - Proratisation du **plafond 25%/50%** pour les embauches en milieu de semaine (la bande +25% se décale au lieu de rester bloquée à 43h). Témoin Dylan : 4h à 25% + 3h à 50%. ### Récap salaire (PDF mensuel) - Forfait : congés imputés **N-1** non affichés et comptés en présence. - Colonne « Heures payés » **scindée 25% / 50%** (en-tête fusionné). - **Exclusion des salariés sans contrat** sur le mois (ex. Marine, contrat terminé). ### Exports heures annuelles (par salarié + tous) - **Tous les jours sous contrat** affichés, même vides/non saisis (corrige les lignes manquantes). - Samedis/dimanches en **gris plus foncé**. ### Panier de nuit - **Ne s'applique pas aux conducteurs** (vue semaine + récap salaire). ## Tests - 11 tests ajoutés. Suite verte hors un test legacy pré-existant dépendant de la date (`EmployeeRttSummaryProviderTest::testNoQueryParamsKeepsLegacyYearDefaulting`, non modifié par cette branche). ## À noter (hors scope) - L'export heures annuelles *tous salariés* peut dépasser `memory_limit=256M` (Dompdf) — limitation **pré-existante**, non corrigée ici. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #21 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #21.
This commit is contained in:
@@ -19,6 +19,7 @@ use App\Repository\ObservationRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use Dompdf\Dompdf;
|
||||
@@ -42,6 +43,8 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
private ObservationRepository $observationRepository,
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
|
||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||
@@ -59,12 +62,25 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
$from = DateTimeImmutable::createFromFormat('Y-m-d', $month.'-01');
|
||||
$to = $from->modify('last day of this month');
|
||||
|
||||
$employees = $this->employeeRepository->findForPrintBySiteIds([]);
|
||||
// N'inclure que les employés ayant un contrat couvrant tout ou partie du mois.
|
||||
// Sans ce filtre, un salarié dont le contrat est terminé (ex. parti en février)
|
||||
// apparaît à tort sur le récap des mois suivants.
|
||||
$employees = array_values(array_filter(
|
||||
$this->employeeRepository->findForPrintBySiteIds([]),
|
||||
fn (Employee $employee): bool => $this->hasContractInRange($employee, $from, $to)
|
||||
));
|
||||
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees);
|
||||
$absences = $this->absenceRepository->findForPrint($from, $to, $employees);
|
||||
|
||||
$year = (int) $from->format('Y');
|
||||
$monthNumber = (int) $from->format('n');
|
||||
|
||||
// Congés depuis le début de l'exercice forfait (année civile) jusqu'à la fin du mois :
|
||||
// nécessaires pour consommer chronologiquement le budget N-1 d'un forfait (un congé
|
||||
// imputé N-1 ne doit ni s'afficher ni manquer en présence sur le récap).
|
||||
$yearStart = new DateTimeImmutable(sprintf('%d-01-01', $year));
|
||||
$ytdAbsences = $this->absenceRepository->findForPrint($yearStart, $to, $employees);
|
||||
$ytdAbsenceMap = $this->buildAbsenceMap($ytdAbsences);
|
||||
$rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber);
|
||||
|
||||
$bonuses = $this->bonusRepository->findByMonth($from, $to);
|
||||
@@ -83,7 +99,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
$mileageMap = $this->buildMileageMap($mileages);
|
||||
$observationMap = $this->buildObservationMap($observations);
|
||||
|
||||
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap, $holidayMap);
|
||||
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap, $holidayMap, $ytdAbsenceMap, $year, $from, $to);
|
||||
|
||||
$options = new Options();
|
||||
$options->set('isRemoteEnabled', true);
|
||||
@@ -110,6 +126,22 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
]);
|
||||
}
|
||||
|
||||
private function hasContractInRange(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): bool
|
||||
{
|
||||
$fromDay = $from->format('Y-m-d');
|
||||
$toDay = $to->format('Y-m-d');
|
||||
|
||||
foreach ($employee->getContractPeriods() as $period) {
|
||||
$start = $period->getStartDate()->format('Y-m-d');
|
||||
$end = $period->getEndDate()?->format('Y-m-d');
|
||||
if ($start <= $toDay && (null === $end || $end >= $fromDay)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -164,6 +196,9 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
/**
|
||||
* @return array<int, array{m25: int, m50: int}>
|
||||
*/
|
||||
private function buildRttPaymentMap(array $rttPayments): array
|
||||
{
|
||||
$map = [];
|
||||
@@ -172,7 +207,9 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
if (!$employeeId) {
|
||||
continue;
|
||||
}
|
||||
$map[$employeeId] = ($map[$employeeId] ?? 0) + $payment->getBase25Minutes() + $payment->getBase50Minutes();
|
||||
$map[$employeeId] ??= ['m25' => 0, 'm50' => 0];
|
||||
$map[$employeeId]['m25'] += $payment->getBase25Minutes();
|
||||
$map[$employeeId]['m50'] += $payment->getBase50Minutes();
|
||||
}
|
||||
|
||||
return $map;
|
||||
@@ -264,6 +301,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
array $mileageMap,
|
||||
array $observationMap,
|
||||
array $holidayMap,
|
||||
array $ytdAbsenceMap,
|
||||
int $year,
|
||||
DateTimeImmutable $monthFrom,
|
||||
DateTimeImmutable $monthTo,
|
||||
): array {
|
||||
$siteGroups = [];
|
||||
|
||||
@@ -281,11 +322,15 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
$driverMap[$employeeId] ?? [],
|
||||
$workHourMap[$employeeId] ?? [],
|
||||
$absenceMap[$employeeId] ?? [],
|
||||
$rttPaymentMap[$employeeId] ?? 0,
|
||||
$rttPaymentMap[$employeeId] ?? ['m25' => 0, 'm50' => 0],
|
||||
$bonusMap[$employeeId] ?? 0.0,
|
||||
$mileageMap[$employeeId] ?? 0.0,
|
||||
$observationMap[$employeeId] ?? '',
|
||||
$holidayMap,
|
||||
$ytdAbsenceMap[$employeeId] ?? [],
|
||||
$year,
|
||||
$monthFrom,
|
||||
$monthTo,
|
||||
);
|
||||
|
||||
if (!isset($siteGroups[$siteId])) {
|
||||
@@ -310,11 +355,15 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
array $driverByDate,
|
||||
array $workHoursByDate,
|
||||
array $absences,
|
||||
int $rttPaidMinutes,
|
||||
array $rttPaid,
|
||||
float $bonusAmount,
|
||||
float $mileageKm,
|
||||
string $observation,
|
||||
array $holidayMap,
|
||||
array $ytdAbsences,
|
||||
int $year,
|
||||
DateTimeImmutable $monthFrom,
|
||||
DateTimeImmutable $monthTo,
|
||||
): array {
|
||||
$contractName = null;
|
||||
$presenceDays = 0.0;
|
||||
@@ -356,9 +405,8 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
$dayMin = $wh->getDayHoursMinutes() ?? 0;
|
||||
$nightMin = $wh->getNightHoursMinutes() ?? 0;
|
||||
$workshopMin = $wh->getWorkshopHoursMinutes() ?? 0;
|
||||
if (($nightMin > $dayMin && $nightMin > 0) || $nightMin >= 240) {
|
||||
++$nightBasketCount;
|
||||
}
|
||||
// Le panier de nuit ne s'applique pas aux conducteurs (primes repas/nuitée
|
||||
// dédiées). Aucun panier de nuit crédité ici.
|
||||
|
||||
if ($wh->getHasBreakfast()) {
|
||||
++$driverBreakfast;
|
||||
@@ -415,11 +463,26 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
}
|
||||
}
|
||||
|
||||
$conges = $this->countAbsencesByCode($absences, ['C']);
|
||||
// Forfait : un congé imputé sur le stock N-1 ne doit pas s'afficher dans le récap
|
||||
// et doit compter comme jour de présence. On consomme le budget N-1 chronologiquement
|
||||
// sur tous les congés de l'exercice (année civile) jusqu'à la fin du mois imprimé.
|
||||
$n1Budget = $isForfait ? $this->leaveSummaryProvider->resolvePreviousYearTakenDays($employee, $year) : 0.0;
|
||||
if ($isForfait && $n1Budget > 0.0) {
|
||||
$ytdConges = array_values(array_filter(
|
||||
$ytdAbsences,
|
||||
static fn (Absence $a): bool => 'C' === $a->getType()?->getCode()
|
||||
));
|
||||
$split = $this->splitForfaitCongesByN1($ytdConges, $n1Budget, $monthFrom, $monthTo);
|
||||
$conges = ['count' => $split['count'], 'dates' => $split['dates']];
|
||||
$presenceDays += $split['n1PresenceDays'];
|
||||
} else {
|
||||
$conges = $this->countAbsencesByCode($absences, ['C']);
|
||||
}
|
||||
$maladie = $this->countAbsencesByCode($absences, ['M', 'AT']);
|
||||
|
||||
$nightHours = round($nightMinutesTotal / 60, 2);
|
||||
$paidHours = round($rttPaidMinutes / 60, 2);
|
||||
$paid25Hours = round(($rttPaid['m25'] ?? 0) / 60, 2);
|
||||
$paid50Hours = round(($rttPaid['m50'] ?? 0) / 60, 2);
|
||||
$sundayHours = round($sundayMinutesTotal / 60, 2);
|
||||
$holidayHours = round($holidayMinutesTotal / 60, 2);
|
||||
|
||||
@@ -431,7 +494,8 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
'mileageKm' => $mileageKm,
|
||||
'nightHours' => $nightHours,
|
||||
'nightBasketCount' => $nightBasketCount,
|
||||
'paidHours' => $paidHours,
|
||||
'paid25Hours' => $paid25Hours,
|
||||
'paid50Hours' => $paid50Hours,
|
||||
'sundayHours' => $sundayHours,
|
||||
'holidayHours' => $holidayHours,
|
||||
'bonusAmount' => $bonusAmount,
|
||||
@@ -574,6 +638,73 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Répartit les congés ('C') d'un forfait entre N-1 (budget consommé chronologiquement,
|
||||
* non affiché et compté en présence) et N (affiché en congé). Seuls les jours tombant
|
||||
* dans le mois imprimé alimentent le retour ; les congés des mois antérieurs ne servent
|
||||
* qu'à consommer le budget N-1.
|
||||
*
|
||||
* @param list<Absence> $ytdConges congés depuis le début d'exercice jusqu'à la fin du mois
|
||||
*
|
||||
* @return array{count: float, dates: string, n1PresenceDays: float}
|
||||
*/
|
||||
private function splitForfaitCongesByN1(
|
||||
array $ytdConges,
|
||||
float $n1Budget,
|
||||
DateTimeImmutable $monthFrom,
|
||||
DateTimeImmutable $monthTo
|
||||
): array {
|
||||
usort($ytdConges, static fn (Absence $a, Absence $b): int => $a->getStartDate() <=> $b->getStartDate());
|
||||
|
||||
$remaining = $n1Budget;
|
||||
$count = 0.0;
|
||||
$n1PresenceDays = 0.0;
|
||||
$dayKeys = [];
|
||||
|
||||
foreach ($ytdConges as $absence) {
|
||||
$start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0);
|
||||
$end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0);
|
||||
|
||||
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
|
||||
if ((int) $day->format('N') >= 6) {
|
||||
continue; // week-ends ignorés
|
||||
}
|
||||
[$am, $pm] = $this->absenceSegmentsResolver->resolveForDate($absence, $day->format('Y-m-d'));
|
||||
$amount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
|
||||
if ($amount <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$covered = 0.0;
|
||||
if ($remaining > 0.0) {
|
||||
$covered = min($remaining, $amount);
|
||||
$remaining -= $covered;
|
||||
}
|
||||
$displayed = $amount - $covered;
|
||||
|
||||
// Seul le mois imprimé alimente le récap ; les mois antérieurs ne font que consommer.
|
||||
if ($day < $monthFrom || $day > $monthTo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$n1PresenceDays += $covered;
|
||||
if ($displayed > 0.0) {
|
||||
$count += $displayed;
|
||||
$dayKeys[] = $day->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort($dayKeys);
|
||||
$dayKeys = array_unique($dayKeys);
|
||||
|
||||
return [
|
||||
'count' => $count,
|
||||
'dates' => implode(', ', $this->mergeDaysIntoPeriods($dayKeys)),
|
||||
'n1PresenceDays' => $n1PresenceDays,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Absence> $absences
|
||||
* @param list<string> $codes
|
||||
|
||||
Reference in New Issue
Block a user