fix : correction des Heures et ajout d'une validation pour les chefs de site
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled

This commit is contained in:
2026-02-26 14:49:28 +01:00
parent 5cced46254
commit b68fef61c4
20 changed files with 547 additions and 106 deletions

View File

@@ -8,6 +8,7 @@ final class DayContextRow
{
public function __construct(
public int $employeeId,
public bool $hasContractAtDate = true,
public ?string $absenceLabel = null,
public ?string $absenceHalf = null,
public bool $absentMorning = false,
@@ -45,6 +46,7 @@ final class DayContextRow
/**
* @return array{
* employeeId:int,
* hasContractAtDate:bool,
* absenceLabel:?string,
* absenceHalf:?string,
* absentMorning:bool,
@@ -57,6 +59,7 @@ final class DayContextRow
{
return [
'employeeId' => $this->employeeId,
'hasContractAtDate' => $this->hasContractAtDate,
'absenceLabel' => $this->absenceLabel,
'absenceHalf' => $this->absenceHalf,
'absentMorning' => $this->absentMorning,

View File

@@ -13,6 +13,7 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Repository\WorkHourRepository;
use App\State\WorkHourSiteValidationProcessor;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -33,6 +34,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
denormalizationContext: ['groups' => ['work_hour:validate']],
security: "is_granted('ROLE_ADMIN')"
),
new Patch(
uriTemplate: '/work_hours/{id}/site-validation',
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
denormalizationContext: ['groups' => ['work_hour:site_validate']],
security: "is_granted('ROLE_USER')",
processor: WorkHourSiteValidationProcessor::class
),
],
)]
#[ApiFilter(DateFilter::class, properties: ['workDate'])]
@@ -94,6 +102,10 @@ class WorkHour
#[Groups(['work_hour:read', 'work_hour:validate'])]
private bool $isValid = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['work_hour:read', 'work_hour:site_validate'])]
private bool $isSiteValid = false;
public function getId(): ?int
{
return $this->id;
@@ -245,4 +257,21 @@ class WorkHour
return $this;
}
public function isSiteValid(): bool
{
return $this->isSiteValid;
}
public function getIsSiteValid(): bool
{
return $this->isSiteValid;
}
public function setIsSiteValid(bool $isSiteValid): self
{
$this->isSiteValid = $isSiteValid;
return $this;
}
}

View File

@@ -21,4 +21,6 @@ interface WorkHourReadRepositoryInterface
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour;
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
public function hasSiteValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
}

View File

@@ -102,6 +102,26 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
}
public function hasSiteValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool
{
$fromDate = DateTimeImmutable::createFromInterface($from);
$toDate = DateTimeImmutable::createFromInterface($to);
$qb = $this->createQueryBuilder('w')
->select('COUNT(w.id)')
->andWhere('w.employee = :employee')
->andWhere('w.workDate >= :from')
->andWhere('w.workDate <= :to')
->andWhere('w.isSiteValid = :isSiteValid')
->setParameter('employee', $employee)
->setParameter('from', $fromDate)
->setParameter('to', $toDate)
->setParameter('isSiteValid', true)
;
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
}
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour
{
$workDate = DateTimeImmutable::createFromInterface($date);
@@ -114,7 +134,7 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
->setMaxResults(1)
;
/** @var null|WorkHour $workHour */
// @var null|WorkHour $workHour
return $qb->getQuery()->getOneOrNullResult();
}
}

View File

@@ -8,7 +8,6 @@ use App\Entity\Contract;
use App\Entity\Employee;
use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable;
use LogicException;
readonly class EmployeeContractResolver
{
@@ -18,17 +17,9 @@ readonly class EmployeeContractResolver
public function resolveForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ?Contract
{
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
$contract = $period?->getContract();
if (null === $contract) {
throw new LogicException(sprintf(
'Missing contract period for employee %d on %s.',
$employee->getId() ?? 0,
$date->format('Y-m-d')
));
}
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
return $contract;
return $period?->getContract();
}
/**
@@ -75,23 +66,6 @@ readonly class EmployeeContractResolver
}
}
foreach ($employees as $employee) {
$employeeId = $employee->getId();
if (!$employeeId) {
continue;
}
foreach ($days as $day) {
if (null === ($resolved[$employeeId][$day] ?? null)) {
throw new LogicException(sprintf(
'Missing contract period for employee %d on %s.',
$employeeId,
$day
));
}
}
}
return $resolved;
}
}

View File

@@ -9,6 +9,7 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Absence;
use App\Entity\Employee;
use App\Entity\User;
use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
@@ -16,7 +17,9 @@ use DateInterval;
use DatePeriod;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
@@ -26,6 +29,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
private EntityManagerInterface $entityManager,
private AbsenceReadRepositoryInterface $absenceRepository,
private WorkHourReadRepositoryInterface $workHourRepository,
private Security $security,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -39,8 +43,11 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
return $data;
}
$user = $this->security->getUser();
$isAdmin = $user instanceof User && in_array('ROLE_ADMIN', $user->getRoles(), true);
if ($operation instanceof DeleteOperationInterface) {
if ($this->workHourRepository->hasValidatedInRange($employee, $data->getStartDate(), $data->getEndDate())) {
if ($this->isLockedByValidation($employee, $data->getStartDate(), $data->getEndDate(), $isAdmin)) {
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
}
@@ -58,7 +65,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
$from = DateTimeImmutable::createFromInterface($segments[0]['date']);
$to = DateTimeImmutable::createFromInterface($segments[count($segments) - 1]['date']);
if ($this->workHourRepository->hasValidatedInRange($employee, $from, $to)) {
if ($this->isLockedByValidation($employee, $from, $to, $isAdmin)) {
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
}
@@ -178,6 +185,19 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
return DateTime::createFromImmutable($date);
}
private function isLockedByValidation(Employee $employee, DateTimeInterface $from, DateTimeInterface $to, bool $isAdmin): bool
{
if ($this->workHourRepository->hasValidatedInRange($employee, $from, $to)) {
return true;
}
if ($isAdmin) {
return false;
}
return $this->workHourRepository->hasSiteValidatedInRange($employee, $from, $to);
}
/**
* @param array{date: DateTimeImmutable, startHalf: HalfDay, endHalf: HalfDay} $segment
*/
@@ -193,6 +213,8 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
$workHour
->setMorningFrom(null)
->setMorningTo(null)
->setIsSiteValid(false)
->setIsValid(false)
;
return;
@@ -205,6 +227,8 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
->setAfternoonTo(null)
->setEveningFrom(null)
->setEveningTo(null)
->setIsSiteValid(false)
->setIsValid(false)
;
return;
@@ -218,6 +242,8 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
->setAfternoonTo(null)
->setEveningFrom(null)
->setEveningTo(null)
->setIsSiteValid(false)
->setIsValid(false)
;
}
}

View File

@@ -51,7 +51,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
$today = new DateTimeImmutable('today');
if ($isNew) {
$this->ensureContractPeriodExists($data, $currentContract, $today);
$this->ensureContractPeriodExists($data, $currentContract, new DateTimeImmutable('1970-01-01'));
return $result;
}
@@ -61,7 +61,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
}
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
if (null !== $todayPeriod && null === $todayPeriod->getEndDate() && $todayPeriod->getStartDate() === $today) {
if (null !== $todayPeriod && null === $todayPeriod->getEndDate() && $todayPeriod->getStartDate()->format('Y-m-d') === $today->format('Y-m-d')) {
$todayPeriod->setContract($currentContract);
$this->entityManager->flush();

View File

@@ -11,6 +11,7 @@ use App\ApiResource\WorkHourBulkUpsertResult;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
@@ -28,6 +29,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
private Security $security,
private EmployeeRepository $employeeRepository,
private WorkHourRepository $workHourRepository,
private AbsenceReadRepositoryInterface $absenceRepository,
private EmployeeContractResolver $contractResolver,
) {}
@@ -67,6 +69,13 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
$existingByEmployeeId = $this->workHourRepository
->findByDateAndEmployeesIndexedByEmployeeId($workDate, array_values($employeesById))
;
$absenceByEmployeeId = [];
foreach ($this->absenceRepository->findByDateAndEmployees($workDate, array_values($employeesById)) as $absence) {
$absenceEmployeeId = $absence->getEmployee()?->getId();
if ($absenceEmployeeId) {
$absenceByEmployeeId[$absenceEmployeeId] = true;
}
}
$result = new WorkHourBulkUpsertResult();
@@ -77,10 +86,18 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
}
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
if (null === $contract) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d has no active contract on %s.',
$employeeId,
$data->workDate
));
}
$isPresenceTracking = TrackingMode::PRESENCE->value === $contract?->getTrackingMode();
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
$existing = $existingByEmployeeId[$employeeId] ?? null;
$isAdmin = in_array('ROLE_ADMIN', $user->getRoles(), true);
if ($existing?->isValid()) {
if (!$this->isSameAsExisting($existing, $normalized)) {
@@ -95,11 +112,34 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
continue;
}
if (!$isAdmin && $existing?->isSiteValid()) {
if (!$this->isSameAsExisting($existing, $normalized)) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d: site validated work hour cannot be modified.',
$employeeId
));
}
++$result->processed;
continue;
}
if ($this->isEntryEmpty($normalized)) {
// Convention choisie: une ligne vide supprime l'enregistrement existant.
if ($existing) {
$this->entityManager->remove($existing);
++$result->deleted;
} elseif (($absenceByEmployeeId[$employeeId] ?? false) === true) {
// Si une absence existe ce jour, on garde une ligne technique pour pouvoir valider la journée.
$workHour = new WorkHour()
->setEmployee($employee)
->setWorkDate($workDate)
;
$this->hydrateWorkHour($workHour, $normalized);
$this->entityManager->persist($workHour);
$existingByEmployeeId[$employeeId] = $workHour;
++$result->created;
}
++$result->processed;
@@ -187,14 +227,16 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
}
return [
'morningFrom' => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'),
'morningTo' => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'),
'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'),
'afternoonTo' => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'),
'eveningFrom' => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'),
'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'),
'isPresentMorning' => false,
'isPresentAfternoon' => false,
'morningFrom' => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'),
'morningTo' => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'),
'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'),
'afternoonTo' => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'),
'eveningFrom' => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'),
'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'),
// On conserve aussi la présence si envoyée (cas forfait affiché côté UI),
// même si le contrat résolu ce jour est en suivi horaire.
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
];
}
@@ -284,6 +326,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
->setEveningTo($entry['eveningTo'])
->setIsPresentMorning($entry['isPresentMorning'])
->setIsPresentAfternoon($entry['isPresentAfternoon'])
// Toute modification invalide la validation chef de site.
->setIsSiteValid(false)
// Toute modification utilisateur repasse la ligne en attente de validation RH.
->setIsValid(false)
;

View File

@@ -11,6 +11,7 @@ use App\Dto\WorkHours\DayContextRow;
use App\Entity\User;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable;
@@ -26,6 +27,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
private RequestStack $requestStack,
private EmployeeScopedRepositoryInterface $employeeRepository,
private AbsenceReadRepositoryInterface $absenceRepository,
private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
) {}
@@ -50,7 +52,10 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
}
// On initialise toutes les lignes, même sans absence ce jour-là.
$rowsByEmployeeId[$employeeId] = new DayContextRow(employeeId: $employeeId);
$rowsByEmployeeId[$employeeId] = new DayContextRow(
employeeId: $employeeId,
hasContractAtDate: null !== $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate)
);
}
$dateKey = $workDate->format('Y-m-d');

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Security\EmployeeScopeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
final readonly class WorkHourSiteValidationProcessor implements ProcessorInterface
{
public function __construct(
private Security $security,
private EmployeeScopeService $employeeScopeService,
private EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): WorkHour
{
if (!$data instanceof WorkHour) {
throw new AccessDeniedHttpException('Invalid payload.');
}
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
// Réservé aux profils "Sites" (ni admin, ni self).
if (in_array('ROLE_ADMIN', $user->getRoles(), true) || in_array('ROLE_SELF', $user->getRoles(), true)) {
throw new AccessDeniedHttpException('Only site managers can update site validation.');
}
$siteId = $data->getEmployee()?->getSite()?->getId();
if (!$siteId) {
throw new AccessDeniedHttpException('Employee site is required.');
}
$allowedSiteIds = $this->employeeScopeService->getAllowedSiteIds($user);
if (!in_array($siteId, $allowedSiteIds, true)) {
throw new AccessDeniedHttpException('Employee is outside your site scope.');
}
$this->entityManager->flush();
return $data;
}
}