806 lines
35 KiB
PHP
806 lines
35 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Command;
|
|
|
|
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
|
use App\Dto\Rtt\WeekRecoveryDetail;
|
|
use App\Entity\Employee;
|
|
use App\Enum\ContractType;
|
|
use App\Enum\TrackingMode;
|
|
use App\Repository\AbsenceRepository;
|
|
use App\Repository\EmployeeContractPeriodRepository;
|
|
use App\Repository\EmployeeRepository;
|
|
use App\Repository\EmployeeRttBalanceRepository;
|
|
use App\Repository\EmployeeRttPaymentRepository;
|
|
use App\Repository\WorkHourRepository;
|
|
use App\Service\Leave\LeaveRecapRowBuilder;
|
|
use App\Service\Rtt\RttRecoveryComputationService;
|
|
use App\State\EmployeeLeaveSummaryProvider;
|
|
use DateTimeImmutable;
|
|
use Symfony\Component\Console\Attribute\AsCommand;
|
|
use Symfony\Component\Console\Command\Command;
|
|
use Symfony\Component\Console\Input\InputArgument;
|
|
use Symfony\Component\Console\Input\InputInterface;
|
|
use Symfony\Component\Console\Input\InputOption;
|
|
use Symfony\Component\Console\Output\OutputInterface;
|
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
|
|
#[AsCommand(
|
|
name: 'app:verification:snapshot',
|
|
description: 'Dump per-employee Markdown snapshot of RTT (monthly tab view) and leave balances, to serve as a regression baseline before business-rule refactors.'
|
|
)]
|
|
final class DumpVerificationSnapshotCommand extends Command
|
|
{
|
|
private const array MONTH_LABELS = [
|
|
1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril',
|
|
5 => 'Mai', 6 => 'Juin', 7 => 'Juillet', 8 => 'Août',
|
|
9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre',
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly EmployeeRepository $employeeRepository,
|
|
private readonly EmployeeContractPeriodRepository $contractPeriodRepository,
|
|
private readonly EmployeeLeaveSummaryProvider $leaveSummaryProvider,
|
|
private readonly LeaveRecapRowBuilder $leaveRecapRowBuilder,
|
|
private readonly RttRecoveryComputationService $rttRecoveryService,
|
|
private readonly EmployeeRttBalanceRepository $rttBalanceRepository,
|
|
private readonly EmployeeRttPaymentRepository $rttPaymentRepository,
|
|
private readonly WorkHourRepository $workHourRepository,
|
|
private readonly AbsenceRepository $absenceRepository,
|
|
#[Autowire('%kernel.project_dir%')]
|
|
private readonly string $projectDir,
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
protected function configure(): void
|
|
{
|
|
$this
|
|
->addArgument(
|
|
'employee_ids',
|
|
InputArgument::IS_ARRAY | InputArgument::REQUIRED,
|
|
'Employee IDs to snapshot (space-separated).'
|
|
)
|
|
->addOption(
|
|
'output-dir',
|
|
null,
|
|
InputOption::VALUE_OPTIONAL,
|
|
'Output directory (relative to project root, or absolute).',
|
|
'docs/verifications'
|
|
)
|
|
->addOption(
|
|
'rtt-year',
|
|
null,
|
|
InputOption::VALUE_OPTIONAL,
|
|
'RTT exercise year (ending year, e.g. 2026 = June 2025 → May 2026). Defaults to current exercise.'
|
|
)
|
|
;
|
|
}
|
|
|
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
|
{
|
|
$io = new SymfonyStyle($input, $output);
|
|
$ids = array_map('intval', $input->getArgument('employee_ids'));
|
|
|
|
$outputDirOpt = (string) $input->getOption('output-dir');
|
|
$outputDir = str_starts_with($outputDirOpt, '/')
|
|
? $outputDirOpt
|
|
: $this->projectDir.'/'.$outputDirOpt;
|
|
|
|
if (!is_dir($outputDir) && !mkdir($outputDir, 0o755, true) && !is_dir($outputDir)) {
|
|
$io->error('Could not create output directory: '.$outputDir);
|
|
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
$today = new DateTimeImmutable('today');
|
|
$rttYearOpt = $input->getOption('rtt-year');
|
|
$rttYear = null !== $rttYearOpt && '' !== (string) $rttYearOpt
|
|
? (int) $rttYearOpt
|
|
: $this->resolveCurrentRttExerciseYear($today);
|
|
|
|
foreach ($ids as $id) {
|
|
$employee = $this->employeeRepository->find($id);
|
|
if (!$employee instanceof Employee) {
|
|
$io->warning(sprintf('Employee id=%d not found — skipped.', $id));
|
|
|
|
continue;
|
|
}
|
|
|
|
$markdown = $this->buildEmployeeDoc($employee, $rttYear, $today);
|
|
$slug = $this->slugify($employee->getFirstName().'-'.$employee->getLastName());
|
|
$filename = sprintf('%s/verification-rtt-conges-%s.md', $outputDir, $slug);
|
|
file_put_contents($filename, $markdown);
|
|
$io->success(sprintf('Wrote %s', $filename));
|
|
}
|
|
|
|
return Command::SUCCESS;
|
|
}
|
|
|
|
private function buildEmployeeDoc(Employee $employee, int $rttYear, DateTimeImmutable $today): string
|
|
{
|
|
$parts = [];
|
|
$parts[] = $this->buildHeader($employee, $rttYear, $today);
|
|
$parts[] = $this->buildProfileSection($employee);
|
|
$parts[] = $this->buildLeaveSection($employee, $today);
|
|
$parts[] = $this->buildRecapRowSection($employee, $today);
|
|
$parts[] = $this->buildRttSection($employee, $rttYear, $today);
|
|
|
|
return implode("\n\n", $parts)."\n";
|
|
}
|
|
|
|
private function buildHeader(Employee $employee, int $rttYear, DateTimeImmutable $today): string
|
|
{
|
|
$rttFrom = sprintf('01/06/%d', $rttYear - 1);
|
|
$rttTo = sprintf('31/05/%d', $rttYear);
|
|
|
|
return sprintf(
|
|
"# Vérification RTT & Congés — %s %s (id=%d)\n\n"
|
|
."Généré le %s. \n"
|
|
."Exercice RTT de référence : **%d** (%s → %s). \n"
|
|
."Pour les contrats Forfait, l'exercice de congés est l'année civile.",
|
|
$employee->getFirstName(),
|
|
$employee->getLastName(),
|
|
(int) $employee->getId(),
|
|
$today->format('Y-m-d'),
|
|
$rttYear,
|
|
$rttFrom,
|
|
$rttTo
|
|
);
|
|
}
|
|
|
|
private function buildProfileSection(Employee $employee): string
|
|
{
|
|
$contract = $employee->getContract();
|
|
$contractName = $contract?->getName() ?? '—';
|
|
$tracking = $contract?->getTrackingMode() ?? '—';
|
|
$weekly = $contract?->getWeeklyHours();
|
|
$weeklyLabel = null === $weekly ? '—' : ($weekly.'h');
|
|
$nature = $employee->getCurrentContractNature();
|
|
|
|
$lines = [];
|
|
$lines[] = '## 1. Profil';
|
|
$lines[] = '';
|
|
$lines[] = sprintf('- **ID** : %d', (int) $employee->getId());
|
|
$lines[] = sprintf('- **Nom / Prénom** : %s %s', $employee->getLastName(), $employee->getFirstName());
|
|
$lines[] = sprintf('- **Contrat actif** : %s — tracking `%s` — %s', $contractName, $tracking, $weeklyLabel);
|
|
$lines[] = sprintf('- **Nature** : %s', $nature);
|
|
|
|
$lines[] = '';
|
|
$lines[] = '### Périodes de contrat';
|
|
$lines[] = '';
|
|
$lines[] = '| Début | Fin | Contrat | Nature | Conducteur | Solde CP soldé | Commentaire |';
|
|
$lines[] = '|-------|-----|---------|--------|------------|----------------|-------------|';
|
|
|
|
$periods = $this->contractPeriodRepository->findBy(['employee' => $employee], ['startDate' => 'ASC']);
|
|
foreach ($periods as $period) {
|
|
$lines[] = sprintf(
|
|
'| %s | %s | %s | %s | %s | %s | %s |',
|
|
$period->getStartDate()->format('Y-m-d'),
|
|
null !== $period->getEndDate() ? $period->getEndDate()->format('Y-m-d') : '—',
|
|
$period->getContract()?->getName() ?? '—',
|
|
$period->getContractNature(),
|
|
$period->getIsDriver() ? 'oui' : 'non',
|
|
$period->isPaidLeaveSettled() ? 'oui' : 'non',
|
|
str_replace("\n", ' ', (string) ($period->getComment() ?? ''))
|
|
);
|
|
}
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
private function buildLeaveSection(Employee $employee, DateTimeImmutable $today): string
|
|
{
|
|
$lines = [];
|
|
$lines[] = '## 2. Congés';
|
|
$lines[] = '';
|
|
|
|
$leaveYear = $this->leaveSummaryProvider->resolveLeaveYearForToday($employee);
|
|
$isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
|
|
$yearSummary = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear);
|
|
|
|
if (null === $yearSummary) {
|
|
$lines[] = '_Aucun résumé congés disponible (contrat non supporté : INTERIM ou autre)._';
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
// Forfait: recompute with paid leave days if any.
|
|
$paidLeaveDays = $this->leaveSummaryProvider->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $leaveYear);
|
|
if ($paidLeaveDays > 0.0) {
|
|
$recomputed = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, $paidLeaveDays);
|
|
if (null !== $recomputed) {
|
|
$yearSummary = $recomputed;
|
|
}
|
|
}
|
|
|
|
[$from, $to] = $isForfait
|
|
? [
|
|
new DateTimeImmutable(sprintf('%d-01-01', $leaveYear)),
|
|
new DateTimeImmutable(sprintf('%d-12-31', $leaveYear)),
|
|
]
|
|
: [
|
|
new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1)),
|
|
new DateTimeImmutable(sprintf('%d-05-31', $leaveYear)),
|
|
];
|
|
|
|
$lines[] = sprintf('**Règle applicable** : `%s`', $yearSummary['ruleCode']);
|
|
$lines[] = sprintf('**Période** : %s → %s', $from->format('Y-m-d'), $to->format('Y-m-d'));
|
|
$lines[] = '';
|
|
$lines[] = '### 2.1 Soldes (tels que calculés aujourd\'hui)';
|
|
$lines[] = '';
|
|
$lines[] = '| Indicateur | Valeur |';
|
|
$lines[] = '|------------|--------|';
|
|
$lines[] = sprintf('| Acquis (report N-1) | %s j |', $this->fmtDays($yearSummary['acquiredDays']));
|
|
$lines[] = sprintf('| Acquis samedis | %s j |', $this->fmtDays($yearSummary['acquiredSaturdays']));
|
|
$lines[] = sprintf('| En cours d\'acquisition | %s j |', $this->fmtDays($yearSummary['accruingDays']));
|
|
$lines[] = sprintf('| Pris | %s j |', $this->fmtDays($yearSummary['takenDays']));
|
|
$lines[] = sprintf('| Pris samedis | %s j |', $this->fmtDays($yearSummary['takenSaturdays']));
|
|
$lines[] = sprintf('| Restant (report N-1) | %s j |', $this->fmtDays($yearSummary['remainingDays']));
|
|
$lines[] = sprintf('| Restant samedis | %s j |', $this->fmtDays($yearSummary['remainingSaturdays']));
|
|
if ($isForfait) {
|
|
$lines[] = sprintf('| N-1 acquis | %s j |', $this->fmtDays($yearSummary['previousYearAcquiredDays']));
|
|
$lines[] = sprintf('| N-1 pris | %s j |', $this->fmtDays($yearSummary['previousYearTakenDays']));
|
|
$lines[] = sprintf('| N-1 restant | %s j |', $this->fmtDays($yearSummary['previousYearRemainingDays']));
|
|
$lines[] = sprintf('| N-1 payés | %s j |', $this->fmtDays($paidLeaveDays));
|
|
}
|
|
|
|
$lines[] = '';
|
|
$lines[] = '### 2.2 Absences de la période';
|
|
$lines[] = '';
|
|
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
|
if ([] === $absences) {
|
|
$lines[] = '_Aucune absence sur la période._';
|
|
} else {
|
|
$lines[] = '| Début | Fin | Demi-début | Demi-fin | Type | Commentaire |';
|
|
$lines[] = '|-------|-----|------------|----------|------|-------------|';
|
|
foreach ($absences as $absence) {
|
|
$lines[] = sprintf(
|
|
'| %s | %s | %s | %s | %s (%s) | %s |',
|
|
$absence->getStartDate()->format('Y-m-d'),
|
|
$absence->getEndDate()->format('Y-m-d'),
|
|
$absence->getStartHalf()->value,
|
|
$absence->getEndHalf()->value,
|
|
$absence->getType()?->getCode() ?? '—',
|
|
$absence->getType()?->getLabel() ?? '—',
|
|
str_replace("\n", ' ', (string) ($absence->getComment() ?? ''))
|
|
);
|
|
}
|
|
}
|
|
|
|
$lines[] = '';
|
|
$lines[] = '### 2.3 Jours de présence par mois (calcul provider)';
|
|
$lines[] = '';
|
|
$presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $leaveYear);
|
|
if ([] === $presenceDaysByMonth) {
|
|
$lines[] = '_Aucun jour de présence sur la période._';
|
|
} else {
|
|
$lines[] = '| Mois | Jours de présence |';
|
|
$lines[] = '|------|-------------------|';
|
|
ksort($presenceDaysByMonth);
|
|
foreach ($presenceDaysByMonth as $monthKey => $days) {
|
|
$lines[] = sprintf('| %s | %s |', $monthKey, $this->fmtDays($days));
|
|
}
|
|
}
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, float>
|
|
*/
|
|
private function computePresenceDaysByMonth(Employee $employee, int $leaveYear): array
|
|
{
|
|
// The provider method is private; we re-invoke `provide()` via its public path by
|
|
// calling computeYearSummary then reading $summary->presenceDaysByMonth.
|
|
// But computeYearSummary doesn't populate that. So we call the provider publicly
|
|
// through LeaveRecapRowBuilder? No — we just call the summary API resource directly
|
|
// via a small helper below.
|
|
//
|
|
// Workaround: reuse the provider's provide() would require security; instead we
|
|
// rebuild the map from WorkHour/absences here, mirroring the provider logic.
|
|
$isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
|
|
[$from, $to] = $isForfait
|
|
? [
|
|
new DateTimeImmutable(sprintf('%d-01-01', $leaveYear)),
|
|
new DateTimeImmutable(sprintf('%d-12-31', $leaveYear)),
|
|
]
|
|
: [
|
|
new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1)),
|
|
new DateTimeImmutable(sprintf('%d-05-31', $leaveYear)),
|
|
];
|
|
|
|
// Leave this aggregated figure available only for forfait (this is where the UI
|
|
// shows it). For non-forfait we skip — the UI doesn't show presence per month.
|
|
if (!$isForfait) {
|
|
return [];
|
|
}
|
|
|
|
$weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to);
|
|
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
|
|
|
$absenceDaysByMonth = [];
|
|
foreach ($absences 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;
|
|
}
|
|
$startDate = $absence->getStartDate()->format('Y-m-d');
|
|
$endDate = $absence->getEndDate()->format('Y-m-d');
|
|
$startHalf = $absence->getStartHalf()->value;
|
|
$endHalf = $absence->getEndHalf()->value;
|
|
$dateStr = $day->format('Y-m-d');
|
|
$isStart = $dateStr === $startDate;
|
|
$isEnd = $dateStr === $endDate;
|
|
if ($startDate === $endDate) {
|
|
$am = 'AM' === $startHalf;
|
|
$pm = 'PM' === $endHalf;
|
|
} elseif ($isStart) {
|
|
$am = 'AM' === $startHalf;
|
|
$pm = true;
|
|
} elseif ($isEnd) {
|
|
$am = true;
|
|
$pm = 'PM' === $endHalf;
|
|
} else {
|
|
$am = true;
|
|
$pm = true;
|
|
}
|
|
$dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
|
|
if ($dayAmount <= 0.0) {
|
|
continue;
|
|
}
|
|
$mk = $day->format('Y-m');
|
|
$absenceDaysByMonth[$mk] = ($absenceDaysByMonth[$mk] ?? 0.0) + $dayAmount;
|
|
}
|
|
}
|
|
|
|
$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')) {
|
|
if ((int) $day->format('N') <= 5) {
|
|
++$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;
|
|
}
|
|
|
|
private function buildRecapRowSection(Employee $employee, DateTimeImmutable $today): string
|
|
{
|
|
$row = $this->leaveRecapRowBuilder->build($employee);
|
|
$lines = [];
|
|
|
|
$lines[] = '## 3. Ligne écran « Récap. congés » (live, as of today)';
|
|
$lines[] = '';
|
|
$lines[] = '| CP N-1 restant | CP N | Samedis | RTT |';
|
|
$lines[] = '|----------------|------|---------|-----|';
|
|
$lines[] = sprintf(
|
|
'| %s | %s | %s | %s |',
|
|
(string) $row['cpN1Remaining'],
|
|
$row['cpN'],
|
|
$row['acquiredSaturdays'],
|
|
$row['rtt']
|
|
);
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
private function buildRttSection(Employee $employee, int $rttYear, DateTimeImmutable $today): string
|
|
{
|
|
$lines = [];
|
|
$lines[] = '## 4. RTT — Onglet par mois';
|
|
$lines[] = '';
|
|
|
|
$contract = $employee->getContract();
|
|
$trackingMode = $contract?->getTrackingMode();
|
|
if (TrackingMode::PRESENCE->value === $trackingMode) {
|
|
$lines[] = '_Contrat en mode `PRESENCE` (Forfait) : aucun calcul RTT (heures supplémentaires)._';
|
|
$lines[] = '_Sur l\'UI, l\'onglet RTT ne contient aucune donnée exploitable._';
|
|
$lines[] = '';
|
|
$lines[] = '> Voir toutefois la section Congés pour les bonus week-end / jours fériés travaillés intégrés au stock Forfait (acquisDays).';
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($rttYear);
|
|
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
|
|
$weekRanges = array_map(
|
|
static fn (array $w): array => ['weekNumber' => (int) $w['weekNumber'], 'start' => $w['start'], 'end' => $w['end']],
|
|
$weeks
|
|
);
|
|
|
|
$currentExerciseYear = $this->resolveCurrentRttExerciseYear($today);
|
|
if ($rttYear > $currentExerciseYear) {
|
|
$limitDate = $periodFrom->modify('-1 day');
|
|
} else {
|
|
$isoDay = (int) $today->format('N');
|
|
$limitDate = 7 === $isoDay ? $today : $today->modify('last sunday');
|
|
if (7 !== $isoDay) {
|
|
$currentWeekStart = $today->modify('monday this week');
|
|
$currentWeekEnd = $currentWeekStart->modify('+6 days');
|
|
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today);
|
|
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
|
|
$limitDate = $currentWeekEnd;
|
|
}
|
|
}
|
|
}
|
|
|
|
$recoveryByWeek = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
|
|
[$carry, $carryMonth] = $this->resolveCarry($employee, $rttYear);
|
|
$weekSummaries = $this->buildWeekSummaries($weekRanges, $recoveryByWeek, $periodFrom, $periodTo);
|
|
$weekSummaries = $this->distributeDeficits($weekSummaries, $carry);
|
|
|
|
// Aggregate payments per month.
|
|
$paymentsByMonth = [];
|
|
foreach ($this->rttPaymentRepository->findByEmployeeAndYear($employee, $rttYear) as $payment) {
|
|
$m = $payment->getMonth();
|
|
if (!isset($paymentsByMonth[$m])) {
|
|
$paymentsByMonth[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0];
|
|
}
|
|
$paymentsByMonth[$m]['base25'] += $payment->getBase25Minutes();
|
|
$paymentsByMonth[$m]['bonus25'] += $payment->getBonus25Minutes();
|
|
$paymentsByMonth[$m]['base50'] += $payment->getBase50Minutes();
|
|
$paymentsByMonth[$m]['bonus50'] += $payment->getBonus50Minutes();
|
|
}
|
|
|
|
$lines[] = sprintf('**Limite des semaines prises en compte** : %s (exclut la semaine en cours incomplète)', $limitDate->format('Y-m-d'));
|
|
$lines[] = sprintf('**Report N-1 (carry)** : `Base 25%%=%s` / `+25%%=%s` / `Base 50%%=%s` / `+50%%=%s` — **Total %s**', $this->fmtMin($carry->base25Minutes), $this->fmtMin($carry->bonus25Minutes), $this->fmtMin($carry->base50Minutes), $this->fmtMin($carry->bonus50Minutes), $this->fmtMin($carry->totalMinutes));
|
|
$lines[] = '';
|
|
|
|
// Iterate the 12 exercise months (June → May).
|
|
$cumulativeCarry = [
|
|
'base25' => $carry->base25Minutes,
|
|
'bonus25' => $carry->bonus25Minutes,
|
|
'base50' => $carry->base50Minutes,
|
|
'bonus50' => $carry->bonus50Minutes,
|
|
];
|
|
|
|
$monthsInExercise = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5];
|
|
foreach ($monthsInExercise as $i => $month) {
|
|
$calYear = $month >= 6 ? $rttYear - 1 : $rttYear;
|
|
$label = self::MONTH_LABELS[$month].' '.$calYear;
|
|
|
|
$lines[] = '### '.$label;
|
|
$lines[] = '';
|
|
$lines[] = '| Ligne | Heure | Base 25% | +25% | Total 25% | Base 50% | +50% | Total 50% | Total |';
|
|
$lines[] = '|-------|-------|----------|------|-----------|----------|------|-----------|-------|';
|
|
|
|
// Report line only on the first month (June).
|
|
if (6 === $month) {
|
|
$lines[] = sprintf(
|
|
'| Report N-1 | | %s | %s | %s | %s | %s | %s | %s |',
|
|
$this->fmtMin($carry->base25Minutes),
|
|
$this->fmtMin($carry->bonus25Minutes),
|
|
$this->fmtMin($carry->base25Minutes + $carry->bonus25Minutes),
|
|
$this->fmtMin($carry->base50Minutes),
|
|
$this->fmtMin($carry->bonus50Minutes),
|
|
$this->fmtMin($carry->base50Minutes + $carry->bonus50Minutes),
|
|
$this->fmtMin($carry->totalMinutes),
|
|
);
|
|
}
|
|
|
|
$monthWeeks = array_values(array_filter($weekSummaries, static fn (EmployeeRttWeekSummary $w): bool => $w->month === $month));
|
|
$totals = ['over' => 0, 'b25' => 0, 's25' => 0, 'b50' => 0, 's50' => 0, 'total' => 0];
|
|
|
|
foreach ($monthWeeks as $w) {
|
|
$lines[] = sprintf(
|
|
'| Semaine %d (%s → %s) | %s | %s | %s | %s | %s | %s | %s | %s |',
|
|
$w->weekNumber,
|
|
$w->weekStart,
|
|
$w->weekEnd,
|
|
$this->fmtMin($w->overtimeMinutes),
|
|
$this->fmtMin($w->base25Minutes),
|
|
$this->fmtMin($w->bonus25Minutes),
|
|
$this->fmtMin($w->base25Minutes + $w->bonus25Minutes),
|
|
$this->fmtMin($w->base50Minutes),
|
|
$this->fmtMin($w->bonus50Minutes),
|
|
$this->fmtMin($w->base50Minutes + $w->bonus50Minutes),
|
|
$this->fmtMin($w->totalMinutes),
|
|
);
|
|
$totals['over'] += $w->overtimeMinutes;
|
|
$totals['b25'] += $w->base25Minutes;
|
|
$totals['s25'] += $w->bonus25Minutes;
|
|
$totals['b50'] += $w->base50Minutes;
|
|
$totals['s50'] += $w->bonus50Minutes;
|
|
$totals['total'] += $w->totalMinutes;
|
|
}
|
|
|
|
if ([] === $monthWeeks && 6 !== $month) {
|
|
$lines[] = '| _aucune semaine_ | | | | | | | | |';
|
|
}
|
|
|
|
$lines[] = sprintf(
|
|
'| **Total** | %s | %s | %s | %s | %s | %s | %s | **%s** |',
|
|
$this->fmtMin($totals['over']),
|
|
$this->fmtMin($totals['b25']),
|
|
$this->fmtMin($totals['s25']),
|
|
$this->fmtMin($totals['b25'] + $totals['s25']),
|
|
$this->fmtMin($totals['b50']),
|
|
$this->fmtMin($totals['s50']),
|
|
$this->fmtMin($totals['b50'] + $totals['s50']),
|
|
$this->fmtMin($totals['total']),
|
|
);
|
|
|
|
$p = $paymentsByMonth[$month] ?? null;
|
|
$hasPayment = null !== $p;
|
|
if ($hasPayment) {
|
|
$lines[] = sprintf(
|
|
'| Payé | | -%s | -%s | -%s | -%s | -%s | -%s | -%s |',
|
|
$this->fmtMin($p['base25']),
|
|
$this->fmtMin($p['bonus25']),
|
|
$this->fmtMin($p['base25'] + $p['bonus25']),
|
|
$this->fmtMin($p['base50']),
|
|
$this->fmtMin($p['bonus50']),
|
|
$this->fmtMin($p['base50'] + $p['bonus50']),
|
|
$this->fmtMin($p['base25'] + $p['bonus25'] + $p['base50'] + $p['bonus50']),
|
|
);
|
|
} else {
|
|
$lines[] = '| Payé | | 0h | 0h | 0h | 0h | 0h | 0h | 0h |';
|
|
}
|
|
|
|
// Cumulative carry update — add month totals, subtract payments.
|
|
$cumulativeCarry['base25'] += $totals['b25'] - ($p['base25'] ?? 0);
|
|
$cumulativeCarry['bonus25'] += $totals['s25'] - ($p['bonus25'] ?? 0);
|
|
$cumulativeCarry['base50'] += $totals['b50'] - ($p['base50'] ?? 0);
|
|
$cumulativeCarry['bonus50'] += $totals['s50'] - ($p['bonus50'] ?? 0);
|
|
|
|
$cb25 = $cumulativeCarry['base25'];
|
|
$cs25 = $cumulativeCarry['bonus25'];
|
|
$cb50 = $cumulativeCarry['base50'];
|
|
$cs50 = $cumulativeCarry['bonus50'];
|
|
$cTotal = $cb25 + $cs25 + $cb50 + $cs50;
|
|
$lines[] = sprintf(
|
|
'| **Reste (cumul)** | | %s | %s | %s | %s | %s | %s | **%s** |',
|
|
$this->fmtMin($cb25),
|
|
$this->fmtMin($cs25),
|
|
$this->fmtMin($cb25 + $cs25),
|
|
$this->fmtMin($cb50),
|
|
$this->fmtMin($cs50),
|
|
$this->fmtMin($cb50 + $cs50),
|
|
$this->fmtMin($cTotal),
|
|
);
|
|
|
|
$lines[] = '';
|
|
}
|
|
|
|
// Final summary.
|
|
$currentYearRecovery = array_sum(array_map(static fn (EmployeeRttWeekSummary $w): int => $w->totalMinutes, $weekSummaries));
|
|
$totalPaid = 0;
|
|
foreach ($paymentsByMonth as $p) {
|
|
$totalPaid += $p['base25'] + $p['bonus25'] + $p['base50'] + $p['bonus50'];
|
|
}
|
|
$available = $carry->totalMinutes + $currentYearRecovery - $totalPaid;
|
|
|
|
$lines[] = '### Solde RTT total (fin de période calculée)';
|
|
$lines[] = '';
|
|
$lines[] = sprintf('- Report N-1 (opening) : **%s**', $this->fmtMin($carry->totalMinutes));
|
|
$lines[] = sprintf('- Cumul récupération exercice : **%s**', $this->fmtMin($currentYearRecovery));
|
|
$lines[] = sprintf('- Total payé : **%s**', $this->fmtMin($totalPaid));
|
|
$lines[] = sprintf('- **Disponible** : **%s**', $this->fmtMin($available));
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
/**
|
|
* Mirrors EmployeeRttSummaryProvider::buildWeekSummaries().
|
|
*
|
|
* @param list<array{weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weekRanges
|
|
* @param array<string, WeekRecoveryDetail> $recoveryByWeek
|
|
*
|
|
* @return list<EmployeeRttWeekSummary>
|
|
*/
|
|
private function buildWeekSummaries(array $weekRanges, array $recoveryByWeek, DateTimeImmutable $periodFrom, DateTimeImmutable $periodTo): array
|
|
{
|
|
$result = [];
|
|
foreach ($weekRanges as $week) {
|
|
$weekStart = $week['start'];
|
|
$weekEnd = $week['end'];
|
|
$weekKey = $weekStart->format('Y-m-d');
|
|
$detail = $recoveryByWeek[$weekKey] ?? new WeekRecoveryDetail();
|
|
|
|
$effectiveStart = $weekStart < $periodFrom ? $periodFrom : $weekStart;
|
|
$effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd;
|
|
$startMonth = (int) $effectiveStart->format('n');
|
|
$endMonth = (int) $effectiveEnd->format('n');
|
|
|
|
if ($startMonth === $endMonth) {
|
|
$result[] = new EmployeeRttWeekSummary(
|
|
month: $startMonth,
|
|
weekNumber: (int) $week['weekNumber'],
|
|
weekStart: $weekStart->format('Y-m-d'),
|
|
weekEnd: $weekEnd->format('Y-m-d'),
|
|
overtimeMinutes: $detail->overtimeMinutes,
|
|
base25Minutes: $detail->base25Minutes,
|
|
bonus25Minutes: $detail->bonus25Minutes,
|
|
base50Minutes: $detail->base50Minutes,
|
|
bonus50Minutes: $detail->bonus50Minutes,
|
|
totalMinutes: $detail->totalMinutes,
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$monthMinutes = [];
|
|
$monthWeekdays = [];
|
|
foreach ($detail->dailyMinutes as $date => $mins) {
|
|
$m = (int) new DateTimeImmutable($date)->format('n');
|
|
$monthMinutes[$m] = ($monthMinutes[$m] ?? 0) + $mins;
|
|
if ((int) new DateTimeImmutable($date)->format('N') < 6) {
|
|
$monthWeekdays[$m] = ($monthWeekdays[$m] ?? 0) + 1;
|
|
}
|
|
}
|
|
$totalWorked = array_sum($monthMinutes);
|
|
$totalWeekdays = array_sum($monthWeekdays);
|
|
|
|
foreach ([$startMonth, $endMonth] as $m) {
|
|
if ($totalWorked > 0) {
|
|
$ratio = ($monthMinutes[$m] ?? 0) / $totalWorked;
|
|
} elseif ($totalWeekdays > 0) {
|
|
$ratio = ($monthWeekdays[$m] ?? 0) / $totalWeekdays;
|
|
} else {
|
|
$ratio = 0.0;
|
|
}
|
|
$result[] = new EmployeeRttWeekSummary(
|
|
month: $m,
|
|
weekNumber: (int) $week['weekNumber'],
|
|
weekStart: $weekStart->format('Y-m-d'),
|
|
weekEnd: $weekEnd->format('Y-m-d'),
|
|
overtimeMinutes: (int) round($detail->overtimeMinutes * $ratio),
|
|
base25Minutes: (int) round($detail->base25Minutes * $ratio),
|
|
bonus25Minutes: (int) round($detail->bonus25Minutes * $ratio),
|
|
base50Minutes: (int) round($detail->base50Minutes * $ratio),
|
|
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
|
|
totalMinutes: (int) round($detail->totalMinutes * $ratio),
|
|
);
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Mirrors the deficit-distribution step in EmployeeRttSummaryProvider::provide().
|
|
*
|
|
* @param list<EmployeeRttWeekSummary> $weeks
|
|
*
|
|
* @return list<EmployeeRttWeekSummary>
|
|
*/
|
|
private function distributeDeficits(array $weeks, WeekRecoveryDetail $carry): array
|
|
{
|
|
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
|
|
$cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes;
|
|
|
|
foreach ($weeks as $i => $week) {
|
|
if ($week->totalMinutes >= 0) {
|
|
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
|
|
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
|
|
|
|
continue;
|
|
}
|
|
$deficit = -$week->totalMinutes;
|
|
$from50 = min($deficit, max(0, $cumulative50));
|
|
$from25 = $deficit - $from50;
|
|
$cumulative50 -= $from50;
|
|
$cumulative25 -= $from25;
|
|
$weeks[$i] = new EmployeeRttWeekSummary(
|
|
month: $week->month,
|
|
weekNumber: $week->weekNumber,
|
|
weekStart: $week->weekStart,
|
|
weekEnd: $week->weekEnd,
|
|
overtimeMinutes: $week->overtimeMinutes,
|
|
base25Minutes: $from25 > 0 ? -$from25 : 0,
|
|
bonus25Minutes: 0,
|
|
base50Minutes: $from50 > 0 ? -$from50 : 0,
|
|
bonus50Minutes: 0,
|
|
totalMinutes: $week->totalMinutes,
|
|
);
|
|
}
|
|
|
|
return $weeks;
|
|
}
|
|
|
|
/**
|
|
* @return array{WeekRecoveryDetail, int}
|
|
*/
|
|
private function resolveCarry(Employee $employee, int $year): array
|
|
{
|
|
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year);
|
|
if (null !== $balance) {
|
|
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), 5];
|
|
}
|
|
|
|
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
|
|
{
|
|
foreach ($employee->getContractPeriods() as $period) {
|
|
if ($period->getStartDate() > $today) {
|
|
continue;
|
|
}
|
|
$endDate = $period->getEndDate();
|
|
if (null === $endDate) {
|
|
continue;
|
|
}
|
|
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
|
|
return $endDate;
|
|
}
|
|
}
|
|
|
|
return $weekEnd;
|
|
}
|
|
|
|
private function resolveCurrentRttExerciseYear(DateTimeImmutable $today): int
|
|
{
|
|
$y = (int) $today->format('Y');
|
|
$m = (int) $today->format('n');
|
|
|
|
return $m >= 6 ? $y + 1 : $y;
|
|
}
|
|
|
|
private function fmtMin(int $minutes): string
|
|
{
|
|
if (0 === $minutes) {
|
|
return '0h';
|
|
}
|
|
$sign = $minutes < 0 ? '-' : '';
|
|
$abs = abs($minutes);
|
|
$h = intdiv($abs, 60);
|
|
$m = $abs % 60;
|
|
|
|
return 0 === $m ? sprintf('%s%dh', $sign, $h) : sprintf('%s%dh%02d', $sign, $h, $m);
|
|
}
|
|
|
|
private function fmtDays(float $value): string
|
|
{
|
|
if (abs($value - round($value)) < 0.001) {
|
|
return (string) (int) round($value);
|
|
}
|
|
|
|
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
|
}
|
|
|
|
private function slugify(string $value): string
|
|
{
|
|
$value = trim($value);
|
|
$ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
|
|
if (false === $ascii) {
|
|
$ascii = $value;
|
|
}
|
|
$ascii = strtolower($ascii);
|
|
$ascii = preg_replace('/[^a-z0-9]+/', '-', $ascii) ?? $ascii;
|
|
|
|
return trim($ascii, '-');
|
|
}
|
|
}
|