diff --git a/CLAUDE.md b/CLAUDE.md index 69efe7d..d022603 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,12 @@ - File uploads: `deserialize: false` on Post, access file via RequestStack - Upload dir: `%kernel.project_dir%/var/uploads` +## Audit Logging +- All processors that modify entities impacting calculations (heures, absences, contrats, RTT) MUST inject `AuditLogger` and log create/update/delete actions +- `AuditLogger::log()` persists without flushing — the processor's `flush()` handles both the data change and the audit entry atomically +- Audit logs are accessible only via `ROLE_SUPER_ADMIN` (hidden role, added manually in DB) +- Documentation: `doc/audit-logging.md` + ## Backend Conventions - Prefer explicit DTOs over associative arrays - Business rules in backend (providers/processors/services), frontend is display/interaction only diff --git a/doc/audit-logging.md b/doc/audit-logging.md new file mode 100644 index 0000000..971bca1 --- /dev/null +++ b/doc/audit-logging.md @@ -0,0 +1,57 @@ +# Journal des actions (Audit Log) + +## Objectif + +Tracer les actions utilisateurs pour diagnostiquer rapidement les problèmes de calcul signalés. +Quand un utilisateur signale une incohérence dans ses heures, RTT ou congés, le journal permet de voir +exactement ce qui a été modifié, par qui, et quand. + +## Accès + +- **Rôle requis** : `ROLE_SUPER_ADMIN` (rôle caché, non visible dans l'interface de gestion des utilisateurs) +- **Ajout du rôle** : directement en base de données + ```sql + UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'xxx'; + ``` +- **Page** : `/audit-logs` (lien "Journal" dans la sidebar, visible uniquement avec le rôle) + +## Actions tracées + +| Processor | Entité | Actions | +|---|---|---| +| `AbsenceWriteProcessor` | Absence | create, delete | +| `WorkHourBulkUpsertProcessor` | WorkHour | create, update, delete | +| `WorkHourSiteValidationProcessor` | WorkHour | site_validate | +| `WorkHourBulkValidationProcessor` | WorkHour | validate | +| `WorkHourBulkSiteValidationProcessor` | WorkHour | site_validate | +| `EmployeeWriteProcessor` | Employee | create, update (changement contrat) | +| `ContractSuspensionWriteProcessor` | ContractSuspension | create, update | +| `EmployeeRttPaymentProcessor` | EmployeeRttPayment | update | +| `EmployeeFractionedDaysProcessor` | EmployeeLeaveBalance | update | + +## Données stockées + +Chaque entrée contient : +- **employee** : l'employé concerné (FK, nullable) +- **username** : l'utilisateur qui a effectué l'action +- **action** : type d'action (create, update, delete, validate, site_validate) +- **entityType** : type d'entité (work_hour, absence, employee, etc.) +- **description** : description lisible en français +- **changes** : diff JSON `{old: {...}, new: {...}}` avec les anciennes/nouvelles valeurs +- **affectedDate** : date de travail ou début d'absence (pour filtrage par période) +- **createdAt** : horodatage de l'action + +## Filtres disponibles + +- Par employé +- Par plage de dates (date affectée) +- Par type d'entité + +## Pagination + +Les résultats sont paginés par 50 entrées. L'API retourne `{items, total, page, perPage}` et accepte un query param `page`. + +## Convention + +Tout nouveau processor traitant des entités impactant les calculs (heures, absences, contrats, RTT) +doit intégrer le service `AuditLogger` et logger les actions create/update/delete. diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index fa1ad72..3e3fbe7 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -82,6 +82,17 @@

Utilisateurs

+ + +

Journal

+
@@ -103,5 +114,6 @@ const auth = useAuthStore() const {version} = useAppVersion() const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false) +const isSuperAdmin = computed(() => auth.user?.roles?.includes('ROLE_SUPER_ADMIN') ?? false) const route = useRoute() diff --git a/frontend/middleware/super-admin.ts b/frontend/middleware/super-admin.ts new file mode 100644 index 0000000..915292f --- /dev/null +++ b/frontend/middleware/super-admin.ts @@ -0,0 +1,12 @@ +export default defineNuxtRouteMiddleware(async () => { + const auth = useAuthStore() + + if (!auth.checked) { + await auth.ensureSession() + } + + const isSuperAdmin = auth.user?.roles?.includes('ROLE_SUPER_ADMIN') + if (!isSuperAdmin) { + return navigateTo('/') + } +}) diff --git a/frontend/pages/audit-logs.vue b/frontend/pages/audit-logs.vue new file mode 100644 index 0000000..44c433d --- /dev/null +++ b/frontend/pages/audit-logs.vue @@ -0,0 +1,252 @@ + + + diff --git a/frontend/services/audit-logs.ts b/frontend/services/audit-logs.ts new file mode 100644 index 0000000..6fd5f32 --- /dev/null +++ b/frontend/services/audit-logs.ts @@ -0,0 +1,33 @@ +import type { AuditLog } from './dto/audit-log' + +export type AuditLogFilters = { + employeeId?: number + from?: string + to?: string + entityType?: string + page?: number +} + +export type AuditLogPage = { + items: AuditLog[] + total: number + page: number + perPage: number +} + +export const fetchAuditLogs = async (filters: AuditLogFilters = {}): Promise => { + const api = useApi() + const params: Record = {} + + if (filters.employeeId) params.employeeId = String(filters.employeeId) + if (filters.from) params.from = filters.from + if (filters.to) params.to = filters.to + if (filters.entityType) params.entityType = filters.entityType + if (filters.page) params.page = String(filters.page) + + return api.get( + '/audit-logs', + params, + { toast: false } + ) +} diff --git a/frontend/services/dto/audit-log.ts b/frontend/services/dto/audit-log.ts new file mode 100644 index 0000000..5db48ae --- /dev/null +++ b/frontend/services/dto/audit-log.ts @@ -0,0 +1,12 @@ +export type AuditLog = { + id: number + employeeName: string | null + employeeId: number | null + username: string + action: string + entityType: string + description: string + changes: { old?: Record; new?: Record } | null + affectedDate: string | null + createdAt: string +} diff --git a/migrations/Version20260330120000.php b/migrations/Version20260330120000.php new file mode 100644 index 0000000..bd67ee7 --- /dev/null +++ b/migrations/Version20260330120000.php @@ -0,0 +1,43 @@ +addSql('CREATE TABLE audit_logs ( + id SERIAL PRIMARY KEY, + employee_id INTEGER DEFAULT NULL, + username VARCHAR(180) NOT NULL, + action VARCHAR(30) NOT NULL, + entity_type VARCHAR(50) NOT NULL, + entity_id INTEGER DEFAULT NULL, + description TEXT NOT NULL, + changes JSON DEFAULT NULL, + affected_date DATE DEFAULT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + CONSTRAINT fk_audit_employee FOREIGN KEY (employee_id) REFERENCES employees (id) ON DELETE SET NULL + )'); + + $this->addSql('CREATE INDEX idx_audit_employee_created ON audit_logs (employee_id, created_at)'); + $this->addSql('CREATE INDEX idx_audit_entity ON audit_logs (entity_type, entity_id)'); + $this->addSql('CREATE INDEX idx_audit_created ON audit_logs (created_at)'); + $this->addSql('CREATE INDEX idx_audit_affected_date ON audit_logs (affected_date)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE audit_logs'); + } +} diff --git a/src/ApiResource/AuditLogResource.php b/src/ApiResource/AuditLogResource.php new file mode 100644 index 0000000..684809f --- /dev/null +++ b/src/ApiResource/AuditLogResource.php @@ -0,0 +1,27 @@ +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; + } +} diff --git a/src/Repository/AuditLogRepository.php b/src/Repository/AuditLogRepository.php new file mode 100644 index 0000000..8d9ea09 --- /dev/null +++ b/src/Repository/AuditLogRepository.php @@ -0,0 +1,102 @@ + + */ +final class AuditLogRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AuditLog::class); + } + + /** + * @return list + */ + 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(); + } +} diff --git a/src/Service/AuditLogger.php b/src/Service/AuditLogger.php new file mode 100644 index 0000000..f0358f3 --- /dev/null +++ b/src/Service/AuditLogger.php @@ -0,0 +1,47 @@ +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); + } +} diff --git a/src/State/AbsenceWriteProcessor.php b/src/State/AbsenceWriteProcessor.php index 4ec8624..8b45f83 100644 --- a/src/State/AbsenceWriteProcessor.php +++ b/src/State/AbsenceWriteProcessor.php @@ -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; diff --git a/src/State/AuditLogProvider.php b/src/State/AuditLogProvider.php new file mode 100644 index 0000000..9d28354 --- /dev/null +++ b/src/State/AuditLogProvider.php @@ -0,0 +1,73 @@ +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, + ]); + } +} diff --git a/src/State/ContractSuspensionWriteProcessor.php b/src/State/ContractSuspensionWriteProcessor.php index eb8db67..7f8984f 100644 --- a/src/State/ContractSuspensionWriteProcessor.php +++ b/src/State/ContractSuspensionWriteProcessor.php @@ -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 diff --git a/src/State/EmployeeFractionedDaysProcessor.php b/src/State/EmployeeFractionedDaysProcessor.php index b64bc9b..01cf9e1 100644 --- a/src/State/EmployeeFractionedDaysProcessor.php +++ b/src/State/EmployeeFractionedDaysProcessor.php @@ -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; diff --git a/src/State/EmployeeRttPaymentProcessor.php b/src/State/EmployeeRttPaymentProcessor.php index ea81bdf..4b661ab 100644 --- a/src/State/EmployeeRttPaymentProcessor.php +++ b/src/State/EmployeeRttPaymentProcessor.php @@ -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; diff --git a/src/State/EmployeeWriteProcessor.php b/src/State/EmployeeWriteProcessor.php index 1fefcd3..0c7c56a 100644 --- a/src/State/EmployeeWriteProcessor.php +++ b/src/State/EmployeeWriteProcessor.php @@ -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(); diff --git a/src/State/WorkHourBulkSiteValidationProcessor.php b/src/State/WorkHourBulkSiteValidationProcessor.php index ab3ab96..57d45e9 100644 --- a/src/State/WorkHourBulkSiteValidationProcessor.php +++ b/src/State/WorkHourBulkSiteValidationProcessor.php @@ -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); } diff --git a/src/State/WorkHourBulkUpsertProcessor.php b/src/State/WorkHourBulkUpsertProcessor.php index b2b49f3..70e7f71 100644 --- a/src/State/WorkHourBulkUpsertProcessor.php +++ b/src/State/WorkHourBulkUpsertProcessor.php @@ -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 + */ + 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, diff --git a/src/State/WorkHourBulkValidationProcessor.php b/src/State/WorkHourBulkValidationProcessor.php index d0d471f..ef0d938 100644 --- a/src/State/WorkHourBulkValidationProcessor.php +++ b/src/State/WorkHourBulkValidationProcessor.php @@ -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; } } diff --git a/src/State/WorkHourSiteValidationProcessor.php b/src/State/WorkHourSiteValidationProcessor.php index 608f54f..c40d474 100644 --- a/src/State/WorkHourSiteValidationProcessor.php +++ b/src/State/WorkHourSiteValidationProcessor.php @@ -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. diff --git a/tests/State/AbsenceWriteProcessorTest.php b/tests/State/AbsenceWriteProcessorTest.php index c7d6af3..d4bc357 100644 --- a/tests/State/AbsenceWriteProcessorTest.php +++ b/tests/State/AbsenceWriteProcessorTest.php @@ -14,6 +14,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 App\State\AbsenceWriteProcessor; use DateTime; @@ -36,7 +37,7 @@ final class AbsenceWriteProcessorTest extends TestCase $absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::class); $workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class); $security = $this->createAdminSecurityStub(); - $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub()); + $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub(), $this->createStub(AuditLogger::class)); $absence = $this->buildAbsence('2026-02-16', '2026-02-18', HalfDay::AM, HalfDay::PM); @@ -64,7 +65,7 @@ final class AbsenceWriteProcessorTest extends TestCase $absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); $workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class); $security = $this->createAdminSecurityStub(); - $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub()); + $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub(), $this->createStub(AuditLogger::class)); $absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM); @@ -85,7 +86,7 @@ final class AbsenceWriteProcessorTest extends TestCase $absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); $workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class); $security = $this->createAdminSecurityStub(); - $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub()); + $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub(), $this->createStub(AuditLogger::class)); $absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM); @@ -107,7 +108,7 @@ final class AbsenceWriteProcessorTest extends TestCase $absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); $workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class); $security = $this->createAdminSecurityStub(); - $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub()); + $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub(), $this->createStub(AuditLogger::class)); $absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::PM, HalfDay::AM); diff --git a/tests/State/ContractSuspensionWriteProcessorTest.php b/tests/State/ContractSuspensionWriteProcessorTest.php index 7ee8256..785b19e 100644 --- a/tests/State/ContractSuspensionWriteProcessorTest.php +++ b/tests/State/ContractSuspensionWriteProcessorTest.php @@ -9,6 +9,7 @@ use ApiPlatform\State\ProcessorInterface; use App\Entity\ContractSuspension; use App\Entity\EmployeeContractPeriod; use App\Enum\ContractNature; +use App\Service\AuditLogger; use App\State\ContractSuspensionWriteProcessor; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; @@ -35,7 +36,7 @@ final class ContractSuspensionWriteProcessorTest extends TestCase $entityManager = $this->createStub(EntityManagerInterface::class); - $processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager); + $processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager, $this->createStub(AuditLogger::class)); $result = $processor->process($suspension, new Post()); self::assertSame($suspension, $result); @@ -52,7 +53,7 @@ final class ContractSuspensionWriteProcessorTest extends TestCase $persistProcessor = $this->createStub(ProcessorInterface::class); $entityManager = $this->createStub(EntityManagerInterface::class); - $processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager); + $processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager, $this->createStub(AuditLogger::class)); $this->expectException(UnprocessableEntityHttpException::class); $processor->process($suspension, new Post()); @@ -68,7 +69,7 @@ final class ContractSuspensionWriteProcessorTest extends TestCase $persistProcessor = $this->createStub(ProcessorInterface::class); $entityManager = $this->createStub(EntityManagerInterface::class); - $processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager); + $processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager, $this->createStub(AuditLogger::class)); $this->expectException(UnprocessableEntityHttpException::class); $processor->process($suspension, new Post()); @@ -92,7 +93,7 @@ final class ContractSuspensionWriteProcessorTest extends TestCase $persistProcessor = $this->createStub(ProcessorInterface::class); $entityManager = $this->createStub(EntityManagerInterface::class); - $processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager); + $processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager, $this->createStub(AuditLogger::class)); $this->expectException(UnprocessableEntityHttpException::class); $processor->process($suspension, new Post()); @@ -109,7 +110,7 @@ final class ContractSuspensionWriteProcessorTest extends TestCase $persistProcessor = $this->createStub(ProcessorInterface::class); $entityManager = $this->createStub(EntityManagerInterface::class); - $processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager); + $processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager, $this->createStub(AuditLogger::class)); $this->expectException(UnprocessableEntityHttpException::class); $processor->process($suspension, new Post()); diff --git a/tests/State/EmployeeWriteProcessorTest.php b/tests/State/EmployeeWriteProcessorTest.php index d5a79e1..e2b37fa 100644 --- a/tests/State/EmployeeWriteProcessorTest.php +++ b/tests/State/EmployeeWriteProcessorTest.php @@ -13,6 +13,7 @@ use App\Entity\Employee; use App\Entity\EmployeeContractPeriod; use App\Enum\ContractNature; use App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface; +use App\Service\AuditLogger; use App\Service\Contracts\EmployeeContractChangeRequestFactory; use App\Service\Contracts\EmployeeContractPeriodManagerInterface; use App\State\EmployeeWriteProcessor; @@ -83,7 +84,8 @@ final class EmployeeWriteProcessorTest extends TestCase $entityManager, $periodRepository, $changeRequestFactory, - $periodManager + $periodManager, + $this->createStub(AuditLogger::class) ); $result = $processor->process($employee, new Patch()); @@ -149,7 +151,8 @@ final class EmployeeWriteProcessorTest extends TestCase $entityManager, $periodRepository, $changeRequestFactory, - $periodManager + $periodManager, + $this->createStub(AuditLogger::class) ); $result = $processor->process($employee, new Patch()); @@ -187,7 +190,8 @@ final class EmployeeWriteProcessorTest extends TestCase $entityManager, $periodRepository, $changeRequestFactory, - $periodManager + $periodManager, + $this->createStub(AuditLogger::class) ); $result = $processor->process($employee, new Patch()); @@ -234,7 +238,8 @@ final class EmployeeWriteProcessorTest extends TestCase $entityManager, $periodRepository, $changeRequestFactory, - $periodManager + $periodManager, + $this->createStub(AuditLogger::class) ); $processor->process($employee, new Post()); @@ -268,7 +273,8 @@ final class EmployeeWriteProcessorTest extends TestCase $entityManager, $periodRepository, $changeRequestFactory, - $periodManager + $periodManager, + $this->createStub(AuditLogger::class) ); $result = $processor->process($employee, new Delete());