1edb8d956f
Auto Tag Develop / tag (push) Successful in 7s
## Besoin RH Pouvoir saisir un paiement RTT sur l'exercice précédent (ex. RTT de mai réglés après la bascule du 1er juin). ## Implémentation (Option B) - Paiement autorisé sur l'exercice courant + l'exercice immédiatement précédent (N-1). - Après saisie sur N-1, le report d'ouverture de l'exercice courant est recalculé automatiquement (computeClosingBalance) dans une transaction → aucun double comptage. - Refus si ce report est verrouillé (is_locked) : la RH le déverrouille d'abord. - Fallback EmployeeRttSummaryProvider::resolveCarry aligné sur computeClosingBalance : disponible correct même sans ligne stockée. - Front : bouton « + Payer les RTT » actif sur l'exercice précédent. - Docs : CLAUDE.md, doc/rtt-tab.md, documentation-content.ts. ## Vérification - ✅ 172 tests OK, cs-fixer OK, conteneur compile. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #23 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
170 lines
7.0 KiB
PHP
170 lines
7.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State;
|
|
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\State\ProcessorInterface;
|
|
use App\ApiResource\EmployeeRttPaymentInput;
|
|
use App\Entity\Employee;
|
|
use App\Entity\EmployeeRttBalance;
|
|
use App\Entity\EmployeeRttPayment;
|
|
use App\Repository\EmployeeRepository;
|
|
use App\Repository\EmployeeRttBalanceRepository;
|
|
use App\Repository\EmployeeRttPaymentRepository;
|
|
use App\Service\AuditLogger;
|
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
|
use App\Service\Exercise\ExerciseYearResolver;
|
|
use App\Service\Rtt\RttClosingBalanceService;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Psr\Clock\ClockInterface;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
|
|
|
final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
|
{
|
|
public function __construct(
|
|
private EmployeeRepository $employeeRepository,
|
|
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
|
private EmployeeRttBalanceRepository $rttBalanceRepository,
|
|
private EntityManagerInterface $entityManager,
|
|
private AuditLogger $auditLogger,
|
|
private EmployeeContractPhaseResolver $phaseResolver,
|
|
private ClockInterface $clock,
|
|
private ExerciseYearResolver $exerciseYearResolver,
|
|
private RttClosingBalanceService $rttClosingService,
|
|
) {}
|
|
|
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
|
|
{
|
|
if (!$data instanceof EmployeeRttPaymentInput) {
|
|
throw new UnprocessableEntityHttpException('Invalid payload.');
|
|
}
|
|
|
|
$employeeId = (int) ($uriVariables['id'] ?? 0);
|
|
if ($employeeId <= 0) {
|
|
throw new UnprocessableEntityHttpException('id must be a positive integer.');
|
|
}
|
|
|
|
$employee = $this->employeeRepository->find($employeeId);
|
|
if (!$employee instanceof Employee) {
|
|
throw new NotFoundHttpException('Employee not found.');
|
|
}
|
|
|
|
if ($data->month < 1 || $data->month > 12) {
|
|
throw new UnprocessableEntityHttpException('month must be between 1 and 12.');
|
|
}
|
|
|
|
$year = $data->year ?? $this->resolveCurrentExerciseYear();
|
|
$currentExerciseYear = $this->resolveCurrentExerciseYear();
|
|
|
|
$this->assertYearAllowedForPayment($employee, $year);
|
|
|
|
// Option B — retroactive payment on the previous exercise: the next exercise's
|
|
// opening report (a frozen snapshot) must be recomputed so the carry stays correct.
|
|
// Refuse upfront if that report has been locked (validated) by RH.
|
|
$downstreamBalance = null;
|
|
if ($year === $currentExerciseYear - 1) {
|
|
$downstreamBalance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $currentExerciseYear);
|
|
$this->assertReportNotLocked($downstreamBalance);
|
|
}
|
|
|
|
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
|
|
|
|
if (null === $payment) {
|
|
$payment = new EmployeeRttPayment();
|
|
$payment->setEmployee($employee);
|
|
$payment->setYear($year);
|
|
$payment->setMonth($data->month);
|
|
$this->entityManager->persist($payment);
|
|
}
|
|
|
|
$payment->setBase25Minutes($data->base25Minutes);
|
|
$payment->setBonus25Minutes($data->bonus25Minutes);
|
|
$payment->setBase50Minutes($data->base50Minutes);
|
|
$payment->setBonus50Minutes($data->bonus50Minutes);
|
|
$payment->touch();
|
|
|
|
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
|
$this->auditLogger->log(
|
|
$employee,
|
|
'update',
|
|
'rtt_payment',
|
|
$payment->getId(),
|
|
sprintf('Paiement RTT modifié pour %s (%02d/%d)', $empName, $data->month, $year),
|
|
['new' => ['month' => $data->month, 'year' => $year, 'base25' => $data->base25Minutes, 'bonus25' => $data->bonus25Minutes, 'base50' => $data->base50Minutes, 'bonus50' => $data->bonus50Minutes]],
|
|
);
|
|
|
|
// Persist the payment and, atomically, refresh the next exercise's opening report.
|
|
// The flush inside the transaction makes the new payment visible to the closing
|
|
// recomputation (same DB connection), so the carry reflects it.
|
|
$this->entityManager->wrapInTransaction(function () use ($employee, $year, $downstreamBalance): void {
|
|
$this->entityManager->flush();
|
|
|
|
if (null !== $downstreamBalance) {
|
|
$closing = $this->rttClosingService->computeClosingBalance($employee, $year);
|
|
$downstreamBalance
|
|
->setOpeningBase25Minutes($closing->base25Minutes)
|
|
->setOpeningBonus25Minutes($closing->bonus25Minutes)
|
|
->setOpeningBase50Minutes($closing->base50Minutes)
|
|
->setOpeningBonus50Minutes($closing->bonus50Minutes)
|
|
->touch()
|
|
;
|
|
$this->entityManager->flush();
|
|
}
|
|
});
|
|
|
|
$data->year = $year;
|
|
|
|
return $data;
|
|
}
|
|
|
|
private function resolveCurrentExerciseYear(): int
|
|
{
|
|
return $this->exerciseYearResolver->forDate($this->clock->now());
|
|
}
|
|
|
|
/**
|
|
* Allow payment when the requested exercise is the current one, the
|
|
* immediately previous one (retroactive payment — Option B), or the last
|
|
* exercise of a closed contract phase (the one containing the phase end
|
|
* date). Reject any other exercise (older past or future).
|
|
*/
|
|
private function assertYearAllowedForPayment(Employee $employee, int $year): void
|
|
{
|
|
$currentExerciseYear = $this->resolveCurrentExerciseYear();
|
|
if ($year === $currentExerciseYear || $year === $currentExerciseYear - 1) {
|
|
return;
|
|
}
|
|
|
|
$phases = $this->phaseResolver->resolvePhases($employee);
|
|
foreach ($phases as $phase) {
|
|
if ($phase->isCurrent || null === $phase->endDate) {
|
|
continue;
|
|
}
|
|
if ($year === $this->exerciseYearResolver->forDate($phase->endDate)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
throw new UnprocessableEntityHttpException(
|
|
'RTT payment is only allowed on the current exercise, the previous one, or the last exercise of a closed contract phase.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Refuse a retroactive payment when the next exercise's opening report has
|
|
* been locked (validated) by RH: recomputing it would either be impossible
|
|
* or silently desync the carry. A missing report (null) never blocks.
|
|
*/
|
|
private function assertReportNotLocked(?EmployeeRttBalance $downstreamBalance): void
|
|
{
|
|
if (null !== $downstreamBalance && $downstreamBalance->isLocked()) {
|
|
throw new UnprocessableEntityHttpException(
|
|
'Impossible : le report RTT de l\'exercice suivant est verrouillé. Déverrouillez-le pour saisir un paiement rétroactif.'
|
|
);
|
|
}
|
|
}
|
|
}
|