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 @@
+
+
+
Journal des actions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Chargement...
+
+
+
+ Aucune entrée trouvée.
+
+
+
+
+
+ Date action
+ Utilisateur
+ Action
+ Type
+ Employé
+ Description
+ Date affectée
+
+
+
+
+ {{ formatDateTime(log.createdAt) }}
+ {{ log.username }}
+
+
+ {{ actionLabel(log.action) }}
+
+
+ {{ entityTypeLabel(log.entityType) }}
+ {{ log.employeeName ?? '-' }}
+ {{ log.description }}
+ {{ log.affectedDate ? formatDate(log.affectedDate) : '-' }}
+
+
+
+
+
+
Ancien
+
{{ JSON.stringify(log.changes.old, null, 2) }}
+
+
+
Nouveau
+
{{ JSON.stringify(log.changes.new, null, 2) }}
+
+
+
Pas de détail disponible.
+
+
+
+
+
+
+
+ {{ total }} résultat{{ total > 1 ? 's' : '' }} — page {{ currentPage }}/{{ totalPages }}
+
+
+
+
+
+
+
+
+
+
+
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());