feat : ajout du nouveau système de contrat et ajout de filtre d'impression
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s

This commit is contained in:
2026-02-26 17:15:13 +01:00
parent 9261cb5b1a
commit 4d90f2cb42
24 changed files with 853 additions and 114 deletions

View File

@@ -18,6 +18,8 @@ use App\State\AbsencePrintProvider;
new QueryParameter(key: 'from', required: true),
new QueryParameter(key: 'to', required: true),
new QueryParameter(key: 'sites', required: false),
new QueryParameter(key: 'contractNatures', required: false),
new QueryParameter(key: 'workContracts', required: false),
],
security: "is_granted('ROLE_ADMIN')"
),

View File

@@ -26,6 +26,7 @@ final class WorkHourDayContext
* @var list<array{
* employeeId:int,
* absenceLabel:?string,
* absenceColor:?string,
* absenceHalf:?string,
* absentMorning:bool,
* absentAfternoon:bool,

View File

@@ -10,6 +10,7 @@ final class DayContextRow
public int $employeeId,
public bool $hasContractAtDate = true,
public ?string $absenceLabel = null,
public ?string $absenceColor = null,
public ?string $absenceHalf = null,
public bool $absentMorning = false,
public bool $absentAfternoon = false,
@@ -19,6 +20,7 @@ final class DayContextRow
public function addAbsence(
?string $label,
?string $color,
bool $morning,
bool $afternoon,
int $creditedMinutes,
@@ -35,6 +37,14 @@ final class DayContextRow
$this->absenceLabel = 'Absences multiples';
}
// Si plusieurs types d'absence différents sont fusionnés sur la même journée,
// on retire la couleur métier spécifique.
if (null === $this->absenceColor) {
$this->absenceColor = $color;
} elseif ($color !== $this->absenceColor) {
$this->absenceColor = null;
}
// AM/PM seulement pour les demi-journées, null pour journée complète.
$this->absenceHalf = $this->resolveHalfLabel($this->absentMorning, $this->absentAfternoon);
// Cumule les minutes créditées par les absences "comptées comme travaillées".
@@ -48,6 +58,7 @@ final class DayContextRow
* employeeId:int,
* hasContractAtDate:bool,
* absenceLabel:?string,
* absenceColor:?string,
* absenceHalf:?string,
* absentMorning:bool,
* absentAfternoon:bool,
@@ -61,6 +72,7 @@ final class DayContextRow
'employeeId' => $this->employeeId,
'hasContractAtDate' => $this->hasContractAtDate,
'absenceLabel' => $this->absenceLabel,
'absenceColor' => $this->absenceColor,
'absenceHalf' => $this->absenceHalf,
'absentMorning' => $this->absentMorning,
'absentAfternoon' => $this->absentAfternoon,

View File

@@ -6,9 +6,12 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use App\Enum\ContractNature;
use App\Repository\EmployeeRepository;
use App\State\EmployeeWriteProcessor;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -56,9 +59,25 @@ class Employee
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
/**
* @var Collection<int, EmployeeContractPeriod>
*/
#[ORM\OneToMany(mappedBy: 'employee', targetEntity: EmployeeContractPeriod::class)]
private Collection $contractPeriods;
#[Groups(['employee:write'])]
private ?string $contractNature = null;
#[Groups(['employee:write'])]
private ?string $contractStartDate = null;
#[Groups(['employee:write'])]
private ?string $contractEndDate = null;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->createdAt = new DateTimeImmutable();
$this->contractPeriods = new ArrayCollection();
}
public function getId(): ?int
@@ -130,4 +149,81 @@ class Employee
return $this;
}
public function getContractNature(): ?string
{
return $this->contractNature;
}
public function setContractNature(?string $contractNature): self
{
$this->contractNature = $contractNature;
return $this;
}
public function getContractStartDate(): ?string
{
return $this->contractStartDate;
}
public function setContractStartDate(?string $contractStartDate): self
{
$this->contractStartDate = $contractStartDate;
return $this;
}
public function getContractEndDate(): ?string
{
return $this->contractEndDate;
}
public function setContractEndDate(?string $contractEndDate): self
{
$this->contractEndDate = $contractEndDate;
return $this;
}
#[Groups(['employee:read'])]
public function getCurrentContractNature(): string
{
return $this->resolveCurrentContractPeriod()?->getContractNatureEnum()->value ?? ContractNature::CDI->value;
}
#[Groups(['employee:read'])]
public function getCurrentContractStartDate(): ?string
{
return $this->resolveCurrentContractPeriod()?->getStartDate()->format('Y-m-d');
}
#[Groups(['employee:read'])]
public function getCurrentContractEndDate(): ?string
{
return $this->resolveCurrentContractPeriod()?->getEndDate()?->format('Y-m-d');
}
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
{
$today = new DateTimeImmutable('today');
$current = null;
foreach ($this->contractPeriods as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null !== $endDate && $endDate < $today) {
continue;
}
if (null === $current || $period->getStartDate() > $current->getStartDate()) {
$current = $period;
}
}
return $current;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Entity;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
@@ -19,7 +20,7 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\ManyToOne(targetEntity: Employee::class, inversedBy: 'contractPeriods')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Employee $employee = null;
@@ -33,6 +34,9 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'date_immutable', nullable: true)]
private ?DateTimeImmutable $endDate = null;
#[ORM\Column(type: 'string', length: 20, options: ['default' => ContractNature::CDI->value])]
private string $contractNature = ContractNature::CDI->value;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
@@ -95,6 +99,24 @@ class EmployeeContractPeriod
return $this;
}
public function getContractNature(): string
{
return $this->contractNature;
}
public function getContractNatureEnum(): ContractNature
{
return ContractNature::tryFrom($this->contractNature) ?? ContractNature::CDI;
}
public function setContractNature(ContractNature|string $contractNature): self
{
$value = $contractNature instanceof ContractNature ? $contractNature->value : $contractNature;
$this->contractNature = ContractNature::tryFrom($value)?->value ?? ContractNature::CDI->value;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum ContractNature: string
{
case CDI = 'CDI';
case CDD = 'CDD';
case INTERIM = 'INTERIM';
public function requiresEndDate(): bool
{
return self::CDD === $this || self::INTERIM === $this;
}
}

View File

@@ -85,6 +85,8 @@ final class EmployeeRepository extends ServiceEntityRepository implements Employ
$qb = $this->createQueryBuilder('e')
->leftJoin('e.site', 's')
->addSelect('s')
->leftJoin('e.contract', 'c')
->addSelect('c')
->orderBy('s.displayOrder', 'ASC')
->addOrderBy('s.name', 'ASC')
->addOrderBy('e.displayOrder', 'ASC')

View File

@@ -6,6 +6,7 @@ namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable;
@@ -22,6 +23,13 @@ readonly class EmployeeContractResolver
return $period?->getContract();
}
public function resolveNatureForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ContractNature
{
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
return $period?->getContractNatureEnum() ?? ContractNature::CDI;
}
/**
* @param list<Employee> $employees
* @param list<string> $days
@@ -68,4 +76,51 @@ readonly class EmployeeContractResolver
return $resolved;
}
/**
* @param list<Employee> $employees
* @param list<string> $days
*
* @return array<int, array<string, ContractNature>>
*/
public function resolveNaturesForEmployeesAndDays(array $employees, array $days): array
{
$resolved = [];
if ([] === $employees || [] === $days) {
return $resolved;
}
foreach ($employees as $employee) {
$employeeId = $employee->getId();
if (!$employeeId) {
continue;
}
foreach ($days as $day) {
$resolved[$employeeId][$day] = ContractNature::CDI;
}
}
$from = new DateTimeImmutable(min($days));
$to = new DateTimeImmutable(max($days));
$periods = $this->periodRepository->findByEmployeesAndDateRange($employees, $from, $to);
foreach ($periods as $period) {
$employeeId = $period->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$start = $period->getStartDate()->format('Y-m-d');
$end = $period->getEndDate()?->format('Y-m-d') ?? '9999-12-31';
$nature = $period->getContractNatureEnum();
foreach ($days as $day) {
if ($day < $start || $day > $end) {
continue;
}
$resolved[$employeeId][$day] = $nature;
}
}
return $resolved;
}
}

View File

@@ -6,6 +6,7 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Enum\ContractNature;
use App\Enum\HalfDay;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeRepository;
@@ -53,9 +54,11 @@ class AbsencePrintProvider implements ProviderInterface
$fromDate = DateTimeImmutable::createFromFormat('Y-m-d', $from);
$toDate = DateTimeImmutable::createFromFormat('Y-m-d', $to);
$siteIds = $this->parseIds($request->query->get('sites'));
$siteIds = $this->parseIds($request->query->get('sites'));
$workContractIds = $this->parseIds($request->query->get('workContracts'));
$contractNatures = $this->parseContractNatures($request->query->get('contractNatures'));
$employees = $this->loadEmployees($siteIds);
$employees = $this->loadEmployees($siteIds, $contractNatures, $workContractIds);
$absences = $this->loadAbsences($fromDate, $toDate, $employees);
$days = $this->buildDays($fromDate, $toDate);
@@ -108,9 +111,19 @@ class AbsencePrintProvider implements ProviderInterface
return array_values(array_unique($ids));
}
private function loadEmployees(array $siteIds): array
private function loadEmployees(array $siteIds, array $contractNatures, array $workContractIds): array
{
return $this->employeeRepository->findForPrintBySiteIds($siteIds);
$employees = $this->employeeRepository->findForPrintBySiteIds($siteIds);
return array_values(array_filter($employees, static function ($employee) use ($contractNatures, $workContractIds): bool {
$employeeNature = (string) $employee->getCurrentContractNature();
$employeeContractId = $employee->getContract()?->getId();
$natureMatches = [] === $contractNatures || in_array($employeeNature, $contractNatures, true);
$contractMatches = [] === $workContractIds || (null !== $employeeContractId && in_array($employeeContractId, $workContractIds, true));
return $natureMatches && $contractMatches;
}));
}
private function loadAbsences(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
@@ -209,4 +222,24 @@ class AbsencePrintProvider implements ProviderInterface
return $map;
}
/**
* @return list<string>
*/
private function parseContractNatures(?string $value): array
{
if (null === $value || '' === trim($value)) {
return [];
}
$values = [];
foreach (explode(',', $value) as $part) {
$nature = strtoupper(trim($part));
if (null !== ContractNature::tryFrom($nature)) {
$values[] = $nature;
}
}
return array_values(array_unique($values));
}
}

View File

@@ -10,10 +10,12 @@ 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 DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class EmployeeWriteProcessor implements ProcessorInterface
{
@@ -49,27 +51,46 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
return $result;
}
$today = new DateTimeImmutable('today');
$today = new DateTimeImmutable('today');
$requestedContractNature = $this->resolveContractNature($data->getContractNature());
$requestedStartDate = $this->parseOptionalYmd($data->getContractStartDate(), 'contractStartDate');
$requestedEndDate = $this->parseOptionalYmd($data->getContractEndDate(), 'contractEndDate');
if ($isNew) {
$this->ensureContractPeriodExists($data, $currentContract, new DateTimeImmutable('1970-01-01'));
$startDate = $requestedStartDate ?? new DateTimeImmutable('1970-01-01');
$nature = $requestedContractNature ?? ContractNature::CDI;
$this->assertPeriodDates($startDate, $requestedEndDate, $nature);
$this->ensureContractPeriodExists($data, $currentContract, $startDate, $requestedEndDate, $nature);
return $result;
}
if ($this->isSameContract($previousContract, $currentContract)) {
$hasPeriodChangeRequest = null !== $requestedContractNature || null !== $requestedStartDate || null !== $requestedEndDate;
if ($this->isSameContract($previousContract, $currentContract) && !$hasPeriodChangeRequest) {
return $result;
}
$startDate = $requestedStartDate ?? $today;
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
if (null !== $todayPeriod && null === $todayPeriod->getEndDate() && $todayPeriod->getStartDate()->format('Y-m-d') === $today->format('Y-m-d')) {
$nature = $requestedContractNature ?? $todayPeriod?->getContractNatureEnum() ?? ContractNature::CDI;
$endDate = $requestedEndDate;
$this->assertPeriodDates($startDate, $endDate, $nature);
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();
return $result;
}
$this->periodRepository->closeOpenPeriods($data, $today->modify('-1 day'));
$this->createPeriod($data, $currentContract, $today);
$this->periodRepository->closeOpenPeriods($data, $startDate->modify('-1 day'));
$this->createPeriod($data, $currentContract, $startDate, $endDate, $nature);
$this->entityManager->flush();
return $result;
@@ -96,26 +117,80 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
return $first->getId() === $second->getId();
}
private function ensureContractPeriodExists(Employee $employee, Contract $contract, DateTimeImmutable $startDate): void
{
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);
$this->createPeriod($employee, $contract, $startDate, $endDate, $nature);
$this->entityManager->flush();
}
private function createPeriod(Employee $employee, Contract $contract, DateTimeImmutable $startDate): void
{
private function createPeriod(
Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
): void {
$period = new EmployeeContractPeriod()
->setEmployee($employee)
->setContract($contract)
->setStartDate($startDate)
->setEndDate(null)
->setEndDate($endDate)
->setContractNature($nature)
;
$this->entityManager->persist($period);
}
private function resolveContractNature(?string $raw): ?ContractNature
{
if (null === $raw || '' === trim($raw)) {
return null;
}
return ContractNature::tryFrom(trim($raw))
?? throw new UnprocessableEntityHttpException('contractNature must be one of CDI, CDD, INTERIM.');
}
private function parseOptionalYmd(?string $raw, string $field): ?DateTimeImmutable
{
if (null === $raw || '' === trim($raw)) {
return null;
}
$value = trim($raw);
$date = DateTimeImmutable::createFromFormat('Y-m-d', $value);
if (!$date || $date->format('Y-m-d') !== $value) {
throw new UnprocessableEntityHttpException(sprintf('%s must use Y-m-d format.', $field));
}
return $date;
}
private function assertPeriodDates(
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature
): void {
if (null !== $endDate && $endDate < $startDate) {
throw new UnprocessableEntityHttpException('contractEndDate cannot be before contractStartDate.');
}
if ($nature->requiresEndDate() && null === $endDate) {
throw new UnprocessableEntityHttpException('contractEndDate is required for CDD and INTERIM.');
}
if (ContractNature::CDI === $nature && null !== $endDate) {
throw new UnprocessableEntityHttpException('contractEndDate must be empty for CDI.');
}
}
}

View File

@@ -77,6 +77,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
$creditedPresenceUnits = $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $dateKey, $absentMorning, $absentAfternoon);
$rowsByEmployeeId[$employeeId]->addAbsence(
label: $absence->getType()?->getLabel(),
color: $absence->getType()?->getColor(),
morning: $absentMorning,
afternoon: $absentAfternoon,
creditedMinutes: $creditedMinutes,

View File

@@ -15,6 +15,7 @@ use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
@@ -113,8 +114,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
*/
private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd): array
{
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$metricsByEmployeeDate = [];
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
$metricsByEmployeeDate = [];
foreach ($workHours as $workHour) {
$employeeId = $workHour->getEmployee()?->getId();
if (!$employeeId) {
@@ -182,6 +184,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd]
?? $contractsByEmployeeDate[$employeeId][$days[0]]
?? null;
$weekAnchorContractNature = $contractNaturesByEmployeeDate[$employeeId][$anchorDateYmd]
?? $contractNaturesByEmployeeDate[$employeeId][$days[0]]
?? ContractNature::CDI;
$employeeContractsByDate = [];
foreach ($days as $date) {
$employeeContractsByDate[$date] = $contractsByEmployeeDate[$employeeId][$date] ?? null;
@@ -223,7 +228,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
}
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract);
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorContractNature);
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate);
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate);
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
@@ -407,8 +412,12 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
return (int) round($trancheMinutes * 0.5);
}
private function hasDisabledOvertimeBonuses(?Contract $contract): bool
private function hasDisabledOvertimeBonuses(?Contract $contract, ContractNature $contractNature): bool
{
if (ContractNature::INTERIM === $contractNature) {
return true;
}
$type = ContractType::resolve(
$contract?->getName(),
$contract?->getTrackingMode(),