Files
SIRH/src/Command/RttRolloverCommand.php
T
tristan ac8a36eb4f
Auto Tag Develop / tag (push) Successful in 7s
[#SIRH-34] fix RTT bascule ne fonctionne pas (#22)
La bascule app:rtt:rollover ne reprenait que les RTT acquis de l'exercice qui
se terminait : le report d'ouverture déjà présent était perdu et les paiements
n'étaient pas déduits. Le nouveau report reprend le solde de clôture =
report d'ouverture(N-1) + acquis(N-1) − RTT payés(N-1), soit le "Disponible"
affiché par EmployeeRttSummaryProvider.

- nouveau RttClosingBalanceService (fold pur testé : invariant somme tranches =
  disponible, cascade déficit 50% avant 25%, récup CUSTOM non perdue)
- RttRolloverCommand branché dessus + option --recompute (écrase les lignes
  existantes non verrouillées, pour reprise d'une bascule erronée)
- test date-sensible EmployeeRttSummaryProviderTest rendu robuste
- docs: doc/rtt-rollover.md, CLAUDE.md, documentation-content.ts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #22
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-08 08:56:19 +00:00

187 lines
6.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Employee;
use App\Entity\EmployeeRttBalance;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Service\Rtt\RttClosingBalanceService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
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;
use Throwable;
#[AsCommand(
name: 'app:rtt:rollover',
description: 'Create yearly RTT opening balances (idempotent).'
)]
final class RttRolloverCommand extends Command
{
public function __construct(
private readonly EmployeeRepository $employeeRepository,
private readonly EmployeeRttBalanceRepository $rttBalanceRepository,
private readonly RttClosingBalanceService $rttClosingService,
private readonly EntityManagerInterface $entityManager,
#[Autowire(service: 'monolog.logger.cron')]
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption(
'force',
null,
InputOption::VALUE_NONE,
'Run rollover regardless of business date (manual recovery mode).'
);
$this->addOption(
'recompute',
null,
InputOption::VALUE_NONE,
'Recompute and overwrite existing (non-locked) balances instead of skipping them.'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$today = new DateTimeImmutable('today');
$force = (bool) $input->getOption('force');
$recompute = (bool) $input->getOption('recompute');
$this->logger->info('app:rtt:rollover started.', ['date' => $today->format('Y-m-d'), 'force' => $force, 'recompute' => $recompute]);
if (!$force && '06-01' !== $today->format('m-d')) {
$message = 'No RTT rollover today: business date is not 01/06.';
$this->logger->info($message, ['date' => $today->format('Y-m-d')]);
$io->success($message);
return Command::SUCCESS;
}
$targetYear = $this->resolveTargetYear($today);
$created = 0;
$updated = 0;
$skipped = 0;
foreach ($this->employeeRepository->findAll() as $employee) {
if (!$employee instanceof Employee) {
continue;
}
if (!$this->isEligible($employee)) {
$this->logger->info('Employee skipped: not eligible.', ['employeeId' => $employee->getId()]);
++$skipped;
continue;
}
$existing = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $targetYear);
if (null !== $existing && !$recompute) {
$this->logger->info('Employee skipped: balance already exists.', ['employeeId' => $employee->getId(), 'year' => $targetYear]);
++$skipped;
continue;
}
if (null !== $existing && $existing->isLocked()) {
// Never overwrite a balance an RH user has validated/frozen.
$this->logger->info('Employee skipped: balance is locked.', ['employeeId' => $employee->getId(), 'year' => $targetYear]);
++$skipped;
continue;
}
try {
$previousYear = $targetYear - 1;
// Closing of the previous exercise = opening report + earned paid.
$closing = $this->rttClosingService->computeClosingBalance($employee, $previousYear);
} catch (Throwable $e) {
$this->logger->error('Error computing closing balance for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]);
++$skipped;
continue;
}
$balance = $existing ?? new EmployeeRttBalance()
->setEmployee($employee)
->setYear($targetYear)
->setIsLocked(false)
;
$balance
->setOpeningBase25Minutes($closing->base25Minutes)
->setOpeningBonus25Minutes($closing->bonus25Minutes)
->setOpeningBase50Minutes($closing->base50Minutes)
->setOpeningBonus50Minutes($closing->bonus50Minutes)
;
if (null === $existing) {
$this->entityManager->persist($balance);
$this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $closing->totalMinutes]);
++$created;
} else {
$balance->touch();
$this->logger->info('Balance recomputed.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $closing->totalMinutes]);
++$updated;
}
}
try {
$this->entityManager->flush();
} catch (Throwable $e) {
$this->logger->error('Error flushing RTT balances.', ['error' => $e->getMessage()]);
$io->error('RTT rollover failed: '.$e->getMessage());
return Command::FAILURE;
}
$message = sprintf('RTT rollover done: %d created, %d recomputed, %d skipped.', $created, $updated, $skipped);
$this->logger->info($message);
$io->success($message);
return Command::SUCCESS;
}
private function resolveTargetYear(DateTimeImmutable $today): int
{
$year = (int) $today->format('Y');
$month = (int) $today->format('n');
return $month >= 6 ? $year + 1 : $year;
}
private function isEligible(Employee $employee): bool
{
$contract = $employee->getContract();
if (null === $contract) {
return false;
}
if (TrackingMode::PRESENCE->value === $contract->getTrackingMode()) {
return false;
}
$type = ContractType::resolve(
$contract->getName(),
$contract->getTrackingMode(),
$contract->getWeeklyHours()
);
return ContractType::INTERIM !== $type;
}
}