[#339] Ajout d'une page listant les règles de calcules (#5)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket | |------------------|-----------------| | #339 | Ajout d'une page listant les règles de calcules | ## Description de la PR [#339] Ajout d'une page listant les règles de calcules ## Modification du .env ## Check list - [ ] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: #5 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #5.
This commit is contained in:
121
src/State/EmployeeWriteProcessor.php
Normal file
121
src/State/EmployeeWriteProcessor.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Repository\EmployeeContractPeriodRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private ProcessorInterface $removeProcessor,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private EmployeeContractPeriodRepository $periodRepository,
|
||||
) {}
|
||||
|
||||
public function process(
|
||||
mixed $data,
|
||||
Operation $operation,
|
||||
array $uriVariables = [],
|
||||
array $context = []
|
||||
): mixed {
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if (!$data instanceof Employee) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$isNew = null === $data->getId();
|
||||
$previousContract = $this->resolvePreviousContract($data);
|
||||
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
|
||||
$currentContract = $data->getContract();
|
||||
if (!$currentContract instanceof Contract) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$today = new DateTimeImmutable('today');
|
||||
if ($isNew) {
|
||||
$this->ensureContractPeriodExists($data, $currentContract, $today);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($this->isSameContract($previousContract, $currentContract)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
|
||||
if (null !== $todayPeriod && null === $todayPeriod->getEndDate() && $todayPeriod->getStartDate() === $today) {
|
||||
$todayPeriod->setContract($currentContract);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$this->periodRepository->closeOpenPeriods($data, $today->modify('-1 day'));
|
||||
$this->createPeriod($data, $currentContract, $today);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function resolvePreviousContract(Employee $employee): ?Contract
|
||||
{
|
||||
if (null === $employee->getId()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($employee);
|
||||
$original = $originalData['contract'] ?? null;
|
||||
|
||||
return $original instanceof Contract ? $original : null;
|
||||
}
|
||||
|
||||
private function isSameContract(?Contract $first, ?Contract $second): bool
|
||||
{
|
||||
if (null === $first || null === $second) {
|
||||
return $first === $second;
|
||||
}
|
||||
|
||||
return $first->getId() === $second->getId();
|
||||
}
|
||||
|
||||
private function ensureContractPeriodExists(Employee $employee, Contract $contract, DateTimeImmutable $startDate): void
|
||||
{
|
||||
$covered = $this->periodRepository->findOneCoveringDate($employee, $startDate);
|
||||
if (null !== $covered) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->createPeriod($employee, $contract, $startDate);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
private function createPeriod(Employee $employee, Contract $contract, DateTimeImmutable $startDate): void
|
||||
{
|
||||
$period = new EmployeeContractPeriod()
|
||||
->setEmployee($employee)
|
||||
->setContract($contract)
|
||||
->setStartDate($startDate)
|
||||
->setEndDate(null)
|
||||
;
|
||||
|
||||
$this->entityManager->persist($period);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use App\Entity\WorkHour;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
@@ -27,6 +28,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
private Security $security,
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private WorkHourRepository $workHourRepository,
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
) {}
|
||||
|
||||
public function process(
|
||||
@@ -75,7 +77,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
|
||||
}
|
||||
|
||||
$isPresenceTracking = TrackingMode::PRESENCE->value === $employee->getContract()?->getTrackingMode();
|
||||
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
|
||||
$isPresenceTracking = TrackingMode::PRESENCE->value === $contract?->getTrackingMode();
|
||||
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
|
||||
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
|
||||
|
||||
// Calcule le crédit d'heures selon la politique métier (type d'absence + contrat).
|
||||
$creditedMinutes = $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $dateKey, $absentMorning, $absentAfternoon);
|
||||
$creditedPresenceUnits = $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $absentMorning, $absentAfternoon);
|
||||
$creditedPresenceUnits = $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $dateKey, $absentMorning, $absentAfternoon);
|
||||
$rowsByEmployeeId[$employeeId]->addAbsence(
|
||||
label: $absence->getType()?->getLabel(),
|
||||
morning: $absentMorning,
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Dto\WorkHours\WeeklyDaySummary;
|
||||
use App\Dto\WorkHours\WeeklySummaryRow;
|
||||
use App\Dto\WorkHours\WorkMetrics;
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use App\Entity\WorkHour;
|
||||
@@ -19,6 +20,7 @@ use App\Enum\TrackingMode;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||
use DateTimeImmutable;
|
||||
@@ -37,6 +39,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
||||
@@ -58,7 +61,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
$summary->weekStart = $weekStart->format('Y-m-d');
|
||||
$summary->weekEnd = $weekEnd->format('Y-m-d');
|
||||
$summary->days = $days;
|
||||
$summary->rows = $this->buildRows($employees, $workHours, $absences, $days);
|
||||
$summary->rows = $this->buildRows($employees, $workHours, $absences, $days, $anchorDate->format('Y-m-d'));
|
||||
|
||||
return $summary;
|
||||
}
|
||||
@@ -108,9 +111,10 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
*
|
||||
* @return list<WeeklySummaryRow>
|
||||
*/
|
||||
private function buildRows(array $employees, array $workHours, array $absences, array $days): array
|
||||
private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd): array
|
||||
{
|
||||
$metricsByEmployeeDate = [];
|
||||
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
||||
$metricsByEmployeeDate = [];
|
||||
foreach ($workHours as $workHour) {
|
||||
$employeeId = $workHour->getEmployee()?->getId();
|
||||
if (!$employeeId) {
|
||||
@@ -158,7 +162,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
$creditedByEmployeeDate[$employeeId][$date] = ($creditedByEmployeeDate[$employeeId][$date] ?? 0)
|
||||
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
|
||||
$creditedPresenceByEmployeeDate[$employeeId][$date] = ($creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0)
|
||||
+ $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $absentMorning, $absentAfternoon);
|
||||
+ $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $date, $absentMorning, $absentAfternoon);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,12 +179,20 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
$weeklyPresenceCount = 0.0;
|
||||
$daily = [];
|
||||
// Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées.
|
||||
$isPresenceTracking = TrackingMode::PRESENCE->value === $employee->getContract()?->getTrackingMode();
|
||||
$weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd]
|
||||
?? $contractsByEmployeeDate[$employeeId][$days[0]]
|
||||
?? null;
|
||||
$employeeContractsByDate = [];
|
||||
foreach ($days as $date) {
|
||||
$employeeContractsByDate[$date] = $contractsByEmployeeDate[$employeeId][$date] ?? null;
|
||||
}
|
||||
|
||||
foreach ($days as $date) {
|
||||
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
|
||||
$metrics = $entry['metrics'] ?? new WorkMetrics();
|
||||
$creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0;
|
||||
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
|
||||
$metrics = $entry['metrics'] ?? new WorkMetrics();
|
||||
$creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0;
|
||||
$contractAtDate = $employeeContractsByDate[$date] ?? null;
|
||||
$isPresenceTracking = TrackingMode::PRESENCE->value === $contractAtDate?->getTrackingMode();
|
||||
// Les absences "comptées comme travaillées" alimentent le total du jour.
|
||||
$metrics->addCreditedMinutes($creditedMinutes);
|
||||
$present = null;
|
||||
@@ -210,18 +222,20 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
);
|
||||
}
|
||||
|
||||
$contractWeeklyHours = $employee->getContract()?->getWeeklyHours();
|
||||
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($employee);
|
||||
$weeklyOvertimeTotalMinutes = $isPresenceTracking
|
||||
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
|
||||
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract);
|
||||
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate);
|
||||
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate);
|
||||
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
|
||||
? 0
|
||||
: $this->computeOvertimeTotalMinutes($weeklyTotalMinutes, $contractWeeklyHours);
|
||||
$weeklyOvertime25Minutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
||||
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
|
||||
$weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
||||
? 0
|
||||
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $contractWeeklyHours);
|
||||
$weeklyOvertime50Minutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
||||
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $overtime25StartMinutes);
|
||||
$weeklyOvertime50Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
||||
? 0
|
||||
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
|
||||
$weeklyRecoveryMinutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
||||
$weeklyRecoveryMinutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
||||
? 0
|
||||
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
|
||||
|
||||
@@ -230,9 +244,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
firstName: $employee->getFirstName(),
|
||||
lastName: $employee->getLastName(),
|
||||
siteName: $employee->getSite()?->getName(),
|
||||
contractName: $employee->getContract()?->getName(),
|
||||
contractType: $employee->getContract()?->getType()->value,
|
||||
trackingMode: $employee->getContract()?->getTrackingMode(),
|
||||
contractName: $weekAnchorContract?->getName(),
|
||||
contractType: $weekAnchorContract?->getType()->value,
|
||||
trackingMode: $weekAnchorContract?->getTrackingMode(),
|
||||
daily: $daily,
|
||||
weeklyDayMinutes: $weeklyDayMinutes,
|
||||
weeklyNightMinutes: $weeklyNightMinutes,
|
||||
@@ -344,25 +358,43 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
private function computeOvertimeTotalMinutes(int $weeklyTotalMinutes, ?int $contractWeeklyHours): int
|
||||
/**
|
||||
* @param array<string, ?Contract> $contractsByDate
|
||||
*/
|
||||
private function computeWeeklyOvertimeReferenceMinutes(array $days, array $contractsByDate): int
|
||||
{
|
||||
if (null === $contractWeeklyHours || $contractWeeklyHours <= 0) {
|
||||
return 0;
|
||||
$total = 0;
|
||||
foreach ($days as $date) {
|
||||
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
||||
$contract = $contractsByDate[$date] ?? null;
|
||||
$hours = $contract?->getWeeklyHours();
|
||||
$referenceHours = (null !== $hours && $hours > 0) ? max(35, $hours) : null;
|
||||
$total += $this->resolveDailyReferenceMinutes($referenceHours, $isoDay);
|
||||
}
|
||||
|
||||
// Règle métier: tout contrat < 35h est traité comme un 35h pour la base supp.
|
||||
$referenceHours = max(35, $contractWeeklyHours);
|
||||
|
||||
return max(0, $weeklyTotalMinutes - ($referenceHours * 60));
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, ?int $contractWeeklyHours): int
|
||||
/**
|
||||
* @param array<string, ?Contract> $contractsByDate
|
||||
*/
|
||||
private function computeWeeklyOvertime25StartMinutes(array $days, array $contractsByDate): int
|
||||
{
|
||||
// Règle métier:
|
||||
// - contrats <= 35h: 25% entre 35h et 43h
|
||||
// - contrats >= 39h: 25% entre 39h et 43h
|
||||
$startHours = (null !== $contractWeeklyHours && $contractWeeklyHours >= 39) ? 39 : 35;
|
||||
$trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - ($startHours * 60));
|
||||
$total = 0;
|
||||
foreach ($days as $date) {
|
||||
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
||||
$contract = $contractsByDate[$date] ?? null;
|
||||
$hours = $contract?->getWeeklyHours();
|
||||
$startHours = (null !== $hours && $hours >= 39) ? 39 : 35;
|
||||
$total += $this->resolveDailyReferenceMinutes($startHours, $isoDay);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, int $startMinutes): int
|
||||
{
|
||||
$trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - $startMinutes);
|
||||
|
||||
return (int) round($trancheMinutes * 0.25);
|
||||
}
|
||||
@@ -375,10 +407,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
return (int) round($trancheMinutes * 0.5);
|
||||
}
|
||||
|
||||
private function hasDisabledOvertimeBonuses(Employee $employee): bool
|
||||
private function hasDisabledOvertimeBonuses(?Contract $contract): bool
|
||||
{
|
||||
$contract = $employee->getContract();
|
||||
$type = ContractType::resolve(
|
||||
$type = ContractType::resolve(
|
||||
$contract?->getName(),
|
||||
$contract?->getTrackingMode(),
|
||||
$contract?->getWeeklyHours()
|
||||
@@ -386,4 +417,26 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
|
||||
return ContractType::INTERIM === $type;
|
||||
}
|
||||
|
||||
private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int
|
||||
{
|
||||
// Week-end hors base de référence.
|
||||
if ($isoWeekDay >= 6) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (null === $weeklyHours || $weeklyHours <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (39 === $weeklyHours) {
|
||||
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
|
||||
}
|
||||
|
||||
if (35 === $weeklyHours) {
|
||||
return 7 * 60;
|
||||
}
|
||||
|
||||
return (int) round(($weeklyHours * 60) / 5);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user