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 EmployeeContractResolver $contractResolver,
|
||||||
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
||||||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||||
|
private SolidarityDayResolver $solidarityDayResolver,
|
||||||
string $rttStartDate = '',
|
string $rttStartDate = '',
|
||||||
) {
|
) {
|
||||||
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
|
$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) {
|
foreach ($weeks as $week) {
|
||||||
$weekStart = $week['start'];
|
$weekStart = $week['start'];
|
||||||
$weekEnd = $week['end'];
|
$weekEnd = $week['end'];
|
||||||
@@ -244,6 +246,30 @@ final readonly class RttRecoveryComputationService
|
|||||||
? 0
|
? 0
|
||||||
: $weeklyTotalMinutes - $overtimeReferenceMinutes;
|
: $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);
|
[$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes);
|
||||||
|
|
||||||
$results[$weekKey] = $this->buildWeekRecoveryDetail(
|
$results[$weekKey] = $this->buildWeekRecoveryDetail(
|
||||||
@@ -451,6 +477,59 @@ final readonly class RttRecoveryComputationService
|
|||||||
return $weekDays[0];
|
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 list<string> $days
|
||||||
* @param array<string, ?Contract> $contractsByDate
|
* @param array<string, ?Contract> $contractsByDate
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Service\Rtt;
|
namespace App\Tests\Service\Rtt;
|
||||||
|
|
||||||
use App\Entity\Contract;
|
use App\Entity\Contract;
|
||||||
|
use App\Enum\TrackingMode;
|
||||||
use App\Service\Rtt\RttRecoveryComputationService;
|
use App\Service\Rtt\RttRecoveryComputationService;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use ReflectionClass;
|
use ReflectionClass;
|
||||||
@@ -161,6 +162,109 @@ final class RttRecoveryComputationServiceTest extends TestCase
|
|||||||
self::assertSame(300 + 60 + 30, $detail->totalMinutes);
|
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
|
private function invokePrivate(object $obj, string $method, mixed ...$args): mixed
|
||||||
{
|
{
|
||||||
return new ReflectionClass($obj::class)->getMethod($method)->invoke($obj, ...$args);
|
return new ReflectionClass($obj::class)->getMethod($method)->invoke($obj, ...$args);
|
||||||
|
|||||||
Reference in New Issue
Block a user