diff --git a/migrations/Version20260216100000.php b/migrations/Version20260216100000.php new file mode 100644 index 0000000..eba7870 --- /dev/null +++ b/migrations/Version20260216100000.php @@ -0,0 +1,31 @@ +addSql('CREATE TABLE work_hours (id SERIAL NOT NULL, employee_id INT NOT NULL, work_date DATE NOT NULL, morning_from VARCHAR(5) DEFAULT NULL, morning_to VARCHAR(5) DEFAULT NULL, afternoon_from VARCHAR(5) DEFAULT NULL, afternoon_to VARCHAR(5) DEFAULT NULL, evening_from VARCHAR(5) DEFAULT NULL, evening_to VARCHAR(5) DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_WORK_HOURS_EMPLOYEE ON work_hours (employee_id)'); + $this->addSql('CREATE INDEX IDX_WORK_HOURS_DATE ON work_hours (work_date)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_WORK_HOURS_EMPLOYEE_DATE ON work_hours (employee_id, work_date)'); + $this->addSql('ALTER TABLE work_hours ADD CONSTRAINT FK_WORK_HOURS_EMPLOYEE FOREIGN KEY (employee_id) REFERENCES employees (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE work_hours DROP CONSTRAINT FK_WORK_HOURS_EMPLOYEE'); + $this->addSql('DROP TABLE work_hours'); + } +} diff --git a/src/ApiResource/WorkHourBulkUpsert.php b/src/ApiResource/WorkHourBulkUpsert.php new file mode 100644 index 0000000..f82e7fe --- /dev/null +++ b/src/ApiResource/WorkHourBulkUpsert.php @@ -0,0 +1,37 @@ + + */ + public array $entries = []; +} diff --git a/src/ApiResource/WorkHourBulkUpsertResult.php b/src/ApiResource/WorkHourBulkUpsertResult.php new file mode 100644 index 0000000..98912c2 --- /dev/null +++ b/src/ApiResource/WorkHourBulkUpsertResult.php @@ -0,0 +1,13 @@ +security->getUser(); + if (!$user instanceof User) { + // Pas d'utilisateur => aucune ligne renvoyée. + $queryBuilder->andWhere('1 = 0'); + + return; + } + + $rootAlias = $queryBuilder->getRootAliases()[0]; + $employeeAlias = 'employee_scope'; + + $queryBuilder->leftJoin(sprintf('%s.employee', $rootAlias), $employeeAlias) + ->addSelect($employeeAlias) + ; + + // Filtrage SQL par scope (admin/self/site) avant retour API. + $this->employeeScopeService->applyEmployeeScope($queryBuilder, $employeeAlias, 'work_hour_scope', $user); + } +} diff --git a/src/Entity/WorkHour.php b/src/Entity/WorkHour.php new file mode 100644 index 0000000..5121f5e --- /dev/null +++ b/src/Entity/WorkHour.php @@ -0,0 +1,178 @@ + ['work_hour:read', 'employee:read', 'site:read']], + security: "is_granted('ROLE_USER')" + ), + new Get( + normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']], + security: "is_granted('WORK_HOUR_VIEW', object)" + ), + ], +)] +#[ApiFilter(DateFilter::class, properties: ['workDate'])] +#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact', 'employee.site' => 'exact'])] +#[ORM\Entity] +#[ORM\Table(name: 'work_hours')] +#[ORM\UniqueConstraint(name: 'uniq_work_hours_employee_date', fields: ['employee', 'workDate'])] +class WorkHour +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + #[Groups(['work_hour:read'])] + private ?int $id = null; + + #[ApiProperty(readableLink: true)] + #[ORM\ManyToOne(targetEntity: Employee::class)] + #[ORM\JoinColumn(nullable: false)] + #[Groups(['work_hour:read'])] + private ?Employee $employee = null; + + #[ORM\Column(type: 'date_immutable')] + #[Groups(['work_hour:read'])] + private DateTimeInterface $workDate; + + #[ORM\Column(type: 'string', length: 5, nullable: true)] + #[Groups(['work_hour:read'])] + private ?string $morningFrom = null; + + #[ORM\Column(type: 'string', length: 5, nullable: true)] + #[Groups(['work_hour:read'])] + private ?string $morningTo = null; + + #[ORM\Column(type: 'string', length: 5, nullable: true)] + #[Groups(['work_hour:read'])] + private ?string $afternoonFrom = null; + + #[ORM\Column(type: 'string', length: 5, nullable: true)] + #[Groups(['work_hour:read'])] + private ?string $afternoonTo = null; + + #[ORM\Column(type: 'string', length: 5, nullable: true)] + #[Groups(['work_hour:read'])] + private ?string $eveningFrom = null; + + #[ORM\Column(type: 'string', length: 5, nullable: true)] + #[Groups(['work_hour:read'])] + private ?string $eveningTo = null; + + 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 getWorkDate(): DateTimeInterface + { + return $this->workDate; + } + + public function setWorkDate(DateTimeInterface $workDate): self + { + $this->workDate = $workDate; + + return $this; + } + + public function getMorningFrom(): ?string + { + return $this->morningFrom; + } + + public function setMorningFrom(?string $morningFrom): self + { + $this->morningFrom = $morningFrom; + + return $this; + } + + public function getMorningTo(): ?string + { + return $this->morningTo; + } + + public function setMorningTo(?string $morningTo): self + { + $this->morningTo = $morningTo; + + return $this; + } + + public function getAfternoonFrom(): ?string + { + return $this->afternoonFrom; + } + + public function setAfternoonFrom(?string $afternoonFrom): self + { + $this->afternoonFrom = $afternoonFrom; + + return $this; + } + + public function getAfternoonTo(): ?string + { + return $this->afternoonTo; + } + + public function setAfternoonTo(?string $afternoonTo): self + { + $this->afternoonTo = $afternoonTo; + + return $this; + } + + public function getEveningFrom(): ?string + { + return $this->eveningFrom; + } + + public function setEveningFrom(?string $eveningFrom): self + { + $this->eveningFrom = $eveningFrom; + + return $this; + } + + public function getEveningTo(): ?string + { + return $this->eveningTo; + } + + public function setEveningTo(?string $eveningTo): self + { + $this->eveningTo = $eveningTo; + + return $this; + } +} diff --git a/src/Security/EmployeeScopeService.php b/src/Security/EmployeeScopeService.php new file mode 100644 index 0000000..401fdf8 --- /dev/null +++ b/src/Security/EmployeeScopeService.php @@ -0,0 +1,99 @@ +getRoles(), true)) { + return true; + } + + if (in_array('ROLE_SELF', $user->getRoles(), true)) { + return $user->getEmployee()?->getId() === $employee->getId(); + } + + $employeeSiteId = $employee->getSite()?->getId(); + if (!$employeeSiteId) { + return false; + } + + return in_array($employeeSiteId, $this->getAllowedSiteIds($user), true); + } + + /** + * Retourne la liste des sites accessibles via user_site_roles. + * + * @return list + */ + public function getAllowedSiteIds(User $user): array + { + $siteIds = []; + + foreach ($user->getSiteRoles() as $siteRole) { + if (self::SITE_ACCESS_ROLE !== $siteRole->getRole()) { + continue; + } + + $siteId = $siteRole->getSite()?->getId(); + if ($siteId) { + $siteIds[] = $siteId; + } + } + + return array_values(array_unique($siteIds)); + } + + /** + * Applique le scope directement sur un QueryBuilder Doctrine. + * Cette méthode est utilisée pour filtrer les collections SQL + * avant sérialisation (plus sûr et plus performant). + */ + public function applyEmployeeScope(QueryBuilder $qb, string $employeeAlias, string $paramPrefix, User $user): void + { + if (in_array('ROLE_ADMIN', $user->getRoles(), true)) { + return; + } + + if (in_array('ROLE_SELF', $user->getRoles(), true)) { + $employeeId = $user->getEmployee()?->getId(); + if (!$employeeId) { + $qb->andWhere('1 = 0'); + + return; + } + + $qb->andWhere(sprintf('%s.id = :%s_employee_id', $employeeAlias, $paramPrefix)) + ->setParameter(sprintf('%s_employee_id', $paramPrefix), $employeeId) + ; + + return; + } + + $siteIds = $this->getAllowedSiteIds($user); + if ([] === $siteIds) { + $qb->andWhere('1 = 0'); + + return; + } + + $qb->andWhere(sprintf('%s.site IN (:%s_site_ids)', $employeeAlias, $paramPrefix)) + ->setParameter(sprintf('%s_site_ids', $paramPrefix), $siteIds) + ; + } +} diff --git a/src/Security/Voter/WorkHourVoter.php b/src/Security/Voter/WorkHourVoter.php new file mode 100644 index 0000000..72d1b27 --- /dev/null +++ b/src/Security/Voter/WorkHourVoter.php @@ -0,0 +1,50 @@ +security->getUser(); + if (!$user instanceof User) { + return false; + } + + if (!$subject instanceof WorkHour) { + return false; + } + + $employee = $subject->getEmployee(); + if (null === $employee) { + return false; + } + + // Délégation de la règle au service de scope unique (évite la duplication). + return $this->employeeScopeService->canAccessEmployee($user, $employee); + } +} diff --git a/src/State/WorkHourBulkUpsertProcessor.php b/src/State/WorkHourBulkUpsertProcessor.php new file mode 100644 index 0000000..95b9ccf --- /dev/null +++ b/src/State/WorkHourBulkUpsertProcessor.php @@ -0,0 +1,384 @@ +security->getUser(); + if (!$user instanceof User) { + throw new AccessDeniedHttpException('Authentication required.'); + } + + $workDate = DateTimeImmutable::createFromFormat('Y-m-d', $data->workDate); + if (!$workDate || $workDate->format('Y-m-d') !== $data->workDate) { + throw new UnprocessableEntityHttpException('workDate must use Y-m-d format.'); + } + + if ([] === $data->entries) { + throw new UnprocessableEntityHttpException('entries must contain at least one employee.'); + } + + // Vérifie que tous les employés envoyés sont dans le scope de l'utilisateur courant. + $employeeIds = $this->extractEmployeeIds($data->entries); + $employeesById = $this->loadAccessibleEmployees($employeeIds, $user); + + if (count($employeesById) !== count($employeeIds)) { + throw new AccessDeniedHttpException('At least one employee is unknown or outside your scope.'); + } + + $existingByEmployeeId = $this->loadExistingWorkHours($workDate, array_values($employeesById)); + + $result = new WorkHourBulkUpsertResult(); + + foreach ($data->entries as $entry) { + $employeeId = (int) $entry['employeeId']; + $employee = $employeesById[$employeeId] ?? null; + if (!$employee) { + throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId)); + } + + $normalized = $this->normalizeEntry($entry, $employeeId); + $existing = $existingByEmployeeId[$employeeId] ?? null; + + if ($this->isEntryEmpty($normalized)) { + // Convention choisie: une ligne vide supprime l'enregistrement existant. + if ($existing) { + $this->entityManager->remove($existing); + ++$result->deleted; + } + + ++$result->processed; + + continue; + } + + if ($existing) { + $workHour = $existing; + ++$result->updated; + } else { + // Upsert: création si aucune ligne n'existe pour (employé, date). + $workHour = new WorkHour() + ->setEmployee($employee) + ->setWorkDate($workDate) + ; + $this->entityManager->persist($workHour); + ++$result->created; + } + + $this->hydrateWorkHour($workHour, $normalized); + ++$result->processed; + } + + $this->entityManager->flush(); + + return $result; + } + + /** + * @param list> $entries + * + * @return list + */ + private function extractEmployeeIds(array $entries): array + { + $ids = []; + foreach ($entries as $index => $entry) { + if (!is_array($entry) || !array_key_exists('employeeId', $entry)) { + throw new UnprocessableEntityHttpException(sprintf('entries[%d].employeeId is required.', $index)); + } + + $employeeId = (int) $entry['employeeId']; + if ($employeeId <= 0) { + throw new UnprocessableEntityHttpException(sprintf('entries[%d].employeeId must be a positive integer.', $index)); + } + + if (isset($ids[$employeeId])) { + throw new UnprocessableEntityHttpException(sprintf('Employee %d appears multiple times in the same bulk payload.', $employeeId)); + } + + $ids[$employeeId] = $employeeId; + } + + return array_values($ids); + } + + /** + * @param list $employeeIds + * + * @return array + */ + private function loadAccessibleEmployees(array $employeeIds, User $user): array + { + if ([] === $employeeIds) { + return []; + } + + $qb = $this->entityManager + ->getRepository(Employee::class) + ->createQueryBuilder('e') + ->andWhere('e.id IN (:ids)') + ->setParameter('ids', $employeeIds) + ; + + $this->employeeScopeService->applyEmployeeScope($qb, 'e', 'bulk_scope', $user); + + /** @var list $employees */ + $employees = $qb->getQuery()->getResult(); + + $byId = []; + foreach ($employees as $employee) { + $employeeId = $employee->getId(); + if ($employeeId) { + $byId[$employeeId] = $employee; + } + } + + return $byId; + } + + /** + * @param list $employees + * + * @return array + */ + private function loadExistingWorkHours(DateTimeImmutable $workDate, array $employees): array + { + if ([] === $employees) { + return []; + } + + $qb = $this->entityManager + ->getRepository(WorkHour::class) + ->createQueryBuilder('w') + ->leftJoin('w.employee', 'e') + ->addSelect('e') + ->andWhere('w.workDate = :workDate') + ->andWhere('w.employee IN (:employees)') + ->setParameter('workDate', $workDate) + ->setParameter('employees', $employees) + ; + + /** @var list $workHours */ + $workHours = $qb->getQuery()->getResult(); + + $byEmployeeId = []; + foreach ($workHours as $workHour) { + $employeeId = $workHour->getEmployee()?->getId(); + if ($employeeId) { + $byEmployeeId[$employeeId] = $workHour; + } + } + + return $byEmployeeId; + } + + /** + * @param array $entry + * + * @return array{ + * morningFrom:?string, + * morningTo:?string, + * afternoonFrom:?string, + * afternoonTo:?string, + * eveningFrom:?string, + * eveningTo:?string + * } + */ + private function normalizeEntry(array $entry, int $employeeId): array + { + $normalized = [ + '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'), + ]; + + $this->validateRanges($normalized, $employeeId); + + return $normalized; + } + + private function normalizeTime(mixed $value, int $employeeId, string $field): ?string + { + if (null === $value || '' === $value) { + return null; + } + + if (!is_string($value)) { + throw new UnprocessableEntityHttpException(sprintf( + 'Employee %d: %s must be a string in HH:MM format.', + $employeeId, + $field + )); + } + + $time = trim($value); + if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time)) { + throw new UnprocessableEntityHttpException(sprintf( + 'Employee %d: %s must use HH:MM format.', + $employeeId, + $field + )); + } + + return $time; + } + + /** + * @param array{ + * morningFrom:?string, + * morningTo:?string, + * afternoonFrom:?string, + * afternoonTo:?string, + * eveningFrom:?string, + * eveningTo:?string + * } $entry + */ + private function validateRanges(array $entry, int $employeeId): void + { + $ranges = [ + 'morning' => [$entry['morningFrom'], $entry['morningTo']], + 'afternoon' => [$entry['afternoonFrom'], $entry['afternoonTo']], + 'evening' => [$entry['eveningFrom'], $entry['eveningTo']], + ]; + + $normalizedRanges = []; + + foreach ($ranges as $label => [$from, $to]) { + // On force des paires from/to complètes par créneau. + if ((null === $from) xor (null === $to)) { + throw new UnprocessableEntityHttpException(sprintf( + 'Employee %d: %s range must contain both from and to.', + $employeeId, + $label + )); + } + + if (null === $from || null === $to) { + continue; + } + + $fromMinutes = $this->toMinutes($from); + $toMinutes = $this->toMinutes($to); + + if ($fromMinutes >= $toMinutes) { + throw new UnprocessableEntityHttpException(sprintf( + 'Employee %d: %s from must be earlier than to.', + $employeeId, + $label + )); + } + + $normalizedRanges[] = [ + 'label' => $label, + 'from' => $fromMinutes, + 'to' => $toMinutes, + ]; + } + + usort( + $normalizedRanges, + static fn (array $rangeA, array $rangeB): int => $rangeA['from'] <=> $rangeB['from'] + ); + + $previous = null; + foreach ($normalizedRanges as $range) { + // Empêche deux créneaux qui se chevauchent sur une même journée. + if (null !== $previous && $range['from'] < $previous['to']) { + throw new UnprocessableEntityHttpException(sprintf( + 'Employee %d: %s overlaps %s.', + $employeeId, + $range['label'], + $previous['label'] + )); + } + + $previous = $range; + } + } + + /** + * @param array{ + * morningFrom:?string, + * morningTo:?string, + * afternoonFrom:?string, + * afternoonTo:?string, + * eveningFrom:?string, + * eveningTo:?string + * } $entry + */ + private function isEntryEmpty(array $entry): bool + { + return null === $entry['morningFrom'] + && null === $entry['morningTo'] + && null === $entry['afternoonFrom'] + && null === $entry['afternoonTo'] + && null === $entry['eveningFrom'] + && null === $entry['eveningTo']; + } + + /** + * @param array{ + * morningFrom:?string, + * morningTo:?string, + * afternoonFrom:?string, + * afternoonTo:?string, + * eveningFrom:?string, + * eveningTo:?string + * } $entry + */ + private function hydrateWorkHour(WorkHour $workHour, array $entry): void + { + $workHour + ->setMorningFrom($entry['morningFrom']) + ->setMorningTo($entry['morningTo']) + ->setAfternoonFrom($entry['afternoonFrom']) + ->setAfternoonTo($entry['afternoonTo']) + ->setEveningFrom($entry['eveningFrom']) + ->setEveningTo($entry['eveningTo']) + ; + } + + private function toMinutes(string $time): int + { + [$hours, $minutes] = array_map('intval', explode(':', $time, 2)); + + return ($hours * 60) + $minutes; + } +}