Ajout des notification + page employé (#6)
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 | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: #6 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #6.
This commit is contained in:
88
src/State/EmployeeFractionedDaysProcessor.php
Normal file
88
src/State/EmployeeFractionedDaysProcessor.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\ApiResource\EmployeeFractionedDaysInput;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeLeaveBalance;
|
||||
use App\Enum\ContractType;
|
||||
use App\Enum\LeaveRuleCode;
|
||||
use App\Repository\EmployeeLeaveBalanceRepository;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
final readonly class EmployeeFractionedDaysProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeFractionedDaysInput
|
||||
{
|
||||
if (!$data instanceof EmployeeFractionedDaysInput) {
|
||||
throw new UnprocessableEntityHttpException('Invalid payload.');
|
||||
}
|
||||
|
||||
$employeeId = (int) ($uriVariables['id'] ?? 0);
|
||||
if ($employeeId <= 0) {
|
||||
throw new UnprocessableEntityHttpException('id must be a positive integer.');
|
||||
}
|
||||
|
||||
$employee = $this->employeeRepository->find($employeeId);
|
||||
if (!$employee instanceof Employee) {
|
||||
throw new NotFoundHttpException('Employee not found.');
|
||||
}
|
||||
|
||||
$year = $data->year ?? $this->resolveCurrentYear($employee);
|
||||
$ruleCode = $this->resolveRuleCode($employee);
|
||||
|
||||
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
|
||||
|
||||
if (null === $balance) {
|
||||
$balance = new EmployeeLeaveBalance();
|
||||
$balance->setEmployee($employee);
|
||||
$balance->setRuleCode($ruleCode);
|
||||
$balance->setYear($year);
|
||||
$this->entityManager->persist($balance);
|
||||
}
|
||||
|
||||
$balance->setFractionedDays($data->fractionedDays);
|
||||
$balance->touch();
|
||||
$this->entityManager->flush();
|
||||
|
||||
$data->year = $year;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function resolveRuleCode(Employee $employee): LeaveRuleCode
|
||||
{
|
||||
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
|
||||
return LeaveRuleCode::FORFAIT_218;
|
||||
}
|
||||
|
||||
return LeaveRuleCode::CDI_CDD_NON_FORFAIT;
|
||||
}
|
||||
|
||||
private function resolveCurrentYear(Employee $employee): int
|
||||
{
|
||||
$today = new DateTimeImmutable('today');
|
||||
|
||||
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
|
||||
return (int) $today->format('Y');
|
||||
}
|
||||
|
||||
$month = (int) $today->format('n');
|
||||
|
||||
return $month >= 6 ? (int) $today->format('Y') + 1 : (int) $today->format('Y');
|
||||
}
|
||||
}
|
||||
17
src/State/EmployeeFractionedDaysProvider.php
Normal file
17
src/State/EmployeeFractionedDaysProvider.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\EmployeeFractionedDaysInput;
|
||||
|
||||
final readonly class EmployeeFractionedDaysProvider implements ProviderInterface
|
||||
{
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeFractionedDaysInput
|
||||
{
|
||||
return new EmployeeFractionedDaysInput();
|
||||
}
|
||||
}
|
||||
662
src/State/EmployeeLeaveSummaryProvider.php
Normal file
662
src/State/EmployeeLeaveSummaryProvider.php
Normal file
@@ -0,0 +1,662 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\EmployeeLeaveSummary;
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Enum\ContractType;
|
||||
use App\Enum\LeaveRuleCode;
|
||||
use App\Repository\AbsenceRepository;
|
||||
use App\Repository\EmployeeContractPeriodRepository;
|
||||
use App\Repository\EmployeeLeaveBalanceRepository;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use App\Service\Leave\LeaveBalanceComputationService;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Throwable;
|
||||
|
||||
final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
{
|
||||
private const int FORFAIT_TARGET_WORKED_DAYS = 218;
|
||||
private const float CDI_NON_FORFAIT_STANDARD_ACQUIRED_DAYS = 25.0;
|
||||
private const float CDI_NON_FORFAIT_STANDARD_ACQUIRED_SATURDAYS = 5.0;
|
||||
private const float CDI_NON_FORFAIT_STANDARD_ACCRUAL_PER_MONTH = self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_DAYS / 12.0;
|
||||
private const float CDI_NON_FORFAIT_STANDARD_SATURDAY_ACCRUAL_PER_MONTH = self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_SATURDAYS / 12.0;
|
||||
private const float CDI_NON_FORFAIT_4H_ACQUIRED_DAYS = 10.0;
|
||||
private const float CDI_NON_FORFAIT_4H_ACQUIRED_SATURDAYS = 0.0;
|
||||
private const float CDI_NON_FORFAIT_4H_ACCRUAL_PER_MONTH = 0.83;
|
||||
private const float CDI_NON_FORFAIT_4H_SATURDAY_ACCRUAL_PER_MONTH = 0.0;
|
||||
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private RequestStack $requestStack,
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private EmployeeScopeService $employeeScopeService,
|
||||
private AbsenceRepository $absenceRepository,
|
||||
private EmployeeContractPeriodRepository $periodRepository,
|
||||
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
|
||||
private LeaveBalanceComputationService $leaveBalanceComputationService,
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeLeaveSummary
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
throw new AccessDeniedHttpException('Authentication required.');
|
||||
}
|
||||
|
||||
$employeeId = (int) ($uriVariables['id'] ?? 0);
|
||||
if ($employeeId <= 0) {
|
||||
throw new UnprocessableEntityHttpException('id must be a positive integer.');
|
||||
}
|
||||
|
||||
$employee = $this->employeeRepository->find($employeeId);
|
||||
if (!$employee instanceof Employee) {
|
||||
throw new NotFoundHttpException('Employee not found.');
|
||||
}
|
||||
|
||||
if (!$this->employeeScopeService->canAccessEmployee($user, $employee)) {
|
||||
throw new AccessDeniedHttpException('Employee outside your scope.');
|
||||
}
|
||||
|
||||
$year = $this->resolveYear($employee);
|
||||
|
||||
$summary = new EmployeeLeaveSummary();
|
||||
$summary->year = $year;
|
||||
$summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value;
|
||||
|
||||
$yearSummary = $this->computeYearSummary($employee, $year);
|
||||
if (null === $yearSummary) {
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$fractionedDays = $this->resolveFractionedDays($employee, $yearSummary['ruleCode'], $year);
|
||||
|
||||
$summary->isSupported = true;
|
||||
$summary->ruleCode = $yearSummary['ruleCode'];
|
||||
$summary->acquiredDays = $yearSummary['acquiredDays'] + $fractionedDays;
|
||||
$summary->acquiredSaturdays = $yearSummary['acquiredSaturdays'];
|
||||
$summary->fractionedDays = $fractionedDays;
|
||||
$summary->accruingDays = $yearSummary['accruingDays'];
|
||||
$summary->takenDays = $yearSummary['takenDays'];
|
||||
$summary->takenSaturdays = $yearSummary['takenSaturdays'];
|
||||
$summary->remainingDays = $yearSummary['remainingDays'] + $fractionedDays;
|
||||
$summary->remainingSaturdays = $yearSummary['remainingSaturdays'];
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|array{
|
||||
* ruleCode: string,
|
||||
* acquiredDays: float,
|
||||
* acquiredSaturdays: float,
|
||||
* accruingDays: float,
|
||||
* takenDays: float,
|
||||
* takenSaturdays: float,
|
||||
* remainingDays: float,
|
||||
* remainingSaturdays: float
|
||||
* }
|
||||
*/
|
||||
private function computeYearSummary(Employee $employee, int $targetYear): ?array
|
||||
{
|
||||
$firstYear = $this->resolveFirstComputationYear($employee);
|
||||
if ($targetYear < $firstYear) {
|
||||
$targetYear = $firstYear;
|
||||
}
|
||||
|
||||
$previousRemainingDays = 0.0;
|
||||
$previousRemainingSaturdays = 0.0;
|
||||
$targetSummary = null;
|
||||
|
||||
for ($year = $firstYear; $year <= $targetYear; ++$year) {
|
||||
[$from, $to] = $this->resolvePeriodBounds($employee, $year);
|
||||
$leavePolicy = $this->resolveLeavePolicy($employee, $from, $to);
|
||||
if (null === $leavePolicy) {
|
||||
if ($year === $targetYear) {
|
||||
return null;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$carryDays = 0.0;
|
||||
$carrySaturdays = 0.0;
|
||||
$openingBalance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear(
|
||||
$employee,
|
||||
$leavePolicy['ruleCode'],
|
||||
$year
|
||||
);
|
||||
if (null !== $openingBalance) {
|
||||
$carryDays = $openingBalance->getOpeningDays();
|
||||
$carrySaturdays = $leavePolicy['splitSaturdays'] ? $openingBalance->getOpeningSaturdays() : 0.0;
|
||||
} elseif ($year > $firstYear) {
|
||||
$ruleCode = LeaveRuleCode::from($leavePolicy['ruleCode']);
|
||||
[$carryDays, $carrySaturdays] = $this->leaveBalanceComputationService
|
||||
->computeDynamicClosingForYear($employee, $ruleCode, $year - 1)
|
||||
;
|
||||
[$previousFrom, $previousTo] = $this->leaveBalanceComputationService->resolvePeriodBounds($ruleCode, $year - 1);
|
||||
$hasSettlement = $this->leaveBalanceComputationService
|
||||
->hasPaidLeaveSettledClosureBetween($employee, $previousFrom, $previousTo)
|
||||
;
|
||||
if ($hasSettlement) {
|
||||
$carryDays = 0.0;
|
||||
$carrySaturdays = 0.0;
|
||||
} elseif (!$leavePolicy['splitSaturdays']) {
|
||||
$carrySaturdays = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
$effectiveFrom = $this->resolveEffectivePeriodStart($employee, $from, $to);
|
||||
$hasShiftedStart = $effectiveFrom > $from;
|
||||
if ($hasShiftedStart) {
|
||||
$carryDays = 0.0;
|
||||
$carrySaturdays = 0.0;
|
||||
}
|
||||
|
||||
$calculationEnd = $this->resolveCalculationEndDate($leavePolicy['ruleCode'], $year, $to);
|
||||
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
|
||||
? $this->computeAccruedDaysFromStart(
|
||||
$leavePolicy['acquiredDays'],
|
||||
$leavePolicy['accrualPerMonth'],
|
||||
$effectiveFrom,
|
||||
$calculationEnd
|
||||
)
|
||||
: 0.0;
|
||||
$generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
|
||||
? $this->computeAccruedDaysFromStart(
|
||||
$leavePolicy['acquiredSaturdays'],
|
||||
$leavePolicy['saturdayAccrualPerMonth'],
|
||||
$effectiveFrom,
|
||||
$calculationEnd
|
||||
)
|
||||
: 0.0;
|
||||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
||||
[$takenDays, $takenSaturdays] = $this->computeTakenAbsences(
|
||||
$absences,
|
||||
$effectiveFrom,
|
||||
$calculationEnd,
|
||||
$leavePolicy['countOnlyCp'],
|
||||
$leavePolicy['splitSaturdays']
|
||||
);
|
||||
if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']) {
|
||||
$availableAcquired = max(0.0, $carryDays);
|
||||
$takenFromAcquired = min($availableAcquired, $takenDays);
|
||||
$remainingAcquired = $carryDays - $takenFromAcquired;
|
||||
$remainingToImpute = max(0.0, $takenDays - $takenFromAcquired);
|
||||
$remainingGenerated = $generatedDays - $remainingToImpute;
|
||||
|
||||
$availableAcquiredSaturdays = max(0.0, $carrySaturdays);
|
||||
$takenFromAcquiredSaturdays = min($availableAcquiredSaturdays, $takenSaturdays);
|
||||
$remainingAcquiredSaturdays = $carrySaturdays - $takenFromAcquiredSaturdays;
|
||||
$remainingSaturdaysToImpute = max(0.0, $takenSaturdays - $takenFromAcquiredSaturdays);
|
||||
$remainingGeneratedSaturdays = $generatedSaturdays - $remainingSaturdaysToImpute;
|
||||
|
||||
$acquiredDays = $carryDays;
|
||||
$accruingDays = $remainingGenerated + $remainingGeneratedSaturdays;
|
||||
$remainingDays = $remainingAcquired;
|
||||
$acquiredSaturdays = $carrySaturdays;
|
||||
$remainingSaturdays = max(0.0, $remainingAcquiredSaturdays);
|
||||
|
||||
$previousRemainingDays = $remainingAcquired + $remainingGenerated;
|
||||
$previousRemainingSaturdays = $remainingAcquiredSaturdays + $remainingGeneratedSaturdays;
|
||||
} else {
|
||||
// Forfait: no "en cours d'acquisition" counter, all rights are in acquired.
|
||||
$acquiredDays = $carryDays + $leavePolicy['acquiredDays'];
|
||||
$accruingDays = 0.0;
|
||||
$remainingDays = max(0.0, $acquiredDays - $takenDays);
|
||||
$acquiredSaturdays = 0.0;
|
||||
$remainingSaturdays = 0.0;
|
||||
|
||||
$previousRemainingDays = $remainingDays;
|
||||
$previousRemainingSaturdays = 0.0;
|
||||
}
|
||||
|
||||
if ($year === $targetYear) {
|
||||
$targetSummary = [
|
||||
'ruleCode' => $leavePolicy['ruleCode'],
|
||||
'acquiredDays' => $acquiredDays,
|
||||
'acquiredSaturdays' => $acquiredSaturdays,
|
||||
'accruingDays' => $accruingDays,
|
||||
'takenDays' => $takenDays,
|
||||
'takenSaturdays' => $takenSaturdays,
|
||||
'remainingDays' => $remainingDays,
|
||||
'remainingSaturdays' => $remainingSaturdays,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $targetSummary;
|
||||
}
|
||||
|
||||
private function resolveEffectivePeriodStart(
|
||||
Employee $employee,
|
||||
DateTimeImmutable $from,
|
||||
DateTimeImmutable $to
|
||||
): DateTimeImmutable {
|
||||
$latestSettledClosure = $this->periodRepository->findLatestPaidLeaveSettledClosureDateBetween($employee, $from, $to);
|
||||
$start = $from;
|
||||
if (null !== $latestSettledClosure) {
|
||||
$nextDay = $latestSettledClosure->modify('+1 day');
|
||||
if ($nextDay > $start) {
|
||||
$start = $nextDay;
|
||||
}
|
||||
}
|
||||
|
||||
$earliestContractStart = $this->resolveEarliestContractStartWithinRange($employee, $from, $to);
|
||||
if (null !== $earliestContractStart && $earliestContractStart > $start) {
|
||||
$start = $earliestContractStart;
|
||||
}
|
||||
|
||||
return $start;
|
||||
}
|
||||
|
||||
private function resolveEarliestContractStartWithinRange(
|
||||
Employee $employee,
|
||||
DateTimeImmutable $from,
|
||||
DateTimeImmutable $to
|
||||
): ?DateTimeImmutable {
|
||||
$earliest = null;
|
||||
foreach ($employee->getContractHistory() as $period) {
|
||||
$start = DateTimeImmutable::createFromFormat('Y-m-d', $period->startDate);
|
||||
if (!$start instanceof DateTimeImmutable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$end = null;
|
||||
if (null !== $period->endDate && '' !== trim($period->endDate)) {
|
||||
$end = DateTimeImmutable::createFromFormat('Y-m-d', $period->endDate);
|
||||
}
|
||||
|
||||
if ($start > $to) {
|
||||
continue;
|
||||
}
|
||||
if ($end instanceof DateTimeImmutable && $end < $from) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidate = $start < $from ? $from : $start;
|
||||
if (null === $earliest || $candidate < $earliest) {
|
||||
$earliest = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $earliest;
|
||||
}
|
||||
|
||||
private function resolveYear(Employee $employee): int
|
||||
{
|
||||
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
||||
if ('' === $raw) {
|
||||
$today = new DateTimeImmutable('today');
|
||||
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
|
||||
return (int) $today->format('Y');
|
||||
}
|
||||
|
||||
return $this->resolveCurrentLeaveYear($today);
|
||||
}
|
||||
|
||||
if (!preg_match('/^\d{4}$/', $raw)) {
|
||||
throw new UnprocessableEntityHttpException('year must use YYYY format.');
|
||||
}
|
||||
|
||||
$year = (int) $raw;
|
||||
if ($year < 2000 || $year > 2100) {
|
||||
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
||||
}
|
||||
|
||||
return $year;
|
||||
}
|
||||
|
||||
private function computeAccruedDaysFromStart(
|
||||
float $acquiredDays,
|
||||
float $accrualPerMonth,
|
||||
DateTimeImmutable $periodStart,
|
||||
?DateTimeImmutable $periodEnd
|
||||
): float {
|
||||
if ($accrualPerMonth <= 0.0) {
|
||||
return $acquiredDays;
|
||||
}
|
||||
|
||||
if (!$periodEnd instanceof DateTimeImmutable || $periodEnd < $periodStart) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$monthsElapsed = ((int) $periodEnd->format('Y') - (int) $periodStart->format('Y')) * 12
|
||||
+ ((int) $periodEnd->format('n') - (int) $periodStart->format('n'))
|
||||
+ 1;
|
||||
if ($monthsElapsed < 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return min($acquiredDays, $monthsElapsed * $accrualPerMonth);
|
||||
}
|
||||
|
||||
private function resolveCalculationEndDate(
|
||||
string $ruleCode,
|
||||
int $year,
|
||||
DateTimeImmutable $periodEnd
|
||||
): ?DateTimeImmutable {
|
||||
$today = new DateTimeImmutable('today');
|
||||
$currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode
|
||||
? (int) $today->format('Y')
|
||||
: $this->resolveCurrentLeaveYear($today);
|
||||
|
||||
if ($year < $currentYear) {
|
||||
return $periodEnd;
|
||||
}
|
||||
if ($year > $currentYear) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lastDayPreviousMonth = $today
|
||||
->modify('first day of this month')
|
||||
->modify('-1 day')
|
||||
;
|
||||
|
||||
return $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|array{
|
||||
* ruleCode: string,
|
||||
* acquiredDays: float,
|
||||
* acquiredSaturdays: float,
|
||||
* accrualPerMonth: float,
|
||||
* saturdayAccrualPerMonth: float,
|
||||
* countOnlyCp: bool,
|
||||
* splitSaturdays: bool
|
||||
* }
|
||||
*/
|
||||
private function resolveLeavePolicy(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): ?array
|
||||
{
|
||||
$type = $employee->getContract()?->getType();
|
||||
if (ContractType::FORFAIT === $type) {
|
||||
$businessDaysInPeriod = $this->countBusinessDays($from, $to);
|
||||
|
||||
return [
|
||||
'ruleCode' => LeaveRuleCode::FORFAIT_218->value,
|
||||
'acquiredDays' => (float) max(0, $businessDaysInPeriod - self::FORFAIT_TARGET_WORKED_DAYS),
|
||||
'acquiredSaturdays' => 0.0,
|
||||
'accrualPerMonth' => 0.0,
|
||||
'saturdayAccrualPerMonth' => 0.0,
|
||||
'countOnlyCp' => false,
|
||||
'splitSaturdays' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
|
||||
if (ContractNature::CDI !== $nature && ContractNature::CDD !== $nature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$weeklyHours = $employee->getContract()?->getWeeklyHours();
|
||||
if (4 === $weeklyHours) {
|
||||
return [
|
||||
'ruleCode' => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value,
|
||||
'acquiredDays' => self::CDI_NON_FORFAIT_4H_ACQUIRED_DAYS,
|
||||
'acquiredSaturdays' => self::CDI_NON_FORFAIT_4H_ACQUIRED_SATURDAYS,
|
||||
'accrualPerMonth' => self::CDI_NON_FORFAIT_4H_ACCRUAL_PER_MONTH,
|
||||
'saturdayAccrualPerMonth' => self::CDI_NON_FORFAIT_4H_SATURDAY_ACCRUAL_PER_MONTH,
|
||||
'countOnlyCp' => true,
|
||||
'splitSaturdays' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ruleCode' => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value,
|
||||
'acquiredDays' => self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_DAYS,
|
||||
'acquiredSaturdays' => self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_SATURDAYS,
|
||||
'accrualPerMonth' => self::CDI_NON_FORFAIT_STANDARD_ACCRUAL_PER_MONTH,
|
||||
'saturdayAccrualPerMonth' => self::CDI_NON_FORFAIT_STANDARD_SATURDAY_ACCRUAL_PER_MONTH,
|
||||
'countOnlyCp' => true,
|
||||
'splitSaturdays' => true,
|
||||
];
|
||||
}
|
||||
|
||||
private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): int
|
||||
{
|
||||
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
|
||||
$count = 0;
|
||||
for ($cursor = $from; $cursor <= $to; $cursor = $cursor->modify('+1 day')) {
|
||||
$weekDay = (int) $cursor->format('N');
|
||||
$dayKey = $cursor->format('Y-m-d');
|
||||
if ($weekDay <= 5 && !isset($publicHolidays[$dayKey])) {
|
||||
++$count;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
{
|
||||
$map = [];
|
||||
$startYear = (int) $from->format('Y');
|
||||
$endYear = (int) $to->format('Y');
|
||||
|
||||
try {
|
||||
for ($year = $startYear; $year <= $endYear; ++$year) {
|
||||
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
|
||||
foreach ($holidays as $date => $label) {
|
||||
$map[(string) $date] = (string) $label;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||||
*/
|
||||
private function resolvePeriodBounds(Employee $employee, int $year): array
|
||||
{
|
||||
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
|
||||
return $this->resolveForfaitYearBounds($employee, $year);
|
||||
}
|
||||
|
||||
return $this->resolveLeavePeriodBounds($year);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||||
*/
|
||||
private function resolveLeavePeriodBounds(int $leaveYear): array
|
||||
{
|
||||
// Exercice CP "2026" = du 1er juin 2025 au 31 mai 2026.
|
||||
$from = new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1));
|
||||
$to = new DateTimeImmutable(sprintf('%d-05-31', $leaveYear));
|
||||
|
||||
return [$from, $to];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||||
*/
|
||||
private function resolveForfaitYearBounds(Employee $employee, int $year): array
|
||||
{
|
||||
$from = new DateTimeImmutable(sprintf('%d-01-01', $year));
|
||||
$to = new DateTimeImmutable(sprintf('%d-12-31', $year));
|
||||
|
||||
$contractStartRaw = $employee->getCurrentContractStartDate();
|
||||
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
|
||||
$contractStart = DateTimeImmutable::createFromFormat('Y-m-d', $contractStartRaw);
|
||||
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
|
||||
$from = $contractStart;
|
||||
}
|
||||
}
|
||||
|
||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||
$contractEnd = DateTimeImmutable::createFromFormat('Y-m-d', $contractEndRaw);
|
||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
|
||||
$to = $contractEnd;
|
||||
}
|
||||
}
|
||||
|
||||
return [$from, $to];
|
||||
}
|
||||
|
||||
private function resolveFractionedDays(Employee $employee, string $ruleCode, int $year): float
|
||||
{
|
||||
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
|
||||
|
||||
return null !== $balance ? $balance->getFractionedDays() : 0.0;
|
||||
}
|
||||
|
||||
private function resolveCurrentLeaveYear(DateTimeImmutable $today): int
|
||||
{
|
||||
$year = (int) $today->format('Y');
|
||||
$month = (int) $today->format('n');
|
||||
|
||||
return $month >= 6 ? $year + 1 : $year;
|
||||
}
|
||||
|
||||
private function resolveFirstComputationYear(Employee $employee): int
|
||||
{
|
||||
$isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
|
||||
$fallbackYear = $isForfait
|
||||
? (int) new DateTimeImmutable('today')->format('Y')
|
||||
: $this->resolveCurrentLeaveYear(new DateTimeImmutable('today'));
|
||||
|
||||
$history = $employee->getContractHistory();
|
||||
if ([] === $history) {
|
||||
return $fallbackYear;
|
||||
}
|
||||
|
||||
$oldestStartDate = null;
|
||||
foreach ($history as $item) {
|
||||
$start = DateTimeImmutable::createFromFormat('Y-m-d', $item->startDate);
|
||||
if (!$start) {
|
||||
continue;
|
||||
}
|
||||
if (null === $oldestStartDate || $start < $oldestStartDate) {
|
||||
$oldestStartDate = $start;
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $oldestStartDate) {
|
||||
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
||||
|
||||
return null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear);
|
||||
}
|
||||
|
||||
$firstYear = $isForfait
|
||||
? (int) $oldestStartDate->format('Y')
|
||||
: ((int) $oldestStartDate->format('n') >= 6
|
||||
? (int) $oldestStartDate->format('Y') + 1
|
||||
: (int) $oldestStartDate->format('Y'));
|
||||
|
||||
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
||||
if (null !== $oldestBalanceYear && $oldestBalanceYear < $firstYear) {
|
||||
return $oldestBalanceYear;
|
||||
}
|
||||
|
||||
return $firstYear;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Absence> $absences
|
||||
*
|
||||
* @return array{float, float}
|
||||
*/
|
||||
private function computeTakenAbsences(
|
||||
array $absences,
|
||||
DateTimeImmutable $from,
|
||||
?DateTimeImmutable $to,
|
||||
bool $countOnlyCp,
|
||||
bool $splitSaturdays
|
||||
): array {
|
||||
$takenDays = 0.0;
|
||||
$takenSaturdays = 0.0;
|
||||
|
||||
if (!$to instanceof DateTimeImmutable || $to < $from) {
|
||||
return [$takenDays, $takenSaturdays];
|
||||
}
|
||||
|
||||
foreach ($absences as $absence) {
|
||||
if ($countOnlyCp) {
|
||||
$typeCode = strtoupper((string) $absence->getType()?->getCode());
|
||||
if ('C' !== $typeCode) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $absence->getType()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$start = DateTimeImmutable::createFromInterface($absence->getStartDate());
|
||||
$end = DateTimeImmutable::createFromInterface($absence->getEndDate());
|
||||
$rangeStart = $start < $from ? $from : $start;
|
||||
$rangeEnd = $end > $to ? $to : $end;
|
||||
if ($rangeEnd < $rangeStart) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for ($cursor = $rangeStart; $cursor <= $rangeEnd; $cursor = $cursor->modify('+1 day')) {
|
||||
[$am, $pm] = $this->resolveSegmentsForDate($absence, $cursor->format('Y-m-d'));
|
||||
$dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
|
||||
if ($dayAmount <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isSaturday = $splitSaturdays && '6' === $cursor->format('N');
|
||||
if ($isSaturday && $splitSaturdays) {
|
||||
$takenSaturdays += $dayAmount;
|
||||
} else {
|
||||
$takenDays += $dayAmount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [$takenDays, $takenSaturdays];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{bool, bool}
|
||||
*/
|
||||
private function resolveSegmentsForDate(Absence $absence, string $date): array
|
||||
{
|
||||
$startDate = $absence->getStartDate()->format('Y-m-d');
|
||||
$endDate = $absence->getEndDate()->format('Y-m-d');
|
||||
$startHalf = $absence->getStartHalf()->value;
|
||||
$endHalf = $absence->getEndHalf()->value;
|
||||
|
||||
$isStart = $date === $startDate;
|
||||
$isEnd = $date === $endDate;
|
||||
$isSingleDay = $startDate === $endDate;
|
||||
|
||||
if ($isSingleDay) {
|
||||
return ['AM' === $startHalf, 'PM' === $endHalf];
|
||||
}
|
||||
if ($isStart) {
|
||||
return ['AM' === $startHalf, true];
|
||||
}
|
||||
if ($isEnd) {
|
||||
return [true, 'PM' === $endHalf];
|
||||
}
|
||||
|
||||
return [true, true];
|
||||
}
|
||||
}
|
||||
86
src/State/EmployeeRttPaymentProcessor.php
Normal file
86
src/State/EmployeeRttPaymentProcessor.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\ApiResource\EmployeeRttPaymentInput;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeRttPayment;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\EmployeeRttPaymentRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
use function in_array;
|
||||
|
||||
final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
|
||||
{
|
||||
if (!$data instanceof EmployeeRttPaymentInput) {
|
||||
throw new UnprocessableEntityHttpException('Invalid payload.');
|
||||
}
|
||||
|
||||
$employeeId = (int) ($uriVariables['id'] ?? 0);
|
||||
if ($employeeId <= 0) {
|
||||
throw new UnprocessableEntityHttpException('id must be a positive integer.');
|
||||
}
|
||||
|
||||
$employee = $this->employeeRepository->find($employeeId);
|
||||
if (!$employee instanceof Employee) {
|
||||
throw new NotFoundHttpException('Employee not found.');
|
||||
}
|
||||
|
||||
if (!in_array($data->rate, ['25', '50'], true)) {
|
||||
throw new UnprocessableEntityHttpException('rate must be "25" or "50".');
|
||||
}
|
||||
|
||||
if ($data->month < 1 || $data->month > 12) {
|
||||
throw new UnprocessableEntityHttpException('month must be between 1 and 12.');
|
||||
}
|
||||
|
||||
if ($data->minutes < 0) {
|
||||
throw new UnprocessableEntityHttpException('minutes must be >= 0.');
|
||||
}
|
||||
|
||||
$year = $data->year ?? $this->resolveCurrentExerciseYear();
|
||||
|
||||
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonthRate($employee, $year, $data->month, $data->rate);
|
||||
|
||||
if (null === $payment) {
|
||||
$payment = new EmployeeRttPayment();
|
||||
$payment->setEmployee($employee);
|
||||
$payment->setYear($year);
|
||||
$payment->setMonth($data->month);
|
||||
$payment->setRate($data->rate);
|
||||
$this->entityManager->persist($payment);
|
||||
}
|
||||
|
||||
$payment->setMinutes($data->minutes);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$data->year = $year;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function resolveCurrentExerciseYear(): int
|
||||
{
|
||||
$today = new DateTimeImmutable('today');
|
||||
$year = (int) $today->format('Y');
|
||||
$month = (int) $today->format('n');
|
||||
|
||||
return $month >= 6 ? $year + 1 : $year;
|
||||
}
|
||||
}
|
||||
17
src/State/EmployeeRttPaymentProvider.php
Normal file
17
src/State/EmployeeRttPaymentProvider.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\EmployeeRttPaymentInput;
|
||||
|
||||
final readonly class EmployeeRttPaymentProvider implements ProviderInterface
|
||||
{
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
|
||||
{
|
||||
return new EmployeeRttPaymentInput();
|
||||
}
|
||||
}
|
||||
163
src/State/EmployeeRttSummaryProvider.php
Normal file
163
src/State/EmployeeRttSummaryProvider.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\EmployeeRttSummary;
|
||||
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
||||
use App\Dto\Rtt\RttMonthPayment;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\EmployeeRttBalanceRepository;
|
||||
use App\Repository\EmployeeRttPaymentRepository;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use App\Service\Rtt\RttRecoveryComputationService;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private RequestStack $requestStack,
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private EmployeeScopeService $employeeScopeService,
|
||||
private EmployeeRttBalanceRepository $rttBalanceRepository,
|
||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||
private RttRecoveryComputationService $rttRecoveryService,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttSummary
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
throw new AccessDeniedHttpException('Authentication required.');
|
||||
}
|
||||
|
||||
$employeeId = (int) ($uriVariables['id'] ?? 0);
|
||||
if ($employeeId <= 0) {
|
||||
throw new UnprocessableEntityHttpException('id must be a positive integer.');
|
||||
}
|
||||
|
||||
$employee = $this->employeeRepository->find($employeeId);
|
||||
if (!$employee instanceof Employee) {
|
||||
throw new NotFoundHttpException('Employee not found.');
|
||||
}
|
||||
|
||||
if (!$this->employeeScopeService->canAccessEmployee($user, $employee)) {
|
||||
throw new AccessDeniedHttpException('Employee outside your scope.');
|
||||
}
|
||||
|
||||
$year = $this->resolveYear();
|
||||
$today = new DateTimeImmutable('today');
|
||||
$currentExerciseYear = $this->resolveCurrentExerciseYear($today);
|
||||
[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($year);
|
||||
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
|
||||
$weekRanges = array_map(
|
||||
static fn (array $week): array => [
|
||||
'month' => (int) $week['month'],
|
||||
'weekNumber' => (int) $week['weekNumber'],
|
||||
'start' => $week['start'],
|
||||
'end' => $week['end'],
|
||||
],
|
||||
$weeks
|
||||
);
|
||||
|
||||
$limitDate = null;
|
||||
if ($year > $currentExerciseYear) {
|
||||
$limitDate = $periodFrom->modify('-1 day');
|
||||
}
|
||||
|
||||
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
|
||||
$carryMinutes = $this->resolveCarryMinutes($employee, $year);
|
||||
|
||||
$summary = new EmployeeRttSummary();
|
||||
$summary->year = $year;
|
||||
$summary->carryFromPreviousYearMinutes = $carryMinutes;
|
||||
$summary->currentYearRecoveryMinutes = array_sum($currentByWeekStart);
|
||||
$summary->availableMinutes = $summary->carryFromPreviousYearMinutes + $summary->currentYearRecoveryMinutes;
|
||||
$summary->weeks = array_map(
|
||||
static fn (array $week) => new EmployeeRttWeekSummary(
|
||||
month: (int) $week['month'],
|
||||
weekNumber: (int) $week['weekNumber'],
|
||||
weekStart: $week['start']->format('Y-m-d'),
|
||||
weekEnd: $week['end']->format('Y-m-d'),
|
||||
recoveryMinutes: (int) ($currentByWeekStart[$week['start']->format('Y-m-d')] ?? 0),
|
||||
),
|
||||
$weekRanges
|
||||
);
|
||||
|
||||
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year);
|
||||
$monthBuckets = [];
|
||||
|
||||
foreach ($payments as $payment) {
|
||||
$m = $payment->getMonth();
|
||||
if (!isset($monthBuckets[$m])) {
|
||||
$monthBuckets[$m] = ['paidMinutes25' => 0, 'paidMinutes50' => 0];
|
||||
}
|
||||
if ('25' === $payment->getRate()) {
|
||||
$monthBuckets[$m]['paidMinutes25'] += $payment->getMinutes();
|
||||
} else {
|
||||
$monthBuckets[$m]['paidMinutes50'] += $payment->getMinutes();
|
||||
}
|
||||
}
|
||||
|
||||
$monthPayments = [];
|
||||
$totalPaidMinutes = 0;
|
||||
|
||||
foreach ($monthBuckets as $m => $bucket) {
|
||||
$monthPayments[] = new RttMonthPayment($m, $bucket['paidMinutes25'], $bucket['paidMinutes50']);
|
||||
$totalPaidMinutes += $bucket['paidMinutes25'] + $bucket['paidMinutes50'];
|
||||
}
|
||||
|
||||
$summary->totalPaidMinutes = $totalPaidMinutes;
|
||||
$summary->monthPayments = $monthPayments;
|
||||
$summary->availableMinutes -= $totalPaidMinutes;
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function resolveCarryMinutes(Employee $employee, int $year): int
|
||||
{
|
||||
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year);
|
||||
if (null !== $balance) {
|
||||
return $balance->getOpeningMinutes();
|
||||
}
|
||||
|
||||
return $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1);
|
||||
}
|
||||
|
||||
private function resolveYear(): int
|
||||
{
|
||||
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
||||
if ('' === $raw) {
|
||||
return $this->resolveCurrentExerciseYear(new DateTimeImmutable('today'));
|
||||
}
|
||||
if (!preg_match('/^\d{4}$/', $raw)) {
|
||||
throw new UnprocessableEntityHttpException('year must use YYYY format.');
|
||||
}
|
||||
|
||||
$year = (int) $raw;
|
||||
if ($year < 2000 || $year > 2100) {
|
||||
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
||||
}
|
||||
|
||||
return $year;
|
||||
}
|
||||
|
||||
private function resolveCurrentExerciseYear(DateTimeImmutable $today): int
|
||||
{
|
||||
$year = (int) $today->format('Y');
|
||||
$month = (int) $today->format('n');
|
||||
|
||||
return $month >= 6 ? $year + 1 : $year;
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,10 @@ use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Repository\EmployeeContractPeriodRepository;
|
||||
use App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface;
|
||||
use App\Service\Contracts\EmployeeContractChangeRequestFactory;
|
||||
use App\Service\Contracts\EmployeeContractPeriodManagerInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
@@ -25,7 +26,9 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private ProcessorInterface $removeProcessor,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private EmployeeContractPeriodRepository $periodRepository,
|
||||
private EmployeeContractPeriodReadRepositoryInterface $periodRepository,
|
||||
private EmployeeContractChangeRequestFactory $changeRequestFactory,
|
||||
private EmployeeContractPeriodManagerInterface $periodManager,
|
||||
) {}
|
||||
|
||||
public function process(
|
||||
@@ -51,47 +54,59 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
||||
return $result;
|
||||
}
|
||||
|
||||
$today = new DateTimeImmutable('today');
|
||||
$requestedContractNature = $this->resolveContractNature($data->getContractNature());
|
||||
$requestedStartDate = $this->parseOptionalYmd($data->getContractStartDate(), 'contractStartDate');
|
||||
$requestedEndDate = $this->parseOptionalYmd($data->getContractEndDate(), 'contractEndDate');
|
||||
$today = new DateTimeImmutable('today');
|
||||
$changeRequest = $this->changeRequestFactory->fromEmployee($data);
|
||||
|
||||
if ($isNew) {
|
||||
$startDate = $requestedStartDate ?? new DateTimeImmutable('1970-01-01');
|
||||
$nature = $requestedContractNature ?? ContractNature::CDI;
|
||||
$this->assertPeriodDates($startDate, $requestedEndDate, $nature);
|
||||
$this->ensureContractPeriodExists($data, $currentContract, $startDate, $requestedEndDate, $nature);
|
||||
$startDate = $changeRequest->contractStartDate ?? new DateTimeImmutable('1970-01-01');
|
||||
$nature = $changeRequest->contractNature ?? ContractNature::CDI;
|
||||
$this->periodManager->ensureContractPeriodExists(
|
||||
employee: $data,
|
||||
contract: $currentContract,
|
||||
startDate: $startDate,
|
||||
endDate: $changeRequest->contractEndDate,
|
||||
nature: $nature
|
||||
);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$hasPeriodChangeRequest = null !== $requestedContractNature || null !== $requestedStartDate || null !== $requestedEndDate;
|
||||
if ($this->isSameContract($previousContract, $currentContract) && !$hasPeriodChangeRequest) {
|
||||
if ($this->isSameContract($previousContract, $currentContract) && !$changeRequest->hasPeriodChangeRequest()) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$startDate = $requestedStartDate ?? $today;
|
||||
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
|
||||
$nature = $requestedContractNature ?? $todayPeriod?->getContractNatureEnum() ?? ContractNature::CDI;
|
||||
$endDate = $requestedEndDate;
|
||||
$this->assertPeriodDates($startDate, $endDate, $nature);
|
||||
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
|
||||
$currentPeriodContract = $todayPeriod?->getContract();
|
||||
$contractChanged = $currentPeriodContract instanceof Contract
|
||||
? $currentPeriodContract->getId() !== $currentContract->getId()
|
||||
: true;
|
||||
$isCloseOnlyRequest = $changeRequest->isCloseOnlyRequest($contractChanged);
|
||||
|
||||
if (
|
||||
null !== $todayPeriod
|
||||
&& null === $todayPeriod->getEndDate()
|
||||
&& $todayPeriod->getStartDate()->format('Y-m-d') === $startDate->format('Y-m-d')
|
||||
) {
|
||||
$todayPeriod->setContract($currentContract);
|
||||
$todayPeriod->setContractNature($nature);
|
||||
$todayPeriod->setEndDate($endDate);
|
||||
$this->entityManager->flush();
|
||||
if ($isCloseOnlyRequest) {
|
||||
$requestedEndDate = $changeRequest->contractEndDate;
|
||||
if (null === $requestedEndDate) {
|
||||
throw new UnprocessableEntityHttpException('contractEndDate is required for close-only request.');
|
||||
}
|
||||
$this->periodManager->closeCurrentPeriod(
|
||||
$todayPeriod,
|
||||
$requestedEndDate,
|
||||
$changeRequest->contractPaidLeaveSettled ?? false,
|
||||
$changeRequest->contractComment
|
||||
);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$this->periodRepository->closeOpenPeriods($data, $startDate->modify('-1 day'));
|
||||
$this->createPeriod($data, $currentContract, $startDate, $endDate, $nature);
|
||||
$this->entityManager->flush();
|
||||
$startDate = $changeRequest->contractStartDate ?? $today;
|
||||
$nature = $changeRequest->contractNature ?? $todayPeriod?->getContractNatureEnum() ?? ContractNature::CDI;
|
||||
$this->periodManager->createNextPeriod(
|
||||
employee: $data,
|
||||
contract: $currentContract,
|
||||
startDate: $startDate,
|
||||
endDate: $changeRequest->contractEndDate,
|
||||
nature: $nature,
|
||||
todayPeriod: $todayPeriod
|
||||
);
|
||||
|
||||
return $result;
|
||||
}
|
||||
@@ -116,81 +131,4 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
||||
|
||||
return $first->getId() === $second->getId();
|
||||
}
|
||||
|
||||
private function ensureContractPeriodExists(
|
||||
Employee $employee,
|
||||
Contract $contract,
|
||||
DateTimeImmutable $startDate,
|
||||
?DateTimeImmutable $endDate,
|
||||
ContractNature $nature,
|
||||
): void {
|
||||
$covered = $this->periodRepository->findOneCoveringDate($employee, $startDate);
|
||||
if (null !== $covered) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->createPeriod($employee, $contract, $startDate, $endDate, $nature);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
private function createPeriod(
|
||||
Employee $employee,
|
||||
Contract $contract,
|
||||
DateTimeImmutable $startDate,
|
||||
?DateTimeImmutable $endDate,
|
||||
ContractNature $nature,
|
||||
): void {
|
||||
$period = new EmployeeContractPeriod()
|
||||
->setEmployee($employee)
|
||||
->setContract($contract)
|
||||
->setStartDate($startDate)
|
||||
->setEndDate($endDate)
|
||||
->setContractNature($nature)
|
||||
;
|
||||
|
||||
$this->entityManager->persist($period);
|
||||
}
|
||||
|
||||
private function resolveContractNature(?string $raw): ?ContractNature
|
||||
{
|
||||
if (null === $raw || '' === trim($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ContractNature::tryFrom(trim($raw))
|
||||
?? throw new UnprocessableEntityHttpException('contractNature must be one of CDI, CDD, INTERIM.');
|
||||
}
|
||||
|
||||
private function parseOptionalYmd(?string $raw, string $field): ?DateTimeImmutable
|
||||
{
|
||||
if (null === $raw || '' === trim($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($raw);
|
||||
$date = DateTimeImmutable::createFromFormat('Y-m-d', $value);
|
||||
if (!$date || $date->format('Y-m-d') !== $value) {
|
||||
throw new UnprocessableEntityHttpException(sprintf('%s must use Y-m-d format.', $field));
|
||||
}
|
||||
|
||||
return $date;
|
||||
}
|
||||
|
||||
private function assertPeriodDates(
|
||||
DateTimeImmutable $startDate,
|
||||
?DateTimeImmutable $endDate,
|
||||
ContractNature $nature
|
||||
): void {
|
||||
if (null !== $endDate && $endDate < $startDate) {
|
||||
throw new UnprocessableEntityHttpException('contractEndDate cannot be before contractStartDate.');
|
||||
}
|
||||
|
||||
if ($nature->requiresEndDate() && null === $endDate) {
|
||||
throw new UnprocessableEntityHttpException('contractEndDate is required for CDD and INTERIM.');
|
||||
}
|
||||
|
||||
if (ContractNature::CDI === $nature && null !== $endDate) {
|
||||
throw new UnprocessableEntityHttpException('contractEndDate must be empty for CDI.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
32
src/State/MarkAllNotificationsReadProcessor.php
Normal file
32
src/State/MarkAllNotificationsReadProcessor.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\User;
|
||||
use App\Repository\NotificationRepository;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
final readonly class MarkAllNotificationsReadProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private NotificationRepository $notificationRepository,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
throw new AccessDeniedHttpException('Authentication required.');
|
||||
}
|
||||
|
||||
$this->notificationRepository->markAllReadByRecipient($user);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
30
src/State/NotificationHistoryProvider.php
Normal file
30
src/State/NotificationHistoryProvider.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Entity\User;
|
||||
use App\Repository\NotificationRepository;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
final readonly class NotificationHistoryProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private NotificationRepository $notificationRepository,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
throw new AccessDeniedHttpException('Authentication required.');
|
||||
}
|
||||
|
||||
return $this->notificationRepository->findLatestByRecipient($user, 5);
|
||||
}
|
||||
}
|
||||
30
src/State/NotificationTodayProvider.php
Normal file
30
src/State/NotificationTodayProvider.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Entity\User;
|
||||
use App\Repository\NotificationRepository;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
final readonly class NotificationTodayProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private NotificationRepository $notificationRepository,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
throw new AccessDeniedHttpException('Authentication required.');
|
||||
}
|
||||
|
||||
return $this->notificationRepository->findUnreadByRecipient($user);
|
||||
}
|
||||
}
|
||||
30
src/State/UnreadNotificationsProvider.php
Normal file
30
src/State/UnreadNotificationsProvider.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Entity\User;
|
||||
use App\Repository\NotificationRepository;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
final readonly class UnreadNotificationsProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private NotificationRepository $notificationRepository,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
throw new AccessDeniedHttpException('Authentication required.');
|
||||
}
|
||||
|
||||
return $this->notificationRepository->findUnreadByRecipient($user);
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,15 @@ use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\ApiResource\WorkHourBulkSiteValidation;
|
||||
use App\ApiResource\WorkHourBulkValidationResult;
|
||||
use App\Entity\Notification;
|
||||
use App\Entity\User;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use App\Service\WorkHours\WorkHourBulkValidationExecutor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
@@ -20,6 +26,10 @@ final readonly class WorkHourBulkSiteValidationProcessor implements ProcessorInt
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private WorkHourBulkValidationExecutor $executor,
|
||||
private WorkHourRepository $workHourRepository,
|
||||
private UserRepository $userRepository,
|
||||
private EmployeeScopeService $employeeScopeService,
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
public function process(
|
||||
@@ -41,7 +51,7 @@ final readonly class WorkHourBulkSiteValidationProcessor implements ProcessorInt
|
||||
throw new AccessDeniedHttpException('Only site managers can bulk update site validation.');
|
||||
}
|
||||
|
||||
return $this->executor->execute(
|
||||
$result = $this->executor->execute(
|
||||
user: $user,
|
||||
workDateValue: $data->workDate,
|
||||
employeeIds: $data->employeeIds,
|
||||
@@ -50,5 +60,42 @@ final readonly class WorkHourBulkSiteValidationProcessor implements ProcessorInt
|
||||
$workHour->setIsSiteValid($data->isSiteValid);
|
||||
}
|
||||
);
|
||||
|
||||
if ($data->isSiteValid && $result->updated > 0) {
|
||||
$this->createNotificationsIfSiteFullyValidated($user, $data->workDate);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function createNotificationsIfSiteFullyValidated(User $user, string $workDateValue): void
|
||||
{
|
||||
$workDate = DateTimeImmutable::createFromFormat('Y-m-d', $workDateValue);
|
||||
if (!$workDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
$siteIds = $this->employeeScopeService->getAllowedSiteIds($user);
|
||||
|
||||
foreach ($siteIds as $siteId) {
|
||||
if ($this->workHourRepository->hasPendingSiteValidationForSiteAndDate($siteId, $workDate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$message = 'a validé les heures';
|
||||
|
||||
foreach ($this->userRepository->findAllAdmins() as $admin) {
|
||||
$notification = new Notification();
|
||||
$notification->setRecipient($admin)
|
||||
->setActor($user)
|
||||
->setMessage($message)
|
||||
->setCategory('Heures')
|
||||
->setTarget('/hours')
|
||||
;
|
||||
$this->entityManager->persist($notification);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
|
||||
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
||||
$isAdmin = in_array('ROLE_ADMIN', $user->getRoles(), true);
|
||||
$isSelf = in_array('ROLE_SELF', $user->getRoles(), true);
|
||||
|
||||
if ($existing?->isValid()) {
|
||||
if (!$this->isSameAsExisting($existing, $normalized)) {
|
||||
@@ -145,6 +146,9 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
->setWorkDate($workDate)
|
||||
;
|
||||
$this->hydrateWorkHour($workHour, $normalized);
|
||||
if ($isSelf) {
|
||||
$workHour->setUpdatedAt(new DateTimeImmutable());
|
||||
}
|
||||
$this->entityManager->persist($workHour);
|
||||
$existingByEmployeeId[$employeeId] = $workHour;
|
||||
++$result->created;
|
||||
@@ -169,6 +173,9 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
$this->hydrateWorkHour($workHour, $normalized);
|
||||
if (!$isAdmin) {
|
||||
$workHour->setUpdatedAt(new DateTimeImmutable());
|
||||
}
|
||||
++$result->processed;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,11 @@ namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\Notification;
|
||||
use App\Entity\User;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
@@ -18,6 +21,8 @@ final readonly class WorkHourSiteValidationProcessor implements ProcessorInterfa
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private EmployeeScopeService $employeeScopeService,
|
||||
private WorkHourRepository $workHourRepository,
|
||||
private UserRepository $userRepository,
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
@@ -47,8 +52,37 @@ final readonly class WorkHourSiteValidationProcessor implements ProcessorInterfa
|
||||
throw new AccessDeniedHttpException('Employee is outside your site scope.');
|
||||
}
|
||||
|
||||
$uow = $this->entityManager->getUnitOfWork();
|
||||
$uow->computeChangeSets();
|
||||
$changeSet = $uow->getEntityChangeSet($data);
|
||||
$isSiteValidationChangedToTrue = isset($changeSet['isSiteValid'])
|
||||
&& false === $changeSet['isSiteValid'][0]
|
||||
&& true === $changeSet['isSiteValid'][1];
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Notification uniquement quand la dernière ligne du site est validée pour la date.
|
||||
if ($isSiteValidationChangedToTrue) {
|
||||
$workDate = $data->getWorkDate();
|
||||
$hasPending = $this->workHourRepository->hasPendingSiteValidationForSiteAndDate($siteId, $workDate);
|
||||
if (!$hasPending) {
|
||||
$message = 'a validé les heures';
|
||||
|
||||
foreach ($this->userRepository->findAllAdmins() as $admin) {
|
||||
$notification = new Notification();
|
||||
$notification->setRecipient($admin)
|
||||
->setActor($user)
|
||||
->setMessage($message)
|
||||
->setCategory('Heures')
|
||||
->setTarget('/hours')
|
||||
;
|
||||
$this->entityManager->persist($notification);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user