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