feat(night-contingent) : service partage NightHoursCalculator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 11:36:45 +02:00
parent c199665349
commit e969955943
2 changed files with 172 additions and 0 deletions
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Service\WorkHours;
use App\Entity\WorkHour;
/**
* Calcul des minutes travaillees de nuit (fenetre 21h->6h).
*
* Fenetres en minutes depuis 00:00 : [0,360] (00:00-06:00) et [1260,1440]
* (21:00-24:00). On projette sur J+1 pour les shifts qui traversent minuit.
* Source de verite unique partagee par les ecrans Heures et les exports.
*/
final class NightHoursCalculator
{
/**
* Minutes de nuit d'un WorkHour. Conducteurs : champ manuel nightHoursMinutes.
* Non-conducteurs : somme calculee depuis les plages matin/apres-midi/soir.
*/
public function nightMinutesForWorkHour(WorkHour $workHour, bool $isDriver): int
{
if ($isDriver) {
return $workHour->getNightHoursMinutes() ?? 0;
}
return $this->nightMinutesFromRanges($workHour);
}
public function nightMinutesFromRanges(WorkHour $workHour): int
{
$ranges = [
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
];
$total = 0;
foreach ($ranges as [$from, $to]) {
$total += $this->nightIntervalMinutes($from, $to);
}
return $total;
}
public function nightIntervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
$windows = [[0, 360], [1260, 1440]];
$total = 0;
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
$shift = $dayOffset * 1440;
foreach ($windows as [$windowStart, $windowEnd]) {
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
}
}
return $total;
}
/**
* @return null|array{int, int}
*/
private function resolveInterval(?string $from, ?string $to): ?array
{
$fromMinutes = $this->toMinutes($from);
$toMinutes = $this->toMinutes($to);
if (null === $fromMinutes || null === $toMinutes) {
return null;
}
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
return [$fromMinutes, $end];
}
private function toMinutes(?string $time): ?int
{
if (null === $time || '' === $time) {
return null;
}
[$hours, $minutes] = array_map('intval', explode(':', $time));
return ($hours * 60) + $minutes;
}
private function overlap(int $startA, int $endA, int $startB, int $endB): int
{
$start = max($startA, $startB);
$end = min($endA, $endB);
return max(0, $end - $start);
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\WorkHours;
use App\Entity\WorkHour;
use App\Service\WorkHours\NightHoursCalculator;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class NightHoursCalculatorTest extends TestCase
{
public function testNullRangeReturnsZero(): void
{
$calc = new NightHoursCalculator();
self::assertSame(0, $calc->nightIntervalMinutes(null, null));
self::assertSame(0, $calc->nightIntervalMinutes('08:00', null));
}
public function testPureDayRangeHasNoNight(): void
{
$calc = new NightHoursCalculator();
// 08:00 -> 17:00 : entierement hors fenetres nuit (00:00-06:00, 21:00-24:00).
self::assertSame(0, $calc->nightIntervalMinutes('08:00', '17:00'));
}
public function testEveningWindowCounts(): void
{
$calc = new NightHoursCalculator();
// 21:00 -> 24:00 = 180 min de nuit.
self::assertSame(180, $calc->nightIntervalMinutes('21:00', '00:00'));
}
public function testShiftCrossingMidnightCountsBothWindows(): void
{
$calc = new NightHoursCalculator();
// 21:00 -> 05:00 : 21-24 (180) + 00-05 (300) = 480 min.
self::assertSame(480, $calc->nightIntervalMinutes('21:00', '05:00'));
}
public function testNightMinutesForWorkHourDriverUsesManualField(): void
{
$calc = new NightHoursCalculator();
$wh = new WorkHour();
$wh->setWorkDate(new DateTimeImmutable('2026-01-15'))
->setDayHoursMinutes(300)
->setNightHoursMinutes(250)
->setMorningFrom('08:00')->setMorningTo('12:00')
;
// Driver -> champ manuel nightHoursMinutes, plages ignorees.
self::assertSame(250, $calc->nightMinutesForWorkHour($wh, true));
}
public function testNightMinutesForWorkHourNonDriverSumsRanges(): void
{
$calc = new NightHoursCalculator();
$wh = new WorkHour();
$wh->setWorkDate(new DateTimeImmutable('2026-01-15'))
->setMorningFrom('22:00')->setMorningTo('00:00') // 120 min nuit
->setEveningFrom('04:00')->setEveningTo('06:00') // 120 min nuit
;
self::assertSame(240, $calc->nightMinutesForWorkHour($wh, false));
}
}