feat : wip

This commit is contained in:
2026-02-16 14:43:37 +01:00
parent 2a9b047913
commit 1cfbfb0120
8 changed files with 845 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260216100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create work_hours table';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\State\WorkHourBulkUpsertProcessor;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/work-hours/bulk-upsert',
security: "is_granted('ROLE_USER')",
output: WorkHourBulkUpsertResult::class,
processor: WorkHourBulkUpsertProcessor::class
),
]
)]
final class WorkHourBulkUpsert
{
public string $workDate = '';
/**
* @var list<array{
* employeeId:int,
* morningFrom?:?string,
* morningTo?:?string,
* afternoonFrom?:?string,
* afternoonTo?:?string,
* eveningFrom?:?string,
* eveningTo?:?string
* }>
*/
public array $entries = [];
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
final class WorkHourBulkUpsertResult
{
public int $processed = 0;
public int $created = 0;
public int $updated = 0;
public int $deleted = 0;
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Security\EmployeeScopeService;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
final readonly class WorkHourCollectionExtension implements QueryCollectionExtensionInterface
{
public function __construct(
private Security $security,
private EmployeeScopeService $employeeScopeService,
) {}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = []
): void {
// N'applique le filtrage qu'à la ressource WorkHour.
if (WorkHour::class !== $resourceClass) {
return;
}
$user = $this->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);
}
}

178
src/Entity/WorkHour.php Normal file
View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
paginationEnabled: false,
normalizationContext: ['groups' => ['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;
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\Employee;
use App\Entity\User;
use Doctrine\ORM\QueryBuilder;
class EmployeeScopeService
{
public const string SITE_ACCESS_ROLE = 'SITE_ACCESS';
/**
* Règle métier centrale d'accès à un employé.
* - Admin : accès global
* - Self : uniquement son employé lié
* - Site : uniquement les employés des sites autorisés.
*/
public function canAccessEmployee(User $user, Employee $employee): bool
{
if (in_array('ROLE_ADMIN', $user->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<int>
*/
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)
;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Security\EmployeeScopeService;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class WorkHourVoter extends Voter
{
public const string VIEW = 'WORK_HOUR_VIEW';
public const string EDIT = 'WORK_HOUR_EDIT';
public function __construct(
private readonly Security $security,
private readonly EmployeeScopeService $employeeScopeService,
) {}
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::VIEW, self::EDIT], true) && $subject instanceof WorkHour;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
// On ne traite que des utilisateurs applicatifs authentifiés.
$user = $this->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);
}
}

View File

@@ -0,0 +1,384 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\WorkHourBulkUpsert;
use App\ApiResource\WorkHourBulkUpsertResult;
use App\Entity\Employee;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Security\EmployeeScopeService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private EmployeeScopeService $employeeScopeService,
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = []
): WorkHourBulkUpsertResult {
// Endpoint dédié au bulk: on refuse tout autre payload.
if (!$data instanceof WorkHourBulkUpsert) {
throw new BadRequestHttpException('Invalid payload.');
}
$user = $this->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<array<string, mixed>> $entries
*
* @return list<int>
*/
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<int> $employeeIds
*
* @return array<int, Employee>
*/
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<Employee> $employees */
$employees = $qb->getQuery()->getResult();
$byId = [];
foreach ($employees as $employee) {
$employeeId = $employee->getId();
if ($employeeId) {
$byId[$employeeId] = $employee;
}
}
return $byId;
}
/**
* @param list<Employee> $employees
*
* @return array<int, WorkHour>
*/
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<WorkHour> $workHours */
$workHours = $qb->getQuery()->getResult();
$byEmployeeId = [];
foreach ($workHours as $workHour) {
$employeeId = $workHour->getEmployee()?->getId();
if ($employeeId) {
$byEmployeeId[$employeeId] = $workHour;
}
}
return $byEmployeeId;
}
/**
* @param array<string, mixed> $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;
}
}