Retour RH: vue jour par date, RTT mi-semaine, récap salaire & exports, panier de nuit (#21)
Auto Tag Develop / tag (push) Successful in 11s
Auto Tag Develop / tag (push) Successful in 11s
## Correctifs RH (branche fix/retour-rh) ### Vue Jour (Heures) - Mode saisie/présence, libellé de contrat et sauvegarde résolus **à la date affichée** (et non au contrat courant). Corrige les salariés passés 39h/35h → Forfait. ### RTT — heures supplémentaires - Proratisation du **plafond 25%/50%** pour les embauches en milieu de semaine (la bande +25% se décale au lieu de rester bloquée à 43h). Témoin Dylan : 4h à 25% + 3h à 50%. ### Récap salaire (PDF mensuel) - Forfait : congés imputés **N-1** non affichés et comptés en présence. - Colonne « Heures payés » **scindée 25% / 50%** (en-tête fusionné). - **Exclusion des salariés sans contrat** sur le mois (ex. Marine, contrat terminé). ### Exports heures annuelles (par salarié + tous) - **Tous les jours sous contrat** affichés, même vides/non saisis (corrige les lignes manquantes). - Samedis/dimanches en **gris plus foncé**. ### Panier de nuit - **Ne s'applique pas aux conducteurs** (vue semaine + récap salaire). ## Tests - 11 tests ajoutés. Suite verte hors un test legacy pré-existant dépendant de la date (`EmployeeRttSummaryProviderTest::testNoQueryParamsKeepsLegacyYearDefaulting`, non modifié par cette branche). ## À noter (hors scope) - L'export heures annuelles *tous salariés* peut dépasser `memory_limit=256M` (Dompdf) — limitation **pré-existante**, non corrigée ici. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #21 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #21.
This commit is contained in:
@@ -67,6 +67,52 @@ final class RttRecoveryComputationServiceTest extends TestCase
|
||||
self::assertSame('2026-03-16', $anchor);
|
||||
}
|
||||
|
||||
public function testResolveOvertime25BandWidthIs4hForH39(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
$contract = new Contract()->setWeeklyHours(39);
|
||||
|
||||
self::assertSame(4 * 60, $this->invokePrivate($service, 'resolveOvertime25BandWidthMinutes', $contract));
|
||||
}
|
||||
|
||||
public function testResolveOvertime25BandWidthIs8hForH35(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
$contract = new Contract()->setWeeklyHours(35);
|
||||
|
||||
self::assertSame(8 * 60, $this->invokePrivate($service, 'resolveOvertime25BandWidthMinutes', $contract));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dylan Chaboisson, semaine 12 : embauché le jeudi sur un contrat 39h.
|
||||
* Total travaillé 22h (1320 min), départ 25 % proraté aux jours contractés = 15h (900 min),
|
||||
* plafond 25 %/50 % = 15h + bande 4h = 19h (1140 min). Le plafond se décale avec
|
||||
* l'embauche au lieu de rester bloqué à 43h, ouvrant la tranche 50 %.
|
||||
*/
|
||||
public function testMidWeekHireSplitsOvertimeAcross25And50(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
|
||||
[$base25, $base50] = $this->invokePrivate($service, 'computeOvertimeBaseMinutes', 1320, 900, 1140);
|
||||
|
||||
self::assertSame(4 * 60, $base25);
|
||||
self::assertSame(3 * 60, $base50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Régression : semaine pleine 39h (départ 39h, plafond 43h), 46h travaillées →
|
||||
* 4h à 25 % (39→43) et 3h à 50 % (43→46), comportement inchangé.
|
||||
*/
|
||||
public function testFullWeekOvertimeSplitUnchanged(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
|
||||
[$base25, $base50] = $this->invokePrivate($service, 'computeOvertimeBaseMinutes', 2760, 2340, 2580);
|
||||
|
||||
self::assertSame(4 * 60, $base25);
|
||||
self::assertSame(3 * 60, $base50);
|
||||
}
|
||||
|
||||
private function invokePrivate(object $obj, string $method, mixed ...$args): mixed
|
||||
{
|
||||
return new ReflectionClass($obj::class)->getMethod($method)->invoke($obj, ...$args);
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\State;
|
||||
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\HalfDay;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use App\State\SalaryRecapPrintProvider;
|
||||
use DateTime;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use ReflectionProperty;
|
||||
|
||||
/**
|
||||
* Forfait N-1 split for the salary recap. The provider's collaborators are final classes
|
||||
* PHPUnit cannot double, so the pure split helper is exercised via reflection, with a real
|
||||
* AbsenceSegmentsResolver (no deps) injected into the uninitialized property.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SalaryRecapPrintProviderTest extends TestCase
|
||||
{
|
||||
public function testN1BudgetPartiallyCoversADayAndOverflowsToN(): void
|
||||
{
|
||||
// Budget N-1 = 2.5 j ; 3 congés pleins (1 j) lun/mar/mer de janvier.
|
||||
// 1.0 + 1.0 + 0.5 consommés en N-1 → reste 0.5 j affiché en congé (le mercredi).
|
||||
$conges = [
|
||||
$this->buildConge('2026-01-05'),
|
||||
$this->buildConge('2026-01-06'),
|
||||
$this->buildConge('2026-01-07'),
|
||||
];
|
||||
|
||||
$result = $this->split($conges, 2.5, '2026-01-01', '2026-01-31');
|
||||
|
||||
self::assertSame(2.5, $result['n1PresenceDays']);
|
||||
self::assertSame(0.5, $result['count']);
|
||||
self::assertSame('07/01', $result['dates']);
|
||||
}
|
||||
|
||||
public function testN1BudgetConsumedInPriorMonthLeavesCurrentMonthFullyDisplayed(): void
|
||||
{
|
||||
// Budget 1 j, consommé par le congé de janvier. Récap de février → le congé de février
|
||||
// est entièrement imputé N (affiché, 0 présence N-1 dans le mois).
|
||||
$conges = [
|
||||
$this->buildConge('2026-01-12'),
|
||||
$this->buildConge('2026-02-09'),
|
||||
];
|
||||
|
||||
$result = $this->split($conges, 1.0, '2026-02-01', '2026-02-28');
|
||||
|
||||
self::assertSame(0.0, $result['n1PresenceDays']);
|
||||
self::assertSame(1.0, $result['count']);
|
||||
self::assertSame('09/02', $result['dates']);
|
||||
}
|
||||
|
||||
public function testZeroBudgetDisplaysAllCongesInMonth(): void
|
||||
{
|
||||
$conges = [$this->buildConge('2026-03-03')];
|
||||
|
||||
$result = $this->split($conges, 0.0, '2026-03-01', '2026-03-31');
|
||||
|
||||
self::assertSame(0.0, $result['n1PresenceDays']);
|
||||
self::assertSame(1.0, $result['count']);
|
||||
self::assertSame('03/03', $result['dates']);
|
||||
}
|
||||
|
||||
public function testTerminatedContractExcludedFromMonth(): void
|
||||
{
|
||||
// Marine : contrat terminé le 26/02 → absente du récap de juin.
|
||||
$employee = $this->buildEmployeeWithPeriod('2025-02-10', '2026-02-26');
|
||||
|
||||
self::assertFalse($this->hasInRange($employee, '2026-06-01', '2026-06-30'));
|
||||
}
|
||||
|
||||
public function testOngoingContractIncluded(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithPeriod('2025-01-01', null);
|
||||
|
||||
self::assertTrue($this->hasInRange($employee, '2026-06-01', '2026-06-30'));
|
||||
}
|
||||
|
||||
public function testContractEndingOnFromDayIncluded(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithPeriod('2025-01-01', '2026-06-01');
|
||||
|
||||
self::assertTrue($this->hasInRange($employee, '2026-06-01', '2026-06-30'));
|
||||
}
|
||||
|
||||
public function testNoPeriodsExcluded(): void
|
||||
{
|
||||
self::assertFalse($this->hasInRange(new Employee(), '2026-06-01', '2026-06-30'));
|
||||
}
|
||||
|
||||
private function hasInRange(Employee $employee, string $from, string $to): bool
|
||||
{
|
||||
$provider = new ReflectionClass(SalaryRecapPrintProvider::class)->newInstanceWithoutConstructor();
|
||||
|
||||
return new ReflectionClass($provider::class)
|
||||
->getMethod('hasContractInRange')
|
||||
->invoke($provider, $employee, new DateTimeImmutable($from), new DateTimeImmutable($to));
|
||||
}
|
||||
|
||||
private function buildEmployeeWithPeriod(string $start, ?string $end): Employee
|
||||
{
|
||||
$employee = new Employee();
|
||||
$period = new EmployeeContractPeriod();
|
||||
$period->setEmployee($employee);
|
||||
$period->setStartDate(new DateTimeImmutable($start));
|
||||
$period->setEndDate(null !== $end ? new DateTimeImmutable($end) : null);
|
||||
$employee->getContractPeriods()->add($period);
|
||||
|
||||
return $employee;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Absence> $conges
|
||||
*
|
||||
* @return array{count: float, dates: string, n1PresenceDays: float}
|
||||
*/
|
||||
private function split(array $conges, float $budget, string $from, string $to): array
|
||||
{
|
||||
$provider = new ReflectionClass(SalaryRecapPrintProvider::class)->newInstanceWithoutConstructor();
|
||||
new ReflectionProperty(SalaryRecapPrintProvider::class, 'absenceSegmentsResolver')
|
||||
->setValue($provider, new AbsenceSegmentsResolver());
|
||||
|
||||
return new ReflectionClass($provider::class)
|
||||
->getMethod('splitForfaitCongesByN1')
|
||||
->invoke($provider, $conges, $budget, new DateTimeImmutable($from), new DateTimeImmutable($to));
|
||||
}
|
||||
|
||||
private function buildConge(string $date): Absence
|
||||
{
|
||||
return new Absence()
|
||||
->setStartDate(new DateTime($date))
|
||||
->setEndDate(new DateTime($date))
|
||||
->setStartHalf(HalfDay::AM)
|
||||
->setEndHalf(HalfDay::PM)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,60 @@ final class WorkHourDayContextProviderTest extends TestCase
|
||||
self::assertSame(210, $result->rows[0]['creditedMinutes']);
|
||||
}
|
||||
|
||||
public function testRowCarriesContractAtRequestedDate(): void
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
$timeContract = new Contract()
|
||||
->setName('Contrat')
|
||||
->setTrackingMode(Contract::TRACKING_TIME)
|
||||
->setWeeklyHours(39)
|
||||
;
|
||||
$forfaitContract = new Contract()
|
||||
->setName('Forfait')
|
||||
->setTrackingMode(Contract::TRACKING_PRESENCE)
|
||||
->setWeeklyHours(null)
|
||||
;
|
||||
$employee = new Employee()
|
||||
->setFirstName('Jean')
|
||||
->setLastName('Test')
|
||||
->setContract($forfaitContract)
|
||||
;
|
||||
$this->setEntityId($employee, 1);
|
||||
|
||||
// Resolver renvoie le contrat 39h avant 2026-03-01, le forfait à partir de cette date.
|
||||
$resolver = $this->createStub(EmployeeContractResolver::class);
|
||||
$resolver->method('resolveForEmployeeAndDate')->willReturnCallback(
|
||||
static fn (Employee $e, \DateTimeImmutable $d): ?Contract =>
|
||||
$d < new \DateTimeImmutable('2026-03-01') ? $timeContract : $forfaitContract
|
||||
);
|
||||
$resolver->method('resolveNatureForEmployeeAndDate')->willReturn(ContractNature::CDI);
|
||||
|
||||
$this->requestStack->push(new Request(query: ['workDate' => '2026-02-16']));
|
||||
$this->security->method('getUser')->willReturn($user);
|
||||
$this->employeeRepository->method('findScoped')->with($user)->willReturn([$employee]);
|
||||
$this->absenceRepository->method('findByDateAndEmployees')->willReturn([]);
|
||||
|
||||
$provider = new WorkHourDayContextProvider(
|
||||
$this->security,
|
||||
$this->requestStack,
|
||||
$this->employeeRepository,
|
||||
$this->absenceRepository,
|
||||
$this->formationRepository,
|
||||
$resolver,
|
||||
new AbsenceSegmentsResolver(),
|
||||
new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver()),
|
||||
$this->buildHolidayResolver(),
|
||||
);
|
||||
|
||||
$row = $provider->provide(new Get())->rows[0];
|
||||
|
||||
self::assertSame('TIME', $row['trackingMode']);
|
||||
self::assertSame(39, $row['weeklyHours']);
|
||||
self::assertSame('39H', $row['contractType']);
|
||||
self::assertSame('Contrat', $row['contractName']);
|
||||
}
|
||||
|
||||
private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours): Employee
|
||||
{
|
||||
$contract = new Contract()
|
||||
|
||||
Reference in New Issue
Block a user