Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3ed359d3f | ||
| 906c245451 | |||
|
|
100ab340d4 | ||
| 0257e59671 | |||
|
|
f9979c9a19 | ||
| 1091147100 | |||
|
|
fd154a59fb | ||
| 967e3311e5 | |||
|
|
04c5279946 | ||
| b25d40f3d8 | |||
| e654516b82 | |||
|
|
b07146e78d | ||
| b1bf363fa1 | |||
| c13cab6b59 |
10
.idea/db-forest-config.xml
generated
10
.idea/db-forest-config.xml
generated
@@ -1,5 +1,15 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<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">
|
<component name="db-tree-configuration">
|
||||||
<option name="data" value="---------------------------------------- 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 " />
|
<option name="data" value="---------------------------------------- 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 " />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.71'
|
app.version: '0.1.77'
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export SIRH_IMAGE_TAG="$TAG"
|
|||||||
|
|
||||||
echo "==> Deploying sirh:${TAG}..."
|
echo "==> Deploying sirh:${TAG}..."
|
||||||
|
|
||||||
|
echo "==> Enabling maintenance mode..."
|
||||||
|
touch maintenance.on
|
||||||
|
|
||||||
echo "==> Pulling image..."
|
echo "==> Pulling image..."
|
||||||
docker compose pull
|
docker compose pull
|
||||||
|
|
||||||
@@ -18,11 +21,14 @@ echo "==> Waiting for container to be ready..."
|
|||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
echo "==> Running migrations..."
|
echo "==> Running migrations..."
|
||||||
docker compose exec -T app php bin/console doctrine:migrations:migrate --no-interaction
|
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
|
||||||
echo "==> Clearing cache..."
|
echo "==> Clearing cache..."
|
||||||
docker compose exec -T app php bin/console cache:clear --env=prod
|
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||||
docker compose exec -T app php bin/console cache:warmup --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}')
|
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||||
echo "==> Deployed v${VERSION}"
|
echo "==> Deployed v${VERSION}"
|
||||||
|
|||||||
50
deploy/maintenance.html
Normal file
50
deploy/maintenance.html
Normal 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">🛠</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>
|
||||||
@@ -2,6 +2,23 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name sirh.malio-dev.fr;
|
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 / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:8080;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@@ -22,18 +22,42 @@ Se deconnecter/reconnecter pour que le groupe `docker` prenne effet.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt install -y nginx
|
sudo apt install -y nginx
|
||||||
```
|
|
||||||
|
|
||||||
Verifier que Nginx tourne :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl enable nginx
|
sudo systemctl enable nginx
|
||||||
sudo systemctl start nginx
|
sudo systemctl start nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
## Premier deploiement
|
### PostgreSQL
|
||||||
|
|
||||||
### 1. Creer le dossier de deploiement
|
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
|
```bash
|
||||||
sudo mkdir -p /var/www/sirh
|
sudo mkdir -p /var/www/sirh
|
||||||
@@ -41,7 +65,18 @@ sudo chown -R $(whoami):$(whoami) /var/www/sirh
|
|||||||
cd /var/www/sirh
|
cd /var/www/sirh
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Creer les fichiers de deploiement
|
### 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` :
|
Creer `docker-compose.yml` :
|
||||||
|
|
||||||
@@ -84,11 +119,11 @@ echo "==> Waiting for container to be ready..."
|
|||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
echo "==> Running migrations..."
|
echo "==> Running migrations..."
|
||||||
docker compose exec -T app php bin/console doctrine:migrations:migrate --no-interaction
|
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
|
||||||
echo "==> Clearing cache..."
|
echo "==> Clearing cache..."
|
||||||
docker compose exec -T app php bin/console cache:clear --env=prod
|
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||||
docker compose exec -T app php bin/console cache:warmup --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}')
|
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||||
echo "==> Deployed v${VERSION}"
|
echo "==> Deployed v${VERSION}"
|
||||||
@@ -100,7 +135,7 @@ Rendre executable :
|
|||||||
chmod +x deploy.sh
|
chmod +x deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Configurer l'environnement
|
### 5. Configurer l'environnement
|
||||||
|
|
||||||
Creer `.env` avec les variables suivantes :
|
Creer `.env` avec les variables suivantes :
|
||||||
|
|
||||||
@@ -110,8 +145,8 @@ APP_ENV=prod
|
|||||||
APP_DEBUG=0
|
APP_DEBUG=0
|
||||||
APP_SECRET=<generer avec: openssl rand -hex 32>
|
APP_SECRET=<generer avec: openssl rand -hex 32>
|
||||||
|
|
||||||
# Database (host.docker.internal = la machine hote, ou le PG tourne en bare metal)
|
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
|
||||||
DATABASE_URL="postgresql://sirh_user:password@host.docker.internal:5432/sirh?serverVersion=16&charset=utf8"
|
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/sirh_prod?serverVersion=16&charset=utf8"
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||||
@@ -132,37 +167,28 @@ RTT_START_DATE=2026-02-23
|
|||||||
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"
|
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Generer les cles JWT
|
### 6. Generer les cles JWT
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p config/jwt
|
mkdir -p config/jwt
|
||||||
docker run --rm -v $(pwd)/config/jwt:/jwt php:8.4-cli bash -c \
|
openssl genpkey -algorithm RSA -out config/jwt/private.pem -pkeyopt rsa_keygen_bits:4096
|
||||||
"apt-get update -qq && apt-get install -y -qq openssl > /dev/null && \
|
openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem
|
||||||
openssl genpkey -algorithm RSA -out /jwt/private.pem -pkeyopt rsa_keygen_bits:4096 && \
|
|
||||||
openssl rsa -pubout -in /jwt/private.pem -out /jwt/public.pem"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Creer le dossier uploads
|
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
|
```bash
|
||||||
mkdir -p uploads
|
mkdir -p uploads
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Se connecter au registry Docker de Gitea
|
### 8. Configurer Nginx systeme
|
||||||
|
|
||||||
Pour que la machine puisse telecharger les images Docker depuis Gitea, il faut se connecter au registry une fois :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker login gitea.malio.fr
|
|
||||||
```
|
|
||||||
|
|
||||||
Docker va demander :
|
|
||||||
- **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.
|
|
||||||
|
|
||||||
### 7. Configurer Nginx systeme
|
|
||||||
|
|
||||||
Creer `/etc/nginx/sites-available/sirh.conf` :
|
Creer `/etc/nginx/sites-available/sirh.conf` :
|
||||||
|
|
||||||
@@ -171,6 +197,23 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name sirh.malio-dev.fr;
|
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 / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:8080;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -181,20 +224,42 @@ server {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Activer le site :
|
Copier la page de maintenance et activer le site :
|
||||||
|
|
||||||
```bash
|
```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 ln -sf /etc/nginx/sites-available/sirh.conf /etc/nginx/sites-enabled/sirh.conf
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8. Deployer
|
### 9. Deployer
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./deploy.sh
|
./deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Structure finale du dossier
|
### 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/
|
/var/www/sirh/
|
||||||
@@ -204,10 +269,16 @@ sudo nginx -t && sudo systemctl reload nginx
|
|||||||
├── config/jwt/
|
├── config/jwt/
|
||||||
│ ├── private.pem
|
│ ├── private.pem
|
||||||
│ └── public.pem
|
│ └── public.pem
|
||||||
|
├── public/
|
||||||
|
│ └── maintenance.html
|
||||||
└── uploads/
|
└── uploads/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployer une release
|
---
|
||||||
|
|
||||||
|
## Deployer une nouvelle version
|
||||||
|
|
||||||
|
Quand l'app est deja installee, deployer une mise a jour :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/sirh
|
cd /var/www/sirh
|
||||||
@@ -215,6 +286,27 @@ cd /var/www/sirh
|
|||||||
./deploy.sh v0.1.61 # deploie une version specifique
|
./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
|
## Rollback
|
||||||
|
|
||||||
### Image seule (pas de changement de schema BDD)
|
### Image seule (pas de changement de schema BDD)
|
||||||
@@ -227,11 +319,13 @@ cd /var/www/sirh
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Rollback schema (pendant que la version actuelle tourne encore)
|
# 1. Rollback schema (pendant que la version actuelle tourne encore)
|
||||||
docker compose exec -T app php bin/console doctrine:migrations:migrate prev --no-interaction
|
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate prev --no-interaction
|
||||||
# 2. Deployer l'ancienne version
|
# 2. Deployer l'ancienne version
|
||||||
./deploy.sh v0.1.60
|
./deploy.sh v0.1.60
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Voir les logs
|
## Voir les logs
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -240,6 +334,14 @@ docker compose logs -f # tous les logs
|
|||||||
docker compose logs -f --tail=100 # 100 dernieres lignes
|
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)
|
## Migration depuis l'ancien deploiement (tar.gz)
|
||||||
|
|
||||||
Si l'application tourne deja en bare metal :
|
Si l'application tourne deja en bare metal :
|
||||||
@@ -252,10 +354,10 @@ Si l'application tourne deja en bare metal :
|
|||||||
cp -a /var/www/sirh/config/jwt /var/www/sirh-docker/config/jwt
|
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
|
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 2 ci-dessus)
|
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`
|
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 6 ci-dessus)
|
6. Se connecter au registry Gitea (voir etape 3 ci-dessus)
|
||||||
7. Mettre a jour Nginx systeme avec la conf reverse proxy (voir etape 7 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`
|
8. Arreter l'ancien PHP-FPM : `sudo systemctl stop php8.4-fpm`
|
||||||
9. Deployer : `cd /var/www/sirh-docker && ./deploy.sh`
|
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`
|
10. Verifier que tout marche, puis renommer le dossier : `mv /var/www/sirh-docker /var/www/sirh`
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
|||||||
- pour `FORFAIT`:
|
- pour `FORFAIT`:
|
||||||
- pris: basé sur toutes les absences (demi-journées incluses)
|
- pris: basé sur toutes les absences (demi-journées incluses)
|
||||||
- restants = acquis - pris (borné à 0)
|
- 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:
|
- report annuel:
|
||||||
- le reliquat (`restants`) de l'exercice précédent est reporté dans les acquis de l'exercice courant
|
- 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
|
- pour `CDI`/`CDD` non forfait: report séparé jours + samedis
|
||||||
@@ -274,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
|
- total mensuel des minutes de récupération
|
||||||
- compteur global exercice = `report N-1 + acquis N`
|
- compteur global exercice = `report N-1 + acquis N`
|
||||||
- attribution mensuelle des semaines:
|
- attribution mensuelle des semaines:
|
||||||
- une semaine ISO est affichée une seule fois, dans le mois qui contient le **samedi** de cette 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
|
||||||
- si le weekend tombe en début de mois suivant, c'est le mois suivant qui porte la semaine
|
- 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:
|
- logique de calcul:
|
||||||
- base identique aux calculs d'heures supplémentaires de la vue semaine Heures
|
- 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%`
|
- minutes de récupération hebdomadaires = `HS totales + bonus 25% + bonus 50%`
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ Principe:
|
|||||||
|
|
||||||
## 4) Attribution mensuelle des semaines
|
## 4) Attribution mensuelle des semaines
|
||||||
|
|
||||||
- une semaine ISO est affichee une seule fois, dans le mois qui contient le **samedi** de cette semaine
|
- une semaine ISO qui chevauche deux mois est affichee dans **les deux mois**, avec les valeurs reparties proportionnellement aux minutes travaillees de chaque portion
|
||||||
- si le weekend tombe en debut du mois suivant, c'est ce mois qui porte la semaine
|
- le calcul des heures supplementaires reste hebdomadaire (seuils 35h/39h/43h appliques sur la semaine entiere), seul l'affichage est scinde
|
||||||
- pas de prorata: la totalite des minutes de recuperation de la semaine est comptee dans un seul mois
|
- 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
|
## 5) Table cible
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
<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
|
{{ formatCount(summary?.previousYearRemainingDays) }} Jours
|
||||||
</p>
|
</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 v-if="!isForfaitRule" class="col-start-4 p-[10px] flex gap-7 items-center">
|
||||||
<div>
|
<div>
|
||||||
<span class="uppercase font-semibold">Fractionné acquis : </span>
|
<span class="uppercase font-semibold">Fractionné acquis : </span>
|
||||||
@@ -112,6 +124,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -136,11 +181,15 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'update-fractioned-days', days: number): void
|
(event: 'update-fractioned-days', days: number): void
|
||||||
|
(event: 'update-paid-leave-days', days: number): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isFractionedDrawerOpen = ref(false)
|
const isFractionedDrawerOpen = ref(false)
|
||||||
const fractionedForm = reactive({days: 0})
|
const fractionedForm = reactive({days: 0})
|
||||||
|
|
||||||
|
const isPaidLeaveDrawerOpen = ref(false)
|
||||||
|
const paidLeaveForm = reactive({days: 0})
|
||||||
|
|
||||||
const openFractionedDrawer = () => {
|
const openFractionedDrawer = () => {
|
||||||
fractionedForm.days = props.summary?.fractionedDays ?? 0
|
fractionedForm.days = props.summary?.fractionedDays ?? 0
|
||||||
isFractionedDrawerOpen.value = true
|
isFractionedDrawerOpen.value = true
|
||||||
@@ -153,6 +202,18 @@ const handleSubmitFractioned = () => {
|
|||||||
isFractionedDrawerOpen.value = false
|
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 = [
|
const monthLabels = [
|
||||||
'Janvier',
|
'Janvier',
|
||||||
'Fevrier',
|
'Fevrier',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary
|
|||||||
import type { Employee } from '~/services/dto/employee'
|
import type { Employee } from '~/services/dto/employee'
|
||||||
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||||
import { listAbsences } from '~/services/absences'
|
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'
|
import { listPublicHolidays } from '~/services/public-holidays'
|
||||||
|
|
||||||
export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
||||||
@@ -57,6 +57,13 @@ export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee:
|
|||||||
await 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 {
|
return {
|
||||||
employeeAbsences,
|
employeeAbsences,
|
||||||
leaveSummary,
|
leaveSummary,
|
||||||
@@ -65,6 +72,7 @@ export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee:
|
|||||||
leaveDataLoaded,
|
leaveDataLoaded,
|
||||||
loadLeaveData,
|
loadLeaveData,
|
||||||
resetLoaded,
|
resetLoaded,
|
||||||
submitFractionedDays
|
submitFractionedDays,
|
||||||
|
submitPaidLeaveDays
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
<option value="contract_suspension">Suspension</option>
|
<option value="contract_suspension">Suspension</option>
|
||||||
<option value="rtt_payment">Paiement RTT</option>
|
<option value="rtt_payment">Paiement RTT</option>
|
||||||
<option value="fractioned_days">Jours fractionnés</option>
|
<option value="fractioned_days">Jours fractionnés</option>
|
||||||
|
<option value="paid_leave_days">Congés N-1 payés</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -241,6 +242,7 @@ const entityTypeLabel = (type: string): string => {
|
|||||||
contract_suspension: 'Suspension',
|
contract_suspension: 'Suspension',
|
||||||
rtt_payment: 'RTT',
|
rtt_payment: 'RTT',
|
||||||
fractioned_days: 'Fract.',
|
fractioned_days: 'Fract.',
|
||||||
|
paid_leave_days: 'Congés payés',
|
||||||
}
|
}
|
||||||
return map[type] ?? type
|
return map[type] ?? type
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,7 @@
|
|||||||
:summary="leaveSummary"
|
:summary="leaveSummary"
|
||||||
:public-holidays="publicHolidays"
|
:public-holidays="publicHolidays"
|
||||||
@update-fractioned-days="submitFractionedDays"
|
@update-fractioned-days="submitFractionedDays"
|
||||||
|
@update-paid-leave-days="submitPaidLeaveDays"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="showRttTab && activeTab === 'rtt'" class="h-full">
|
<div v-else-if="showRttTab && activeTab === 'rtt'" class="h-full">
|
||||||
@@ -259,6 +260,7 @@ const {
|
|||||||
submitContractUpdate,
|
submitContractUpdate,
|
||||||
submitCreateContract,
|
submitCreateContract,
|
||||||
submitFractionedDays,
|
submitFractionedDays,
|
||||||
|
submitPaidLeaveDays,
|
||||||
submitRttPayment,
|
submitRttPayment,
|
||||||
suspensionForms,
|
suspensionForms,
|
||||||
isSuspensionSubmitting,
|
isSuspensionSubmitting,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type EmployeeLeaveSummary = {
|
|||||||
previousYearAcquiredDays: number
|
previousYearAcquiredDays: number
|
||||||
previousYearTakenDays: number
|
previousYearTakenDays: number
|
||||||
previousYearRemainingDays: number
|
previousYearRemainingDays: number
|
||||||
|
previousYearPaidDays: number
|
||||||
presenceDaysByMonth: Record<string, number>
|
presenceDaysByMonth: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,3 +16,11 @@ export const updateFractionedDays = async (employeeId: number, fractionedDays: n
|
|||||||
return api.patch(`/employees/${employeeId}/fractioned-days`, body)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
27
migrations/Version20260402064647.php
Normal file
27
migrations/Version20260402064647.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ final class EmployeeLeaveSummary
|
|||||||
public float $previousYearAcquiredDays = 0.0;
|
public float $previousYearAcquiredDays = 0.0;
|
||||||
public float $previousYearTakenDays = 0.0;
|
public float $previousYearTakenDays = 0.0;
|
||||||
public float $previousYearRemainingDays = 0.0;
|
public float $previousYearRemainingDays = 0.0;
|
||||||
|
public float $previousYearPaidDays = 0.0;
|
||||||
|
|
||||||
/** @var array<string, float> YYYY-MM => count (0.5 for half-days) */
|
/** @var array<string, float> YYYY-MM => count (0.5 for half-days) */
|
||||||
public array $presenceDaysByMonth = [];
|
public array $presenceDaysByMonth = [];
|
||||||
|
|||||||
27
src/ApiResource/EmployeePaidLeaveDaysInput.php
Normal file
27
src/ApiResource/EmployeePaidLeaveDaysInput.php
Normal 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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ namespace App\Dto\Rtt;
|
|||||||
|
|
||||||
final class WeekRecoveryDetail
|
final class WeekRecoveryDetail
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $dailyMinutes date (Y-m-d) => worked minutes
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $overtimeMinutes = 0,
|
public int $overtimeMinutes = 0,
|
||||||
public int $base25Minutes = 0,
|
public int $base25Minutes = 0,
|
||||||
@@ -13,5 +16,6 @@ final class WeekRecoveryDetail
|
|||||||
public int $base50Minutes = 0,
|
public int $base50Minutes = 0,
|
||||||
public int $bonus50Minutes = 0,
|
public int $bonus50Minutes = 0,
|
||||||
public int $totalMinutes = 0,
|
public int $totalMinutes = 0,
|
||||||
|
public array $dailyMinutes = [],
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ class EmployeeLeaveBalance
|
|||||||
#[ORM\Column(type: 'float', options: ['default' => 0, 'comment' => 'Jours de fractionnement saisis par la RH.'])]
|
#[ORM\Column(type: 'float', options: ['default' => 0, 'comment' => 'Jours de fractionnement saisis par la RH.'])]
|
||||||
private float $fractionedDays = 0.0;
|
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).'])]
|
#[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Indique si le solde de l exercice est fige (verrouille RH).'])]
|
||||||
private bool $isLocked = false;
|
private bool $isLocked = false;
|
||||||
|
|
||||||
@@ -222,6 +225,18 @@ class EmployeeLeaveBalance
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getPaidLeaveDays(): float
|
||||||
|
{
|
||||||
|
return $this->paidLeaveDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPaidLeaveDays(float $paidLeaveDays): self
|
||||||
|
{
|
||||||
|
$this->paidLeaveDays = $paidLeaveDays;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function isLocked(): bool
|
public function isLocked(): bool
|
||||||
{
|
{
|
||||||
return $this->isLocked;
|
return $this->isLocked;
|
||||||
|
|||||||
@@ -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
|
public function buildWeeksForExercise(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
{
|
{
|
||||||
@@ -61,10 +61,7 @@ final readonly class RttRecoveryComputationService
|
|||||||
$effectiveEnd = $end > $to ? $to : $end;
|
$effectiveEnd = $end > $to ? $to : $end;
|
||||||
|
|
||||||
if ($effectiveEnd >= $effectiveStart) {
|
if ($effectiveEnd >= $effectiveStart) {
|
||||||
$saturday = $start->modify('+5 days');
|
$weeks[] = [
|
||||||
$monthAnchor = $saturday < $from ? $from : ($saturday > $to ? $to : $saturday);
|
|
||||||
$weeks[] = [
|
|
||||||
'month' => (int) $monthAnchor->format('n'),
|
|
||||||
'weekNumber' => (int) $effectiveStart->format('W'),
|
'weekNumber' => (int) $effectiveStart->format('W'),
|
||||||
'start' => $start,
|
'start' => $start,
|
||||||
'end' => $end,
|
'end' => $end,
|
||||||
@@ -82,7 +79,6 @@ final readonly class RttRecoveryComputationService
|
|||||||
$weeks = $this->buildWeeksForExercise($from, $to);
|
$weeks = $this->buildWeeksForExercise($from, $to);
|
||||||
$weekRanges = array_map(
|
$weekRanges = array_map(
|
||||||
static fn (array $week): array => [
|
static fn (array $week): array => [
|
||||||
'month' => (int) $week['month'],
|
|
||||||
'weekNumber' => (int) $week['weekNumber'],
|
'weekNumber' => (int) $week['weekNumber'],
|
||||||
'start' => $week['start'],
|
'start' => $week['start'],
|
||||||
'end' => $week['end'],
|
'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>
|
* @return array<string, WeekRecoveryDetail>
|
||||||
*/
|
*/
|
||||||
@@ -189,6 +185,7 @@ final readonly class RttRecoveryComputationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$weeklyTotalMinutes = 0;
|
$weeklyTotalMinutes = 0;
|
||||||
|
$dailyWorkedMinutes = [];
|
||||||
$employeeContractsByDate = [];
|
$employeeContractsByDate = [];
|
||||||
foreach ($weekDays as $date) {
|
foreach ($weekDays as $date) {
|
||||||
$employeeContractsByDate[$date] = $contractsByDate[$employeeId][$date] ?? null;
|
$employeeContractsByDate[$date] = $contractsByDate[$employeeId][$date] ?? null;
|
||||||
@@ -198,6 +195,7 @@ final readonly class RttRecoveryComputationService
|
|||||||
$metrics = $metricsByDate[$date] ?? new WorkMetrics();
|
$metrics = $metricsByDate[$date] ?? new WorkMetrics();
|
||||||
$metrics->addCreditedMinutes($creditedByDate[$date] ?? 0);
|
$metrics->addCreditedMinutes($creditedByDate[$date] ?? 0);
|
||||||
$weeklyTotalMinutes += $metrics->totalMinutes;
|
$weeklyTotalMinutes += $metrics->totalMinutes;
|
||||||
|
$dailyWorkedMinutes[$date] = $metrics->totalMinutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ([] === $weekDays) {
|
if ([] === $weekDays) {
|
||||||
@@ -244,6 +242,7 @@ final readonly class RttRecoveryComputationService
|
|||||||
base50Minutes: $base50,
|
base50Minutes: $base50,
|
||||||
bonus50Minutes: $bonus50,
|
bonus50Minutes: $bonus50,
|
||||||
totalMinutes: $totalMinutes,
|
totalMinutes: $totalMinutes,
|
||||||
|
dailyMinutes: $dailyWorkedMinutes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,16 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$fractionedDays = $this->resolveFractionedDays($employee, $yearSummary['ruleCode'], $year);
|
$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->isSupported = true;
|
||||||
$summary->ruleCode = $yearSummary['ruleCode'];
|
$summary->ruleCode = $yearSummary['ruleCode'];
|
||||||
@@ -107,6 +117,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$summary->previousYearAcquiredDays = $yearSummary['previousYearAcquiredDays'];
|
$summary->previousYearAcquiredDays = $yearSummary['previousYearAcquiredDays'];
|
||||||
$summary->previousYearTakenDays = $yearSummary['previousYearTakenDays'];
|
$summary->previousYearTakenDays = $yearSummary['previousYearTakenDays'];
|
||||||
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
|
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
|
||||||
|
$summary->previousYearPaidDays = $paidLeaveDays;
|
||||||
|
|
||||||
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
|
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
|
||||||
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo);
|
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo);
|
||||||
@@ -129,7 +140,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
* previousYearRemainingDays: float
|
* 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);
|
$firstYear = max($this->resolveFirstComputationYear($employee), $targetYear - 1);
|
||||||
if ($targetYear < $firstYear) {
|
if ($targetYear < $firstYear) {
|
||||||
@@ -269,13 +280,15 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
} else {
|
} else {
|
||||||
// Forfait: no "en cours d'acquisition" counter, all rights are in acquired.
|
// Forfait: no "en cours d'acquisition" counter, all rights are in acquired.
|
||||||
// Suspensions do not impact forfait 218 leave calculation.
|
// Suspensions do not impact forfait 218 leave calculation.
|
||||||
// Taken days are first deducted from N-1 carry, then from current year.
|
// Paid days reduce N-1 stock first, then taken days are attributed to what remains in N-1.
|
||||||
$previousYearAcquired = $carryDays;
|
$previousYearAcquired = $carryDays;
|
||||||
$takenFromPrevious = min(max(0.0, $previousYearAcquired), $takenDays);
|
$effectivePaidDays = ($year === $targetYear) ? $paidLeaveDays : 0.0;
|
||||||
$previousYearTaken = $takenFromPrevious;
|
$availableAfterPayment = max(0.0, $previousYearAcquired - $effectivePaidDays);
|
||||||
$takenFromCurrent = $takenDays - $takenFromPrevious;
|
$takenFromPrevious = min($availableAfterPayment, $takenDays);
|
||||||
|
$previousYearTaken = $takenFromPrevious;
|
||||||
|
$takenFromCurrent = $takenDays - $takenFromPrevious;
|
||||||
|
|
||||||
$previousYearRemaining = max(0.0, $previousYearAcquired - $takenFromPrevious);
|
$previousYearRemaining = max(0.0, $availableAfterPayment - $takenFromPrevious);
|
||||||
|
|
||||||
$acquiredDays = $leavePolicy['acquiredDays'];
|
$acquiredDays = $leavePolicy['acquiredDays'];
|
||||||
$accruingDays = 0.0;
|
$accruingDays = 0.0;
|
||||||
@@ -765,6 +778,13 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
return null !== $balance ? $balance->getFractionedDays() : 0.0;
|
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
|
private function resolveCurrentLeaveYear(DateTimeImmutable $today): int
|
||||||
{
|
{
|
||||||
$year = (int) $today->format('Y');
|
$year = (int) $today->format('Y');
|
||||||
|
|||||||
101
src/State/EmployeePaidLeaveDaysProcessor.php
Normal file
101
src/State/EmployeePaidLeaveDaysProcessor.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/State/EmployeePaidLeaveDaysProvider.php
Normal file
17
src/State/EmployeePaidLeaveDaysProvider.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,6 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
|
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
|
||||||
$weekRanges = array_map(
|
$weekRanges = array_map(
|
||||||
static fn (array $week): array => [
|
static fn (array $week): array => [
|
||||||
'month' => (int) $week['month'],
|
|
||||||
'weekNumber' => (int) $week['weekNumber'],
|
'weekNumber' => (int) $week['weekNumber'],
|
||||||
'start' => $week['start'],
|
'start' => $week['start'],
|
||||||
'end' => $week['end'],
|
'end' => $week['end'],
|
||||||
@@ -118,25 +117,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
$summary->rttStartDate = $this->rttStartDate;
|
$summary->rttStartDate = $this->rttStartDate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$summary->weeks = array_map(
|
$summary->weeks = $this->buildWeekSummaries($weekRanges, $currentByWeekStart, $periodFrom, $periodTo);
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%)
|
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%)
|
||||||
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
|
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
|
||||||
@@ -269,4 +250,77 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
return $weekEnd;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user