Ajout des notification + page employé (#6)
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:
2026-03-10 12:35:17 +00:00
committed by Autin
parent ae42c70d50
commit f493ea237b
126 changed files with 9215 additions and 935 deletions

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Patch;
use App\State\EmployeeFractionedDaysProcessor;
use App\State\EmployeeFractionedDaysProvider;
#[ApiResource(
operations: [
new Patch(
uriTemplate: '/employees/{id}/fractioned-days',
security: "is_granted('ROLE_ADMIN')",
provider: EmployeeFractionedDaysProvider::class,
processor: EmployeeFractionedDaysProcessor::class
),
],
paginationEnabled: false
)]
final class EmployeeFractionedDaysInput
{
public float $fractionedDays = 0.0;
public ?int $year = null;
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\EmployeeLeaveSummaryProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/employees/{id}/leave-summary',
security: "is_granted('ROLE_USER')",
provider: EmployeeLeaveSummaryProvider::class
),
],
paginationEnabled: false
)]
final class EmployeeLeaveSummary
{
public int $year = 0;
public bool $isSupported = false;
public string $ruleCode = '';
public float $acquiredDays = 0.0;
public float $remainingDays = 0.0;
public float $takenDays = 0.0;
public float $acquiredSaturdays = 0.0;
public float $remainingSaturdays = 0.0;
public float $takenSaturdays = 0.0;
public float $fractionedDays = 0.0;
public float $accruingDays = 0.0;
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Patch;
use App\State\EmployeeRttPaymentProcessor;
use App\State\EmployeeRttPaymentProvider;
#[ApiResource(
operations: [
new Patch(
uriTemplate: '/employees/{id}/rtt-payments',
security: "is_granted('ROLE_ADMIN')",
provider: EmployeeRttPaymentProvider::class,
processor: EmployeeRttPaymentProcessor::class
),
],
paginationEnabled: false
)]
final class EmployeeRttPaymentInput
{
public int $month = 0;
public int $minutes = 0;
public string $rate = '25';
public ?int $year = null;
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Dto\Rtt\EmployeeRttWeekSummary;
use App\Dto\Rtt\RttMonthPayment;
use App\State\EmployeeRttSummaryProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/employees/{id}/rtt-summary',
security: "is_granted('ROLE_USER')",
provider: EmployeeRttSummaryProvider::class
),
],
paginationEnabled: false
)]
final class EmployeeRttSummary
{
public int $year = 0;
public int $carryFromPreviousYearMinutes = 0;
public int $currentYearRecoveryMinutes = 0;
public int $availableMinutes = 0;
public int $totalPaidMinutes = 0;
/** @var list<RttMonthPayment> */
public array $monthPayments = [];
/** @var list<EmployeeRttWeekSummary> */
public array $weeks = [];
}

View File

@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace App\Command;
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 App\Service\Leave\LeaveBalanceComputationService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Throwable;
#[AsCommand(
name: 'app:leave:rollover',
description: 'Create yearly leave opening balances (idempotent).'
)]
final class LeaveRolloverCommand extends Command
{
public function __construct(
private readonly EmployeeRepository $employeeRepository,
private readonly EmployeeLeaveBalanceRepository $leaveBalanceRepository,
private readonly LeaveBalanceComputationService $leaveBalanceComputationService,
private readonly EntityManagerInterface $entityManager,
#[Autowire(service: 'monolog.logger.cron')]
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption(
'force',
null,
InputOption::VALUE_NONE,
'Run rollover regardless of business date (manual recovery mode).'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$today = new DateTimeImmutable('today');
$force = (bool) $input->getOption('force');
$this->logger->info('app:leave:rollover started.', ['date' => $today->format('Y-m-d'), 'force' => $force]);
if (!$force && !$this->isBusinessRolloverDate($today)) {
$message = 'No rollover today: business date is neither 01/01 nor 01/06.';
$this->logger->info($message, ['date' => $today->format('Y-m-d')]);
$io->success($message);
return Command::SUCCESS;
}
$created = 0;
$skipped = 0;
foreach ($this->employeeRepository->findAll() as $employee) {
if (!$employee instanceof Employee) {
continue;
}
$ruleCode = $this->resolveRuleCode($employee);
if (null === $ruleCode) {
$this->logger->info('Employee skipped: no eligible rule.', ['employeeId' => $employee->getId()]);
++$skipped;
continue;
}
if (!$force && !$this->shouldProcessRuleToday($ruleCode, $today)) {
++$skipped;
continue;
}
$targetYear = $this->resolveTargetYear($ruleCode, $today);
$existing = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $targetYear);
if (null !== $existing) {
$this->logger->info('Employee skipped: balance already exists.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'rule' => $ruleCode->value]);
++$skipped;
continue;
}
try {
[$carryDays, $carrySaturdays] = $this->resolveCarry($employee, $ruleCode, $targetYear);
} catch (Throwable $e) {
$this->logger->error('Error computing carry for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]);
++$skipped;
continue;
}
$balance = new EmployeeLeaveBalance()
->setEmployee($employee)
->setRuleCode($ruleCode)
->setYear($targetYear)
->setOpeningDays($carryDays)
->setOpeningSaturdays($carrySaturdays)
->setAccruedDays(0.0)
->setAccruedSaturdays(0.0)
->setTakenDays(0.0)
->setTakenSaturdays(0.0)
->setClosingDays($carryDays)
->setClosingSaturdays($carrySaturdays)
->setIsLocked(false)
;
$this->entityManager->persist($balance);
$this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'rule' => $ruleCode->value, 'carryDays' => $carryDays, 'carrySaturdays' => $carrySaturdays]);
++$created;
}
try {
$this->entityManager->flush();
} catch (Throwable $e) {
$this->logger->error('Error flushing leave balances.', ['error' => $e->getMessage()]);
$io->error('Leave rollover failed: '.$e->getMessage());
return Command::FAILURE;
}
$message = sprintf('Leave rollover done: %d created, %d skipped.', $created, $skipped);
$this->logger->info($message);
$io->success($message);
return Command::SUCCESS;
}
private function resolveRuleCode(Employee $employee): ?LeaveRuleCode
{
$type = $employee->getContract()?->getType();
if (null === $type || ContractType::INTERIM === $type) {
return null;
}
if (ContractType::FORFAIT === $type) {
return LeaveRuleCode::FORFAIT_218;
}
return LeaveRuleCode::CDI_CDD_NON_FORFAIT;
}
private function resolveTargetYear(LeaveRuleCode $ruleCode, DateTimeImmutable $today): int
{
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
return (int) $today->format('Y');
}
$year = (int) $today->format('Y');
$month = (int) $today->format('n');
return $month >= 6 ? $year + 1 : $year;
}
private function isBusinessRolloverDate(DateTimeImmutable $today): bool
{
return in_array($today->format('m-d'), ['01-01', '06-01'], true);
}
private function shouldProcessRuleToday(LeaveRuleCode $ruleCode, DateTimeImmutable $today): bool
{
$day = $today->format('m-d');
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
return '01-01' === $day;
}
return '06-01' === $day;
}
/**
* @return array{float, float}
*/
private function resolveCarry(Employee $employee, LeaveRuleCode $ruleCode, int $targetYear): array
{
$previousYear = $targetYear - 1;
$previous = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $previousYear);
if (null !== $previous) {
$carryDays = $previous->getClosingDays() + $previous->getFractionedDays();
$carrySaturdays = LeaveRuleCode::CDI_CDD_NON_FORFAIT === $ruleCode
? $previous->getClosingSaturdays()
: 0.0;
} else {
[$carryDays, $carrySaturdays] = $this->leaveBalanceComputationService
->computeDynamicClosingForYear($employee, $ruleCode, $previousYear)
;
}
[$from, $to] = $this->leaveBalanceComputationService->resolvePeriodBounds($ruleCode, $previousYear);
$hasSettlement = $this->leaveBalanceComputationService
->hasPaidLeaveSettledClosureBetween($employee, $from, $to)
;
if ($hasSettlement) {
return [0.0, 0.0];
}
return [$carryDays, $carrySaturdays];
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Employee;
use App\Entity\EmployeeRttBalance;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Service\Rtt\RttRecoveryComputationService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Throwable;
#[AsCommand(
name: 'app:rtt:rollover',
description: 'Create yearly RTT opening balances (idempotent).'
)]
final class RttRolloverCommand extends Command
{
public function __construct(
private readonly EmployeeRepository $employeeRepository,
private readonly EmployeeRttBalanceRepository $rttBalanceRepository,
private readonly RttRecoveryComputationService $rttRecoveryService,
private readonly EntityManagerInterface $entityManager,
#[Autowire(service: 'monolog.logger.cron')]
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption(
'force',
null,
InputOption::VALUE_NONE,
'Run rollover regardless of business date (manual recovery mode).'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$today = new DateTimeImmutable('today');
$force = (bool) $input->getOption('force');
$this->logger->info('app:rtt:rollover started.', ['date' => $today->format('Y-m-d'), 'force' => $force]);
if (!$force && '06-01' !== $today->format('m-d')) {
$message = 'No RTT rollover today: business date is not 01/06.';
$this->logger->info($message, ['date' => $today->format('Y-m-d')]);
$io->success($message);
return Command::SUCCESS;
}
$targetYear = $this->resolveTargetYear($today);
$created = 0;
$skipped = 0;
foreach ($this->employeeRepository->findAll() as $employee) {
if (!$employee instanceof Employee) {
continue;
}
if (!$this->isEligible($employee)) {
$this->logger->info('Employee skipped: not eligible.', ['employeeId' => $employee->getId()]);
++$skipped;
continue;
}
$existing = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $targetYear);
if (null !== $existing) {
$this->logger->info('Employee skipped: balance already exists.', ['employeeId' => $employee->getId(), 'year' => $targetYear]);
++$skipped;
continue;
}
try {
$previousYear = $targetYear - 1;
$carryMinutes = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear);
} catch (Throwable $e) {
$this->logger->error('Error computing carry for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]);
++$skipped;
continue;
}
$balance = new EmployeeRttBalance()
->setEmployee($employee)
->setYear($targetYear)
->setOpeningMinutes($carryMinutes)
->setIsLocked(false)
;
$this->entityManager->persist($balance);
$this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $carryMinutes]);
++$created;
}
try {
$this->entityManager->flush();
} catch (Throwable $e) {
$this->logger->error('Error flushing RTT balances.', ['error' => $e->getMessage()]);
$io->error('RTT rollover failed: '.$e->getMessage());
return Command::FAILURE;
}
$message = sprintf('RTT rollover done: %d created, %d skipped.', $created, $skipped);
$this->logger->info($message);
$io->success($message);
return Command::SUCCESS;
}
private function resolveTargetYear(DateTimeImmutable $today): int
{
$year = (int) $today->format('Y');
$month = (int) $today->format('n');
return $month >= 6 ? $year + 1 : $year;
}
private function isEligible(Employee $employee): bool
{
$contract = $employee->getContract();
if (null === $contract) {
return false;
}
if (TrackingMode::PRESENCE->value === $contract->getTrackingMode()) {
return false;
}
$type = ContractType::resolve(
$contract->getName(),
$contract->getTrackingMode(),
$contract->getWeeklyHours()
);
return ContractType::INTERIM !== $type;
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\Absence;
use App\Entity\AbsenceType;
use App\Entity\Employee;
use App\Enum\HalfDay;
use DateTime;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
final class AbsenceFixtures extends Fixture implements DependentFixtureInterface
{
public function load(ObjectManager $manager): void
{
$this->createAbsence(
$manager,
$this->getReference(FixtureReferences::EMPLOYEE_STANDARD, Employee::class),
$this->getReference(FixtureReferences::ABSENCE_TYPE_CONGE, AbsenceType::class),
'2026-03-03',
HalfDay::AM,
'2026-03-03',
HalfDay::PM,
'CP standard non forfait'
);
$this->createAbsence(
$manager,
$this->getReference(FixtureReferences::EMPLOYEE_4H, Employee::class),
$this->getReference(FixtureReferences::ABSENCE_TYPE_CONGE, AbsenceType::class),
'2026-03-04',
HalfDay::AM,
'2026-03-04',
HalfDay::PM,
'CP employe 4h'
);
$this->createAbsence(
$manager,
$this->getReference(FixtureReferences::EMPLOYEE_FORFAIT, Employee::class),
$this->getReference(FixtureReferences::ABSENCE_TYPE_AUTRE, AbsenceType::class),
'2026-03-05',
HalfDay::AM,
'2026-03-05',
HalfDay::AM,
'Absence forfait demi-journee'
);
$this->createAbsence(
$manager,
$this->getReference(FixtureReferences::EMPLOYEE_INTERIM, Employee::class),
$this->getReference(FixtureReferences::ABSENCE_TYPE_ABSENT, AbsenceType::class),
'2026-03-06',
HalfDay::AM,
'2026-03-06',
HalfDay::PM,
'Absence interim'
);
$manager->flush();
}
public function getDependencies(): array
{
return [
EmployeeFixtures::class,
AbsenceTypeFixtures::class,
];
}
private function createAbsence(
ObjectManager $manager,
Employee $employee,
AbsenceType $type,
string $startDate,
HalfDay $startHalf,
string $endDate,
HalfDay $endHalf,
string $comment
): void {
$absence = new Absence()
->setEmployee($employee)
->setType($type)
->setStartDate(new DateTime($startDate))
->setStartHalf($startHalf)
->setEndDate(new DateTime($endDate))
->setEndHalf($endHalf)
->setComment($comment)
;
$manager->persist($absence);
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\AbsenceType;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
final class AbsenceTypeFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$definitions = [
[FixtureReferences::ABSENCE_TYPE_RTT, 'R', 'RTT', '#feba01', false],
[FixtureReferences::ABSENCE_TYPE_CONGE, 'C', 'CONGÉ', '#008000', true],
[FixtureReferences::ABSENCE_TYPE_MALADIE, 'M', 'MALADIE', '#ed07da', true],
[FixtureReferences::ABSENCE_TYPE_AT, 'AT', 'ACCIDENT DE TRAVAIL', '#e41111', true],
[FixtureReferences::ABSENCE_TYPE_FORMATION, 'F', 'FORMATION', '#6c72e0', true],
[FixtureReferences::ABSENCE_TYPE_EXCEPTIONNEL, 'EX', 'CONGE EXCEPTIONNEL', '#8834ae', true],
[FixtureReferences::ABSENCE_TYPE_ABSENT, 'ABS', 'ABSENT', '#823a21', false],
[FixtureReferences::ABSENCE_TYPE_AUTRE, 'AUT', 'AUTRE', '#f07d60', true],
];
foreach ($definitions as [$reference, $code, $label, $color, $countAsWorkedHours]) {
$absenceType = new AbsenceType()
->setCode($code)
->setLabel($label)
->setColor($color)
->setCountAsWorkedHours($countAsWorkedHours)
;
$manager->persist($absenceType);
$this->addReference($reference, $absenceType);
}
$manager->flush();
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\Contract;
use App\Enum\TrackingMode;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
final class ContractFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$contract35 = new Contract()
->setName('35h')
->setTrackingMode(TrackingMode::TIME)
->setWeeklyHours(35)
->setIsActive(true)
;
$contract4h = new Contract()
->setName('4h')
->setTrackingMode(TrackingMode::TIME)
->setWeeklyHours(4)
->setIsActive(true)
;
$forfait = new Contract()
->setName('Forfait')
->setTrackingMode(TrackingMode::PRESENCE)
->setWeeklyHours(null)
->setIsActive(true)
;
$interim = new Contract()
->setName('Interim')
->setTrackingMode(TrackingMode::TIME)
->setWeeklyHours(35)
->setIsActive(true)
;
$manager->persist($contract35);
$manager->persist($contract4h);
$manager->persist($forfait);
$manager->persist($interim);
$manager->flush();
$this->addReference(FixtureReferences::CONTRACT_35, $contract35);
$this->addReference(FixtureReferences::CONTRACT_4H, $contract4h);
$this->addReference(FixtureReferences::CONTRACT_FORFAIT, $forfait);
$this->addReference(FixtureReferences::CONTRACT_INTERIM, $interim);
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use DateTimeImmutable;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
final class EmployeeContractPeriodFixtures extends Fixture implements DependentFixtureInterface
{
public function load(ObjectManager $manager): void
{
$this->createPeriod(
$manager,
$this->getReference(FixtureReferences::EMPLOYEE_STANDARD, Employee::class),
$this->getReference(FixtureReferences::CONTRACT_35, Contract::class),
'2025-01-01',
null,
ContractNature::CDI,
false
);
$this->createPeriod(
$manager,
$this->getReference(FixtureReferences::EMPLOYEE_4H, Employee::class),
$this->getReference(FixtureReferences::CONTRACT_4H, Contract::class),
'2026-01-01',
'2026-12-31',
ContractNature::CDD,
false
);
$this->createPeriod(
$manager,
$this->getReference(FixtureReferences::EMPLOYEE_FORFAIT, Employee::class),
$this->getReference(FixtureReferences::CONTRACT_FORFAIT, Contract::class),
'2024-01-01',
null,
ContractNature::CDI,
false
);
$this->createPeriod(
$manager,
$this->getReference(FixtureReferences::EMPLOYEE_INTERIM, Employee::class),
$this->getReference(FixtureReferences::CONTRACT_INTERIM, Contract::class),
'2026-02-01',
'2026-06-30',
ContractNature::INTERIM,
false
);
$manager->flush();
}
public function getDependencies(): array
{
return [
EmployeeFixtures::class,
ContractFixtures::class,
];
}
private function createPeriod(
ObjectManager $manager,
Employee $employee,
Contract $contract,
string $startDate,
?string $endDate,
ContractNature $nature,
bool $paidLeaveSettled
): void {
$period = new EmployeeContractPeriod()
->setEmployee($employee)
->setContract($contract)
->setStartDate(new DateTimeImmutable($startDate))
->setEndDate(null === $endDate ? null : new DateTimeImmutable($endDate))
->setContractNature($nature)
->setPaidLeaveSettled($paidLeaveSettled)
;
$manager->persist($period);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\Site;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
final class EmployeeFixtures extends Fixture implements DependentFixtureInterface
{
public function load(ObjectManager $manager): void
{
$site = $this->getReference(FixtureReferences::SITE_MAIN, Site::class);
$employeeStandard = new Employee()
->setFirstName('Alice')
->setLastName('Martin')
->setSite($site)
->setContract($this->getReference(FixtureReferences::CONTRACT_35, Contract::class))
->setDisplayOrder(1)
;
$employee4h = new Employee()
->setFirstName('Bruno')
->setLastName('Petit')
->setSite($site)
->setContract($this->getReference(FixtureReferences::CONTRACT_4H, Contract::class))
->setDisplayOrder(2)
;
$employeeForfait = new Employee()
->setFirstName('Chloe')
->setLastName('Durand')
->setSite($site)
->setContract($this->getReference(FixtureReferences::CONTRACT_FORFAIT, Contract::class))
->setDisplayOrder(3)
;
$employeeInterim = new Employee()
->setFirstName('David')
->setLastName('Leroy')
->setSite($site)
->setContract($this->getReference(FixtureReferences::CONTRACT_INTERIM, Contract::class))
->setDisplayOrder(4)
;
$manager->persist($employeeStandard);
$manager->persist($employee4h);
$manager->persist($employeeForfait);
$manager->persist($employeeInterim);
$manager->flush();
$this->addReference(FixtureReferences::EMPLOYEE_STANDARD, $employeeStandard);
$this->addReference(FixtureReferences::EMPLOYEE_4H, $employee4h);
$this->addReference(FixtureReferences::EMPLOYEE_FORFAIT, $employeeForfait);
$this->addReference(FixtureReferences::EMPLOYEE_INTERIM, $employeeInterim);
}
public function getDependencies(): array
{
return [
SiteFixtures::class,
ContractFixtures::class,
];
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\Employee;
use App\Entity\EmployeeLeaveBalance;
use App\Enum\LeaveRuleCode;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
final class EmployeeLeaveBalanceFixtures extends Fixture implements DependentFixtureInterface
{
public function load(ObjectManager $manager): void
{
$this->createOpeningBalance(
$manager,
$this->getReference(FixtureReferences::EMPLOYEE_STANDARD, Employee::class),
LeaveRuleCode::CDI_CDD_NON_FORFAIT,
2026,
0.0,
0.0
);
$this->createOpeningBalance(
$manager,
$this->getReference(FixtureReferences::EMPLOYEE_4H, Employee::class),
LeaveRuleCode::CDI_CDD_NON_FORFAIT,
2026,
0.0,
0.0
);
$this->createOpeningBalance(
$manager,
$this->getReference(FixtureReferences::EMPLOYEE_FORFAIT, Employee::class),
LeaveRuleCode::FORFAIT_218,
2026,
0.0,
0.0
);
$manager->flush();
}
public function getDependencies(): array
{
return [
EmployeeFixtures::class,
];
}
private function createOpeningBalance(
ObjectManager $manager,
Employee $employee,
LeaveRuleCode $ruleCode,
int $year,
float $openingDays,
float $openingSaturdays
): void {
$balance = new EmployeeLeaveBalance()
->setEmployee($employee)
->setRuleCode($ruleCode)
->setYear($year)
->setOpeningDays($openingDays)
->setOpeningSaturdays($openingSaturdays)
->setAccruedDays(0.0)
->setAccruedSaturdays(0.0)
->setTakenDays(0.0)
->setTakenSaturdays(0.0)
->setClosingDays($openingDays)
->setClosingSaturdays($openingSaturdays)
->setIsLocked(false)
;
$manager->persist($balance);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\DataFixtures;
final class FixtureReferences
{
public const SITE_MAIN = 'site.main';
public const CONTRACT_35 = 'contract.35h';
public const CONTRACT_4H = 'contract.4h';
public const CONTRACT_FORFAIT = 'contract.forfait';
public const CONTRACT_INTERIM = 'contract.interim';
public const ABSENCE_TYPE_RTT = 'absence_type.rtt';
public const ABSENCE_TYPE_CONGE = 'absence_type.conge';
public const ABSENCE_TYPE_MALADIE = 'absence_type.maladie';
public const ABSENCE_TYPE_AT = 'absence_type.at';
public const ABSENCE_TYPE_FORMATION = 'absence_type.formation';
public const ABSENCE_TYPE_EXCEPTIONNEL = 'absence_type.exceptionnel';
public const ABSENCE_TYPE_ABSENT = 'absence_type.absent';
public const ABSENCE_TYPE_AUTRE = 'absence_type.autre';
public const EMPLOYEE_STANDARD = 'employee.standard_non_forfait';
public const EMPLOYEE_4H = 'employee.four_hours';
public const EMPLOYEE_FORFAIT = 'employee.forfait';
public const EMPLOYEE_INTERIM = 'employee.interim';
public const USER_ADMIN_EMILIE = 'user.admin.emilie';
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\Site;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
final class SiteFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$site = new Site()
->setName('SITE TEST')
->setColor('#75aed9')
->setDisplayOrder(1)
;
$manager->persist($site);
$manager->flush();
$this->addReference(FixtureReferences::SITE_MAIN, $site);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
final class UserFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$emilie = new User()
->setUsername('emilie')
->setRoles(['ROLE_ADMIN'])
->setPassword('$2y$13$swd6WU4z7Z4MeZwl9hHaFez8xB/GMZxyrCDQUlFEY55JQUzTW0bPO')
;
$manager->persist($emilie);
$manager->flush();
$this->addReference(FixtureReferences::USER_ADMIN_EMILIE, $emilie);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Dto\Employees;
use Symfony\Component\Serializer\Attribute\Groups;
final class ContractHistoryItem
{
public function __construct(
#[Groups(['employee:read'])]
public ?int $contractId,
#[Groups(['employee:read'])]
public ?string $contractName,
#[Groups(['employee:read'])]
public ?float $weeklyHours,
#[Groups(['employee:read'])]
public string $contractNature,
#[Groups(['employee:read'])]
public string $startDate,
#[Groups(['employee:read'])]
public ?string $endDate,
#[Groups(['employee:read'])]
public ?string $comment = null,
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Dto\Rtt;
final class EmployeeRttWeekSummary
{
public function __construct(
public int $month,
public int $weekNumber,
public string $weekStart,
public string $weekEnd,
public int $recoveryMinutes,
) {}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Dto\Rtt;
final class RttMonthPayment
{
public function __construct(
public int $month,
public int $paidMinutes25 = 0,
public int $paidMinutes50 = 0,
) {}
}

View File

@@ -52,7 +52,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
paginationEnabled: false,
)]
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact', 'employee.site' => 'exact'])]
#[ORM\Entity(repositoryClass: AbsenceRepository::class)]
#[ORM\Table(name: 'absences')]
class Absence

View File

@@ -6,6 +6,7 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use App\Dto\Employees\ContractHistoryItem;
use App\Enum\ContractNature;
use App\Repository\EmployeeRepository;
use App\State\EmployeeWriteProcessor;
@@ -74,6 +75,12 @@ class Employee
#[Groups(['employee:write'])]
private ?string $contractEndDate = null;
#[Groups(['employee:write'])]
private ?bool $contractPaidLeaveSettled = null;
#[Groups(['employee:write'])]
private ?string $contractComment = null;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
@@ -109,6 +116,15 @@ class Employee
return $this;
}
#[Groups(['employee:read'])]
public function getInitials(): string
{
$first = mb_strtoupper(mb_substr(trim($this->firstName), 0, 1));
$last = mb_strtoupper(mb_substr(trim($this->lastName), 0, 1));
return $first.$last;
}
public function getSite(): ?Site
{
return $this->site;
@@ -186,6 +202,30 @@ class Employee
return $this;
}
public function getContractPaidLeaveSettled(): ?bool
{
return $this->contractPaidLeaveSettled;
}
public function setContractPaidLeaveSettled(?bool $contractPaidLeaveSettled): self
{
$this->contractPaidLeaveSettled = $contractPaidLeaveSettled;
return $this;
}
public function getContractComment(): ?string
{
return $this->contractComment;
}
public function setContractComment(?string $contractComment): self
{
$this->contractComment = $contractComment;
return $this;
}
#[Groups(['employee:read'])]
public function getCurrentContractNature(): string
{
@@ -204,6 +244,36 @@ class Employee
return $this->resolveCurrentContractPeriod()?->getEndDate()?->format('Y-m-d');
}
/**
* @return list<ContractHistoryItem>
*/
#[Groups(['employee:read'])]
public function getContractHistory(): array
{
$periods = $this->contractPeriods->toArray();
usort(
$periods,
static fn (EmployeeContractPeriod $a, EmployeeContractPeriod $b): int => $b->getStartDate() <=> $a->getStartDate()
);
return array_map(
static function (EmployeeContractPeriod $period): ContractHistoryItem {
$contract = $period->getContract();
return new ContractHistoryItem(
contractId: $contract?->getId(),
contractName: $contract?->getName(),
weeklyHours: $contract?->getWeeklyHours(),
contractNature: $period->getContractNatureEnum()->value,
startDate: $period->getStartDate()->format('Y-m-d'),
endDate: $period->getEndDate()?->format('Y-m-d'),
comment: $period->getComment(),
);
},
$periods
);
}
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
{
$today = new DateTimeImmutable('today');

View File

@@ -37,6 +37,12 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'string', length: 20, options: ['default' => ContractNature::CDI->value])]
private string $contractNature = ContractNature::CDI->value;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $paidLeaveSettled = false;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $comment = null;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
@@ -121,4 +127,28 @@ class EmployeeContractPeriod
{
return $this->createdAt;
}
public function isPaidLeaveSettled(): bool
{
return $this->paidLeaveSettled;
}
public function setPaidLeaveSettled(bool $paidLeaveSettled): self
{
$this->paidLeaveSettled = $paidLeaveSettled;
return $this;
}
public function getComment(): ?string
{
return $this->comment;
}
public function setComment(?string $comment): self
{
$this->comment = $comment;
return $this;
}
}

View File

@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\LeaveRuleCode;
use App\Repository\EmployeeLeaveBalanceRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EmployeeLeaveBalanceRepository::class)]
#[ORM\Table(name: 'employee_leave_balances', options: ['comment' => 'Soldes de conges par employe et exercice (ouverture, mouvements, cloture).'])]
#[ORM\UniqueConstraint(name: 'uniq_employee_leave_balance', columns: ['employee_id', 'rule_code', 'year'])]
#[ORM\Index(columns: ['employee_id', 'year'], name: 'idx_leave_balance_employee_year')]
class EmployeeLeaveBalance
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Employee $employee = null;
#[ORM\Column(length: 64, options: ['comment' => 'Code de regle de calcul des conges (CDI_CDD_NON_FORFAIT, FORFAIT_218, ...).'])]
private string $ruleCode = '';
#[ORM\Column(type: 'integer', options: ['comment' => 'Annee d exercice de reference (ex: 2026).'])]
private int $year = 0;
#[ORM\Column(type: 'float', options: ['comment' => 'Report N-1 en jours a l ouverture de l exercice.'])]
private float $openingDays = 0.0;
#[ORM\Column(type: 'float', options: ['comment' => 'Report N-1 en samedis a l ouverture (0 pour forfait).'])]
private float $openingSaturdays = 0.0;
#[ORM\Column(type: 'float', options: ['comment' => 'Droits jours acquis sur l exercice courant.'])]
private float $accruedDays = 0.0;
#[ORM\Column(type: 'float', options: ['comment' => 'Droits samedis acquis sur l exercice courant.'])]
private float $accruedSaturdays = 0.0;
#[ORM\Column(type: 'float', options: ['comment' => 'Jours de conges consommes sur l exercice.'])]
private float $takenDays = 0.0;
#[ORM\Column(type: 'float', options: ['comment' => 'Samedis consommes sur l exercice.'])]
private float $takenSaturdays = 0.0;
#[ORM\Column(type: 'float', options: ['comment' => 'Solde de cloture jours sur l exercice.'])]
private float $closingDays = 0.0;
#[ORM\Column(type: 'float', options: ['comment' => 'Solde de cloture samedis sur l exercice.'])]
private float $closingSaturdays = 0.0;
#[ORM\Column(type: 'float', options: ['default' => 0, 'comment' => 'Jours de fractionnement saisis par la RH.'])]
private float $fractionedDays = 0.0;
#[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Indique si le solde de l exercice est fige (verrouille RH).'])]
private bool $isLocked = false;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
}
public function getId(): ?int
{
return $this->id;
}
public function getEmployee(): ?Employee
{
return $this->employee;
}
public function setEmployee(Employee $employee): self
{
$this->employee = $employee;
return $this;
}
public function getRuleCode(): string
{
return $this->ruleCode;
}
public function setRuleCode(LeaveRuleCode|string $ruleCode): self
{
$this->ruleCode = $ruleCode instanceof LeaveRuleCode ? $ruleCode->value : $ruleCode;
return $this;
}
public function getYear(): int
{
return $this->year;
}
public function setYear(int $year): self
{
$this->year = $year;
return $this;
}
public function getOpeningDays(): float
{
return $this->openingDays;
}
public function setOpeningDays(float $openingDays): self
{
$this->openingDays = $openingDays;
return $this;
}
public function getOpeningSaturdays(): float
{
return $this->openingSaturdays;
}
public function setOpeningSaturdays(float $openingSaturdays): self
{
$this->openingSaturdays = $openingSaturdays;
return $this;
}
public function getAccruedDays(): float
{
return $this->accruedDays;
}
public function setAccruedDays(float $accruedDays): self
{
$this->accruedDays = $accruedDays;
return $this;
}
public function getAccruedSaturdays(): float
{
return $this->accruedSaturdays;
}
public function setAccruedSaturdays(float $accruedSaturdays): self
{
$this->accruedSaturdays = $accruedSaturdays;
return $this;
}
public function getTakenDays(): float
{
return $this->takenDays;
}
public function setTakenDays(float $takenDays): self
{
$this->takenDays = $takenDays;
return $this;
}
public function getTakenSaturdays(): float
{
return $this->takenSaturdays;
}
public function setTakenSaturdays(float $takenSaturdays): self
{
$this->takenSaturdays = $takenSaturdays;
return $this;
}
public function getClosingDays(): float
{
return $this->closingDays;
}
public function setClosingDays(float $closingDays): self
{
$this->closingDays = $closingDays;
return $this;
}
public function getClosingSaturdays(): float
{
return $this->closingSaturdays;
}
public function setClosingSaturdays(float $closingSaturdays): self
{
$this->closingSaturdays = $closingSaturdays;
return $this;
}
public function getFractionedDays(): float
{
return $this->fractionedDays;
}
public function setFractionedDays(float $fractionedDays): self
{
$this->fractionedDays = $fractionedDays;
return $this;
}
public function isLocked(): bool
{
return $this->isLocked;
}
public function setIsLocked(bool $isLocked): self
{
$this->isLocked = $isLocked;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
public function touch(): self
{
$this->updatedAt = new DateTimeImmutable();
return $this;
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\EmployeeRttBalanceRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EmployeeRttBalanceRepository::class)]
#[ORM\Table(name: 'employee_rtt_balances', options: ['comment' => 'Soldes RTT par employe et exercice (report N-1).'])]
#[ORM\UniqueConstraint(name: 'uniq_employee_rtt_balance', columns: ['employee_id', 'year'])]
#[ORM\Index(columns: ['employee_id', 'year'], name: 'idx_rtt_balance_employee_year')]
class EmployeeRttBalance
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Employee $employee = null;
#[ORM\Column(type: 'integer', options: ['comment' => 'Annee d exercice (year = annee de fin, ex: 2026 = 01/06/2025 -> 31/05/2026).'])]
private int $year = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 en minutes (solde d ouverture).'])]
private int $openingMinutes = 0;
#[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Indique si le solde est fige (verrouille RH).'])]
private bool $isLocked = false;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
}
public function getId(): ?int
{
return $this->id;
}
public function getEmployee(): ?Employee
{
return $this->employee;
}
public function setEmployee(Employee $employee): self
{
$this->employee = $employee;
return $this;
}
public function getYear(): int
{
return $this->year;
}
public function setYear(int $year): self
{
$this->year = $year;
return $this;
}
public function getOpeningMinutes(): int
{
return $this->openingMinutes;
}
public function setOpeningMinutes(int $openingMinutes): self
{
$this->openingMinutes = $openingMinutes;
return $this;
}
public function isLocked(): bool
{
return $this->isLocked;
}
public function setIsLocked(bool $isLocked): self
{
$this->isLocked = $isLocked;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
public function touch(): self
{
$this->updatedAt = new DateTimeImmutable();
return $this;
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\EmployeeRttPaymentRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EmployeeRttPaymentRepository::class)]
#[ORM\Table(name: 'employee_rtt_payments', options: ['comment' => 'Paiements RTT par employe, mois et exercice.'])]
#[ORM\Index(columns: ['employee_id', 'year'], name: 'idx_rtt_payment_employee_year')]
class EmployeeRttPayment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Employee $employee = null;
#[ORM\Column(type: 'integer', options: ['comment' => 'Annee d exercice.'])]
private int $year = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Mois du paiement.'])]
private int $month = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Duree en minutes.'])]
private int $minutes = 0;
#[ORM\Column(type: 'string', length: 10, options: ['comment' => 'Taux applique.'])]
private string $rate = '';
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
}
public function getId(): ?int
{
return $this->id;
}
public function getEmployee(): ?Employee
{
return $this->employee;
}
public function setEmployee(Employee $employee): self
{
$this->employee = $employee;
return $this;
}
public function getYear(): int
{
return $this->year;
}
public function setYear(int $year): self
{
$this->year = $year;
return $this;
}
public function getMonth(): int
{
return $this->month;
}
public function setMonth(int $month): self
{
$this->month = $month;
return $this;
}
public function getMinutes(): int
{
return $this->minutes;
}
public function setMinutes(int $minutes): self
{
$this->minutes = $minutes;
return $this;
}
public function getRate(): string
{
return $this->rate;
}
public function setRate(string $rate): self
{
$this->rate = $rate;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
public function touch(): self
{
$this->updatedAt = new DateTimeImmutable();
return $this;
}
}

196
src/Entity/Notification.php Normal file
View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\Repository\NotificationRepository;
use App\State\MarkAllNotificationsReadProcessor;
use App\State\NotificationHistoryProvider;
use App\State\NotificationTodayProvider;
use App\State\UnreadNotificationsProvider;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/notifications/{id}',
uriVariables: ['id'],
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['notification:read']],
security: "is_granted('ROLE_USER')"
),
new GetCollection(
uriTemplate: '/notifications/unread',
normalizationContext: ['groups' => ['notification:read']],
security: "is_granted('ROLE_USER')",
provider: UnreadNotificationsProvider::class,
paginationEnabled: false
),
new GetCollection(
uriTemplate: '/notifications/today',
normalizationContext: ['groups' => ['notification:read']],
security: "is_granted('ROLE_USER')",
provider: NotificationTodayProvider::class,
paginationEnabled: false
),
new GetCollection(
uriTemplate: '/notifications/history',
normalizationContext: ['groups' => ['notification:read']],
security: "is_granted('ROLE_USER')",
provider: NotificationHistoryProvider::class,
paginationEnabled: false
),
new Post(
uriTemplate: '/notifications/mark-all-read',
security: "is_granted('ROLE_USER')",
input: false,
output: false,
read: false,
processor: MarkAllNotificationsReadProcessor::class
),
]
)]
#[ORM\Entity(repositoryClass: NotificationRepository::class)]
#[ORM\Table(name: 'notifications')]
#[ORM\Index(columns: ['recipient_id', 'is_read', 'created_at'], name: 'idx_notifications_recipient_read_created')]
class Notification
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['notification:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?User $recipient = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?User $actor = null;
#[ORM\Column(type: 'text')]
#[Groups(['notification:read'])]
private string $message = '';
#[ORM\Column(type: 'string', length: 60, options: ['default' => ''])]
#[Groups(['notification:read'])]
private string $category = '';
#[ORM\Column(type: 'string', length: 255, options: ['default' => ''])]
#[Groups(['notification:read'])]
private string $target = '';
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['notification:read'])]
private bool $isRead = false;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['notification:read'])]
private DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getRecipient(): ?User
{
return $this->recipient;
}
public function setRecipient(?User $recipient): self
{
$this->recipient = $recipient;
return $this;
}
public function getActor(): ?User
{
return $this->actor;
}
public function setActor(?User $actor): self
{
$this->actor = $actor;
return $this;
}
#[Groups(['notification:read'])]
public function getActorName(): string
{
return $this->actor?->getUsername() ?? '';
}
public function getMessage(): string
{
return $this->message;
}
public function setMessage(string $message): self
{
$this->message = $message;
return $this;
}
public function getCategory(): string
{
return $this->category;
}
public function setCategory(string $category): self
{
$this->category = $category;
return $this;
}
public function getTarget(): string
{
return $this->target;
}
public function setTarget(string $target): self
{
$this->target = $target;
return $this;
}
public function isRead(): bool
{
return $this->isRead;
}
public function getIsRead(): bool
{
return $this->isRead;
}
public function setIsRead(bool $isRead): self
{
$this->isRead = $isRead;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -10,6 +10,7 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\UserRepository;
use App\State\CurrentUserProvider;
use App\State\UserPasswordHasherProcessor;
use Doctrine\Common\Collections\ArrayCollection;
@@ -52,7 +53,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
),
]
)]
#[ORM\Entity]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'users')]
#[ORM\UniqueConstraint(name: 'uniq_users_username', fields: ['username'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface

View File

@@ -14,6 +14,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Repository\WorkHourRepository;
use App\State\WorkHourSiteValidationProcessor;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -106,6 +107,10 @@ class WorkHour
#[Groups(['work_hour:read', 'work_hour:site_validate'])]
private bool $isSiteValid = false;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['work_hour:read'])]
private ?DateTimeImmutable $updatedAt = null;
public function getId(): ?int
{
return $this->id;
@@ -274,4 +279,16 @@ class WorkHour
return $this;
}
public function getUpdatedAt(): ?DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(?DateTimeImmutable $updatedAt): self
{
$this->updatedAt = $updatedAt;
return $this;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum LeaveRuleCode: string
{
case UNSUPPORTED = 'UNSUPPORTED';
case CDI_CDD_NON_FORFAIT = 'CDI_CDD_NON_FORFAIT';
case FORFAIT_218 = 'FORFAIT_218';
}

View File

@@ -99,4 +99,29 @@ final class AbsenceRepository extends ServiceEntityRepository implements Absence
// @var list<Absence> $absences
return $qb->getQuery()->getResult();
}
/**
* @return list<Absence>
*/
public function findByEmployeeAndOverlappingDateRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): array
{
$fromDate = DateTimeImmutable::createFromInterface($from);
$toDate = DateTimeImmutable::createFromInterface($to);
$qb = $this->createQueryBuilder('a')
->leftJoin('a.employee', 'e')
->leftJoin('a.type', 't')
->addSelect('e', 't')
->andWhere('a.employee = :employee')
->andWhere('a.startDate <= :to')
->andWhere('a.endDate >= :from')
->setParameter('employee', $employee)
->setParameter('from', $fromDate)
->setParameter('to', $toDate)
->orderBy('a.startDate', 'ASC')
;
// @var list<Absence> $absences
return $qb->getQuery()->getResult();
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Repository\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use DateTimeImmutable;
interface EmployeeContractPeriodReadRepositoryInterface
{
public function findOneCoveringDate(Employee $employee, DateTimeImmutable $date): ?EmployeeContractPeriod;
}

View File

@@ -6,6 +6,7 @@ namespace App\Repository;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -13,7 +14,7 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EmployeeContractPeriod>
*/
final class EmployeeContractPeriodRepository extends ServiceEntityRepository
final class EmployeeContractPeriodRepository extends ServiceEntityRepository implements EmployeeContractPeriodReadRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
@@ -72,4 +73,56 @@ final class EmployeeContractPeriodRepository extends ServiceEntityRepository
->execute()
;
}
public function hasPaidLeaveSettledClosureBetween(
Employee $employee,
DateTimeImmutable $from,
DateTimeImmutable $to
): bool {
$count = $this->createQueryBuilder('p')
->select('COUNT(p.id)')
->andWhere('p.employee = :employee')
->andWhere('p.paidLeaveSettled = :paidLeaveSettled')
->andWhere('p.endDate IS NOT NULL')
->andWhere('p.endDate >= :from')
->andWhere('p.endDate <= :to')
->setParameter('employee', $employee)
->setParameter('paidLeaveSettled', true)
->setParameter('from', $from)
->setParameter('to', $to)
->getQuery()
->getSingleScalarResult()
;
return (int) $count > 0;
}
public function findLatestPaidLeaveSettledClosureDateBetween(
Employee $employee,
DateTimeImmutable $from,
DateTimeImmutable $to
): ?DateTimeImmutable {
$result = $this->createQueryBuilder('p')
->select('p.endDate AS endDate')
->andWhere('p.employee = :employee')
->andWhere('p.paidLeaveSettled = :paidLeaveSettled')
->andWhere('p.endDate IS NOT NULL')
->andWhere('p.endDate >= :from')
->andWhere('p.endDate <= :to')
->setParameter('employee', $employee)
->setParameter('paidLeaveSettled', true)
->setParameter('from', $from)
->setParameter('to', $to)
->orderBy('p.endDate', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
if (!is_array($result) || !isset($result['endDate']) || !$result['endDate'] instanceof DateTimeImmutable) {
return null;
}
return $result['endDate'];
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Employee;
use App\Entity\EmployeeLeaveBalance;
use App\Enum\LeaveRuleCode;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EmployeeLeaveBalance>
*/
final class EmployeeLeaveBalanceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EmployeeLeaveBalance::class);
}
public function findOneByEmployeeRuleAndYear(
Employee $employee,
LeaveRuleCode|string $ruleCode,
int $year
): ?EmployeeLeaveBalance {
$ruleCodeValue = $ruleCode instanceof LeaveRuleCode ? $ruleCode->value : $ruleCode;
return $this->createQueryBuilder('b')
->andWhere('b.employee = :employee')
->andWhere('b.ruleCode = :ruleCode')
->andWhere('b.year = :year')
->setParameter('employee', $employee)
->setParameter('ruleCode', $ruleCodeValue)
->setParameter('year', $year)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
public function findEarliestYearForEmployee(Employee $employee): ?int
{
$result = $this->createQueryBuilder('b')
->select('MIN(b.year) AS year')
->andWhere('b.employee = :employee')
->setParameter('employee', $employee)
->getQuery()
->getOneOrNullResult()
;
if (!is_array($result) || !array_key_exists('year', $result) || null === $result['year']) {
return null;
}
return (int) $result['year'];
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Employee;
use App\Entity\EmployeeRttBalance;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EmployeeRttBalance>
*/
final class EmployeeRttBalanceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EmployeeRttBalance::class);
}
public function findOneByEmployeeAndYear(Employee $employee, int $year): ?EmployeeRttBalance
{
return $this->createQueryBuilder('b')
->andWhere('b.employee = :employee')
->andWhere('b.year = :year')
->setParameter('employee', $employee)
->setParameter('year', $year)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Employee;
use App\Entity\EmployeeRttPayment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EmployeeRttPayment>
*/
final class EmployeeRttPaymentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EmployeeRttPayment::class);
}
public function findOneByEmployeeYearMonthRate(Employee $employee, int $year, int $month, string $rate): ?EmployeeRttPayment
{
return $this->findOneBy([
'employee' => $employee,
'year' => $year,
'month' => $month,
'rate' => $rate,
]);
}
/**
* @return EmployeeRttPayment[]
*/
public function findByEmployeeAndYear(Employee $employee, int $year): array
{
return $this->createQueryBuilder('p')
->andWhere('p.employee = :employee')
->andWhere('p.year = :year')
->setParameter('employee', $employee)
->setParameter('year', $year)
->addOrderBy('p.month', 'ASC')
->getQuery()
->getResult()
;
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Notification;
use App\Entity\User;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Notification>
*/
final class NotificationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Notification::class);
}
/**
* @return list<Notification>
*/
public function findUnreadByRecipient(User $recipient): array
{
return $this->createQueryBuilder('n')
->andWhere('n.recipient = :recipient')
->andWhere('n.isRead = :isRead')
->setParameter('recipient', $recipient)
->setParameter('isRead', false)
->orderBy('n.createdAt', 'DESC')
->setMaxResults(50)
->getQuery()
->getResult()
;
}
/**
* @return list<Notification>
*/
public function findTodayByRecipient(User $recipient): array
{
$todayStart = new DateTimeImmutable('today 00:00:00');
return $this->createQueryBuilder('n')
->andWhere('n.recipient = :recipient')
->andWhere('n.createdAt >= :todayStart')
->setParameter('recipient', $recipient)
->setParameter('todayStart', $todayStart)
->orderBy('n.createdAt', 'DESC')
->getQuery()
->getResult()
;
}
/**
* @return list<Notification>
*/
public function findLatestByRecipient(User $recipient, int $limit = 10): array
{
return $this->createQueryBuilder('n')
->andWhere('n.recipient = :recipient')
->setParameter('recipient', $recipient)
->orderBy('n.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult()
;
}
public function markAllReadByRecipient(User $recipient): int
{
return $this->createQueryBuilder('n')
->update()
->set('n.isRead', ':isRead')
->andWhere('n.recipient = :recipient')
->andWhere('n.isRead = :current')
->setParameter('isRead', true)
->setParameter('current', false)
->setParameter('recipient', $recipient)
->getQuery()
->execute()
;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<User>
*/
final class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
/**
* @return list<User>
*/
public function findAllAdmins(): array
{
/** @var list<User> $users */
$users = $this->createQueryBuilder('u')
->orderBy('u.id', 'ASC')
->getQuery()
->getResult()
;
return array_values(array_filter(
$users,
static fn (User $user): bool => in_array('ROLE_ADMIN', $user->getRoles(), true)
));
}
}

View File

@@ -137,4 +137,23 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
// @var null|WorkHour $workHour
return $qb->getQuery()->getOneOrNullResult();
}
public function hasPendingSiteValidationForSiteAndDate(int $siteId, DateTimeInterface $date): bool
{
$workDate = DateTimeImmutable::createFromInterface($date);
$qb = $this->createQueryBuilder('w')
->select('COUNT(w.id)')
->leftJoin('w.employee', 'e')
->leftJoin('e.site', 's')
->andWhere('s.id = :siteId')
->andWhere('w.workDate = :workDate')
->andWhere('w.isSiteValid = :isSiteValid')
->setParameter('siteId', $siteId)
->setParameter('workDate', $workDate)
->setParameter('isSiteValid', false)
;
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Service\Contracts;
use App\Enum\ContractNature;
use DateTimeImmutable;
final readonly class EmployeeContractChangeRequest
{
public function __construct(
public ?ContractNature $contractNature,
public ?DateTimeImmutable $contractStartDate,
public ?DateTimeImmutable $contractEndDate,
public ?bool $contractPaidLeaveSettled,
public ?string $contractComment,
) {}
public function hasPeriodChangeRequest(): bool
{
return null !== $this->contractNature
|| null !== $this->contractStartDate
|| null !== $this->contractEndDate
|| null !== $this->contractPaidLeaveSettled
|| null !== $this->contractComment;
}
public function isCloseOnlyRequest(bool $contractChanged): bool
{
return !$contractChanged
&& null === $this->contractStartDate
&& null === $this->contractNature
&& null !== $this->contractEndDate;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Service\Contracts;
use App\Entity\Employee;
use App\Enum\ContractNature;
use DateTimeImmutable;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final class EmployeeContractChangeRequestFactory
{
public function fromEmployee(Employee $employee): EmployeeContractChangeRequest
{
return new EmployeeContractChangeRequest(
contractNature: $this->resolveContractNature($employee->getContractNature()),
contractStartDate: $this->parseOptionalYmd($employee->getContractStartDate(), 'contractStartDate'),
contractEndDate: $this->parseOptionalYmd($employee->getContractEndDate(), 'contractEndDate'),
contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
contractComment: $employee->getContractComment(),
);
}
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;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use DateTimeImmutable;
final class EmployeeContractPeriodBuilder
{
public function build(
Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
): EmployeeContractPeriod {
return new EmployeeContractPeriod()
->setEmployee($employee)
->setContract($contract)
->setStartDate($startDate)
->setEndDate($endDate)
->setContractNature($nature)
;
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class EmployeeContractPeriodManager implements EmployeeContractPeriodManagerInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private EmployeeContractPeriodRepository $periodRepository,
private EmployeeContractPeriodBuilder $periodBuilder,
private EmployeeContractPeriodValidator $periodValidator,
) {}
public function ensureContractPeriodExists(
Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
$covered = $this->periodRepository->findOneCoveringDate($employee, $startDate);
if (null !== $covered) {
return;
}
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature);
$this->entityManager->flush();
}
public function closeCurrentPeriod(
?EmployeeContractPeriod $todayPeriod,
DateTimeImmutable $requestedEndDate,
bool $paidLeaveSettled,
?string $comment = null
): void {
if (null === $todayPeriod) {
throw new UnprocessableEntityHttpException('No active contract period to close.');
}
$this->periodValidator->assertCloseEndDateCanBeApplied(
$todayPeriod->getStartDate(),
$todayPeriod->getEndDate(),
$requestedEndDate,
$todayPeriod->getContractNatureEnum()
);
$todayPeriod->setEndDate($requestedEndDate);
$todayPeriod->setPaidLeaveSettled($paidLeaveSettled);
$todayPeriod->setComment($comment);
$this->entityManager->flush();
}
public function createNextPeriod(
Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
?EmployeeContractPeriod $todayPeriod
): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
if (null !== $todayPeriod) {
$this->periodValidator->assertNextStartDateCompatible($startDate, $todayPeriod);
if (null === $todayPeriod->getEndDate()) {
$todayPeriod->setEndDate($startDate->modify('-1 day'));
}
}
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature);
$this->entityManager->flush();
}
private function persistNewPeriod(
Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
): void {
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature);
$this->entityManager->persist($period);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use DateTimeImmutable;
interface EmployeeContractPeriodManagerInterface
{
public function ensureContractPeriodExists(
Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
): void;
public function closeCurrentPeriod(
?EmployeeContractPeriod $todayPeriod,
DateTimeImmutable $requestedEndDate,
bool $paidLeaveSettled,
?string $comment = null
): void;
public function createNextPeriod(
Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
?EmployeeContractPeriod $todayPeriod
): void;
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Service\Contracts;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use DateTimeImmutable;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final class EmployeeContractPeriodValidator
{
public function assertPeriodDates(
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
bool $allowCdiEndDate = false
): 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 (!$allowCdiEndDate && ContractNature::CDI === $nature && null !== $endDate) {
throw new UnprocessableEntityHttpException('contractEndDate must be empty for CDI.');
}
}
public function assertCloseEndDateCanBeApplied(
DateTimeImmutable $startDate,
?DateTimeImmutable $currentEndDate,
DateTimeImmutable $requestedEndDate,
ContractNature $nature
): void {
$this->assertPeriodDates($startDate, $requestedEndDate, $nature, true);
if (null !== $currentEndDate && $requestedEndDate > $currentEndDate) {
throw new UnprocessableEntityHttpException('contractEndDate cannot be increased on current contract.');
}
}
public function assertNextStartDateCompatible(
DateTimeImmutable $startDate,
EmployeeContractPeriod $currentPeriod
): void {
$currentEndDate = $currentPeriod->getEndDate();
if (null === $currentEndDate) {
if ($startDate <= $currentPeriod->getStartDate()) {
throw new UnprocessableEntityHttpException('contractStartDate must be after current contract start date.');
}
return;
}
if ($startDate <= $currentEndDate) {
throw new UnprocessableEntityHttpException('contractStartDate must be after current contract end date.');
}
}
}

View File

@@ -0,0 +1,396 @@
<?php
declare(strict_types=1);
namespace App\Service\Leave;
use App\Entity\Absence;
use App\Entity\Employee;
use App\Enum\LeaveRuleCode;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeContractPeriodRepository;
use App\Repository\EmployeeLeaveBalanceRepository;
use App\Service\PublicHolidayServiceInterface;
use DateTimeImmutable;
use Throwable;
final readonly class LeaveBalanceComputationService
{
private const int FORFAIT_TARGET_WORKED_DAYS = 218;
private const float STANDARD_ANNUAL_DAYS = 25.0;
private const float STANDARD_ANNUAL_SATURDAYS = 5.0;
private const float STANDARD_ACCRUAL_PER_MONTH = self::STANDARD_ANNUAL_DAYS / 12.0;
private const float STANDARD_SATURDAY_ACCRUAL_PER_MONTH = self::STANDARD_ANNUAL_SATURDAYS / 12.0;
private const float FOUR_HOUR_ANNUAL_DAYS = 10.0;
private const float FOUR_HOUR_ACCRUAL_PER_MONTH = 0.83;
public function __construct(
private AbsenceRepository $absenceRepository,
private EmployeeContractPeriodRepository $periodRepository,
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
private PublicHolidayServiceInterface $publicHolidayService,
) {}
/**
* @return array{float, float}
*/
public function computeDynamicClosingForYear(Employee $employee, LeaveRuleCode $ruleCode, int $targetYear): array
{
$firstYear = $this->resolveFirstComputationYear($employee, $ruleCode, $targetYear);
if ($targetYear < $firstYear) {
return [0.0, 0.0];
}
$previousRemainingDays = 0.0;
$previousRemainingSaturdays = 0.0;
for ($year = $firstYear; $year <= $targetYear; ++$year) {
[$from, $to] = $this->resolvePeriodBounds($ruleCode, $year);
$carryDays = 0.0;
$carrySaturdays = 0.0;
if ($year > $firstYear) {
[$previousFrom, $previousTo] = $this->resolvePeriodBounds($ruleCode, $year - 1);
$hasSettlementOnPreviousYear = $this->periodRepository->hasPaidLeaveSettledClosureBetween($employee, $previousFrom, $previousTo);
if (!$hasSettlementOnPreviousYear) {
$carryDays = $previousRemainingDays;
$carrySaturdays = LeaveRuleCode::CDI_CDD_NON_FORFAIT === $ruleCode ? $previousRemainingSaturdays : 0.0;
}
}
$effectiveFrom = $this->resolveEffectivePeriodStart($employee, $from, $to);
if ($effectiveFrom > $from) {
$carryDays = 0.0;
$carrySaturdays = 0.0;
}
$fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year);
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
$acquiredDays = $carryDays + (float) max(0, $this->countBusinessDays($from, $to) - self::FORFAIT_TARGET_WORKED_DAYS) + $fractionedDays;
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
[$takenDays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false);
$previousRemainingDays = max(0.0, $acquiredDays - $takenDays);
$previousRemainingSaturdays = 0.0;
continue;
}
$generatedDays = $this->computeAccruedDays(
$this->resolveAnnualDays($employee),
$this->resolveDaysAccrualPerMonth($employee),
$effectiveFrom,
$to
);
$generatedSaturdays = $this->computeAccruedDays(
$this->resolveAnnualSaturdays($employee),
$this->resolveSaturdayAccrualPerMonth($employee),
$effectiveFrom,
$to
);
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
[$takenDays, $takenSaturdays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, true, true);
$acquiredWithFractioned = $carryDays + $fractionedDays;
$takenFromAcquired = min(max(0.0, $acquiredWithFractioned), $takenDays);
$remainingAcquired = $acquiredWithFractioned - $takenFromAcquired;
$remainingToImpute = max(0.0, $takenDays - $takenFromAcquired);
$remainingGenerated = $generatedDays - $remainingToImpute;
$takenFromAcquiredSaturdays = min(max(0.0, $carrySaturdays), $takenSaturdays);
$remainingAcquiredSaturdays = $carrySaturdays - $takenFromAcquiredSaturdays;
$remainingSaturdaysToImpute = max(0.0, $takenSaturdays - $takenFromAcquiredSaturdays);
$remainingGeneratedSaturdays = $generatedSaturdays - $remainingSaturdaysToImpute;
$previousRemainingDays = $remainingAcquired + $remainingGenerated;
$previousRemainingSaturdays = $remainingAcquiredSaturdays + $remainingGeneratedSaturdays;
}
return [$previousRemainingDays, $previousRemainingSaturdays];
}
/**
* @return array{DateTimeImmutable, DateTimeImmutable}
*/
public function resolvePeriodBounds(LeaveRuleCode $ruleCode, int $year): array
{
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
return [
new DateTimeImmutable(sprintf('%d-01-01', $year)),
new DateTimeImmutable(sprintf('%d-12-31', $year)),
];
}
return [
new DateTimeImmutable(sprintf('%d-06-01', $year - 1)),
new DateTimeImmutable(sprintf('%d-05-31', $year)),
];
}
public function hasPaidLeaveSettledClosureBetween(
Employee $employee,
DateTimeImmutable $from,
DateTimeImmutable $to
): bool {
return $this->periodRepository->hasPaidLeaveSettledClosureBetween($employee, $from, $to);
}
private function resolveFirstComputationYear(Employee $employee, LeaveRuleCode $ruleCode, int $fallbackYear): int
{
$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) {
return $fallbackYear;
}
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
return (int) $oldestStartDate->format('Y');
}
$startYear = (int) $oldestStartDate->format('Y');
$startMonth = (int) $oldestStartDate->format('n');
return $startMonth >= 6 ? $startYear + 1 : $startYear;
}
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) {
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 resolveFractionedDays(Employee $employee, LeaveRuleCode $ruleCode, int $year): float
{
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
return null !== $balance ? $balance->getFractionedDays() : 0.0;
}
private function resolveAnnualDays(Employee $employee): float
{
return 4 === $employee->getContract()?->getWeeklyHours()
? self::FOUR_HOUR_ANNUAL_DAYS
: self::STANDARD_ANNUAL_DAYS;
}
private function resolveAnnualSaturdays(Employee $employee): float
{
return 4 === $employee->getContract()?->getWeeklyHours()
? 0.0
: self::STANDARD_ANNUAL_SATURDAYS;
}
private function resolveDaysAccrualPerMonth(Employee $employee): float
{
return 4 === $employee->getContract()?->getWeeklyHours()
? self::FOUR_HOUR_ACCRUAL_PER_MONTH
: self::STANDARD_ACCRUAL_PER_MONTH;
}
private function resolveSaturdayAccrualPerMonth(Employee $employee): float
{
return 4 === $employee->getContract()?->getWeeklyHours()
? 0.0
: self::STANDARD_SATURDAY_ACCRUAL_PER_MONTH;
}
private function computeAccruedDays(
float $annualCap,
float $accrualPerMonth,
DateTimeImmutable $periodStart,
DateTimeImmutable $periodEnd
): float {
if ($accrualPerMonth <= 0.0 || $periodEnd < $periodStart) {
return 0.0;
}
$monthsElapsed = ((int) $periodEnd->format('Y') - (int) $periodStart->format('Y')) * 12
+ ((int) $periodEnd->format('n') - (int) $periodStart->format('n'))
+ 1;
return min($annualCap, $monthsElapsed * $accrualPerMonth);
}
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;
}
/**
* @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;
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) {
$takenSaturdays += $dayAmount;
} else {
$takenDays += $dayAmount;
}
}
}
return [$takenDays, $takenSaturdays];
}
/**
* @return array{bool, bool}
*/
private function resolveSegmentsForDate(Absence $absence, string $date): array
{
$startYmd = DateTimeImmutable::createFromInterface($absence->getStartDate())->format('Y-m-d');
$endYmd = DateTimeImmutable::createFromInterface($absence->getEndDate())->format('Y-m-d');
$startHalf = $absence->getStartHalf()->value;
$endHalf = $absence->getEndHalf()->value;
$isSingleDay = $startYmd === $endYmd;
$isStartDay = $date === $startYmd;
$isEndDay = $date === $endYmd;
if ($isSingleDay) {
return ['AM' === $startHalf, 'PM' === $endHalf];
}
if ($isStartDay) {
return ['AM' === $startHalf, true];
}
if ($isEndDay) {
return [true, 'PM' === $endHalf];
}
return [true, true];
}
}

View File

@@ -0,0 +1,377 @@
<?php
declare(strict_types=1);
namespace App\Service\Rtt;
use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\WorkHour;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable;
final readonly class RttRecoveryComputationService
{
public function __construct(
private WorkHourRepository $workHourRepository,
private AbsenceRepository $absenceRepository,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private EmployeeContractResolver $contractResolver,
) {}
/**
* @return array{DateTimeImmutable, DateTimeImmutable}
*/
public function resolveExerciseBounds(int $exerciseYear): array
{
return [
new DateTimeImmutable(sprintf('%d-06-01', $exerciseYear - 1)),
new DateTimeImmutable(sprintf('%d-05-31', $exerciseYear)),
];
}
/**
* @return list<array{month:int,weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}>
*/
public function buildWeeksForExercise(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$dayOfWeek = (int) $from->format('N');
$weekStart = $from->modify(sprintf('-%d days', $dayOfWeek - 1));
$weeks = [];
while ($weekStart <= $to) {
$start = $weekStart;
$end = $start->modify('+6 days');
$effectiveStart = $start < $from ? $from : $start;
$effectiveEnd = $end > $to ? $to : $end;
if ($effectiveEnd >= $effectiveStart) {
$saturday = $start->modify('+5 days');
$monthAnchor = $saturday < $from ? $from : ($saturday > $to ? $to : $saturday);
$weeks[] = [
'month' => (int) $monthAnchor->format('n'),
'weekNumber' => (int) $effectiveStart->format('W'),
'start' => $start,
'end' => $end,
];
}
$weekStart = $weekStart->modify('+7 days');
}
return $weeks;
}
public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear): int
{
[$from, $to] = $this->resolveExerciseBounds($exerciseYear);
$weeks = $this->buildWeeksForExercise($from, $to);
$weekRanges = array_map(
static fn (array $week): array => [
'month' => (int) $week['month'],
'weekNumber' => (int) $week['weekNumber'],
'start' => $week['start'],
'end' => $week['end'],
],
$weeks
);
$byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, null);
return array_sum($byWeek);
}
/**
* @param list<array{month:int,weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weeks
*
* @return array<string, int>
*/
public function computeRecoveryByWeek(
Employee $employee,
array $weeks,
DateTimeImmutable $periodFrom,
DateTimeImmutable $periodTo,
?DateTimeImmutable $limitDate
): array {
if ([] === $weeks) {
return [];
}
$days = [];
for ($cursor = $periodFrom; $cursor <= $periodTo; $cursor = $cursor->modify('+1 day')) {
$days[] = $cursor->format('Y-m-d');
}
$contractsByDate = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days);
$naturesByDate = $this->contractResolver->resolveNaturesForEmployeesAndDays([$employee], $days);
$employeeId = (int) $employee->getId();
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($periodFrom, $periodTo, [$employee]);
$absences = $this->absenceRepository->findForPrint($periodFrom, $periodTo, [$employee]);
$metricsByDate = [];
foreach ($workHours as $workHour) {
$dateKey = $workHour->getWorkDate()->format('Y-m-d');
$metricsByDate[$dateKey] = $this->computeMetrics($workHour);
}
$creditedByDate = [];
foreach ($absences as $absence) {
$start = $absence->getStartDate()->format('Y-m-d');
$end = $absence->getEndDate()->format('Y-m-d');
for ($cursor = $periodFrom; $cursor <= $periodTo; $cursor = $cursor->modify('+1 day')) {
$date = $cursor->format('Y-m-d');
if ($date < $start || $date > $end) {
continue;
}
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
$creditedByDate[$date] = ($creditedByDate[$date] ?? 0)
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
}
}
$results = [];
foreach ($weeks as $week) {
$weekStart = $week['start'];
$weekEnd = $week['end'];
$weekKey = $weekStart->format('Y-m-d');
$effectiveStart = $weekStart < $periodFrom ? $periodFrom : $weekStart;
$effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd;
if ($effectiveEnd < $effectiveStart) {
$results[$weekKey] = 0;
continue;
}
if ($limitDate instanceof DateTimeImmutable && $effectiveStart > $limitDate) {
$results[$weekKey] = 0;
continue;
}
$weekDays = [];
for ($cursor = $effectiveStart; $cursor <= $effectiveEnd; $cursor = $cursor->modify('+1 day')) {
$weekDays[] = $cursor->format('Y-m-d');
}
$weeklyTotalMinutes = 0;
$employeeContractsByDate = [];
foreach ($weekDays as $date) {
$employeeContractsByDate[$date] = $contractsByDate[$employeeId][$date] ?? null;
if ($limitDate instanceof DateTimeImmutable && new DateTimeImmutable($date) > $limitDate) {
continue;
}
$metrics = $metricsByDate[$date] ?? new WorkMetrics();
$metrics->addCreditedMinutes($creditedByDate[$date] ?? 0);
$weeklyTotalMinutes += $metrics->totalMinutes;
}
if ([] === $weekDays) {
$results[$weekKey] = 0;
continue;
}
$weekAnchorNature = $naturesByDate[$employeeId][$weekDays[0]] ?? ContractNature::CDI;
$weekAnchorContract = $employeeContractsByDate[$weekDays[0]] ?? null;
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature);
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate);
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
? 0
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
$weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $overtime25StartMinutes);
$weeklyOvertime50Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
$results[$weekKey] = ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
}
return $results;
}
private function computeMetrics(WorkHour $workHour): WorkMetrics
{
$ranges = [
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
];
$totalMinutes = 0;
$nightMinutes = 0;
foreach ($ranges as [$from, $to]) {
$totalMinutes += $this->intervalMinutes($from, $to);
$nightMinutes += $this->nightIntervalMinutes($from, $to);
}
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
return new WorkMetrics(
dayMinutes: $dayMinutes,
nightMinutes: $nightMinutes,
totalMinutes: $totalMinutes,
);
}
/**
* @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 intervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
return max(0, $end - $start);
}
private 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;
}
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);
}
/**
* @param list<string> $days
* @param array<string, ?Contract> $contractsByDate
*/
private function computeWeeklyOvertimeReferenceMinutes(array $days, array $contractsByDate): int
{
$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);
}
return $total;
}
/**
* @param list<string> $days
* @param array<string, ?Contract> $contractsByDate
*/
private function computeWeeklyOvertime25StartMinutes(array $days, array $contractsByDate): int
{
$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);
}
private function computeOvertime50BonusMinutes(int $weeklyTotalMinutes): int
{
$trancheMinutes = max(0, $weeklyTotalMinutes - (43 * 60));
return (int) round($trancheMinutes * 0.5);
}
private function hasDisabledOvertimeBonuses(?Contract $contract, ContractNature $contractNature): bool
{
if (ContractNature::INTERIM === $contractNature) {
return true;
}
$type = ContractType::resolve(
$contract?->getName(),
$contract?->getTrackingMode(),
$contract?->getWeeklyHours()
);
return ContractType::INTERIM === $type;
}
private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int
{
if ($isoWeekDay >= 6 || 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);
}
}

View File

@@ -69,13 +69,8 @@ final readonly class WorkedHoursCreditPolicy
return 0.0;
}
// Règle forfait:
// - demi-journée d'absence => 0.5 travaillé
// - journée complète d'absence => 0 travaillé
if ($absentMorning xor $absentAfternoon) {
return 0.5;
}
// Règle forfait: les absences ne créditent jamais de présence.
// Seules les checkboxes cochées par l'employé comptent.
return 0.0;
}

View 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');
}
}

View 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();
}
}

View 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];
}
}

View 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;
}
}

View 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();
}
}

View 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;
}
}

View File

@@ -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.');
}
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}