[#SIRH-17] Ajouter un système de log des actions utilisateurs (#9)
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: #9
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #9.
This commit is contained in:
2026-03-30 07:52:49 +00:00
committed by Autin
parent e74a264b37
commit 057d6bf06f
26 changed files with 1107 additions and 17 deletions

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\QueryParameter;
use App\State\AuditLogProvider;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/audit-logs',
provider: AuditLogProvider::class,
parameters: [
new QueryParameter(key: 'employeeId'),
new QueryParameter(key: 'from'),
new QueryParameter(key: 'to'),
new QueryParameter(key: 'entityType'),
],
security: "is_granted('ROLE_SUPER_ADMIN')"
),
]
)]
final class AuditLogResource {}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Dto;
final class AuditLogOutput
{
public function __construct(
public int $id,
public ?string $employeeName,
public ?int $employeeId,
public string $username,
public string $action,
public string $entityType,
public string $description,
public ?array $changes,
public ?string $affectedDate,
public string $createdAt,
) {}
}

169
src/Entity/AuditLog.php Normal file
View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\AuditLogRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AuditLogRepository::class)]
#[ORM\Table(name: 'audit_logs')]
#[ORM\Index(name: 'idx_audit_employee_created', columns: ['employee_id', 'created_at'])]
#[ORM\Index(name: 'idx_audit_entity', columns: ['entity_type', 'entity_id'])]
#[ORM\Index(name: 'idx_audit_created', columns: ['created_at'])]
#[ORM\Index(name: 'idx_audit_affected_date', columns: ['affected_date'])]
class AuditLog
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Employee $employee = null;
#[ORM\Column(type: 'string', length: 180)]
private string $username = '';
#[ORM\Column(type: 'string', length: 30)]
private string $action = '';
#[ORM\Column(type: 'string', length: 50)]
private string $entityType = '';
#[ORM\Column(type: 'integer', nullable: true)]
private ?int $entityId = null;
#[ORM\Column(type: 'text')]
private string $description = '';
#[ORM\Column(type: 'json', nullable: true)]
private ?array $changes = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
private ?DateTimeImmutable $affectedDate = null;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
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 getUsername(): string
{
return $this->username;
}
public function setUsername(string $username): self
{
$this->username = $username;
return $this;
}
public function getAction(): string
{
return $this->action;
}
public function setAction(string $action): self
{
$this->action = $action;
return $this;
}
public function getEntityType(): string
{
return $this->entityType;
}
public function setEntityType(string $entityType): self
{
$this->entityType = $entityType;
return $this;
}
public function getEntityId(): ?int
{
return $this->entityId;
}
public function setEntityId(?int $entityId): self
{
$this->entityId = $entityId;
return $this;
}
public function getDescription(): string
{
return $this->description;
}
public function setDescription(string $description): self
{
$this->description = $description;
return $this;
}
public function getChanges(): ?array
{
return $this->changes;
}
public function setChanges(?array $changes): self
{
$this->changes = $changes;
return $this;
}
public function getAffectedDate(): ?DateTimeImmutable
{
return $this->affectedDate;
}
public function setAffectedDate(?DateTimeImmutable $affectedDate): self
{
$this->affectedDate = $affectedDate;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\AuditLog;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AuditLog>
*/
final class AuditLogRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AuditLog::class);
}
/**
* @return list<AuditLog>
*/
public function findByFilters(
?int $employeeId = null,
?DateTimeImmutable $from = null,
?DateTimeImmutable $to = null,
?string $entityType = null,
int $limit = 50,
int $offset = 0,
): array {
$qb = $this->createQueryBuilder('a')
->orderBy('a.createdAt', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset)
;
if (null !== $employeeId) {
$qb->andWhere('a.employee = :employeeId')
->setParameter('employeeId', $employeeId)
;
}
if (null !== $from) {
$qb->andWhere('a.affectedDate >= :from')
->setParameter('from', $from)
;
}
if (null !== $to) {
$qb->andWhere('a.affectedDate <= :to')
->setParameter('to', $to)
;
}
if (null !== $entityType) {
$qb->andWhere('a.entityType = :entityType')
->setParameter('entityType', $entityType)
;
}
return $qb->getQuery()->getResult();
}
public function countByFilters(
?int $employeeId = null,
?DateTimeImmutable $from = null,
?DateTimeImmutable $to = null,
?string $entityType = null,
): int {
$qb = $this->createQueryBuilder('a')
->select('COUNT(a.id)')
;
if (null !== $employeeId) {
$qb->andWhere('a.employee = :employeeId')
->setParameter('employeeId', $employeeId)
;
}
if (null !== $from) {
$qb->andWhere('a.affectedDate >= :from')
->setParameter('from', $from)
;
}
if (null !== $to) {
$qb->andWhere('a.affectedDate <= :to')
->setParameter('to', $to)
;
}
if (null !== $entityType) {
$qb->andWhere('a.entityType = :entityType')
->setParameter('entityType', $entityType)
;
}
return (int) $qb->getQuery()->getSingleScalarResult();
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\AuditLog;
use App\Entity\Employee;
use App\Entity\User;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
readonly class AuditLogger
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
) {}
public function log(
?Employee $employee,
string $action,
string $entityType,
?int $entityId,
string $description,
?array $changes = null,
?DateTimeImmutable $affectedDate = null,
): void {
$user = $this->security->getUser();
$username = $user instanceof User ? $user->getUsername() : 'system';
$auditLog = new AuditLog();
$auditLog
->setEmployee($employee)
->setUsername($username)
->setAction($action)
->setEntityType($entityType)
->setEntityId($entityId)
->setDescription($description)
->setChanges($changes)
->setAffectedDate($affectedDate)
;
$this->entityManager->persist($auditLog);
}
}

View File

@@ -13,6 +13,7 @@ use App\Entity\User;
use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\AuditLogger;
use App\Service\PublicHolidayServiceInterface;
use DateInterval;
use DatePeriod;
@@ -33,6 +34,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
private WorkHourReadRepositoryInterface $workHourRepository,
private Security $security,
private PublicHolidayServiceInterface $publicHolidayService,
private AuditLogger $auditLogger,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -54,6 +56,21 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
}
$typeName = $data->getType()?->getLabel() ?? 'inconnu';
$startDate = $data->getStartDate()->format('d/m/Y');
$endDate = $data->getEndDate()->format('d/m/Y');
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
$this->auditLogger->log(
$employee,
'delete',
'absence',
$data->getId(),
sprintf('Absence %s supprimée pour %s du %s au %s', $typeName, $empName, $startDate, $endDate),
['old' => ['type' => $typeName, 'start' => $startDate, 'end' => $endDate, 'startHalf' => $data->getStartHalf()->value, 'endHalf' => $data->getEndHalf()->value, 'comment' => $data->getComment()]],
DateTimeImmutable::createFromInterface($data->getStartDate()),
);
$this->entityManager->remove($data);
$this->entityManager->flush();
@@ -110,6 +127,21 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
$this->entityManager->persist($absence);
}
$typeName = $data->getType()?->getLabel() ?? 'inconnu';
$startDate = $data->getStartDate()->format('d/m/Y');
$endDate = $data->getEndDate()->format('d/m/Y');
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
$this->auditLogger->log(
$employee,
'create',
'absence',
null,
sprintf('Absence %s créée pour %s du %s au %s', $typeName, $empName, $startDate, $endDate),
['new' => ['type' => $typeName, 'start' => $startDate, 'end' => $endDate, 'startHalf' => $data->getStartHalf()->value, 'endHalf' => $data->getEndHalf()->value, 'comment' => $data->getComment()]],
DateTimeImmutable::createFromInterface($data->getStartDate()),
);
$this->entityManager->flush();
return $data;

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\AuditLogRepository;
use DateTimeImmutable;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RequestStack;
class AuditLogProvider implements ProviderInterface
{
private const PER_PAGE = 50;
public function __construct(
private readonly RequestStack $requestStack,
private readonly AuditLogRepository $auditLogRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): JsonResponse
{
$request = $this->requestStack->getCurrentRequest();
if (!$request) {
return new JsonResponse(['items' => [], 'total' => 0]);
}
$employeeId = $request->query->get('employeeId');
$from = $request->query->get('from');
$to = $request->query->get('to');
$entityType = $request->query->get('entityType');
$page = max(1, (int) $request->query->get('page', '1'));
$empId = $employeeId ? (int) $employeeId : null;
$fromDt = $from ? new DateTimeImmutable($from) : null;
$toDt = $to ? new DateTimeImmutable($to) : null;
$type = $entityType ?: null;
$offset = ($page - 1) * self::PER_PAGE;
$total = $this->auditLogRepository->countByFilters($empId, $fromDt, $toDt, $type);
$logs = $this->auditLogRepository->findByFilters($empId, $fromDt, $toDt, $type, self::PER_PAGE, $offset);
$items = [];
foreach ($logs as $log) {
$employee = $log->getEmployee();
$employeeName = $employee
? trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''))
: null;
$items[] = [
'id' => $log->getId(),
'employeeName' => $employeeName,
'employeeId' => $employee?->getId(),
'username' => $log->getUsername(),
'action' => $log->getAction(),
'entityType' => $log->getEntityType(),
'description' => $log->getDescription(),
'changes' => $log->getChanges(),
'affectedDate' => $log->getAffectedDate()?->format('Y-m-d'),
'createdAt' => $log->getCreatedAt()->format('Y-m-d H:i:s'),
];
}
return new JsonResponse([
'items' => $items,
'total' => $total,
'page' => $page,
'perPage' => self::PER_PAGE,
]);
}
}

View File

@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\ContractSuspension;
use App\Entity\EmployeeContractPeriod;
use App\Service\AuditLogger;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -19,6 +20,7 @@ final readonly class ContractSuspensionWriteProcessor implements ProcessorInterf
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
private EntityManagerInterface $entityManager,
private AuditLogger $auditLogger,
) {}
public function process(
@@ -46,7 +48,26 @@ final readonly class ContractSuspensionWriteProcessor implements ProcessorInterf
$this->validate($data, $period);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
$isNew = null === $data->getId();
$employee = $period->getEmployee();
$empName = $employee ? trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')) : '';
$start = $data->getStartDate()->format('d/m/Y');
$end = $data->getEndDate()?->format('d/m/Y') ?? 'indéfinie';
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
$this->auditLogger->log(
$employee,
$isNew ? 'create' : 'update',
'contract_suspension',
$data->getId(),
sprintf('Suspension %s pour %s du %s au %s', $isNew ? 'créée' : 'modifiée', $empName, $start, $end),
['new' => ['start' => $start, 'end' => $end]],
DateTimeImmutable::createFromInterface($data->getStartDate()),
);
$this->entityManager->flush();
return $result;
}
private function validate(ContractSuspension $suspension, EmployeeContractPeriod $period): void

View File

@@ -13,6 +13,7 @@ use App\Enum\ContractType;
use App\Enum\LeaveRuleCode;
use App\Repository\EmployeeLeaveBalanceRepository;
use App\Repository\EmployeeRepository;
use App\Service\AuditLogger;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -24,6 +25,7 @@ final readonly class EmployeeFractionedDaysProcessor implements ProcessorInterfa
private EmployeeRepository $employeeRepository,
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
private EntityManagerInterface $entityManager,
private AuditLogger $auditLogger,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeFractionedDaysInput
@@ -57,6 +59,17 @@ final readonly class EmployeeFractionedDaysProcessor implements ProcessorInterfa
$balance->setFractionedDays($data->fractionedDays);
$balance->touch();
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
$this->auditLogger->log(
$employee,
'update',
'fractioned_days',
$balance->getId(),
sprintf('Jours fractionnés modifiés pour %s (année %d) : %s', $empName, $year, (string) $data->fractionedDays),
['new' => ['fractionedDays' => $data->fractionedDays, 'year' => $year]],
);
$this->entityManager->flush();
$data->year = $year;

View File

@@ -11,6 +11,7 @@ use App\Entity\Employee;
use App\Entity\EmployeeRttPayment;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Service\AuditLogger;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -22,6 +23,7 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
private EmployeeRepository $employeeRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private EntityManagerInterface $entityManager,
private AuditLogger $auditLogger,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
@@ -61,6 +63,17 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
$payment->setBase50Minutes($data->base50Minutes);
$payment->setBonus50Minutes($data->bonus50Minutes);
$payment->touch();
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
$this->auditLogger->log(
$employee,
'update',
'rtt_payment',
$payment->getId(),
sprintf('Paiement RTT modifié pour %s (%02d/%d)', $empName, $data->month, $year),
['new' => ['month' => $data->month, 'year' => $year, 'base25' => $data->base25Minutes, 'bonus25' => $data->bonus25Minutes, 'base50' => $data->base50Minutes, 'bonus50' => $data->bonus50Minutes]],
);
$this->entityManager->flush();
$data->year = $year;

View File

@@ -11,6 +11,7 @@ use App\Entity\Contract;
use App\Entity\Employee;
use App\Enum\ContractNature;
use App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface;
use App\Service\AuditLogger;
use App\Service\Contracts\EmployeeContractChangeRequestFactory;
use App\Service\Contracts\EmployeeContractPeriodManagerInterface;
use DateTimeImmutable;
@@ -29,6 +30,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
private EmployeeContractPeriodReadRepositoryInterface $periodRepository,
private EmployeeContractChangeRequestFactory $changeRequestFactory,
private EmployeeContractPeriodManagerInterface $periodManager,
private AuditLogger $auditLogger,
) {}
public function process(
@@ -72,6 +74,17 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
$data->setEntryDate($startDate);
$this->entityManager->flush();
$empName = trim(($data->getLastName() ?? '').' '.($data->getFirstName() ?? ''));
$this->auditLogger->log(
$data,
'create',
'employee',
$data->getId(),
sprintf('Employé %s créé (contrat: %s)', $empName, $currentContract->getName() ?? ''),
['new' => ['name' => $empName, 'contract' => $currentContract->getName(), 'nature' => $nature->value, 'startDate' => $startDate->format('d/m/Y')]],
);
$this->entityManager->flush();
return $result;
}
@@ -79,6 +92,17 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
return $result;
}
$empName = trim(($data->getLastName() ?? '').' '.($data->getFirstName() ?? ''));
$this->auditLogger->log(
$data,
'update',
'employee',
$data->getId(),
sprintf('Contrat modifié pour %s : %s → %s', $empName, $previousContract?->getName() ?? 'aucun', $currentContract->getName() ?? ''),
['old' => ['contract' => $previousContract?->getName()], 'new' => ['contract' => $currentContract->getName()]],
);
$this->entityManager->flush();
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
$effectivePeriod = $todayPeriod ?? $this->periodRepository->findLatestPeriod($data);
$currentPeriodContract = $effectivePeriod?->getContract();

View File

@@ -14,6 +14,7 @@ use App\Entity\WorkHour;
use App\Repository\UserRepository;
use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use App\Service\AuditLogger;
use App\Service\WorkHours\WorkHourBulkValidationExecutor;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
@@ -30,6 +31,7 @@ final readonly class WorkHourBulkSiteValidationProcessor implements ProcessorInt
private UserRepository $userRepository,
private EmployeeScopeService $employeeScopeService,
private EntityManagerInterface $entityManager,
private AuditLogger $auditLogger,
) {}
public function process(
@@ -61,6 +63,21 @@ final readonly class WorkHourBulkSiteValidationProcessor implements ProcessorInt
}
);
if ($result->updated > 0) {
$workDate = DateTimeImmutable::createFromFormat('Y-m-d', $data->workDate);
$action = $data->isSiteValid ? 'validé' : 'dévalidé';
$this->auditLogger->log(
null,
'site_validate',
'work_hour',
null,
sprintf('Validation site %s pour %d employé(s) le %s', $action, $result->updated, $data->workDate),
['employeeIds' => $data->employeeIds, 'isSiteValid' => $data->isSiteValid],
$workDate ?: null,
);
}
if ($data->isSiteValid && $result->updated > 0) {
$this->createNotificationsIfSiteFullyValidated($user, $data->workDate);
}

View File

@@ -14,6 +14,7 @@ use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use App\Service\AuditLogger;
use App\Service\Contracts\EmployeeContractResolver;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
@@ -31,6 +32,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
private WorkHourRepository $workHourRepository,
private AbsenceReadRepositoryInterface $absenceRepository,
private EmployeeContractResolver $contractResolver,
private AuditLogger $auditLogger,
) {}
public function process(
@@ -137,9 +139,20 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
$is4hContract = 4 === $contract->getWeeklyHours();
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
if ($this->isEntryEmpty($normalized)) {
// Convention choisie: une ligne vide supprime l'enregistrement existant.
if ($existing) {
$this->auditLogger->log(
$employee,
'delete',
'work_hour',
$existing->getId(),
sprintf('Heures supprimées pour %s le %s', $empName, $data->workDate),
['old' => $this->snapshotWorkHour($existing)],
$workDate,
);
$this->entityManager->remove($existing);
++$result->deleted;
} elseif (($absenceByEmployeeId[$employeeId] ?? false) === true || $is4hContract) {
@@ -163,9 +176,11 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
}
if ($existing) {
$workHour = $existing;
$oldSnapshot = $this->snapshotWorkHour($existing);
$workHour = $existing;
++$result->updated;
} else {
$oldSnapshot = null;
// Upsert: création si aucune ligne n'existe pour (employé, date).
$workHour = new WorkHour()
->setEmployee($employee)
@@ -179,6 +194,23 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
if (!$isAdmin) {
$workHour->setUpdatedAt(new DateTimeImmutable());
}
$newSnapshot = $this->snapshotWorkHour($workHour);
$action = null !== $oldSnapshot ? 'update' : 'create';
$changes = null !== $oldSnapshot
? ['old' => $oldSnapshot, 'new' => $newSnapshot]
: ['new' => $newSnapshot];
$this->auditLogger->log(
$employee,
$action,
'work_hour',
$workHour->getId(),
sprintf('Heures %s pour %s le %s', null !== $oldSnapshot ? 'modifiées' : 'créées', $empName, $data->workDate),
$changes,
$workDate,
);
++$result->processed;
}
@@ -446,6 +478,30 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
;
}
/**
* @return array<string, mixed>
*/
private function snapshotWorkHour(WorkHour $wh): array
{
return [
'morningFrom' => $wh->getMorningFrom(),
'morningTo' => $wh->getMorningTo(),
'afternoonFrom' => $wh->getAfternoonFrom(),
'afternoonTo' => $wh->getAfternoonTo(),
'eveningFrom' => $wh->getEveningFrom(),
'eveningTo' => $wh->getEveningTo(),
'isPresentMorning' => $wh->getIsPresentMorning(),
'isPresentAfternoon' => $wh->getIsPresentAfternoon(),
'dayHoursMinutes' => $wh->getDayHoursMinutes(),
'nightHoursMinutes' => $wh->getNightHoursMinutes(),
'workshopHoursMinutes' => $wh->getWorkshopHoursMinutes(),
'hasBreakfast' => $wh->getHasBreakfast(),
'hasLunch' => $wh->getHasLunch(),
'hasDinner' => $wh->getHasDinner(),
'hasOvernight' => $wh->getHasOvernight(),
];
}
/**
* @param array{
* morningFrom:?string,

View File

@@ -10,7 +10,9 @@ use App\ApiResource\WorkHourBulkValidation;
use App\ApiResource\WorkHourBulkValidationResult;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Service\AuditLogger;
use App\Service\WorkHours\WorkHourBulkValidationExecutor;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -20,6 +22,7 @@ final readonly class WorkHourBulkValidationProcessor implements ProcessorInterfa
public function __construct(
private Security $security,
private WorkHourBulkValidationExecutor $executor,
private AuditLogger $auditLogger,
) {}
public function process(
@@ -41,7 +44,7 @@ final readonly class WorkHourBulkValidationProcessor implements ProcessorInterfa
throw new AccessDeniedHttpException('Only admins can bulk validate work hours.');
}
return $this->executor->execute(
$result = $this->executor->execute(
user: $user,
workDateValue: $data->workDate,
employeeIds: $data->employeeIds,
@@ -50,5 +53,22 @@ final readonly class WorkHourBulkValidationProcessor implements ProcessorInterfa
$workHour->setIsValid($data->isValid);
}
);
if ($result->updated > 0) {
$workDate = DateTimeImmutable::createFromFormat('Y-m-d', $data->workDate);
$action = $data->isValid ? 'validé' : 'dévalidé';
$this->auditLogger->log(
null,
'validate',
'work_hour',
null,
sprintf('Validation RH %s pour %d employé(s) le %s', $action, $result->updated, $data->workDate),
['employeeIds' => $data->employeeIds, 'isValid' => $data->isValid],
$workDate ?: null,
);
}
return $result;
}
}

View File

@@ -12,6 +12,8 @@ use App\Entity\WorkHour;
use App\Repository\UserRepository;
use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use App\Service\AuditLogger;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -24,6 +26,7 @@ final readonly class WorkHourSiteValidationProcessor implements ProcessorInterfa
private WorkHourRepository $workHourRepository,
private UserRepository $userRepository,
private EntityManagerInterface $entityManager,
private AuditLogger $auditLogger,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): WorkHour
@@ -59,6 +62,23 @@ final readonly class WorkHourSiteValidationProcessor implements ProcessorInterfa
&& false === $changeSet['isSiteValid'][0]
&& true === $changeSet['isSiteValid'][1];
if (isset($changeSet['isSiteValid'])) {
$employee = $data->getEmployee();
$empName = $employee ? trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')) : '';
$workDate = $data->getWorkDate();
$newVal = $changeSet['isSiteValid'][1] ? 'validé' : 'dévalidé';
$this->auditLogger->log(
$employee,
'site_validate',
'work_hour',
$data->getId(),
sprintf('Validation site %s pour %s le %s', $newVal, $empName, $workDate->format('d/m/Y')),
['old' => ['isSiteValid' => $changeSet['isSiteValid'][0]], 'new' => ['isSiteValid' => $changeSet['isSiteValid'][1]]],
$workDate instanceof DateTimeImmutable ? $workDate : DateTimeImmutable::createFromInterface($workDate),
);
}
$this->entityManager->flush();
// Notification uniquement quand la dernière ligne du site est validée pour la date.