[#SIRH-17] Ajouter un système de log des actions utilisateurs (#9)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
57
doc/audit-logging.md
Normal file
57
doc/audit-logging.md
Normal file
@@ -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.
|
||||
@@ -82,6 +82,17 @@
|
||||
<p>Utilisateurs</p>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<NuxtLink
|
||||
v-if="isSuperAdmin"
|
||||
to="/audit-logs"
|
||||
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||
:class="route.path.startsWith('/audit-logs')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
>
|
||||
<Icon name="mdi:clipboard-text-clock-outline" size="24"/>
|
||||
<p>Journal</p>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-col gap-2 items-center p-4">
|
||||
@@ -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()
|
||||
</script>
|
||||
|
||||
12
frontend/middleware/super-admin.ts
Normal file
12
frontend/middleware/super-admin.ts
Normal file
@@ -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('/')
|
||||
}
|
||||
})
|
||||
252
frontend/pages/audit-logs.vue
Normal file
252
frontend/pages/audit-logs.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<h1 class="text-4xl font-bold text-primary-500 pb-6">Journal des actions</h1>
|
||||
|
||||
<div class="flex items-end gap-4 pb-6 flex-wrap">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">Employé</label>
|
||||
<select
|
||||
v-model="filters.employeeId"
|
||||
class="mt-2 h-[42px] w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
>
|
||||
<option :value="undefined">Tous</option>
|
||||
<option v-for="emp in employees" :key="emp.id" :value="emp.id">
|
||||
{{ emp.lastName }} {{ emp.firstName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">Du</label>
|
||||
<input
|
||||
v-model="filters.from"
|
||||
type="date"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">Au</label>
|
||||
<input
|
||||
v-model="filters.to"
|
||||
type="date"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">Type</label>
|
||||
<select
|
||||
v-model="filters.entityType"
|
||||
class="mt-2 h-[42px] w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
>
|
||||
<option :value="undefined">Tous</option>
|
||||
<option value="work_hour">Heures</option>
|
||||
<option value="absence">Absences</option>
|
||||
<option value="employee">Employé</option>
|
||||
<option value="contract_suspension">Suspension</option>
|
||||
<option value="rtt_payment">Paiement RTT</option>
|
||||
<option value="fractioned_days">Jours fractionnés</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="h-[42px] rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
@click="search"
|
||||
>
|
||||
Rechercher
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||
Chargement...
|
||||
</div>
|
||||
|
||||
<div v-else-if="logs.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||
Aucune entrée trouvée.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="min-h-0 flex-1 overflow-auto rounded-md bg-white">
|
||||
<div class="grid grid-cols-[140px_110px_90px_100px_150px_1fr_130px] gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
|
||||
<span>Date action</span>
|
||||
<span>Utilisateur</span>
|
||||
<span>Action</span>
|
||||
<span>Type</span>
|
||||
<span>Employé</span>
|
||||
<span>Description</span>
|
||||
<span>Date affectée</span>
|
||||
</div>
|
||||
<div class="border-x border-b border-primary-500 rounded-b-md">
|
||||
<template v-for="log in logs" :key="log.id">
|
||||
<div
|
||||
class="grid grid-cols-[140px_110px_90px_100px_150px_1fr_130px] items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
|
||||
@click="toggleExpand(log.id)"
|
||||
>
|
||||
<span>{{ formatDateTime(log.createdAt) }}</span>
|
||||
<span>{{ log.username }}</span>
|
||||
<span>
|
||||
<span class="rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass(log.action)">
|
||||
{{ actionLabel(log.action) }}
|
||||
</span>
|
||||
</span>
|
||||
<span>{{ entityTypeLabel(log.entityType) }}</span>
|
||||
<span>{{ log.employeeName ?? '-' }}</span>
|
||||
<span class="truncate font-normal" :title="log.description">{{ log.description }}</span>
|
||||
<span>{{ log.affectedDate ? formatDate(log.affectedDate) : '-' }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="expandedIds.has(log.id)"
|
||||
class="border-b border-primary-500 px-6 py-4 bg-neutral-50"
|
||||
>
|
||||
<div v-if="log.changes" class="grid grid-cols-2 gap-6 text-sm font-mono">
|
||||
<div v-if="log.changes.old">
|
||||
<p class="font-bold text-red-600 mb-2">Ancien</p>
|
||||
<pre class="whitespace-pre-wrap text-neutral-700">{{ JSON.stringify(log.changes.old, null, 2) }}</pre>
|
||||
</div>
|
||||
<div v-if="log.changes.new">
|
||||
<p class="font-bold text-green-600 mb-2">Nouveau</p>
|
||||
<pre class="whitespace-pre-wrap text-neutral-700">{{ JSON.stringify(log.changes.new, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-md text-neutral-400">Pas de détail disponible.</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4">
|
||||
<p class="text-md text-neutral-500">
|
||||
{{ total }} résultat{{ total > 1 ? 's' : '' }} — page {{ currentPage }}/{{ totalPages }}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="currentPage <= 1"
|
||||
class="rounded-lg border border-primary-500 px-4 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import type { AuditLog } from '~/services/dto/audit-log'
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import { fetchAuditLogs } from '~/services/audit-logs'
|
||||
import { listEmployees } from '~/services/employees'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'super-admin'
|
||||
})
|
||||
|
||||
useHead({ title: 'Journal des actions' })
|
||||
|
||||
const logs = ref<AuditLog[]>([])
|
||||
const employees = ref<Employee[]>([])
|
||||
const isLoading = ref(false)
|
||||
const expandedIds = ref(new Set<number>())
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(50)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)))
|
||||
|
||||
const filters = reactive<{
|
||||
employeeId?: number
|
||||
from?: string
|
||||
to?: string
|
||||
entityType?: string
|
||||
}>({})
|
||||
|
||||
const loadLogs = async (page = 1) => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const result = await fetchAuditLogs({ ...filters, page })
|
||||
logs.value = result.items
|
||||
total.value = result.total
|
||||
currentPage.value = result.page
|
||||
perPage.value = result.perPage
|
||||
expandedIds.value.clear()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const search = () => {
|
||||
loadLogs(1)
|
||||
}
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
loadLogs(page)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpand = (id: number) => {
|
||||
if (expandedIds.value.has(id)) {
|
||||
expandedIds.value.delete(id)
|
||||
} else {
|
||||
expandedIds.value.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDateTime = (dt: string) => {
|
||||
const d = new Date(dt)
|
||||
return d.toLocaleDateString('fr-FR') + ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const formatDate = (d: string) => {
|
||||
return d.split('-').reverse().join('/')
|
||||
}
|
||||
|
||||
const actionLabel = (action: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
create: 'Créer',
|
||||
update: 'Modifier',
|
||||
delete: 'Suppr.',
|
||||
validate: 'Valid.',
|
||||
site_validate: 'Valid. site',
|
||||
}
|
||||
return map[action] ?? action
|
||||
}
|
||||
|
||||
const actionClass = (action: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
create: 'bg-green-500',
|
||||
update: 'bg-blue-500',
|
||||
delete: 'bg-red-500',
|
||||
validate: 'bg-purple-500',
|
||||
site_validate: 'bg-indigo-500',
|
||||
}
|
||||
return map[action] ?? 'bg-neutral-500'
|
||||
}
|
||||
|
||||
const entityTypeLabel = (type: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
work_hour: 'Heures',
|
||||
absence: 'Absence',
|
||||
employee: 'Employé',
|
||||
contract_suspension: 'Suspension',
|
||||
rtt_payment: 'RTT',
|
||||
fractioned_days: 'Fract.',
|
||||
}
|
||||
return map[type] ?? type
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
employees.value = await listEmployees()
|
||||
await loadLogs()
|
||||
})
|
||||
</script>
|
||||
33
frontend/services/audit-logs.ts
Normal file
33
frontend/services/audit-logs.ts
Normal file
@@ -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<AuditLogPage> => {
|
||||
const api = useApi()
|
||||
const params: Record<string, string> = {}
|
||||
|
||||
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<AuditLogPage>(
|
||||
'/audit-logs',
|
||||
params,
|
||||
{ toast: false }
|
||||
)
|
||||
}
|
||||
12
frontend/services/dto/audit-log.ts
Normal file
12
frontend/services/dto/audit-log.ts
Normal file
@@ -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<string, unknown>; new?: Record<string, unknown> } | null
|
||||
affectedDate: string | null
|
||||
createdAt: string
|
||||
}
|
||||
43
migrations/Version20260330120000.php
Normal file
43
migrations/Version20260330120000.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260330120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create audit_logs table for tracking user actions.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
27
src/ApiResource/AuditLogResource.php
Normal file
27
src/ApiResource/AuditLogResource.php
Normal 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 {}
|
||||
21
src/Dto/AuditLogOutput.php
Normal file
21
src/Dto/AuditLogOutput.php
Normal 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
169
src/Entity/AuditLog.php
Normal 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;
|
||||
}
|
||||
}
|
||||
102
src/Repository/AuditLogRepository.php
Normal file
102
src/Repository/AuditLogRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
47
src/Service/AuditLogger.php
Normal file
47
src/Service/AuditLogger.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
73
src/State/AuditLogProvider.php
Normal file
73
src/State/AuditLogProvider.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user