feat(rtt) : solidarity-day deficit for CUSTOM <35h contracts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ final readonly class RttRecoveryComputationService
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
||||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||
private SolidarityDayResolver $solidarityDayResolver,
|
||||
string $rttStartDate = '',
|
||||
) {
|
||||
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
|
||||
@@ -162,7 +163,8 @@ final readonly class RttRecoveryComputationService
|
||||
}
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$results = [];
|
||||
$solidarityDates = $this->resolveSolidarityDatesInRange($periodFrom, $periodTo);
|
||||
foreach ($weeks as $week) {
|
||||
$weekStart = $week['start'];
|
||||
$weekEnd = $week['end'];
|
||||
@@ -244,6 +246,30 @@ final readonly class RttRecoveryComputationService
|
||||
? 0
|
||||
: $weeklyTotalMinutes - $overtimeReferenceMinutes;
|
||||
|
||||
foreach ($solidarityDates as $solidarityDate) {
|
||||
// isset ⇒ le jour de solidarité fait partie du sommage de CETTE semaine
|
||||
// (donc ≤ limitDate et ≥ rttStartDate). Sinon : jour futur ou hors service → pas de déficit.
|
||||
if (!isset($dailyWorkedMinutes[$solidarityDate])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contractAtSolidarity = $employeeContractsByDate[$solidarityDate] ?? null;
|
||||
$solidarityIsoDay = (int) new DateTimeImmutable($solidarityDate)->format('N');
|
||||
// Attendu RÉEL du jour (planning workDaysHours), pas la répartition uniforme :
|
||||
// c'est ce qui rend la neutralisation correcte (cf. spec).
|
||||
$solidarityExpected = $this->dailyReferenceResolver->resolve(
|
||||
$contractAtSolidarity?->getWeeklyHours(),
|
||||
$solidarityIsoDay,
|
||||
$workDaysByDate[$employeeId][$solidarityDate] ?? null,
|
||||
);
|
||||
|
||||
$weeklyOvertimeTotalMinutes += $this->computeSolidarityDeficitAdjustment(
|
||||
$contractAtSolidarity,
|
||||
$solidarityExpected,
|
||||
$dailyWorkedMinutes[$solidarityDate],
|
||||
);
|
||||
}
|
||||
|
||||
[$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes);
|
||||
|
||||
$results[$weekKey] = $this->buildWeekRecoveryDetail(
|
||||
@@ -451,6 +477,59 @@ final readonly class RttRecoveryComputationService
|
||||
return $weekDays[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lundi(s) de Pentecôte (jour de solidarité) inclus dans [from, to]. Un exercice
|
||||
* Juin N-1 → Mai N couvre les années civiles N-1 et N ; on retient les dates dans la fenêtre.
|
||||
*
|
||||
* @return list<string> dates au format 'Y-m-d'
|
||||
*/
|
||||
private function resolveSolidarityDatesInRange(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
{
|
||||
$dates = [];
|
||||
$firstYear = (int) $from->format('Y');
|
||||
$lastYear = (int) $to->format('Y');
|
||||
|
||||
for ($year = $firstYear; $year <= $lastYear; ++$year) {
|
||||
$candidate = $this->solidarityDayResolver->pentecostMonday($year);
|
||||
if ($candidate >= $from && $candidate <= $to) {
|
||||
$dates[] = $candidate->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Déficit forfaitaire du jour de solidarité pour les contrats CUSTOM < 35h.
|
||||
*
|
||||
* Le jour est neutralisé puis chargé du prorata légal : on remplace la valeur réelle
|
||||
* du jour ($workedMinutes : RTT posé, heures saisies, vide, ou crédit férié virtuel)
|
||||
* par l'attendu contractuel du jour ($expectedMinutes = workDaysHours), puis on
|
||||
* retranche le prorata = 7h/35h × heuresHebdo = 12 min par heure hebdo. Sur une
|
||||
* semaine par ailleurs normale, le net vaut exactement −prorata. Renvoie le delta à
|
||||
* ajouter à weeklyOvertimeTotalMinutes (0 hors périmètre : non-CUSTOM ou ≥ 35h).
|
||||
*/
|
||||
private function computeSolidarityDeficitAdjustment(
|
||||
?Contract $contractAtSolidarity,
|
||||
int $expectedMinutes,
|
||||
int $workedMinutes,
|
||||
): int {
|
||||
$weeklyHours = $contractAtSolidarity?->getWeeklyHours();
|
||||
$type = ContractType::resolve(
|
||||
$contractAtSolidarity?->getName(),
|
||||
$contractAtSolidarity?->getTrackingMode(),
|
||||
$weeklyHours,
|
||||
);
|
||||
|
||||
if (ContractType::CUSTOM !== $type || null === $weeklyHours || $weeklyHours >= 35) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$prorata = (int) round($weeklyHours * 12);
|
||||
|
||||
return ($expectedMinutes - $workedMinutes) - $prorata;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $days
|
||||
* @param array<string, ?Contract> $contractsByDate
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Service\Rtt;
|
||||
|
||||
use App\Entity\Contract;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Service\Rtt\RttRecoveryComputationService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
@@ -161,6 +162,109 @@ final class RttRecoveryComputationServiceTest extends TestCase
|
||||
self::assertSame(300 + 60 + 30, $detail->totalMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* CUSTOM 4h, jour de solidarité non travaillé (RTT posé ou vide) : delta = (attendu − 0) − prorata.
|
||||
* attendu lundi = workDaysHours = 120 ; prorata = round(4×12) = 48 ; delta = 120 − 48 = 72.
|
||||
* (Combiné au naturel −120 de la semaine, donne −48 min.).
|
||||
*/
|
||||
public function testSolidarityAdjustmentCustomNotWorkedNeutralisesToProrata(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
|
||||
$delta = $this->invokePrivate(
|
||||
$service,
|
||||
'computeSolidarityDeficitAdjustment',
|
||||
self::customContract(4),
|
||||
120, // expectedMinutes (workDaysHours du lundi)
|
||||
0, // workedMinutes (RTT posé / vide)
|
||||
);
|
||||
|
||||
self::assertSame(72, $delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* CUSTOM 4h, jour de solidarité travaillé normalement (120) : delta = (120 − 120) − 48 = −48.
|
||||
*/
|
||||
public function testSolidarityAdjustmentCustomWorkedNormallyChargesProrata(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
|
||||
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 120, 120);
|
||||
|
||||
self::assertSame(-48, $delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* CUSTOM 4h, jour de solidarité travaillé en plus (240) : delta = (120 − 240) − 48 = −168.
|
||||
* Le surplus du jour de solidarité n'est PAS crédité (jour neutralisé, net forcé à −prorata).
|
||||
*/
|
||||
public function testSolidarityAdjustmentCustomWorkedExtraStillNetsProrata(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
|
||||
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 120, 240);
|
||||
|
||||
self::assertSame(-168, $delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* CUSTOM 28h : prorata = round(28×12) = 336 (5h36). worked 0, expected 336 → delta 0.
|
||||
*/
|
||||
public function testSolidarityAdjustmentCustom28hUsesProrata(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
|
||||
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(28), 336, 0);
|
||||
|
||||
self::assertSame(0, $delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* CUSTOM ≥ 35h (36h) : hors périmètre → delta 0.
|
||||
*/
|
||||
public function testSolidarityAdjustmentCustom36hOutOfScope(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
|
||||
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(36), 999, 0);
|
||||
|
||||
self::assertSame(0, $delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* 35h : type H35 (pas CUSTOM) → delta 0 (comportement inchangé, RTT posé fait foi).
|
||||
*/
|
||||
public function testSolidarityAdjustment35hOutOfScope(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
$contract = new Contract()->setName('35h')->setTrackingMode(TrackingMode::TIME)->setWeeklyHours(35);
|
||||
|
||||
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', $contract, 420, 0);
|
||||
|
||||
self::assertSame(0, $delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aucun contrat ce jour-là (salarié parti / pas encore embauché) → delta 0.
|
||||
*/
|
||||
public function testSolidarityAdjustmentNoContractIsZero(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
|
||||
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', null, 0, 0);
|
||||
|
||||
self::assertSame(0, $delta);
|
||||
}
|
||||
|
||||
private static function customContract(int $weeklyHours): Contract
|
||||
{
|
||||
return new Contract()
|
||||
->setName('Temps partiel')
|
||||
->setTrackingMode(TrackingMode::TIME)
|
||||
->setWeeklyHours($weeklyHours)
|
||||
;
|
||||
}
|
||||
|
||||
private function invokePrivate(object $obj, string $method, mixed ...$args): mixed
|
||||
{
|
||||
return new ReflectionClass($obj::class)->getMethod($method)->invoke($obj, ...$args);
|
||||
|
||||
Reference in New Issue
Block a user