Compare commits

..

50 Commits

Author SHA1 Message Date
gitea-actions
f3ed359d3f chore: bump version to v0.1.77
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 57s
2026-04-02 10:05:12 +00:00
906c245451 feat(deploy) : add maintenance mode with automatic toggle during deploy
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-02 11:56:13 +02:00
gitea-actions
100ab340d4 chore: bump version to v0.1.76
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 39s
2026-04-02 08:55:05 +00:00
0257e59671 [#SIRH-21] Revoir l'affichage des RTT pour les semaines qui se chevauchent (#13)
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: #13
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-02 08:54:55 +00:00
gitea-actions
f9979c9a19 chore: bump version to v0.1.75
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build & Push Docker Image / build (push) Successful in 30s
2026-04-02 06:59:10 +00:00
1091147100 [#SIRH-20] Ajouter pour les forfaits le paiement de congés N-1 (#12)
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
| 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: #12
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-02 06:59:03 +00:00
gitea-actions
fd154a59fb chore: bump version to v0.1.74
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 18s
2026-03-31 14:44:07 +00:00
967e3311e5 docs : update doc deployment-docker.md
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-31 16:43:54 +02:00
gitea-actions
04c5279946 chore: bump version to v0.1.73
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 15s
2026-03-31 12:46:03 +00:00
b25d40f3d8 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-31 14:45:55 +02:00
e654516b82 docs : fix JWT key generation and permissions in deployment doc 2026-03-31 14:32:12 +02:00
gitea-actions
b07146e78d chore: bump version to v0.1.72
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 19s
2026-03-31 10:13:39 +00:00
b1bf363fa1 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-31 12:13:31 +02:00
c13cab6b59 fix(deploy) : run console commands as www-data to prevent permission issues 2026-03-31 12:11:58 +02:00
gitea-actions
3752785ed1 chore: bump version to v0.1.71
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 17s
2026-03-31 09:50:51 +00:00
ab44b5439d fix(deploy) : add minimal .env file in Docker image for Symfony boot
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-31 10:38:59 +02:00
699d09e2f4 docs : add nginx install to deployment prerequisites 2026-03-31 10:35:32 +02:00
b62a19513d docs : improve Docker deployment documentation 2026-03-31 09:33:05 +02:00
gitea-actions
3d69346d24 chore: bump version to v0.1.70
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 18s
2026-03-31 07:11:36 +00:00
ea849a4fdd Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
2026-03-31 09:11:28 +02:00
7b3dcc3c54 fix : déploiement docker 2026-03-31 09:11:00 +02:00
gitea-actions
c6ab8e3624 chore: bump version to v0.1.69
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 6s
2026-03-31 06:57:47 +00:00
f3b65c0617 fix(deploy) : use REGISTRY_TOKEN for Docker registry authentication
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-31 08:57:27 +02:00
gitea-actions
15ce234737 chore: bump version to v0.1.68
Some checks failed
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Failing after 6s
2026-03-31 06:38:47 +00:00
caffb74cbf [#INFRA-142] Revoir le système de déploiement (#11)
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
| 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é

Co-authored-by: gitea-actions <gitea-actions@local>
Reviewed-on: #11
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-31 06:38:38 +00:00
gitea-actions
54354c4435 chore: bump version to v0.1.67
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m33s
2026-03-30 13:06:35 +00:00
3dcdf0fb81 [#SIRH-18] Fix connexion conducteur (#10)
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: #10
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-30 13:06:27 +00:00
gitea-actions
1a71ff6834 chore: bump version to v0.1.66
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m30s
2026-03-30 07:52:57 +00:00
057d6bf06f [#SIRH-17] Ajouter un système de log des actions utilisateurs (#9)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #9
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-30 07:52:49 +00:00
gitea-actions
e74a264b37 chore: bump version to v0.1.65
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 2m24s
2026-03-25 09:49:32 +00:00
60bb3cf8c4 fix : verrouillage utilisateur + modification de contrat terminé
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-25 10:49:20 +01:00
gitea-actions
1a485e8780 chore: bump version to v0.1.64
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m14s
2026-03-25 09:19:21 +00:00
5c6d42c729 [#SIRH-14] Ajouter un onglet Observation sur la fiche employé (#8)
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: #8
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-25 09:19:16 +00:00
gitea-actions
3c434d20b2 chore: bump version to v0.1.63
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m16s
2026-03-25 07:51:33 +00:00
bbb020025a [#SIRH-12] Export des heures d'un employé sur une année (#7)
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: #7
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-25 07:51:26 +00:00
gitea-actions
640bb42d3a chore: bump version to v0.1.62
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m38s
2026-03-23 15:40:37 +00:00
50712ccb00 Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
2026-03-23 16:40:24 +01:00
265b19a9d0 fix : calcule des RTT pour les chauffeurs dans le récap des congés 2026-03-23 16:40:06 +01:00
gitea-actions
13743738fd chore: bump version to v0.1.61
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m45s
2026-03-20 13:42:48 +00:00
085fe0c150 fix : calcule des RTT pour les chauffeurs
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-20 14:42:36 +01:00
gitea-actions
a1110069b5 chore: bump version to v0.1.60
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m21s
2026-03-20 10:53:51 +00:00
4901c58ebf Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-20 11:53:34 +01:00
4de891579c feat : ajout des congés bonus pour les forfaits si ils travaillent un weekend ou férié 2026-03-20 11:53:24 +01:00
gitea-actions
a17d6a67cf chore: bump version to v0.1.59
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m59s
2026-03-20 10:26:29 +00:00
29db3b5025 fix : calcule des RTT sur les contrats 4h
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-20 11:26:17 +01:00
gitea-actions
6df9110187 chore: bump version to v0.1.58
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m39s
2026-03-20 07:13:49 +00:00
f0dfb30566 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-20 08:13:34 +01:00
049e64288e fix : calcule des RTT 2026-03-20 08:13:20 +01:00
gitea-actions
9577a70ea3 chore: bump version to v0.1.57
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m9s
2026-03-19 17:18:17 +00:00
e85f7b6f4c fix : calcule des RTT
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-19 18:18:06 +01:00
92 changed files with 3923 additions and 222 deletions

View File

@@ -24,7 +24,9 @@
"Bash(npx xlsx-cli:*)",
"Bash(cat /home/m-tristan/.claude/projects/-home-m-tristan-workspace-SIRH/4b53d9d7-d8ae-451f-a5cc-5d4fd55f2eef/tool-results/toolu_019hng9Cu2m9wiNACuC2Wm3F.json | python3 -c \"import json,sys; data=json.load\\(sys.stdin\\); print\\(data[0]['text']\\)\" 2>/dev/null | head -2000)",
"Bash(pip3 install:*)",
"Bash(find:*)"
"Bash(find:*)",
"Bash(git add:*)",
"Bash(git commit:*)"
]
}
}

23
.dockerignore Normal file
View File

@@ -0,0 +1,23 @@
.git
.gitea
.env.local
.env.test
docker/
deploy/docker/docker-compose.prod.yml
deploy/docker/deploy.sh
deploy/docker/.env.example
frontend/node_modules
frontend/.nuxt
frontend/.output
var/
LOG/
docs/
doc/
tests/
*.sql
*.xlsx
*.png
*.md
!composer.lock
!symfony.lock
!frontend/package-lock.json

View File

@@ -0,0 +1,30 @@
name: Build & Push Docker Image
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Gitea Registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.malio.fr -u "${{ gitea.repository_owner }}" --password-stdin
- name: Build Docker image
run: |
docker build \
-f deploy/docker/Dockerfile.prod \
-t gitea.malio.fr/malio-dev/sirh:${{ github.ref_name }} \
-t gitea.malio.fr/malio-dev/sirh:latest \
.
- name: Push Docker image
run: |
docker push gitea.malio.fr/malio-dev/sirh:${{ github.ref_name }}
docker push gitea.malio.fr/malio-dev/sirh:latest

View File

@@ -1,65 +0,0 @@
name: Build Release Artefact
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install backend deps (prod)
env:
APP_ENV: prod
APP_DEBUG: "0"
run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
- name: Build frontend (static)
run: |
cd frontend
npm ci
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
test -f .output/public/index.html
- name: Build artefact
shell: bash
run: |
set -euo pipefail
mkdir -p release
tar -czf "release/sirh-${GITHUB_REF_NAME}.tar.gz" \
bin \
config \
migrations \
public \
src \
templates \
vendor \
composer.json \
composer.lock \
symfony.lock \
frontend/.output
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: release/sirh-${{ github.ref_name }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/9cad43df-2147-4989-b7a4-443067034884/console_3.sql" value="9cad43df-2147-4989-b7a4-443067034884" />
</component>
</project>

View File

@@ -1,5 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-forest-configuration">
<data version="2">.
----------------------------------------
1:0:9cad43df-2147-4989-b7a4-443067034884
2:0:ae622167-c834-4e7b-87a5-c1721036f5dc
3:0:f407a514-c6b4-4b26-9555-445a85892502
4:0:09e221b8-067a-488b-9c1d-4e155a333079
5:0:9d8c1ad3-2491-4642-964a-666003c14128
.</data>
</component>
<component name="db-tree-configuration">
<option name="data" value="----------------------------------------&#10;1:0:9cad43df-2147-4989-b7a4-443067034884&#10;2:0:ae622167-c834-4e7b-87a5-c1721036f5dc&#10;3:0:f407a514-c6b4-4b26-9555-445a85892502&#10;4:0:09e221b8-067a-488b-9c1d-4e155a333079&#10;" />
</component>

6
.idea/sqldialects.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/sirh.sql" dialect="GenericSQL" />
</component>
</project>

View File

@@ -43,8 +43,10 @@
## Overtime Rules
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery), deficit doesn't impact balance
- INTERIM: no overtime bonuses, no recovery time
- Driver contracts: no overtime calculation
- Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges
- FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only.
## Frais (MileageAllowance)
- Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé
@@ -70,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

View File

@@ -1,4 +1,5 @@
# SIRH
Application de gestion des absences employée
## Importer un dump de prod en dev
@@ -17,3 +18,8 @@ Remplie la base avec le dump :
```shell
docker compose exec -T db psql -U root -d sirh < sirh.sql
```
## Mettre SUPER_ADMIN sur un user
```sql
UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'emilie';
```

View File

@@ -19,6 +19,7 @@ security:
pattern: ^/login_check
stateless: true
provider: app_user_provider
user_checker: App\Security\UserChecker
json_login:
check_path: /login_check
username_path: username
@@ -29,6 +30,7 @@ security:
pattern: ^/api
stateless: true
provider: app_user_provider
user_checker: App\Security\UserChecker
jwt: ~
logout:
path: /api/logout

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.56'
app.version: '0.1.77'

View File

@@ -0,0 +1,25 @@
# Symfony
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=change-me
# Database (use host.docker.internal to reach bare-metal PostgreSQL)
DATABASE_URL="postgresql://sirh_user:password@host.docker.internal:5432/sirh?serverVersion=16&charset=utf8"
# JWT
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=change-me
JWT_COOKIE_SECURE=1
JWT_COOKIE_SAMESITE=lax
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
# CORS
CORS_ALLOW_ORIGIN='^https?://sirh\.malio-dev\.fr$'
# App
DEFAULT_URI=https://sirh.malio-dev.fr
APP_SHARE_DIR=var/share
RTT_START_DATE=2026-02-23
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"

View File

@@ -0,0 +1,80 @@
# --- Stage 1: Build backend ---
FROM php:8.4-cli AS backend-build
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
unzip curl git \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
RUN APP_ENV=prod APP_DEBUG=0 composer install --no-dev --optimize-autoloader --no-scripts --no-interaction
COPY bin bin/
COPY config config/
COPY migrations migrations/
COPY public public/
COPY src src/
COPY templates templates/
# --- Stage 2: Build frontend ---
FROM node:lts-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
ENV CI=1 \
NUXT_TELEMETRY_DISABLED=1 \
NUXT_PUBLIC_API_BASE=/api \
NUXT_PUBLIC_APP_BASE=/
RUN npm run generate
# --- Stage 3: Production image ---
FROM php:8.4-fpm AS production
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/*
# PHP production config
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# PHP-FPM: forward worker output to stderr for docker logs
RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \
&& echo "decorate_workers_output = no" >> /usr/local/etc/php-fpm.d/www.conf
# Nginx: log to stdout/stderr
RUN ln -sf /dev/stdout /var/log/nginx/access.log \
&& ln -sf /dev/stderr /var/log/nginx/error.log
# Remove default nginx site
RUN rm -f /etc/nginx/sites-enabled/default
# Configs
COPY deploy/docker/supervisord.conf /etc/supervisor/conf.d/app.conf
COPY deploy/docker/nginx.conf /etc/nginx/sites-enabled/sirh.conf
# Backend from stage 1
COPY --from=backend-build /app /var/www/html
# Frontend from stage 2
COPY --from=frontend-build /app/frontend/.output/public /var/www/html/frontend/.output/public
# Symfony needs a .env file to boot (variables are overridden by env_file in docker-compose)
RUN echo "APP_ENV=prod" > /var/www/html/.env
# Permissions
RUN mkdir -p /var/www/html/var \
&& chown -R www-data:www-data /var/www/html/var
WORKDIR /var/www/html
EXPOSE 80
CMD ["supervisord", "-n", "-c", "/etc/supervisor/conf.d/app.conf"]

34
deploy/docker/deploy.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
TAG="${1:-latest}"
export SIRH_IMAGE_TAG="$TAG"
echo "==> Deploying sirh:${TAG}..."
echo "==> Enabling maintenance mode..."
touch maintenance.on
echo "==> Pulling image..."
docker compose pull
echo "==> Starting container..."
docker compose up -d
echo "==> Waiting for container to be ready..."
sleep 3
echo "==> Running migrations..."
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Clearing cache..."
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
echo "==> Disabling maintenance mode..."
rm -f maintenance.on
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
echo "==> Deployed v${VERSION}"

View File

@@ -0,0 +1,13 @@
services:
app:
image: gitea.malio.fr/malio-dev/sirh:${SIRH_IMAGE_TAG:-latest}
container_name: sirh-app
env_file: .env
ports:
- "8080:80"
volumes:
- ./config/jwt:/var/www/html/config/jwt:ro
- ./uploads:/var/www/html/var/uploads
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped

46
deploy/docker/nginx.conf Normal file
View File

@@ -0,0 +1,46 @@
server {
listen 80;
server_name _;
root /var/www/html/frontend/.output/public;
index index.html;
access_log /dev/stdout;
error_log /dev/stderr;
location ^~ /api/ {
root /var/www/html/public;
try_files $uri /index.php?$query_string;
}
location ^~ /bundles/ {
root /var/www/html/public;
try_files $uri =404;
}
location = /api/login_check {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param PATH_INFO /login_check;
fastcgi_param REQUEST_URI /login_check;
fastcgi_pass 127.0.0.1:9000;
}
location ~ ^/index\.php(/|$) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
fastcgi_pass 127.0.0.1:9000;
internal;
}
location ~ \.php$ {
return 404;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,28 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/null
logfile_maxbytes=0
pidfile=/var/run/supervisord.pid
[program:php-fpm]
command=php-fpm -F
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopasgroup=true
stopsignal=QUIT
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopasgroup=true
stopsignal=QUIT

50
deploy/maintenance.html Normal file
View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Maintenance en cours</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
color: #1f2937;
}
.container {
text-align: center;
padding: 3rem;
background: white;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
max-width: 500px;
width: 90%;
}
.icon {
font-size: 4rem;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.5rem;
margin-bottom: 0.75rem;
color: #111827;
}
p {
font-size: 1rem;
color: #6b7280;
line-height: 1.6;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">&#128736;</div>
<h1>Maintenance en cours</h1>
<p>L'application est temporairement indisponible pour mise a jour. Elle sera de retour dans quelques instants.</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
server {
listen 80;
server_name sirh.malio-dev.fr;
root /var/www/sirh/public;
# Maintenance mode : si le fichier maintenance.on existe, renvoyer la page 503
if (-f /var/www/sirh/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
internal;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

57
doc/audit-logging.md Normal file
View 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.

363
doc/deployment-docker.md Normal file
View File

@@ -0,0 +1,363 @@
# Deploiement Docker — SIRH
## Pre-requis
### Docker
```bash
# Ubuntu
sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker $USER
```
Se deconnecter/reconnecter pour que le groupe `docker` prenne effet.
### Nginx
```bash
sudo apt install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx
```
### PostgreSQL
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
Il doit etre installe et accessible avant de deployer SIRH.
Creer la base de donnees pour SIRH :
```bash
cd /var/www/postgres
docker compose exec postgres psql -U admin
```
```sql
-- Si le user n'existe pas encore
CREATE USER malio WITH PASSWORD 'motdepasse';
-- Creer la base
CREATE DATABASE sirh_prod OWNER malio;
\q
```
---
## Premiere installation (nouvelle machine)
Guide complet pour mettre en ligne SIRH sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
### 1. Installer les pre-requis
Installer Docker, Nginx et PostgreSQL (voir section Pre-requis ci-dessus).
### 2. Creer le dossier de deploiement
```bash
sudo mkdir -p /var/www/sirh
sudo chown -R $(whoami):$(whoami) /var/www/sirh
cd /var/www/sirh
```
### 3. Se connecter au registry Docker de Gitea
```bash
docker login gitea.malio.fr
```
- **Username** : le nom d'utilisateur du compte organisation Gitea `MALIO-DEV`
- **Password** : le token REGISTRY_TOKEN dispo dans le bitwarden
Le login est sauvegarde dans `~/.docker/config.json`, pas besoin de le refaire a chaque deploiement.
### 4. Creer les fichiers de deploiement
Creer `docker-compose.yml` :
```yaml
services:
app:
image: gitea.malio.fr/malio-dev/sirh:${SIRH_IMAGE_TAG:-latest}
container_name: sirh-app
env_file: .env
ports:
- "8080:80"
volumes:
- ./config/jwt:/var/www/html/config/jwt:ro
- ./uploads:/var/www/html/var/uploads
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
```
Creer `deploy.sh` :
```bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
TAG="${1:-latest}"
export SIRH_IMAGE_TAG="$TAG"
echo "==> Deploying sirh:${TAG}..."
echo "==> Pulling image..."
docker compose pull
echo "==> Starting container..."
docker compose up -d
echo "==> Waiting for container to be ready..."
sleep 3
echo "==> Running migrations..."
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Clearing cache..."
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
echo "==> Deployed v${VERSION}"
```
Rendre executable :
```bash
chmod +x deploy.sh
```
### 5. Configurer l'environnement
Creer `.env` avec les variables suivantes :
```env
# Symfony
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=<generer avec: openssl rand -hex 32>
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/sirh_prod?serverVersion=16&charset=utf8"
# JWT
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
JWT_COOKIE_SECURE=1
JWT_COOKIE_SAMESITE=lax
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
# CORS
CORS_ALLOW_ORIGIN='^https?://sirh\.malio-dev\.fr$'
# App
DEFAULT_URI=https://sirh.malio-dev.fr
APP_SHARE_DIR=var/share
RTT_START_DATE=2026-02-23
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"
```
### 6. Generer les cles JWT
```bash
mkdir -p config/jwt
openssl genpkey -algorithm RSA -out config/jwt/private.pem -pkeyopt rsa_keygen_bits:4096
openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem
```
Rendre les cles lisibles par le conteneur (www-data = uid 33) :
```bash
sudo chown 33:33 config/jwt/private.pem config/jwt/public.pem
sudo chmod 644 config/jwt/private.pem config/jwt/public.pem
```
### 7. Creer le dossier uploads
```bash
mkdir -p uploads
```
### 8. Configurer Nginx systeme
Creer `/etc/nginx/sites-available/sirh.conf` :
```nginx
server {
listen 80;
server_name sirh.malio-dev.fr;
root /var/www/sirh/public;
# Maintenance mode
if (-f /var/www/sirh/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
internal;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Copier la page de maintenance et activer le site :
```bash
cp deploy/maintenance.html /var/www/sirh/public/maintenance.html
sudo ln -sf /etc/nginx/sites-available/sirh.conf /etc/nginx/sites-enabled/sirh.conf
sudo nginx -t && sudo systemctl reload nginx
```
### 9. Deployer
```bash
./deploy.sh
```
### 10. Importer les donnees (optionnel)
Si tu as un dump SQL a importer :
```bash
# Depuis ton PC, envoyer le dump vers le serveur
scp sirh.sql user@serveur:/tmp/sirh.sql
# Sur le serveur, vider la base puis importer
cd /var/www/postgres
docker compose exec -T postgres psql -U malio sirh_prod -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
docker compose exec -T postgres psql -U malio sirh_prod < /tmp/sirh.sql
# Creer les tables manquantes (si le dump a des erreurs de syntaxe)
cd /var/www/sirh
docker compose exec -u www-data app php bin/console doctrine:schema:update --force --env=prod
# Nettoyer
rm /tmp/sirh.sql
```
### Structure finale du dossier
```
/var/www/sirh/
├── docker-compose.yml
├── deploy.sh
├── .env
├── config/jwt/
│ ├── private.pem
│ └── public.pem
├── public/
│ └── maintenance.html
└── uploads/
```
---
## Deployer une nouvelle version
Quand l'app est deja installee, deployer une mise a jour :
```bash
cd /var/www/sirh
./deploy.sh # deploie la derniere version (latest)
./deploy.sh v0.1.61 # deploie une version specifique
```
Le script active automatiquement la maintenance pendant le deploy et la desactive a la fin.
---
## Maintenance manuelle
Activer la maintenance (sans deployer) :
```bash
cd /var/www/sirh
touch maintenance.on
```
Desactiver :
```bash
rm maintenance.on
```
---
## Rollback
### Image seule (pas de changement de schema BDD)
```bash
./deploy.sh v0.1.60
```
### Avec rollback de migration
```bash
# 1. Rollback schema (pendant que la version actuelle tourne encore)
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate prev --no-interaction
# 2. Deployer l'ancienne version
./deploy.sh v0.1.60
```
---
## Voir les logs
```bash
cd /var/www/sirh
docker compose logs -f # tous les logs
docker compose logs -f --tail=100 # 100 dernieres lignes
```
Logs Symfony :
```bash
docker compose exec app cat var/log/prod.log
```
---
## Migration depuis l'ancien deploiement (tar.gz)
Si l'application tourne deja en bare metal :
1. Installer Docker (voir pre-requis)
2. Creer le dossier `/var/www/sirh-docker/` (ne pas ecraser l'ancien)
3. Copier les fichiers existants :
```bash
cp /var/www/sirh/.env /var/www/sirh-docker/.env
cp -a /var/www/sirh/config/jwt /var/www/sirh-docker/config/jwt
cp -a /var/www/sirh/var/uploads /var/www/sirh-docker/uploads
```
4. Creer `docker-compose.yml` et `deploy.sh` dans `/var/www/sirh-docker/` (voir etape 4 ci-dessus)
5. Editer `/var/www/sirh-docker/.env` : changer `DATABASE_URL` pour utiliser `host.docker.internal` au lieu de `127.0.0.1`
6. Se connecter au registry Gitea (voir etape 3 ci-dessus)
7. Mettre a jour Nginx systeme avec la conf reverse proxy (voir etape 8 ci-dessus)
8. Arreter l'ancien PHP-FPM : `sudo systemctl stop php8.4-fpm`
9. Deployer : `cd /var/www/sirh-docker && ./deploy.sh`
10. Verifier que tout marche, puis renommer le dossier : `mv /var/www/sirh-docker /var/www/sirh`

View File

@@ -122,6 +122,10 @@ Documents complementaires:
- Semaine en déficit (heures travaillées < heures contrat):
- le déficit est déduit du cumul RTT : d'abord des heures à 50%, puis des heures à 25%
- si aucun solde 50% ni 25%, les heures à 25% deviennent négatives
- Contrats CUSTOM (heures hebdo ≠ 35h et ≠ 39h, hors INTERIM/FORFAIT):
- référence heures sup = heures contractuelles réelles (ex: 4h → référence 4h)
- pas de bonus 25% ni 50% : 1 heure sup = 1 heure de récupération
- le déficit (travail < contrat) ne génère pas de récup mais n'impacte pas le solde
- Nature `INTERIM`:
- pas de bonus 25%
- pas de bonus 50%
@@ -150,7 +154,7 @@ Documents complementaires:
- jour/nuit/atelier par jour + indicateurs repas/dîner/nuitée
- panier de nuit (PN): affiché par jour si (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit au moins 4h de travail entre 21h et 6h), et total hebdo dans la colonne Jour/Nuit sem.
- totaux hebdo: jour, nuit, atelier, total, compteurs petit déj/déjeuner/dîner/nuitée
- pas de calcul d'heures supplémentaires pour les conducteurs
- les conducteurs utilisent `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` pour le calcul RTT (au lieu des créneaux morning/afternoon/evening)
- Le flag `isDriver` est sur `EmployeeContractPeriod` (un employé peut changer de statut chauffeur selon la période)
- Exposé en API via un getter virtuel sur `Employee` (`employee:read`) qui résout depuis la période active
@@ -190,13 +194,14 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- Détail employé:
- onglet `Suivi contrat` avec affichage de l'historique des périodes de contrat
- chaque ligne expose: nature (`CDI`/`CDD`/`INTERIM`), contrat/temps de travail, date de début, date de fin (ou "En cours")
- action `Clôturer`:
- bouton actif uniquement s'il existe un contrat en cours non déjà clôturé à la date du jour
- action `Modifier` (clôture/solde de tout compte):
- bouton actif s'il existe un contrat en cours non clôturé, ou si le dernier contrat est terminé (sans contrat actif après)
- ouvre un drawer en lecture seule (type/temps de travail/date de début)
- champs saisissables:
- `contractEndDate` (prérempli à aujourd'hui)
- `contractEndDate` (prérempli à aujourd'hui si contrat en cours, à la date de fin existante si contrat terminé)
- `contractPaidLeaveSettled` (checkbox "Soldé dans le solde de tout compte")
- backend: en mode clôture, le flag `contractPaidLeaveSettled` est persisté sur la période clôturée
- cas du contrat déjà terminé: permet de modifier `paidLeaveSettled` et le commentaire sur le dernier contrat terminé (ex: solde de tout compte CDD)
- action `Ajouter`:
- conserve le flux d'ajout d'un nouveau contrat via drawer dédié
- disponible uniquement s'il n'y a pas de contrat en cours, ou si le contrat en cours a déjà une date de fin
@@ -227,6 +232,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- en cas de suspension en cours de mois, l'acquisition est proratisée en jours ouvrés (lun-ven hors fériés) travaillés / 22
- contrat `FORFAIT`:
- base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
- bonus weekend/férié: chaque jour travaillé un weekend ou jour férié donne 1 jour de congé supplémentaire (journée ≥ 5h = 1.0 jour, demi-journée > 0h et < 5h = 0.5 jour), sans plafond
- prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année
- reste à prendre: `acquis - absences` (toutes absences, demi-journées incluses)
- pas de samedi (`0`)
@@ -239,6 +245,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- pour `FORFAIT`:
- pris: basé sur toutes les absences (demi-journées incluses)
- restants = acquis - pris (borné à 0)
- paiement congés N-1: saisie RH via `PATCH /employees/{id}/paid-leave-days` (body: `paidLeaveDays`, `year`). Stocké dans `employee_leave_balances.paid_leave_days`. Les jours payés réduisent le stock N-1 **avant** l'attribution des jours pris : `disponible_N-1 = max(0, acquis_N-1 - payés)`, puis `pris_N-1 = min(disponible_N-1, total_pris)`, surplus pris basculé sur N. Reste à prendre N-1 = `max(0, disponible_N-1 - pris_N-1)`. Uniquement pour les contrats forfait.
- report annuel:
- le reliquat (`restants`) de l'exercice précédent est reporté dans les acquis de l'exercice courant
- pour `CDI`/`CDD` non forfait: report séparé jours + samedis
@@ -268,8 +275,9 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- total mensuel des minutes de récupération
- compteur global exercice = `report N-1 + acquis N`
- attribution mensuelle des semaines:
- une semaine ISO est affichée une seule fois, dans le mois qui contient le **samedi** de cette semaine
- si le weekend tombe en début de mois suivant, c'est le mois suivant qui porte la semaine
- une semaine ISO qui chevauche deux mois est affichée dans **les deux mois**, avec les valeurs réparties proportionnellement aux minutes travaillées de chaque portion
- le calcul des heures supplémentaires reste hebdomadaire (seuils 35h/39h/43h appliqués sur la semaine entière), seul l'affichage est scindé
- exemple: S14 lundi-mardi en mars, mercredi-dimanche en avril → la S14 apparaît en mars (avec la part des heures de lun-mar) et en avril (avec la part mer-dim)
- logique de calcul:
- base identique aux calculs d'heures supplémentaires de la vue semaine Heures
- minutes de récupération hebdomadaires = `HS totales + bonus 25% + bonus 50%`
@@ -366,7 +374,26 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- Justificatif Montant : upload via `/mileage_allowances/{id}/amount-receipt`, téléchargement via GET même URL
- La suppression d'un frais supprime les deux fichiers justificatifs du disque
## 13) Notifications
## 13) Observations
- Onglet "Observation" sur la fiche employé (icône `mdi:note-text-outline`)
- Entité `Observation` (table `observations`)
- Champs:
- `month` (mois, obligatoire)
- `content` (texte d'observation, obligatoire)
- Contrainte: une seule observation par mois par employé (unique sur `employee_id + month`)
- Tableau: colonnes Mois | Observation
- Drawer avec champs mois (`type="month"`) et textarea "Observation"
- CRUD standard: création, modification, suppression avec confirmation
## 14) Verrouillage utilisateur
- Champ `isLocked` (boolean, default false) sur l'entité `User`
- Un admin peut verrouiller/déverrouiller un utilisateur depuis la page Utilisateurs (checkbox dans le drawer)
- Un utilisateur verrouillé ne peut plus se connecter (vérification via `UserChecker` sur les firewalls `login` et `api`)
- Colonne "Statut" dans le tableau utilisateurs avec label "Actif" (vert) ou "Verrouillé" (rouge)
## 15) Notifications
- Icône cloche en topbar:
- badge = nombre de notifications non lues
@@ -379,3 +406,31 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- Une notification est créée uniquement quand un chef de site termine la validation complète:
- condition: plus aucune ligne `work_hours` du site à la date concernée avec `isSiteValid = false`
- destinataires: utilisateurs `ROLE_ADMIN`
## 16) Export PDF des heures annuelles
- Accessible depuis la fiche employé (bouton imprimante à droite du nom)
- Ouvre un drawer pour choisir l'année (civile, Jan-Déc)
- Génère un PDF avec le détail jour par jour des heures de l'employé
- Seuls les jours avec heures saisies ou absence sont affichés
### Colonnes selon le mode de suivi
- **TIME (non-chauffeur)**: Date | Absence | Début matin | Fin matin | Début après-midi | Fin après-midi | Début soir | Fin soir | Total
- **PRESENCE (forfait)**: Date | Absence | Présence matin | Présence après-midi | Total
- **Chauffeur**: Date | Absence | Heures jour | Heures nuit | Heures atelier | Total
### Changement de contrat en cours d'année
- Si l'employé change de mode de suivi (TIME/PRESENCE) ou de statut chauffeur en cours d'année, le PDF affiche des sections séparées avec les colonnes adaptées à chaque période
- Le nom du contrat est affiché en sous-titre de chaque section
### Calcul du total
- TIME non-chauffeur: somme des créneaux matin + après-midi + soir, plus minutes créditées des absences `countAsWorkedHours`
- Chauffeur: `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` + minutes créditées
- PRESENCE: 0.5 par demi-journée présente (matin/après-midi), max 1.0
### Nom du fichier
- Format: `{nom}_{prenom}_{annee}.pdf`

View File

@@ -32,9 +32,9 @@ Principe:
## 4) Attribution mensuelle des semaines
- une semaine ISO est affichee une seule fois, dans le mois qui contient le **samedi** de cette semaine
- si le weekend tombe en debut du mois suivant, c'est ce mois qui porte la semaine
- pas de prorata: la totalite des minutes de recuperation de la semaine est comptee dans un seul mois
- une semaine ISO qui chevauche deux mois est affichee dans **les deux mois**, avec les valeurs reparties proportionnellement aux minutes travaillees de chaque portion
- le calcul des heures supplementaires reste hebdomadaire (seuils 35h/39h/43h appliques sur la semaine entiere), seul l'affichage est scinde
- exemple: S14 lundi-mardi en mars, mercredi-dimanche en avril → la S14 apparait en mars (part lun-mar) et en avril (part mer-dim)
## 5) Table cible

View File

@@ -0,0 +1,67 @@
<template>
<AppDrawer v-model="drawerOpen" title="Export heures annuelles">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-year">
Année <span class="text-red-600">*</span>
</label>
<select
id="yearly-hours-year"
v-model="selectedYear"
:class="selectFieldClass"
>
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
</select>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
>
Imprimer
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
modelValue: boolean
employeeId: number
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'submit', year: number): void
}>()
const drawerOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const currentYear = new Date().getFullYear()
const years = Array.from({ length: 6 }, (_, i) => currentYear - i)
const selectedYear = ref(currentYear)
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
const selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`)
const handleSubmit = () => {
emit('submit', selectedYear.value)
}
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
selectedYear.value = currentYear
}
}
)
</script>

View File

@@ -32,6 +32,18 @@
<p v-if="isForfaitRule" class="col-start-3 p-[10px] border-r-primary-500 bg-primary-500 text-white"><span class="uppercase font-semibold">Reste à prendre :</span>
{{ formatCount(summary?.previousYearRemainingDays) }} Jours
</p>
<div v-if="isForfaitRule" class="col-start-4 p-[10px] flex gap-7 items-center">
<div>
<span class="uppercase font-semibold">Année n-1 payés : </span>
<span> {{ formatCount(summary?.previousYearPaidDays) }} Jours</span>
</div>
<button
class="flex items-center"
@click="openPaidLeaveDrawer"
>
<Icon name="mdi:edit-box" size="24"/>
</button>
</div>
<div v-if="!isForfaitRule" class="col-start-4 p-[10px] flex gap-7 items-center">
<div>
<span class="uppercase font-semibold">Fractionné acquis : </span>
@@ -112,6 +124,39 @@
</div>
</form>
</AppDrawer>
<AppDrawer v-model="isPaidLeaveDrawerOpen" title="Congés N-1 payés">
<form class="space-y-4" @submit.prevent="handleSubmitPaidLeave">
<div>
<label class="text-md font-semibold text-neutral-700" for="paid-leave-days">
Nombre de jours <span class="text-red-600">*</span>
</label>
<input
id="paid-leave-days"
v-model="paidLeaveForm.days"
type="number"
step="0.5"
min="0"
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 class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="isPaidLeaveDrawerOpen = false"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
@@ -136,11 +181,15 @@ const props = defineProps<{
const emit = defineEmits<{
(event: 'update-fractioned-days', days: number): void
(event: 'update-paid-leave-days', days: number): void
}>()
const isFractionedDrawerOpen = ref(false)
const fractionedForm = reactive({days: 0})
const isPaidLeaveDrawerOpen = ref(false)
const paidLeaveForm = reactive({days: 0})
const openFractionedDrawer = () => {
fractionedForm.days = props.summary?.fractionedDays ?? 0
isFractionedDrawerOpen.value = true
@@ -153,6 +202,18 @@ const handleSubmitFractioned = () => {
isFractionedDrawerOpen.value = false
}
const openPaidLeaveDrawer = () => {
paidLeaveForm.days = props.summary?.previousYearPaidDays ?? 0
isPaidLeaveDrawerOpen.value = true
}
const handleSubmitPaidLeave = () => {
const value = Number(paidLeaveForm.days)
if (Number.isNaN(value) || value < 0) return
emit('update-paid-leave-days', value)
isPaidLeaveDrawerOpen.value = false
}
const monthLabels = [
'Janvier',
'Fevrier',

View File

@@ -0,0 +1,187 @@
<template>
<section class="mt-8">
<div class="overflow-hidden bg-white">
<div
class="grid grid-cols-2 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
<p>Mois</p>
<p>Observation</p>
</div>
<div v-if="observations.length === 0" class="px-6 py-4 text-[20px] font-bold text-primary-500 border-x border-b border-primary-500 rounded-b-md">
Aucune observation.
</div>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="item in observations"
:key="item.id"
class="grid grid-cols-2 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="onOpenEditDrawer(item)"
>
<p>{{ formatMonth(item.month) }}</p>
<p class="truncate">{{ item.content }}</p>
</div>
</div>
</div>
<div class="flex justify-center mb-4 mt-8">
<button
type="button"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
@click="onOpenCreateDrawer"
>
+ Ajouter
</button>
</div>
<AppDrawer v-model="isDrawerOpen" :title="isEditing ? 'Modification observation' : 'Nouvelle observation'">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="observation-month">
Mois <span class="text-red-600">*</span>
</label>
<input
id="observation-month"
v-model="form.month"
type="month"
class="capitalize mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base 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" for="observation-content">
Observation <span class="text-red-600">*</span>
</label>
<textarea
id="observation-content"
v-model="form.content"
rows="5"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
placeholder="Observation..."
/>
</div>
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
<button
type="button"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
@click="onDelete"
>
Supprimer
</button>
<button
type="submit"
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
Modifier
</button>
</div>
<div v-else class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
+ Ajouter
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
<script setup lang="ts">
import type { Observation } from '~/services/dto/observation'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
observations: Observation[]
}>()
const emit = defineEmits<{
(event: 'create', data: { month: string; content: string }): void
(event: 'update', id: number, data: { month: string; content: string }): void
(event: 'delete', id: number): void
}>()
const isDrawerOpen = ref(false)
const isEditing = ref(false)
const editingItem = ref<Observation | null>(null)
const currentYearMonth = () => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
const form = reactive({
month: currentYearMonth(),
content: ''
})
const isFormValid = computed(() => {
return form.month && form.content.trim().length > 0
})
const monthLabels: Record<number, string> = {
1: 'Janvier',
2: 'Février',
3: 'Mars',
4: 'Avril',
5: 'Mai',
6: 'Juin',
7: 'Juillet',
8: 'Août',
9: 'Septembre',
10: 'Octobre',
11: 'Novembre',
12: 'Décembre'
}
const formatMonth = (dateStr: string): string => {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return dateStr
const month = date.getMonth() + 1
const year = date.getFullYear()
return `${monthLabels[month]} ${year}`
}
const resetForm = () => {
form.month = currentYearMonth()
form.content = ''
}
const onOpenCreateDrawer = () => {
isEditing.value = false
editingItem.value = null
resetForm()
isDrawerOpen.value = true
}
const onOpenEditDrawer = (item: Observation) => {
isEditing.value = true
editingItem.value = item
form.month = item.month.substring(0, 7)
form.content = item.content
isDrawerOpen.value = true
}
const onSubmit = () => {
const data = {
month: `${form.month}-01`,
content: form.content
}
if (isEditing.value && editingItem.value) {
emit('update', editingItem.value.id, data)
} else {
emit('create', data)
}
isDrawerOpen.value = false
}
const onDelete = () => {
if (!editingItem.value) return
const ok = window.confirm('Supprimer cette observation ?')
if (!ok) return
emit('delete', editingItem.value.id)
isDrawerOpen.value = false
}
</script>

View File

@@ -71,6 +71,17 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
})
const lastEndedContractPeriod = computed(() => {
if (currentActiveContractPeriod.value) return null
const today = getTodayYmd()
const history = employee.value?.contractHistory ?? []
const ended = history.filter((item) => item.endDate && item.endDate < today)
if (ended.length === 0) return null
return ended.reduce((latest, item) => (item.endDate! > latest.endDate! ? item : latest))
})
const editableContractPeriod = computed(() => currentActiveContractPeriod.value ?? lastEndedContractPeriod.value)
const currentActiveContractPeriodId = computed<number | null>(() => {
const period = currentActiveContractPeriod.value
return period?.periodId ?? null
@@ -78,13 +89,15 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
const canCloseCurrentContract = computed(() => {
const active = currentActiveContractPeriod.value
if (!active) return false
if (!active.endDate) return true
return active.endDate > getTodayYmd()
if (active) {
if (!active.endDate) return true
return active.endDate > getTodayYmd()
}
return !!lastEndedContractPeriod.value
})
const canCreateContract = computed(() => {
const active = currentActiveContractPeriod.value
const active = editableContractPeriod.value
if (!active) return true
return !!active.endDate
})
@@ -135,15 +148,15 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
const hydrateContractFormFromCurrent = () => {
const current = employee.value
const active = currentActiveContractPeriod.value
if (!current || !active) return
const period = editableContractPeriod.value
if (!current || !period) return
contractForm.contractId = active.contractId ?? current.contract?.id ?? ''
contractForm.contractName = active.contractName ?? current.contract?.name ?? ''
contractForm.weeklyHours = active.weeklyHours ?? current.contract?.weeklyHours ?? null
contractForm.contractNature = active.contractNature
contractForm.startDate = active.startDate
contractForm.endDate = getTodayYmd()
contractForm.contractId = period.contractId ?? current.contract?.id ?? ''
contractForm.contractName = period.contractName ?? current.contract?.name ?? ''
contractForm.weeklyHours = period.weeklyHours ?? current.contract?.weeklyHours ?? null
contractForm.contractNature = period.contractNature
contractForm.startDate = period.startDate
contractForm.endDate = period.endDate ?? getTodayYmd()
contractForm.paidLeaveSettled = false
contractForm.comment = ''
}
@@ -173,8 +186,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
createContractForm.contractNature = 'CDI'
createContractForm.endDate = ''
createContractForm.isDriver = false
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
createContractForm.startDate = editableContractPeriod.value?.endDate
? (shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate)
: getTodayYmd()
resetCreateValidation()
isCreateContractDrawerOpen.value = true
@@ -185,15 +198,16 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
}
const submitContractUpdate = async () => {
if (!employee.value || isContractSubmitting.value || !currentActiveContractPeriod.value) return
const period = editableContractPeriod.value
if (!employee.value || isContractSubmitting.value || !period) return
validationTouched.endDate = true
if (!isContractEndDateValid.value) return
if (contractForm.endDate < currentActiveContractPeriod.value.startDate) {
if (contractForm.endDate < period.startDate) {
toast.error({
title: 'Erreur',
message: `La date de fin doit être postérieure au ${formatDate(currentActiveContractPeriod.value.startDate)}.`
message: `La date de fin doit être postérieure au ${formatDate(period.startDate)}.`
})
return
}
@@ -226,8 +240,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
createValidationTouched.endDate = true
if (!isCreateContractFormValid.value) return
if (currentActiveContractPeriod.value?.endDate) {
const minStartDate = shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate
if (editableContractPeriod.value?.endDate) {
const minStartDate = shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate
if (createContractForm.startDate < minStartDate) {
toast.error({
title: 'Erreur',

View File

@@ -6,7 +6,7 @@ export const useEmployeeDetailPage = () => {
const route = useRoute()
const employee = ref<Employee | null>(null)
const isLoading = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'bonus'>('contract')
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'bonus' | 'observation'>('contract')
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
@@ -40,6 +40,7 @@ export const useEmployeeDetailPage = () => {
rtt.resetLoaded()
mileage.resetLoaded()
bonus.resetLoaded()
observation.resetLoaded()
if (activeTab.value === 'leave' && showLeaveTab.value) {
await leave.loadLeaveData()
@@ -49,6 +50,8 @@ export const useEmployeeDetailPage = () => {
await mileage.loadMileageData()
} else if (activeTab.value === 'bonus') {
await bonus.loadBonusData()
} else if (activeTab.value === 'observation') {
await observation.loadObservationData()
}
} finally {
isLoading.value = false
@@ -60,6 +63,7 @@ export const useEmployeeDetailPage = () => {
const rtt = useEmployeeRtt(employee, loadEmployee)
const mileage = useEmployeeMileage(employee, loadEmployee)
const bonus = useEmployeeBonus(employee, loadEmployee)
const observation = useEmployeeObservation(employee, loadEmployee)
watch(activeTab, (tab) => {
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
@@ -70,6 +74,8 @@ export const useEmployeeDetailPage = () => {
mileage.loadMileageData()
} else if (tab === 'bonus' && !bonus.bonusDataLoaded.value) {
bonus.loadBonusData()
} else if (tab === 'observation' && !observation.observationDataLoaded.value) {
observation.loadObservationData()
}
})
@@ -89,6 +95,7 @@ export const useEmployeeDetailPage = () => {
...leave,
...rtt,
...mileage,
...bonus
...bonus,
...observation
}
}

View File

@@ -4,7 +4,7 @@ import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary
import type { Employee } from '~/services/dto/employee'
import { CONTRACT_TYPES } from '~/services/dto/contract'
import { listAbsences } from '~/services/absences'
import { getEmployeeLeaveSummary, updateFractionedDays } from '~/services/employee-leave-summary'
import { getEmployeeLeaveSummary, updateFractionedDays, updatePaidLeaveDays } from '~/services/employee-leave-summary'
import { listPublicHolidays } from '~/services/public-holidays'
export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
@@ -57,6 +57,13 @@ export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee:
await reloadEmployee()
}
const submitPaidLeaveDays = async (days: number) => {
if (!employee.value) return
const year = leaveSummary.value?.year ?? undefined
await updatePaidLeaveDays(employee.value.id, days, year)
await reloadEmployee()
}
return {
employeeAbsences,
leaveSummary,
@@ -65,6 +72,7 @@ export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee:
leaveDataLoaded,
loadLeaveData,
resetLoaded,
submitFractionedDays
submitFractionedDays,
submitPaidLeaveDays
}
}

View File

@@ -0,0 +1,61 @@
import type { Ref } from 'vue'
import type { Observation } from '~/services/dto/observation'
import type { Employee } from '~/services/dto/employee'
import {
listObservations,
createObservation,
updateObservation,
deleteObservation
} from '~/services/observations'
export const useEmployeeObservation = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const observations = ref<Observation[]>([])
const isObservationLoading = ref(false)
const observationDataLoaded = ref(false)
const loadObservationData = async () => {
if (!employee.value || isObservationLoading.value) return
isObservationLoading.value = true
try {
observations.value = await listObservations(employee.value.id)
observationDataLoaded.value = true
} finally {
isObservationLoading.value = false
}
}
const resetLoaded = () => {
observationDataLoaded.value = false
}
const submitCreateObservation = async (data: { month: string; content: string }) => {
if (!employee.value) return
await createObservation({
employeeId: employee.value.id,
month: data.month,
content: data.content
})
await reloadEmployee()
}
const submitUpdateObservation = async (id: number, data: { month: string; content: string }) => {
await updateObservation(id, data)
await reloadEmployee()
}
const submitDeleteObservation = async (id: number) => {
await deleteObservation(id)
await reloadEmployee()
}
return {
observations,
isObservationLoading,
observationDataLoaded,
loadObservationData,
resetLoaded,
submitCreateObservation,
submitUpdateObservation,
submitDeleteObservation
}
}

View File

@@ -46,6 +46,11 @@
"create": "Impossible de créer la prime.",
"update": "Impossible de mettre à jour la prime.",
"delete": "Impossible de supprimer la prime."
},
"observation": {
"create": "Impossible de créer l'observation.",
"update": "Impossible de mettre à jour l'observation.",
"delete": "Impossible de supprimer l'observation."
}
},
"success": {
@@ -87,6 +92,11 @@
"create": "Prime créée.",
"update": "Prime mise à jour.",
"delete": "Prime supprimée."
},
"observation": {
"create": "Observation créée.",
"update": "Observation mise à jour.",
"delete": "Observation supprimée."
}
}
}

View File

@@ -19,6 +19,7 @@
</NuxtLink>
</template>
<NuxtLink
v-if="isAdmin || !isDriver"
to="/hours"
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="[
@@ -30,12 +31,13 @@
<p>Heures</p>
</NuxtLink>
<NuxtLink
v-if="isAdmin"
v-if="isAdmin || isDriver"
to="/driver-hours"
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/driver-hours')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
:class="[
route.path.startsWith('/driver-hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
!isAdmin && isDriver ? 'border-t border-secondary-500 pt-3' : ''
]"
>
<Icon name="mdi:truck-outline" size="24"/>
<p>Heures Conducteurs</p>
@@ -82,6 +84,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 +116,7 @@
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 isDriver = computed(() => auth.user?.isDriver ?? false)
const route = useRoute()
</script>

View 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('/')
}
})

View File

@@ -0,0 +1,254 @@
<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>
<option value="paid_leave_days">Congés N-1 payé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.',
paid_leave_days: 'Congés payés',
}
return map[type] ?? type
}
onMounted(async () => {
employees.value = await listEmployees()
await loadLogs()
})
</script>

View File

@@ -13,7 +13,16 @@
<div v-else class="flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between">
<div>
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
<div class="flex items-center gap-4">
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
<button
class="inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 focus:outline-none focus-visible:ring-2 bg-primary-500 hover:bg-secondary-500 active:bg-primary-500 text-white cursor-pointer"
title="Export heures annuelles"
@click="isYearlyHoursDrawerOpen = true"
>
<Icon name="mdi:printer" size="24" />
</button>
</div>
<p>Date d'entrée : {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
</div>
<div class="text-right">
@@ -75,6 +84,16 @@
<Icon name="mdi:money-100" size="24" class="align-self"/>
Prime
</button>
<button
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'observation'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'observation'"
>
<Icon name="mdi:note-text-outline" size="24" class="align-self"/>
Observation
</button>
</div>
</div>
<div class="min-h-0 flex-1">
@@ -129,6 +148,7 @@
:summary="leaveSummary"
:public-holidays="publicHolidays"
@update-fractioned-days="submitFractionedDays"
@update-paid-leave-days="submitPaidLeaveDays"
/>
</div>
<div v-else-if="showRttTab && activeTab === 'rtt'" class="h-full">
@@ -164,12 +184,39 @@
@delete="submitDeleteBonus"
/>
</div>
<div v-else-if="activeTab === 'observation'" class="h-full">
<div v-if="isObservationLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesObservationTab
v-else
class="h-full"
:observations="observations"
@create="submitCreateObservation"
@update="submitUpdateObservation"
@delete="submitDeleteObservation"
/>
</div>
</div>
</div>
<EmployeeYearlyHoursDrawer
v-if="employee"
v-model="isYearlyHoursDrawerOpen"
:employee-id="employee.id"
@submit="handleYearlyHoursPrint"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import EmployeeYearlyHoursDrawer from '~/components/EmployeeYearlyHoursDrawer.vue'
import { usePdfPrinter } from '~/composables/usePdfPrinter'
const { printPdf } = usePdfPrinter()
const isYearlyHoursDrawerOpen = ref(false)
const {
employee,
isLoading,
@@ -213,6 +260,7 @@ const {
submitContractUpdate,
submitCreateContract,
submitFractionedDays,
submitPaidLeaveDays,
submitRttPayment,
suspensionForms,
isSuspensionSubmitting,
@@ -231,9 +279,20 @@ const {
isBonusLoading,
submitCreateBonus,
submitUpdateBonus,
submitDeleteBonus
submitDeleteBonus,
observations,
isObservationLoading,
submitCreateObservation,
submitUpdateObservation,
submitDeleteObservation
} = useEmployeeDetailPage()
const handleYearlyHoursPrint = async (year: number) => {
if (!employee.value) return
await printPdf(`/yearly-hours/print?employeeId=${employee.value.id}&year=${year}`)
isYearlyHoursDrawerOpen.value = false
}
useHead(() => ({
title: employee.value
? `${employee.value.firstName} ${employee.value.lastName}`

View File

@@ -69,7 +69,8 @@ const handleSubmit = async () => {
await auth.login(username.value, password.value)
const isAdmin = auth.user?.roles?.includes('ROLE_ADMIN')
await router.push(isAdmin ? '/calendar' : '/hours')
const isDriver = auth.user?.isDriver
await router.push(isAdmin ? '/calendar' : isDriver ? '/driver-hours' : '/hours')
} finally {
isSubmitting.value = false
}

View File

@@ -19,11 +19,12 @@
</div>
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
<div class="grid grid-cols-4 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">
<div class="grid grid-cols-5 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 class="text-left">Utilisateur</span>
<span class="text-left">Employé</span>
<span class="text-left">Accès</span>
<span class="text-left">Sites</span>
<span class="text-left">Statut</span>
</div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
Chargement...
@@ -32,7 +33,7 @@
<div
v-for="user in users"
:key="user.id"
class="grid grid-cols-4 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"
class="grid grid-cols-5 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="openEdit(user)"
>
<span>{{ user.username }}</span>
@@ -41,6 +42,16 @@
</span>
<span>{{ getAccessLabel(user) }}</span>
<span>{{ getSiteLabels(user) }}</span>
<span>
<span
v-if="user.isLocked"
class="inline-block rounded-full bg-red-100 px-3 py-1 text-sm font-semibold text-red-700"
>Verrouillé</span>
<span
v-else
class="inline-block rounded-full bg-green-100 px-3 py-1 text-sm font-semibold text-green-700"
>Actif</span>
</span>
</div>
</div>
</div>
@@ -164,6 +175,20 @@
</p>
</div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="form.isLocked"
type="checkbox"
class="cursor-pointer"
/>
<span class="text-md font-semibold text-neutral-700">Verrouiller le compte</span>
</label>
<p class="mt-1 text-sm text-neutral-500">
Un compte verrouillé ne peut plus se connecter.
</p>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
@@ -207,7 +232,8 @@ const form = reactive({
password: '',
accessMode: 'admin' as 'admin' | 'self' | 'sites',
employeeId: '' as number | '',
siteIds: [] as number[]
siteIds: [] as number[],
isLocked: false
})
const validationTouched = reactive({
@@ -318,6 +344,7 @@ const resetForm = () => {
form.employeeId = ''
form.accessMode = 'admin'
form.siteIds = []
form.isLocked = false
editingUser.value = null
validationTouched.username = false
validationTouched.password = false
@@ -345,6 +372,7 @@ const openEdit = (user: User) => {
}
form.employeeId = user.employee?.id ?? ''
form.isLocked = user.isLocked
const siteRoles = userAccessById.value.get(user.id) ?? []
form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number')
@@ -398,7 +426,8 @@ const handleSubmit = async () => {
username: form.username,
plainPassword: form.password.trim() ? form.password : undefined,
roles,
employeeId
employeeId,
isLocked: form.isLocked
})
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
@@ -422,7 +451,8 @@ const handleSubmit = async () => {
username: form.username,
plainPassword: form.password,
roles,
employeeId
employeeId,
isLocked: form.isLocked
})
if (form.accessMode === 'sites' && form.siteIds.length > 0) {

View 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 }
)
}

View 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
}

View File

@@ -13,6 +13,7 @@ export type EmployeeLeaveSummary = {
previousYearAcquiredDays: number
previousYearTakenDays: number
previousYearRemainingDays: number
previousYearPaidDays: number
presenceDaysByMonth: Record<string, number>
}

View File

@@ -0,0 +1,6 @@
export type Observation = {
id: number
month: string
content: string
createdAt: string
}

View File

@@ -2,4 +2,5 @@ export type UserData = {
id: number
username: string
roles: string[]
isDriver: boolean
}

View File

@@ -4,5 +4,6 @@ export type User = {
id: number
username: string
roles: string[]
isLocked: boolean
employee?: Employee | null
}

View File

@@ -16,3 +16,11 @@ export const updateFractionedDays = async (employeeId: number, fractionedDays: n
return api.patch(`/employees/${employeeId}/fractioned-days`, body)
}
export const updatePaidLeaveDays = async (employeeId: number, paidLeaveDays: number, year?: number) => {
const api = useApi()
const body: Record<string, unknown> = { paidLeaveDays }
if (year) body.year = year
return api.patch(`/employees/${employeeId}/paid-leave-days`, body)
}

View File

@@ -0,0 +1,50 @@
import type { Observation } from './dto/observation'
import { extractItems } from '~/utils/api'
export const listObservations = async (employeeId: number) => {
const api = useApi()
const data = await api.get<Observation[] | { 'hydra:member'?: Observation[] }>(
'/observations',
{ employee: `/api/employees/${employeeId}` },
{ toast: false }
)
return extractItems<Observation>(data)
}
export const createObservation = async (data: {
employeeId: number
month: string
content: string
}) => {
const api = useApi()
return api.post<Observation>('/observations', {
employee: `/api/employees/${data.employeeId}`,
month: data.month,
content: data.content
}, {
toastSuccessKey: 'success.observation.create',
toastErrorKey: 'errors.observation.create'
})
}
export const updateObservation = async (id: number, data: {
month: string
content: string
}) => {
const api = useApi()
return api.patch<Observation>(`/observations/${id}`, {
month: data.month,
content: data.content
}, {
toastSuccessKey: 'success.observation.update',
toastErrorKey: 'errors.observation.update'
})
}
export const deleteObservation = async (id: number) => {
const api = useApi()
return api.delete(`/observations/${id}`, {}, {
toastSuccessKey: 'success.observation.delete',
toastErrorKey: 'errors.observation.delete'
})
}

View File

@@ -16,6 +16,7 @@ export const createUser = async (payload: {
plainPassword: string
roles: string[]
employeeId?: number | null
isLocked?: boolean
}) => {
const api = useApi()
return api.post<User>(
@@ -24,7 +25,8 @@ export const createUser = async (payload: {
username: payload.username,
plainPassword: payload.plainPassword,
roles: payload.roles,
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
isLocked: payload.isLocked ?? false
},
{
toastSuccessKey: 'success.user.create',
@@ -38,12 +40,14 @@ export const updateUser = async (id: number, payload: {
plainPassword?: string
roles: string[]
employeeId?: number | null
isLocked?: boolean
}) => {
const api = useApi()
const body: Record<string, unknown> = {
username: payload.username,
roles: payload.roles,
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
isLocked: payload.isLocked ?? false
}
if (payload.plainPassword) {

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260325081258 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create observations table with unique constraint on (employee_id, month)';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE observations (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, employee_id INT NOT NULL, month DATE NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_BBC15BA88C03F15C ON observations (employee_id)');
$this->addSql('CREATE UNIQUE INDEX uniq_observation_employee_month ON observations (employee_id, month)');
$this->addSql('ALTER TABLE observations ADD CONSTRAINT FK_BBC15BA88C03F15C FOREIGN KEY (employee_id) REFERENCES employees (id) NOT DEFERRABLE');
$this->addSql("COMMENT ON COLUMN observations.month IS '(DC2Type:date_immutable)'");
$this->addSql("COMMENT ON COLUMN observations.created_at IS '(DC2Type:datetime_immutable)'");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE observations DROP CONSTRAINT FK_BBC15BA88C03F15C');
$this->addSql('DROP TABLE observations');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260325084215 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add is_locked column to users table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE users ADD is_locked BOOLEAN DEFAULT false NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE users DROP is_locked');
}
}

View 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');
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260402064647 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add paid_leave_days column to employee_leave_balances for forfait N-1 leave payment';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_leave_balances ADD paid_leave_days DOUBLE PRECISION DEFAULT 0 NOT NULL');
$this->addSql("COMMENT ON COLUMN employee_leave_balances.paid_leave_days IS 'Jours de conges N-1 payes par la RH (forfait uniquement).'");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_leave_balances DROP paid_leave_days');
}
}

View File

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

View File

@@ -34,6 +34,7 @@ final class EmployeeLeaveSummary
public float $previousYearAcquiredDays = 0.0;
public float $previousYearTakenDays = 0.0;
public float $previousYearRemainingDays = 0.0;
public float $previousYearPaidDays = 0.0;
/** @var array<string, float> YYYY-MM => count (0.5 for half-days) */
public array $presenceDaysByMonth = [];

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Patch;
use App\State\EmployeePaidLeaveDaysProcessor;
use App\State\EmployeePaidLeaveDaysProvider;
#[ApiResource(
operations: [
new Patch(
uriTemplate: '/employees/{id}/paid-leave-days',
security: "is_granted('ROLE_ADMIN')",
provider: EmployeePaidLeaveDaysProvider::class,
processor: EmployeePaidLeaveDaysProcessor::class
),
],
paginationEnabled: false
)]
final class EmployeePaidLeaveDaysInput
{
public float $paidLeaveDays = 0.0;
public ?int $year = null;
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\QueryParameter;
use App\State\EmployeeYearlyHoursPrintProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/yearly-hours/print',
provider: EmployeeYearlyHoursPrintProvider::class,
parameters: [
new QueryParameter(key: 'employeeId', required: true),
new QueryParameter(key: 'year', required: true),
],
security: "is_granted('ROLE_USER')"
),
]
)]
final class EmployeeYearlyHoursPrint {}

View File

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

View File

@@ -6,6 +6,9 @@ namespace App\Dto\Rtt;
final class WeekRecoveryDetail
{
/**
* @param array<string, int> $dailyMinutes date (Y-m-d) => worked minutes
*/
public function __construct(
public int $overtimeMinutes = 0,
public int $base25Minutes = 0,
@@ -13,5 +16,6 @@ final class WeekRecoveryDetail
public int $base50Minutes = 0,
public int $bonus50Minutes = 0,
public int $totalMinutes = 0,
public array $dailyMinutes = [],
) {}
}

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

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

View File

@@ -57,6 +57,9 @@ class EmployeeLeaveBalance
#[ORM\Column(type: 'float', options: ['default' => 0, 'comment' => 'Jours de fractionnement saisis par la RH.'])]
private float $fractionedDays = 0.0;
#[ORM\Column(type: 'float', options: ['default' => 0, 'comment' => 'Jours de conges N-1 payes par la RH (forfait uniquement).'])]
private float $paidLeaveDays = 0.0;
#[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Indique si le solde de l exercice est fige (verrouille RH).'])]
private bool $isLocked = false;
@@ -222,6 +225,18 @@ class EmployeeLeaveBalance
return $this;
}
public function getPaidLeaveDays(): float
{
return $this->paidLeaveDays;
}
public function setPaidLeaveDays(float $paidLeaveDays): self
{
$this->paidLeaveDays = $paidLeaveDays;
return $this;
}
public function isLocked(): bool
{
return $this->isLocked;

130
src/Entity/Observation.php Normal file
View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\ObservationRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
security: "is_granted('ROLE_ADMIN')"
),
new GetCollection(
security: "is_granted('ROLE_ADMIN')"
),
new Post(
security: "is_granted('ROLE_ADMIN')"
),
new Patch(
security: "is_granted('ROLE_ADMIN')"
),
new Delete(
security: "is_granted('ROLE_ADMIN')"
),
],
normalizationContext: [
'groups' => ['observation:read', 'employee:read'],
'datetime_format' => 'Y-m-d',
],
denormalizationContext: [
'groups' => ['observation:write'],
'datetime_format' => 'Y-m-d',
],
order: ['month' => 'DESC'],
paginationEnabled: false,
)]
#[ApiFilter(DateFilter::class, properties: ['month'])]
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact'])]
#[ORM\Entity(repositoryClass: ObservationRepository::class)]
#[ORM\Table(name: 'observations')]
#[ORM\UniqueConstraint(name: 'uniq_observation_employee_month', columns: ['employee_id', 'month'])]
class Observation
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['observation:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['observation:read', 'observation:write'])]
private ?Employee $employee = null;
#[ORM\Column(type: 'date_immutable')]
#[Groups(['observation:read', 'observation:write'])]
private ?DateTimeImmutable $month = null;
#[ORM\Column(type: 'text')]
#[Groups(['observation:read', 'observation:write'])]
private string $content = '';
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['observation:read'])]
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 getMonth(): ?DateTimeImmutable
{
return $this->month;
}
public function setMonth(?DateTimeImmutable $month): self
{
$this->month = $month;
return $this;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): self
{
$this->content = $content;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -19,6 +19,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
#[ApiResource(
operations: [
@@ -84,6 +85,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Groups(['user:read', 'user:write'])]
private ?Employee $employee = null;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['user:read', 'user:write'])]
#[SerializedName('isLocked')]
private bool $isLocked = false;
/**
* @var Collection<int, UserSiteRole>
*/
@@ -204,5 +210,25 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
#[Groups(['user:read'])]
#[SerializedName('isLocked')]
public function isLocked(): bool
{
return $this->isLocked;
}
public function setIsLocked(bool $isLocked): self
{
$this->isLocked = $isLocked;
return $this;
}
#[Groups(['user:read'])]
public function getIsDriver(): bool
{
return $this->employee?->getIsDriver() ?? false;
}
public function eraseCredentials(): void {}
}

View File

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

View File

@@ -11,4 +11,6 @@ use DateTimeImmutable;
interface EmployeeContractPeriodReadRepositoryInterface
{
public function findOneCoveringDate(Employee $employee, DateTimeImmutable $date): ?EmployeeContractPeriod;
public function findLatestPeriod(Employee $employee): ?EmployeeContractPeriod;
}

View File

@@ -60,6 +60,18 @@ final class EmployeeContractPeriodRepository extends ServiceEntityRepository imp
;
}
public function findLatestPeriod(Employee $employee): ?EmployeeContractPeriod
{
return $this->createQueryBuilder('p')
->andWhere('p.employee = :employee')
->setParameter('employee', $employee)
->orderBy('p.startDate', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
public function closeOpenPeriods(Employee $employee, DateTimeImmutable $endDate): int
{
return $this->createQueryBuilder('p')

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Observation;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Observation>
*/
final class ObservationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Observation::class);
}
/**
* @return Observation[]
*/
public function findByMonth(DateTimeImmutable $from, DateTimeImmutable $to): array
{
return $this->createQueryBuilder('o')
->andWhere('o.month >= :from')
->andWhere('o.month <= :to')
->setParameter('from', $from)
->setParameter('to', $to)
->innerJoin('o.employee', 'e')
->addSelect('e')
->getQuery()
->getResult()
;
}
}

View File

@@ -191,6 +191,57 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
return $result;
}
/**
* Count weekend and public holiday worked days for forfait bonus leave (PRESENCE mode only).
* Morning + afternoon = 1.0 day, one only = 0.5 day.
*
* @param list<string> $publicHolidayDates Y-m-d formatted weekday public holiday dates
*/
public function countWeekendAndHolidayWorkedDays(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to, array $publicHolidayDates = []): float
{
$targetDates = [];
// Collect weekend dates in range
for ($cursor = $from; $cursor <= $to; $cursor = $cursor->modify('+1 day')) {
if ((int) $cursor->format('N') >= 6) {
$targetDates[] = $cursor;
}
}
// Add weekday public holidays
foreach ($publicHolidayDates as $date) {
$targetDates[] = new DateTimeImmutable($date);
}
if ([] === $targetDates) {
return 0.0;
}
$dateStrings = array_map(static fn (DateTimeImmutable $d): string => $d->format('Y-m-d'), $targetDates);
/** @var list<WorkHour> $rows */
$rows = $this->createQueryBuilder('w')
->andWhere('w.employee = :employee')
->andWhere('w.workDate IN (:dates)')
->andWhere('w.isPresentMorning = true OR w.isPresentAfternoon = true')
->setParameter('employee', $employee)
->setParameter('dates', $dateStrings)
->getQuery()
->getResult()
;
$total = 0.0;
foreach ($rows as $row) {
if ($row->isPresentMorning() && $row->isPresentAfternoon()) {
$total += 1.0;
} else {
$total += 0.5;
}
}
return $total;
}
/**
* Return the set of Y-m-d dates where the employee has worked hours on the given dates.
*
@@ -230,26 +281,36 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
public function isWeekFullyValidated(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): bool
{
// At least one validated day must exist
$validatedCount = (int) $this->createQueryBuilder('w')
// Count weekdays (Mon-Fri) in range
$expectedWeekdays = 0;
for ($d = $from; $d <= $to; $d = $d->modify('+1 day')) {
if ((int) $d->format('N') <= 5) {
++$expectedWeekdays;
}
}
if (0 === $expectedWeekdays) {
return false;
}
// Every weekday must have a work_hour row
$totalCount = (int) $this->createQueryBuilder('w')
->select('COUNT(w.id)')
->andWhere('w.employee = :employee')
->andWhere('w.workDate >= :from')
->andWhere('w.workDate <= :to')
->andWhere('w.isValid = :isValid')
->setParameter('employee', $employee)
->setParameter('from', $from)
->setParameter('to', $to)
->setParameter('isValid', true)
->getQuery()
->getSingleScalarResult()
;
if (0 === $validatedCount) {
if ($totalCount < $expectedWeekdays) {
return false;
}
// No non-validated day must exist in the range
// All rows must be validated
$nonValidatedCount = (int) $this->createQueryBuilder('w')
->select('COUNT(w.id)')
->andWhere('w.employee = :employee')

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
final class UserChecker implements UserCheckerInterface
{
public function checkPreAuth(UserInterface $user): void
{
if (!$user instanceof User) {
return;
}
if ($user->isLocked()) {
throw new CustomUserMessageAccountStatusException('Ce compte est verrouillé.');
}
}
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void {}
}

View File

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

View File

@@ -45,18 +45,21 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
?EmployeeContractPeriod $todayPeriod,
DateTimeImmutable $requestedEndDate,
bool $paidLeaveSettled,
?string $comment = null
?string $comment = null,
bool $isAlreadyEnded = false
): void {
if (null === $todayPeriod) {
throw new UnprocessableEntityHttpException('No active contract period to close.');
}
$this->periodValidator->assertCloseEndDateCanBeApplied(
$todayPeriod->getStartDate(),
$todayPeriod->getEndDate(),
$requestedEndDate,
$todayPeriod->getContractNatureEnum()
);
if (!$isAlreadyEnded) {
$this->periodValidator->assertCloseEndDateCanBeApplied(
$todayPeriod->getStartDate(),
$todayPeriod->getEndDate(),
$requestedEndDate,
$todayPeriod->getContractNatureEnum()
);
}
$todayPeriod->setEndDate($requestedEndDate);
$todayPeriod->setPaidLeaveSettled($paidLeaveSettled);

View File

@@ -25,7 +25,8 @@ interface EmployeeContractPeriodManagerInterface
?EmployeeContractPeriod $todayPeriod,
DateTimeImmutable $requestedEndDate,
bool $paidLeaveSettled,
?string $comment = null
?string $comment = null,
bool $isAlreadyEnded = false
): void;
public function createNextPeriod(

View File

@@ -46,7 +46,7 @@ final readonly class RttRecoveryComputationService
}
/**
* @return list<array{month:int,weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}>
* @return list<array{weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}>
*/
public function buildWeeksForExercise(DateTimeImmutable $from, DateTimeImmutable $to): array
{
@@ -61,10 +61,7 @@ final readonly class RttRecoveryComputationService
$effectiveEnd = $end > $to ? $to : $end;
if ($effectiveEnd >= $effectiveStart) {
$saturday = $start->modify('+5 days');
$monthAnchor = $saturday < $from ? $from : ($saturday > $to ? $to : $saturday);
$weeks[] = [
'month' => (int) $monthAnchor->format('n'),
$weeks[] = [
'weekNumber' => (int) $effectiveStart->format('W'),
'start' => $start,
'end' => $end,
@@ -82,7 +79,6 @@ final readonly class RttRecoveryComputationService
$weeks = $this->buildWeeksForExercise($from, $to);
$weekRanges = array_map(
static fn (array $week): array => [
'month' => (int) $week['month'],
'weekNumber' => (int) $week['weekNumber'],
'start' => $week['start'],
'end' => $week['end'],
@@ -108,7 +104,7 @@ final readonly class RttRecoveryComputationService
}
/**
* @param list<array{month:int,weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weeks
* @param list<array{weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weeks
*
* @return array<string, WeekRecoveryDetail>
*/
@@ -189,6 +185,7 @@ final readonly class RttRecoveryComputationService
}
$weeklyTotalMinutes = 0;
$dailyWorkedMinutes = [];
$employeeContractsByDate = [];
foreach ($weekDays as $date) {
$employeeContractsByDate[$date] = $contractsByDate[$employeeId][$date] ?? null;
@@ -198,6 +195,7 @@ final readonly class RttRecoveryComputationService
$metrics = $metricsByDate[$date] ?? new WorkMetrics();
$metrics->addCreditedMinutes($creditedByDate[$date] ?? 0);
$weeklyTotalMinutes += $metrics->totalMinutes;
$dailyWorkedMinutes[$date] = $metrics->totalMinutes;
}
if ([] === $weekDays) {
@@ -206,20 +204,36 @@ final readonly class RttRecoveryComputationService
continue;
}
$weekAnchorNature = $naturesByDate[$employeeId][$weekDays[0]] ?? ContractNature::CDI;
$weekAnchorContract = $employeeContractsByDate[$weekDays[0]] ?? null;
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature);
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
$weekAnchorNature = $naturesByDate[$employeeId][$weekDays[0]] ?? ContractNature::CDI;
$weekAnchorContract = $employeeContractsByDate[$weekDays[0]] ?? null;
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature);
$weekContractType = ContractType::resolve(
$weekAnchorContract?->getName(),
$weekAnchorContract?->getTrackingMode(),
$weekAnchorContract?->getWeeklyHours()
);
$isCustomContract = ContractType::CUSTOM === $weekContractType;
$overtimeReferenceMinutes = $isCustomContract
? $this->computeWeeklyCustomReferenceMinutes($weekDays, $employeeContractsByDate)
: $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate);
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
? 0
: $weeklyTotalMinutes - $overtimeReferenceMinutes;
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes);
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base25 * 0.25);
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60);
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base50 * 0.5);
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes);
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base25 * 0.25);
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60);
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base50 * 0.5);
if ($isWeekPresenceTracking || $disableOvertimeBonuses) {
$totalMinutes = 0;
} elseif ($isCustomContract) {
$totalMinutes = max(0, $weeklyOvertimeTotalMinutes);
} else {
$totalMinutes = $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50;
}
$results[$weekKey] = new WeekRecoveryDetail(
overtimeMinutes: $weeklyOvertimeTotalMinutes,
@@ -227,9 +241,8 @@ final readonly class RttRecoveryComputationService
bonus25Minutes: $bonus25,
base50Minutes: $base50,
bonus50Minutes: $bonus50,
totalMinutes: ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50,
totalMinutes: $totalMinutes,
dailyMinutes: $dailyWorkedMinutes,
);
}
@@ -238,6 +251,20 @@ final readonly class RttRecoveryComputationService
private function computeMetrics(WorkHour $workHour): WorkMetrics
{
$driverDay = $workHour->getDayHoursMinutes() ?? 0;
$driverNight = $workHour->getNightHoursMinutes() ?? 0;
$driverWorkshop = $workHour->getWorkshopHoursMinutes() ?? 0;
if ($driverDay > 0 || $driverNight > 0 || $driverWorkshop > 0) {
$totalMinutes = $driverDay + $driverNight + $driverWorkshop;
return new WorkMetrics(
dayMinutes: $driverDay + $driverWorkshop,
nightMinutes: $driverNight,
totalMinutes: $totalMinutes,
);
}
$ranges = [
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
@@ -326,6 +353,23 @@ final readonly class RttRecoveryComputationService
return max(0, $end - $start);
}
/**
* @param list<string> $days
* @param array<string, ?Contract> $contractsByDate
*/
private function computeWeeklyCustomReferenceMinutes(array $days, array $contractsByDate): int
{
$total = 0;
foreach ($days as $date) {
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$contract = $contractsByDate[$date] ?? null;
$hours = $contract?->getWeeklyHours();
$total += $this->resolveDailyReferenceMinutes($hours, $isoDay);
}
return $total;
}
/**
* @param list<string> $days
* @param array<string, ?Contract> $contractsByDate

View File

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

View File

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

View File

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

View File

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

View File

@@ -93,6 +93,16 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
}
$fractionedDays = $this->resolveFractionedDays($employee, $yearSummary['ruleCode'], $year);
$paidLeaveDays = $this->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $year);
// For forfait contracts, paid days reduce N-1 stock before taken-day attribution.
// Recompute with paidLeaveDays so taken days shift from N-1 to N when N-1 is consumed by payment.
if ($paidLeaveDays > 0.0) {
$yearSummary = $this->computeYearSummary($employee, $year, $paidLeaveDays);
if (null === $yearSummary) {
return $summary;
}
}
$summary->isSupported = true;
$summary->ruleCode = $yearSummary['ruleCode'];
@@ -107,6 +117,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$summary->previousYearAcquiredDays = $yearSummary['previousYearAcquiredDays'];
$summary->previousYearTakenDays = $yearSummary['previousYearTakenDays'];
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
$summary->previousYearPaidDays = $paidLeaveDays;
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo);
@@ -129,7 +140,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
* previousYearRemainingDays: float
* }
*/
public function computeYearSummary(Employee $employee, int $targetYear): ?array
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0): ?array
{
$firstYear = max($this->resolveFirstComputationYear($employee), $targetYear - 1);
if ($targetYear < $firstYear) {
@@ -269,13 +280,15 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
} else {
// Forfait: no "en cours d'acquisition" counter, all rights are in acquired.
// Suspensions do not impact forfait 218 leave calculation.
// Taken days are first deducted from N-1 carry, then from current year.
$previousYearAcquired = $carryDays;
$takenFromPrevious = min(max(0.0, $previousYearAcquired), $takenDays);
$previousYearTaken = $takenFromPrevious;
$takenFromCurrent = $takenDays - $takenFromPrevious;
// Paid days reduce N-1 stock first, then taken days are attributed to what remains in N-1.
$previousYearAcquired = $carryDays;
$effectivePaidDays = ($year === $targetYear) ? $paidLeaveDays : 0.0;
$availableAfterPayment = max(0.0, $previousYearAcquired - $effectivePaidDays);
$takenFromPrevious = min($availableAfterPayment, $takenDays);
$previousYearTaken = $takenFromPrevious;
$takenFromCurrent = $takenDays - $takenFromPrevious;
$previousYearRemaining = max(0.0, $previousYearAcquired - $takenFromPrevious);
$previousYearRemaining = max(0.0, $availableAfterPayment - $takenFromPrevious);
$acquiredDays = $leavePolicy['acquiredDays'];
$accruingDays = 0.0;
@@ -535,10 +548,21 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$type = $employee->getContract()?->getType();
if (ContractType::FORFAIT === $type) {
$businessDaysInPeriod = $this->countBusinessDays($from, $to);
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
$weekdayHolidays = array_filter(
array_keys($publicHolidays),
static fn (string $date): bool => (int) new DateTimeImmutable($date)->format('N') <= 5
);
$bonusDays = $this->workHourRepository->countWeekendAndHolidayWorkedDays(
$employee,
$from,
$to,
array_values($weekdayHolidays)
);
return [
'ruleCode' => LeaveRuleCode::FORFAIT_218->value,
'acquiredDays' => (float) max(0, $businessDaysInPeriod - self::FORFAIT_TARGET_WORKED_DAYS),
'acquiredDays' => (float) max(0, $businessDaysInPeriod - self::FORFAIT_TARGET_WORKED_DAYS) + $bonusDays,
'acquiredSaturdays' => 0.0,
'accrualPerMonth' => 0.0,
'saturdayAccrualPerMonth' => 0.0,
@@ -754,6 +778,13 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return null !== $balance ? $balance->getFractionedDays() : 0.0;
}
private function resolvePaidLeaveDays(Employee $employee, string $ruleCode, int $year): float
{
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
return null !== $balance ? $balance->getPaidLeaveDays() : 0.0;
}
private function resolveCurrentLeaveYear(DateTimeImmutable $today): int
{
$year = (int) $today->format('Y');

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\EmployeePaidLeaveDaysInput;
use App\Entity\Employee;
use App\Entity\EmployeeLeaveBalance;
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;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class EmployeePaidLeaveDaysProcessor implements ProcessorInterface
{
public function __construct(
private EmployeeRepository $employeeRepository,
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
private EntityManagerInterface $entityManager,
private AuditLogger $auditLogger,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeePaidLeaveDaysInput
{
if (!$data instanceof EmployeePaidLeaveDaysInput) {
throw new UnprocessableEntityHttpException('Invalid payload.');
}
$employeeId = (int) ($uriVariables['id'] ?? 0);
if ($employeeId <= 0) {
throw new UnprocessableEntityHttpException('id must be a positive integer.');
}
$employee = $this->employeeRepository->find($employeeId);
if (!$employee instanceof Employee) {
throw new NotFoundHttpException('Employee not found.');
}
$year = $data->year ?? $this->resolveCurrentYear($employee);
$ruleCode = $this->resolveRuleCode($employee);
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
if (null === $balance) {
$balance = new EmployeeLeaveBalance();
$balance->setEmployee($employee);
$balance->setRuleCode($ruleCode);
$balance->setYear($year);
$this->entityManager->persist($balance);
}
$balance->setPaidLeaveDays($data->paidLeaveDays);
$balance->touch();
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
$this->auditLogger->log(
$employee,
'update',
'paid_leave_days',
$balance->getId(),
sprintf('Congés N-1 payés modifiés pour %s (année %d) : %s', $empName, $year, (string) $data->paidLeaveDays),
['new' => ['paidLeaveDays' => $data->paidLeaveDays, 'year' => $year]],
);
$this->entityManager->flush();
$data->year = $year;
return $data;
}
private function resolveRuleCode(Employee $employee): LeaveRuleCode
{
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
return LeaveRuleCode::FORFAIT_218;
}
return LeaveRuleCode::CDI_CDD_NON_FORFAIT;
}
private function resolveCurrentYear(Employee $employee): int
{
$today = new DateTimeImmutable('today');
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
return (int) $today->format('Y');
}
$month = (int) $today->format('n');
return $month >= 6 ? (int) $today->format('Y') + 1 : (int) $today->format('Y');
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EmployeePaidLeaveDaysInput;
final readonly class EmployeePaidLeaveDaysProvider implements ProviderInterface
{
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeePaidLeaveDaysInput
{
return new EmployeePaidLeaveDaysInput();
}
}

View File

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

View File

@@ -71,7 +71,6 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
$weekRanges = array_map(
static fn (array $week): array => [
'month' => (int) $week['month'],
'weekNumber' => (int) $week['weekNumber'],
'start' => $week['start'],
'end' => $week['end'],
@@ -118,25 +117,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
$summary->rttStartDate = $this->rttStartDate;
}
}
$summary->weeks = array_map(
static function (array $week) use ($currentByWeekStart) {
$detail = $currentByWeekStart[$week['start']->format('Y-m-d')] ?? new WeekRecoveryDetail();
return new EmployeeRttWeekSummary(
month: (int) $week['month'],
weekNumber: (int) $week['weekNumber'],
weekStart: $week['start']->format('Y-m-d'),
weekEnd: $week['end']->format('Y-m-d'),
overtimeMinutes: $detail->overtimeMinutes,
base25Minutes: $detail->base25Minutes,
bonus25Minutes: $detail->bonus25Minutes,
base50Minutes: $detail->base50Minutes,
bonus50Minutes: $detail->bonus50Minutes,
totalMinutes: $detail->totalMinutes,
);
},
$weekRanges
);
$summary->weeks = $this->buildWeekSummaries($weekRanges, $currentByWeekStart, $periodFrom, $periodTo);
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%)
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
@@ -269,4 +250,77 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
return $weekEnd;
}
/**
* Build week summaries, splitting weeks that span two months into two entries
* with values distributed proportionally based on daily worked minutes.
*
* @param list<array{weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weekRanges
* @param array<string, WeekRecoveryDetail> $recoveryByWeek
*
* @return list<EmployeeRttWeekSummary>
*/
private function buildWeekSummaries(array $weekRanges, array $recoveryByWeek, DateTimeImmutable $periodFrom, DateTimeImmutable $periodTo): array
{
$result = [];
foreach ($weekRanges as $week) {
$weekStart = $week['start'];
$weekEnd = $week['end'];
$weekKey = $weekStart->format('Y-m-d');
$detail = $recoveryByWeek[$weekKey] ?? new WeekRecoveryDetail();
$effectiveStart = $weekStart < $periodFrom ? $periodFrom : $weekStart;
$effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd;
$startMonth = (int) $effectiveStart->format('n');
$endMonth = (int) $effectiveEnd->format('n');
if ($startMonth === $endMonth) {
$result[] = new EmployeeRttWeekSummary(
month: $startMonth,
weekNumber: (int) $week['weekNumber'],
weekStart: $weekStart->format('Y-m-d'),
weekEnd: $weekEnd->format('Y-m-d'),
overtimeMinutes: $detail->overtimeMinutes,
base25Minutes: $detail->base25Minutes,
bonus25Minutes: $detail->bonus25Minutes,
base50Minutes: $detail->base50Minutes,
bonus50Minutes: $detail->bonus50Minutes,
totalMinutes: $detail->totalMinutes,
);
continue;
}
// Week spans two months — split proportionally by daily worked minutes
$monthMinutes = [];
foreach ($detail->dailyMinutes as $date => $mins) {
$m = (int) new DateTimeImmutable($date)->format('n');
$monthMinutes[$m] = ($monthMinutes[$m] ?? 0) + $mins;
}
$totalWorked = array_sum($monthMinutes);
foreach ([$startMonth, $endMonth] as $month) {
$portion = $monthMinutes[$month] ?? 0;
$ratio = $totalWorked > 0 ? $portion / $totalWorked : 0.0;
$result[] = new EmployeeRttWeekSummary(
month: $month,
weekNumber: (int) $week['weekNumber'],
weekStart: $weekStart->format('Y-m-d'),
weekEnd: $weekEnd->format('Y-m-d'),
overtimeMinutes: (int) round($detail->overtimeMinutes * $ratio),
base25Minutes: (int) round($detail->base25Minutes * $ratio),
bonus25Minutes: (int) round($detail->bonus25Minutes * $ratio),
base50Minutes: (int) round($detail->base50Minutes * $ratio),
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
totalMinutes: (int) round($detail->totalMinutes * $ratio),
);
}
}
return $result;
}
}

View File

@@ -11,6 +11,7 @@ use App\Entity\Contract;
use App\Entity\Employee;
use App\Enum\ContractNature;
use App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface;
use App\Service\AuditLogger;
use App\Service\Contracts\EmployeeContractChangeRequestFactory;
use App\Service\Contracts\EmployeeContractPeriodManagerInterface;
use DateTimeImmutable;
@@ -29,6 +30,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
private EmployeeContractPeriodReadRepositoryInterface $periodRepository,
private EmployeeContractChangeRequestFactory $changeRequestFactory,
private EmployeeContractPeriodManagerInterface $periodManager,
private AuditLogger $auditLogger,
) {}
public function process(
@@ -72,6 +74,17 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
$data->setEntryDate($startDate);
$this->entityManager->flush();
$empName = trim(($data->getLastName() ?? '').' '.($data->getFirstName() ?? ''));
$this->auditLogger->log(
$data,
'create',
'employee',
$data->getId(),
sprintf('Employé %s créé (contrat: %s)', $empName, $currentContract->getName() ?? ''),
['new' => ['name' => $empName, 'contract' => $currentContract->getName(), 'nature' => $nature->value, 'startDate' => $startDate->format('d/m/Y')]],
);
$this->entityManager->flush();
return $result;
}
@@ -79,8 +92,20 @@ 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);
$currentPeriodContract = $todayPeriod?->getContract();
$effectivePeriod = $todayPeriod ?? $this->periodRepository->findLatestPeriod($data);
$currentPeriodContract = $effectivePeriod?->getContract();
$contractChanged = $currentPeriodContract instanceof Contract
? $currentPeriodContract->getId() !== $currentContract->getId()
: true;
@@ -91,25 +116,27 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
if (null === $requestedEndDate) {
throw new UnprocessableEntityHttpException('contractEndDate is required for close-only request.');
}
$isAlreadyEnded = null === $todayPeriod;
$this->periodManager->closeCurrentPeriod(
$todayPeriod,
$effectivePeriod,
$requestedEndDate,
$changeRequest->contractPaidLeaveSettled ?? false,
$changeRequest->contractComment
$changeRequest->contractComment,
$isAlreadyEnded
);
return $result;
}
$startDate = $changeRequest->contractStartDate ?? $today;
$nature = $changeRequest->contractNature ?? $todayPeriod?->getContractNatureEnum() ?? ContractNature::CDI;
$nature = $changeRequest->contractNature ?? $effectivePeriod?->getContractNatureEnum() ?? ContractNature::CDI;
$this->periodManager->createNextPeriod(
employee: $data,
contract: $currentContract,
startDate: $startDate,
endDate: $changeRequest->contractEndDate,
nature: $nature,
todayPeriod: $todayPeriod,
todayPeriod: $effectivePeriod,
isDriver: $changeRequest->isDriver ?? false,
);

View File

@@ -0,0 +1,420 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Employee;
use App\Entity\WorkHour;
use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateInterval;
use DateTimeImmutable;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Twig\Environment;
class EmployeeYearlyHoursPrintProvider implements ProviderInterface
{
public function __construct(
private Environment $twig,
private readonly RequestStack $requestStack,
private EmployeeRepository $employeeRepository,
private WorkHourRepository $workHourRepository,
private AbsenceRepository $absenceRepository,
private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
$request = $this->requestStack->getCurrentRequest();
if (!$request) {
return new Response('Missing request.', Response::HTTP_BAD_REQUEST);
}
$employeeId = (int) $request->query->get('employeeId', '0');
if ($employeeId <= 0) {
throw new UnprocessableEntityHttpException('employeeId must be a positive integer.');
}
$employee = $this->employeeRepository->find($employeeId);
if (!$employee instanceof Employee) {
throw new NotFoundHttpException('Employee not found.');
}
$yearRaw = (string) $request->query->get('year');
if (!preg_match('/^\d{4}$/', $yearRaw)) {
throw new UnprocessableEntityHttpException('year must use YYYY format.');
}
$year = (int) $yearRaw;
$from = new DateTimeImmutable("{$year}-01-01");
$to = new DateTimeImmutable("{$year}-12-31");
$days = $this->buildDays($from, $to);
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, [$employee]);
$absences = $this->absenceRepository->findForPrint($from, $to, [$employee]);
$contractMap = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days);
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays([$employee], $days);
$workHourMap = $this->buildWorkHourMap($workHours);
$absenceData = $this->buildAbsenceData($absences, $days, $employee);
$segments = $this->buildSegments(
$employee,
$days,
$contractMap[$employee->getId()] ?? [],
$driverMap[$employee->getId()] ?? [],
$workHourMap[$employee->getId()] ?? [],
$absenceData,
);
$employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
$options = new Options();
$options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options);
$html = $this->twig->render('employee-yearly-hours/print.html.twig', [
'employeeName' => $employeeName,
'year' => $year,
'segments' => $segments,
]);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
$filename = sprintf(
'%s_%s_%d.pdf',
$this->sanitizeFilename($employee->getLastName() ?? ''),
$this->sanitizeFilename($employee->getFirstName() ?? ''),
$year,
);
return new Response($dompdf->output(), Response::HTTP_OK, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
/**
* @return list<string>
*/
private function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$days = [];
$current = $from;
while ($current <= $to) {
$days[] = $current->format('Y-m-d');
$current = $current->add(new DateInterval('P1D'));
}
return $days;
}
/**
* @return array<int, array<string, WorkHour>>
*/
private function buildWorkHourMap(array $workHours): array
{
$map = [];
foreach ($workHours as $wh) {
$employeeId = $wh->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$date = $wh->getWorkDate()->format('Y-m-d');
$map[$employeeId][$date] = $wh;
}
return $map;
}
/**
* @return array{credited: array<string, int>, labels: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>}
*/
private function buildAbsenceData(array $absences, array $days, Employee $employee): array
{
$credited = [];
$labels = [];
$absentMorning = [];
$absentAfternoon = [];
$hasDayAbsence = [];
foreach ($absences as $absence) {
$absEmployeeId = $absence->getEmployee()?->getId();
if ($absEmployeeId !== $employee->getId()) {
continue;
}
$start = $absence->getStartDate()->format('Y-m-d');
$end = $absence->getEndDate()->format('Y-m-d');
foreach ($days as $date) {
if ($date < $start || $date > $end) {
continue;
}
[$isMorning, $isAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
if ($isMorning || $isAfternoon) {
$hasDayAbsence[$date] = true;
$absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning;
$absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon;
if (!isset($labels[$date])) {
$labels[$date] = $absence->getType()?->getLabel() ?? '';
}
}
$credited[$date] = ($credited[$date] ?? 0)
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $isMorning, $isAfternoon);
}
}
return [
'credited' => $credited,
'labels' => $labels,
'absentMorning' => $absentMorning,
'absentAfternoon' => $absentAfternoon,
'hasDayAbsence' => $hasDayAbsence,
];
}
/**
* @return list<array{mode: string, contractName: ?string, rows: list<array>}>
*/
private function buildSegments(
Employee $employee,
array $days,
array $contractsByDate,
array $driverByDate,
array $workHoursByDate,
array $absenceData,
): array {
$segments = [];
$currentMode = null;
$currentRows = [];
$currentName = null;
foreach ($days as $date) {
$contract = $contractsByDate[$date] ?? null;
$isDriver = $driverByDate[$date] ?? false;
$wh = $workHoursByDate[$date] ?? null;
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
if (!$hasData) {
continue;
}
$trackingMode = $contract?->getTrackingMode() ?? TrackingMode::TIME->value;
$mode = $this->resolveSegmentMode($trackingMode, $isDriver);
$contractName = $contract?->getName();
if ($mode !== $currentMode) {
if (null !== $currentMode && [] !== $currentRows) {
$segments[] = [
'mode' => $currentMode,
'contractName' => $currentName,
'rows' => $currentRows,
];
}
$currentMode = $mode;
$currentRows = [];
$currentName = $contractName;
}
$creditedMinutes = $absenceData['credited'][$date] ?? 0;
$absenceLabel = $absenceData['labels'][$date] ?? null;
$row = [
'date' => new DateTimeImmutable($date)->format('d/m/Y'),
'absenceLabel' => $absenceLabel,
];
if ('presence' === $mode) {
$absentMorning = $absenceData['absentMorning'][$date] ?? false;
$absentAfternoon = $absenceData['absentAfternoon'][$date] ?? false;
$morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0;
$afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
$total = $morning + $afternoon;
$row['presentMorning'] = $morning > 0;
$row['presentAfternoon'] = $afternoon > 0;
$row['total'] = $total > 0 ? (string) $total : '';
} elseif ('driver' === $mode) {
$dayMin = $wh?->getDayHoursMinutes() ?? 0;
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
$workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0;
$totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes;
$row['dayHours'] = $this->formatMinutes($dayMin);
$row['nightHours'] = $this->formatMinutes($nightMin);
$row['workshopHours'] = $this->formatMinutes($workshopMin);
$row['total'] = $this->formatMinutes($totalMin);
} else {
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
$metrics->addCreditedMinutes($creditedMinutes);
$row['morningFrom'] = $wh?->getMorningFrom() ?? '';
$row['morningTo'] = $wh?->getMorningTo() ?? '';
$row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? '';
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
$row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
$row['eveningTo'] = $wh?->getEveningTo() ?? '';
$row['total'] = $this->formatMinutes($metrics->totalMinutes);
}
$currentRows[] = $row;
}
if (null !== $currentMode && [] !== $currentRows) {
$segments[] = [
'mode' => $currentMode,
'contractName' => $currentName,
'rows' => $currentRows,
];
}
return $segments;
}
private function resolveSegmentMode(string $trackingMode, bool $isDriver): string
{
if ($isDriver) {
return 'driver';
}
if (TrackingMode::PRESENCE->value === $trackingMode) {
return 'presence';
}
return 'time';
}
private function computeMetrics(WorkHour $workHour): WorkMetrics
{
$ranges = [
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
];
$totalMinutes = 0;
$nightMinutes = 0;
foreach ($ranges as [$from, $to]) {
$totalMinutes += $this->intervalMinutes($from, $to);
$nightMinutes += $this->nightIntervalMinutes($from, $to);
}
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
return new WorkMetrics(
dayMinutes: $dayMinutes,
nightMinutes: $nightMinutes,
totalMinutes: $totalMinutes,
);
}
/**
* @return null|array{int, int}
*/
private function resolveInterval(?string $from, ?string $to): ?array
{
$fromMinutes = $this->toMinutes($from);
$toMinutes = $this->toMinutes($to);
if (null === $fromMinutes || null === $toMinutes) {
return null;
}
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
return [$fromMinutes, $end];
}
private function toMinutes(?string $time): ?int
{
if (null === $time || '' === $time) {
return null;
}
[$hours, $minutes] = array_map('intval', explode(':', $time));
return ($hours * 60) + $minutes;
}
private function intervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
return max(0, $end - $start);
}
private function nightIntervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
$windows = [[0, 360], [1260, 1440]];
$total = 0;
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
$shift = $dayOffset * 1440;
foreach ($windows as [$windowStart, $windowEnd]) {
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
}
}
return $total;
}
private function overlap(int $startA, int $endA, int $startB, int $endB): int
{
$start = max($startA, $startB);
$end = min($endA, $endB);
return max(0, $end - $start);
}
private function formatMinutes(int $minutes): string
{
if (0 === $minutes) {
return '';
}
$h = intdiv($minutes, 60);
$m = $minutes % 60;
return 0 === $m ? "{$h}h" : "{$h}h{$m}m";
}
private function sanitizeFilename(string $name): string
{
$name = str_replace(' ', '_', $name);
return preg_replace('/[^a-zA-Z0-9_\-]/', '', $name) ?? $name;
}
}

View File

@@ -171,7 +171,8 @@ class LeaveRecapPrintProvider implements ProviderInterface
$paid = 0;
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $exerciseYear);
foreach ($payments as $payment) {
$paid += $payment->getBase25Minutes() + $payment->getBase50Minutes();
$paid += $payment->getBase25Minutes() + $payment->getBonus25Minutes()
+ $payment->getBase50Minutes() + $payment->getBonus50Minutes();
}
return $carry + $current->totalMinutes - $paid;

View File

@@ -15,6 +15,7 @@ use App\Repository\BonusRepository;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\MileageAllowanceRepository;
use App\Repository\ObservationRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use DateInterval;
@@ -36,6 +37,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
private EmployeeRttPaymentRepository $rttPaymentRepository,
private BonusRepository $bonusRepository,
private MileageAllowanceRepository $mileageAllowanceRepository,
private ObservationRepository $observationRepository,
private EmployeeContractResolver $contractResolver,
) {}
@@ -62,20 +64,22 @@ class SalaryRecapPrintProvider implements ProviderInterface
$monthNumber = (int) $from->format('n');
$rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber);
$bonuses = $this->bonusRepository->findByMonth($from, $to);
$mileages = $this->mileageAllowanceRepository->findByMonth($from, $to);
$bonuses = $this->bonusRepository->findByMonth($from, $to);
$mileages = $this->mileageAllowanceRepository->findByMonth($from, $to);
$observations = $this->observationRepository->findByMonth($from, $to);
$days = $this->buildDays($from, $to);
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$workHourMap = $this->buildWorkHourMap($workHours);
$absenceMap = $this->buildAbsenceMap($absences);
$rttPaymentMap = $this->buildRttPaymentMap($rttPayments);
$bonusMap = $this->buildBonusMap($bonuses);
$mileageMap = $this->buildMileageMap($mileages);
$workHourMap = $this->buildWorkHourMap($workHours);
$absenceMap = $this->buildAbsenceMap($absences);
$rttPaymentMap = $this->buildRttPaymentMap($rttPayments);
$bonusMap = $this->buildBonusMap($bonuses);
$mileageMap = $this->buildMileageMap($mileages);
$observationMap = $this->buildObservationMap($observations);
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap);
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap);
$options = new Options();
$options->set('isRemoteEnabled', true);
@@ -204,6 +208,23 @@ class SalaryRecapPrintProvider implements ProviderInterface
return $map;
}
/**
* @return array<int, string>
*/
private function buildObservationMap(array $observations): array
{
$map = [];
foreach ($observations as $observation) {
$employeeId = $observation->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$map[$employeeId] = $observation->getContent();
}
return $map;
}
private function aggregateBySite(
array $employees,
array $days,
@@ -214,6 +235,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
array $rttPaymentMap,
array $bonusMap,
array $mileageMap,
array $observationMap,
): array {
$siteGroups = [];
@@ -234,6 +256,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
$rttPaymentMap[$employeeId] ?? 0,
$bonusMap[$employeeId] ?? 0.0,
$mileageMap[$employeeId] ?? 0.0,
$observationMap[$employeeId] ?? '',
);
if (!isset($siteGroups[$siteId])) {
@@ -261,6 +284,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
int $rttPaidMinutes,
float $bonusAmount,
float $mileageKm,
string $observation,
): array {
$contractName = null;
$presenceDays = 0.0;
@@ -373,6 +397,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
'driverMeals' => $driverMeals,
'driverOvernight' => $driverOvernight,
'driverSaturdays' => $driverSaturdays,
'observation' => $observation,
];
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,151 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>{{ employeeName }} - {{ year }}</title>
<style>
@page { size: A4 portrait; margin: 4mm; }
html, body {
margin: 0;
padding: 2mm;
font-family: Helvetica, sans-serif;
font-size: 9px;
}
h1 {
text-align: center;
font-size: 16px;
margin: 0 0 4mm 0;
}
h2 {
font-size: 12px;
margin: 4mm 0 2mm 0;
padding: 2px 6px;
background: #e8e8e8;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: auto;
border: 2px solid #0a0a0a;
}
th, td {
border: 1px solid #0a0a0a;
padding: 2px 4px;
vertical-align: middle;
white-space: nowrap;
}
thead th {
text-align: center;
font-weight: 700;
font-size: 9px;
background: #d9e2f3;
}
td { font-size: 9px; }
td.date { text-align: left; font-weight: bold; }
td.absence { text-align: left; color: #c00; }
td.time { text-align: center; }
td.presence { text-align: center; }
td.total { text-align: center; font-weight: bold; }
</style>
</head>
<body>
<h1>{{ employeeName }} - {{ year }}</h1>
{% for segment in segments %}
{% if segments|length > 1 %}
<h2>{{ segment.contractName ?? 'Contrat inconnu' }}{% if segment.mode == 'driver' %} (Chauffeur){% endif %}</h2>
{% endif %}
{% if segment.mode == 'presence' %}
<table>
<thead>
<tr>
<th>Date</th>
<th>Absence</th>
<th>Présence matin</th>
<th>Présence après-midi</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for row in segment.rows %}
<tr>
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
<td class="presence">{{ row.presentAfternoon ? 'X' : '' }}</td>
<td class="total">{{ row.total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% elseif segment.mode == 'driver' %}
<table>
<thead>
<tr>
<th>Date</th>
<th>Absence</th>
<th>Heures jour</th>
<th>Heures nuit</th>
<th>Heures atelier</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for row in segment.rows %}
<tr>
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="time">{{ row.dayHours }}</td>
<td class="time">{{ row.nightHours }}</td>
<td class="time">{{ row.workshopHours }}</td>
<td class="total">{{ row.total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<table>
<thead>
<tr>
<th>Date</th>
<th>Absence</th>
<th>Début matin</th>
<th>Fin matin</th>
<th>Début après-midi</th>
<th>Fin après-midi</th>
<th>Début soir</th>
<th>Fin soir</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for row in segment.rows %}
<tr>
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="time">{{ row.morningFrom }}</td>
<td class="time">{{ row.morningTo }}</td>
<td class="time">{{ row.afternoonFrom }}</td>
<td class="time">{{ row.afternoonTo }}</td>
<td class="time">{{ row.eveningFrom }}</td>
<td class="time">{{ row.eveningTo }}</td>
<td class="total">{{ row.total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endfor %}
</body>
</html>

View File

@@ -76,7 +76,12 @@
word-break: break-word;
font-size: 10px;
}
td.obs { }
td.obs {
text-align: left;
white-space: normal;
word-break: break-word;
font-size: 9px;
}
tbody td { font-size: 10px; }
</style>
@@ -139,7 +144,7 @@
<td class="num">{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}</td>
<td class="num">{{ row.paidHours > 0 ? row.paidHours : '' }}</td>
<td class="num">{{ row.sundayHours > 0 ? row.sundayHours : '' }}</td>
<td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount : '' }}</td>
<td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount ~ ' €' : '' }}</td>
<td class="num">{{ row.congesCount > 0 ? row.congesCount : '' }}</td>
<td class="dates">{{ row.congesDates }}</td>
<td class="num">{{ row.maladieCount > 0 ? row.maladieCount : '' }}</td>
@@ -148,7 +153,7 @@
<td class="num">{{ row.isDriver and row.driverMeals > 0 ? row.driverMeals : '' }}</td>
<td class="num">{{ row.isDriver and row.driverOvernight > 0 ? row.driverOvernight : '' }}</td>
<td class="num">{{ row.isDriver and row.driverSaturdays > 0 ? row.driverSaturdays : '' }}</td>
<td class="obs"></td>
<td class="obs">{{ row.observation }}</td>
</tr>
{% else %}
<tr>

View File

@@ -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);

View File

@@ -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());

View File

@@ -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());