Compare commits
20 Commits
increase-r
...
c5bcbf51bc
| Author | SHA1 | Date | |
|---|---|---|---|
| c5bcbf51bc | |||
|
1a679ea1c7
|
|||
|
b6fa09a709
|
|||
|
e9977f5a1b
|
|||
| fd128dd09b | |||
| 21fd877d7a | |||
| bd5bc496eb | |||
| 2d85fffd8d | |||
| c7a0a77d1f | |||
| 82beb6241f | |||
| bfa48ee8a8 | |||
|
0956b22986
|
|||
|
8fe13615d0
|
|||
| 7e2f158c09 | |||
| cc664b3555 | |||
| 59d64885d4 | |||
| 6d5e58273f | |||
|
d768156e0d
|
|||
|
e781c06593
|
|||
| d97565f1a1 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,3 +5,5 @@ __pycache__/
|
|||||||
instance/
|
instance/
|
||||||
.env
|
.env
|
||||||
*.db
|
*.db
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# CLAUDE.md
|
# AGENTS.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
Ce fichier fournit des directives et des consignes pour les agents d'intelligence artificielle et assistants de développement (multi-modèles et multi-fournisseurs) travaillant sur ce dépôt.
|
||||||
|
|
||||||
|
Pour une présentation complète de l'architecture, de la configuration et du guide de démarrage, se référer à [docs/onboarding.md](docs/onboarding.md).
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -25,14 +27,28 @@ python -m venv .venv
|
|||||||
sudo systemctl edit --full tableau-de-bord-pro # configurer SECRET_KEY
|
sudo systemctl edit --full tableau-de-bord-pro # configurer SECRET_KEY
|
||||||
sudo systemctl restart tableau-de-bord-pro
|
sudo systemctl restart tableau-de-bord-pro
|
||||||
|
|
||||||
# Git commit (GPG signing désactivé — pinentry inaccessible dans cet env)
|
# Qualité du code (Ruff)
|
||||||
git -c commit.gpgsign=false commit -m "..."
|
.venv/bin/ruff check .
|
||||||
|
.venv/bin/ruff format --check .
|
||||||
|
.venv/bin/ruff format .
|
||||||
|
|
||||||
|
# Découverte des modèles OpenCode (configuration environnementale hors dépôt)
|
||||||
|
grep -A 1 -B 1 subagent /home/antoine/.config/opencode/opencode.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Variables d'environnement
|
## Variables d'environnement
|
||||||
|
|
||||||
- `SECRET_KEY` : requis en production (défaut `dev-secret-change-in-prod` en dev)
|
- `SECRET_KEY` : requis en production (défaut `dev-secret-change-in-prod` en dev)
|
||||||
|
|
||||||
|
## Git & Conventions de Commit
|
||||||
|
|
||||||
|
- **Politique Git** : Les commits intermédiaires générés par les agents d'IA doivent être non signés en utilisant l'option `git -c commit.gpgsign=false commit -m "..."` (car pinentry est inaccessible dans cet environnement). L'utilisateur effectuera un amend signé (`git commit --amend -S`) du commit final lorsqu'il sera disponible.
|
||||||
|
- **Convention de trailers** : Chaque commit réalisé par un agent d'IA doit inclure le trailer suivant à la fin du message de commit pour identifier le modèle utilisé :
|
||||||
|
```text
|
||||||
|
Co-authored-by: Fournisseur/Modèle <vibecoder@antoineve.me>
|
||||||
|
```
|
||||||
|
*Exemple :* `Co-authored-by: Anthropic/Claude-3.5-Sonnet <vibecoder@antoineve.me>` ou `Co-authored-by: Google/Gemini-3.5-Flash <vibecoder@antoineve.me>`.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Flask app using the factory pattern (`create_app()` in `app/__init__.py`). The DB is SQLite via SQLAlchemy, stored in `instance/worklog.db`. All vehicle/journey/tax configuration lives in `config.toml` (loaded at startup into `app.config["TOML"]`), not in the database.
|
Flask app using the factory pattern (`create_app()` in `app/__init__.py`). The DB is SQLite via SQLAlchemy, stored in `instance/worklog.db`. All vehicle/journey/tax configuration lives in `config.toml` (loaded at startup into `app.config["TOML"]`), not in the database.
|
||||||
@@ -62,7 +78,8 @@ Flask app using the factory pattern (`create_app()` in `app/__init__.py`). The D
|
|||||||
|
|
||||||
- **Pas de migration de schéma** : l'app utilise `db.create_all()` uniquement (pas d'Alembic). Tout changement de modèle nécessite de supprimer `instance/worklog.db` en dev, ou une migration manuelle en prod.
|
- **Pas de migration de schéma** : l'app utilise `db.create_all()` uniquement (pas d'Alembic). Tout changement de modèle nécessite de supprimer `instance/worklog.db` en dev, ou une migration manuelle en prod.
|
||||||
- **Barème kilométrique** : les tranches dans `config.toml` sont à mettre à jour manuellement chaque année (section `[bareme_kilometrique.YYYY]`).
|
- **Barème kilométrique** : les tranches dans `config.toml` sont à mettre à jour manuellement chaque année (section `[bareme_kilometrique.YYYY]`).
|
||||||
- **`datetime.utcnow()` deprecated** : les modèles utilisent `datetime.utcnow` (warning sur Python 3.14+). À remplacer par `datetime.now(UTC)` lors d'une prochaine évolution des modèles.
|
- **Métadonnées et horodatages (`created_at` / `updated_at`)** : L'application utilise `datetime.now(UTC)` pour enregistrer ces métadonnées. Bien que les valeurs soient émises en UTC, SQLite et SQLAlchemy restituent par défaut des objets `datetime` naïfs (sans `tzinfo`). Ces champs sont actuellement informatifs et non exploités par la logique métier. En cas de besoin ultérieur d'exploitation de ces métadonnées avec fuseau, les pistes incluent la conservation explicite du fuseau (`DateTime(timezone=True)`) ou la stricte convention documentée « naïf = UTC ».
|
||||||
|
- **Calculs de durée métier et transitions DST** : Le calcul de la durée des plages horaires de travail (`TimeSlot` via `total_minutes()`) est totalement indépendant des métadonnées et repose sur des heures murales (locales). Une plage horaire traversant un changement d'heure saisonnier (passage heure d'été/hiver / DST) soulève un enjeu métier spécifique (gestion des durées d'heures locales) qui nécessiterait, le cas échéant, une représentation dédiée ou une politique métier spécifique.
|
||||||
- **Filtres Jinja2** (définis dans `app/__init__.py`) : `{{ date | date_fr }}` pour les dates en français ; `{{ day_type | day_type_fr }}` pour les libellés de types de jours (WORK→Travail, TT→Télétravail, etc.).
|
- **Filtres Jinja2** (définis dans `app/__init__.py`) : `{{ date | date_fr }}` pour les dates en français ; `{{ day_type | day_type_fr }}` pour les libellés de types de jours (WORK→Travail, TT→Télétravail, etc.).
|
||||||
- **`db.get_engine()` deprecated** en Flask-SQLAlchemy 3.x → utiliser `db.engine`.
|
- **`db.get_engine()` deprecated** en Flask-SQLAlchemy 3.x → utiliser `db.engine`.
|
||||||
- **Migration `_migrate_db`** : vérifier l'existence de la table avant `ALTER TABLE` — SQLite peut avoir un fichier DB sans tables (ex: premier démarrage avec `instance/worklog.db` vide).
|
- **Migration `_migrate_db`** : vérifier l'existence de la table avant `ALTER TABLE` — SQLite peut avoir un fichier DB sans tables (ex: premier démarrage avec `instance/worklog.db` vide).
|
||||||
34
README.md
34
README.md
@@ -74,6 +74,40 @@ Toute la configuration métier se trouve dans `config.toml` :
|
|||||||
.venv/bin/python -m pytest
|
.venv/bin/python -m pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Qualité Python
|
||||||
|
|
||||||
|
Ruff est l'unique outil de lint, de tri des imports et de formatage Python :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/ruff check .
|
||||||
|
.venv/bin/ruff format --check .
|
||||||
|
```
|
||||||
|
|
||||||
|
La configuration se trouve dans `pyproject.toml` (Python 3.11+, lignes de 100
|
||||||
|
caractères). Le formatage peut être appliqué avec `.venv/bin/ruff format .` lors
|
||||||
|
de l'étape de correction dédiée.
|
||||||
|
|
||||||
|
### Import bulk depuis CSV
|
||||||
|
|
||||||
|
Un script est disponible pour importer des entrées en masse depuis un fichier CSV :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Format du CSV : date,day_type,journey_profile_id,motor_vehicle_id,start_time,end_time,comment
|
||||||
|
# Exemple :
|
||||||
|
# 2025-06-02,WORK,moteur_seul,familiale,09:00;14:00,17:45;12:00,Travail normal
|
||||||
|
# 2025-06-03,TT,,,09:00,17:45,Télétravail
|
||||||
|
|
||||||
|
.venv/bin/python scripts/import_csv.py mon_fichier.csv
|
||||||
|
|
||||||
|
# Avec une config personnalisée
|
||||||
|
.venv/bin/python scripts/import_csv.py mon_fichier.csv --config /chemin/vers/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comportement :**
|
||||||
|
- En cas de conflit sur une date, les données existantes sont conservées et un avertissement est affiché
|
||||||
|
- Les plages horaires multiples peuvent être séparées par des points-virgules (`;`)
|
||||||
|
- Types de jour valides : WORK, TT, GARDE, ASTREINTE, FORMATION, RTT, CONGE, MALADE, FERIE
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
[MIT](LICENSE.md) — Copyright (c) 2026 Antoine Van-Elstraete
|
[MIT](LICENSE.md) — Copyright (c) 2026 Antoine Van-Elstraete
|
||||||
|
|||||||
105
app/__init__.py
105
app/__init__.py
@@ -1,15 +1,41 @@
|
|||||||
|
"""Package principal de l'application Flask.
|
||||||
|
|
||||||
|
Ce package initialise l'application Flask en utilisant le "Factory Pattern" (via la fonction `create_app`).
|
||||||
|
Il configure également la base de données SQLite (via Flask-SQLAlchemy), charge la configuration TOML,
|
||||||
|
enregistre les blueprints de routes, applique les migrations de schéma manuelles, et définit les filtres Jinja2 personnalisés.
|
||||||
|
|
||||||
|
Architecture et composants clés :
|
||||||
|
1. Factory Pattern : `create_app` permet d'instancier l'application de manière isolée, facilitant les tests.
|
||||||
|
2. Base de données : SQLite stockée dans `instance/worklog.db`.
|
||||||
|
3. Migration de schéma : Gérée manuellement par `_migrate_db` sans utiliser Alembic.
|
||||||
|
4. Filtres Jinja2 :
|
||||||
|
- `date_fr` : Formate une date Python en chaîne lisible en français (ex: "mercredi 11 mars 2026").
|
||||||
|
- `day_type_fr` : Traduit les codes internes des types de jours (ex: "WORK" -> "Travail").
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
import tomllib
|
|
||||||
import os
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
|
||||||
def _migrate_db(app):
|
def _migrate_db(app):
|
||||||
"""Applique les migrations de schéma manquantes (pas d'Alembic)."""
|
"""Applique les migrations de schéma manquantes de manière incrémentale (sans Alembic).
|
||||||
|
|
||||||
|
Cette fonction vérifie l'état actuel de la base de données SQLite avant d'exécuter
|
||||||
|
des instructions DDL (comme `ALTER TABLE`). Elle permet d'éviter les erreurs si la table
|
||||||
|
`work_entries` n'existe pas encore (auquel cas `db.create_all()` s'en charge) ou si la
|
||||||
|
colonne `motor_vehicle_id` a déjà été ajoutée lors d'un démarrage précédent.
|
||||||
|
|
||||||
|
Paramètres:
|
||||||
|
app (Flask): L'instance de l'application Flask en cours d'initialisation.
|
||||||
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
db_path = os.path.join(app.instance_path, "worklog.db")
|
db_path = os.path.join(app.instance_path, "worklog.db")
|
||||||
if not os.path.exists(db_path):
|
if not os.path.exists(db_path):
|
||||||
return # Nouvelle DB, create_all() s'en charge
|
return # Nouvelle DB, create_all() s'en charge
|
||||||
@@ -25,15 +51,28 @@ def _migrate_db(app):
|
|||||||
engine = db.engine
|
engine = db.engine
|
||||||
if "motor_vehicle_id" not in columns:
|
if "motor_vehicle_id" not in columns:
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
conn.execute(sa.text(
|
conn.execute(
|
||||||
"ALTER TABLE work_entries ADD COLUMN motor_vehicle_id VARCHAR(64)"
|
sa.text("ALTER TABLE work_entries ADD COLUMN motor_vehicle_id VARCHAR(64)")
|
||||||
))
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
_JOURS_FR = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
|
_JOURS_FR = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
|
||||||
_MOIS_FR = ["", "janvier", "février", "mars", "avril", "mai", "juin",
|
_MOIS_FR = [
|
||||||
"juillet", "août", "septembre", "octobre", "novembre", "décembre"]
|
"",
|
||||||
|
"janvier",
|
||||||
|
"février",
|
||||||
|
"mars",
|
||||||
|
"avril",
|
||||||
|
"mai",
|
||||||
|
"juin",
|
||||||
|
"juillet",
|
||||||
|
"août",
|
||||||
|
"septembre",
|
||||||
|
"octobre",
|
||||||
|
"novembre",
|
||||||
|
"décembre",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
_DAY_TYPE_LABELS = {
|
_DAY_TYPE_LABELS = {
|
||||||
@@ -50,23 +89,64 @@ _DAY_TYPE_LABELS = {
|
|||||||
|
|
||||||
|
|
||||||
def _day_type_fr(code):
|
def _day_type_fr(code):
|
||||||
|
"""Filtre Jinja2 pour traduire un code de type de jour en libellé français.
|
||||||
|
|
||||||
|
Exemple:
|
||||||
|
`{{ 'WORK' | day_type_fr }}` -> "Travail"
|
||||||
|
|
||||||
|
Paramètres:
|
||||||
|
code (str): Le code interne du type de jour (ex: "WORK", "TT", "GARDE").
|
||||||
|
|
||||||
|
Retourne:
|
||||||
|
str: Le libellé en français correspondant, ou le code d'origine si aucune traduction n'est définie.
|
||||||
|
"""
|
||||||
return _DAY_TYPE_LABELS.get(code, code)
|
return _DAY_TYPE_LABELS.get(code, code)
|
||||||
|
|
||||||
|
|
||||||
def _date_fr(d):
|
def _date_fr(d):
|
||||||
"""Formate une date en français : 'mercredi 11 mars 2026'."""
|
"""Filtre Jinja2 pour formater une date en français lisible.
|
||||||
from datetime import date as date_type
|
|
||||||
|
Exemple:
|
||||||
|
`{{ entry.date | date_fr }}` -> "mercredi 11 mars 2026"
|
||||||
|
|
||||||
|
Paramètres:
|
||||||
|
d (datetime.date): L'objet date à formater.
|
||||||
|
|
||||||
|
Retourne:
|
||||||
|
str: La date formatée en français (jour de la semaine, jour du mois, mois en toutes lettres, année).
|
||||||
|
"""
|
||||||
jour = _JOURS_FR[d.weekday()]
|
jour = _JOURS_FR[d.weekday()]
|
||||||
mois = _MOIS_FR[d.month]
|
mois = _MOIS_FR[d.month]
|
||||||
return f"{jour} {d.day} {mois} {d.year}"
|
return f"{jour} {d.day} {mois} {d.year}"
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_path=None):
|
def create_app(config_path=None):
|
||||||
|
"""Factory de création et de configuration de l'application Flask.
|
||||||
|
|
||||||
|
Cette fonction réalise les étapes suivantes :
|
||||||
|
1. Instancie l'application Flask avec le support des configurations relatives à l'instance.
|
||||||
|
2. Crée le dossier d'instance s'il n'existe pas.
|
||||||
|
3. Configure l'URI de la base de données SQLite (`instance/worklog.db`).
|
||||||
|
4. Charge la configuration TOML depuis `config.toml` (ou le chemin spécifié).
|
||||||
|
5. Initialise l'extension Flask-SQLAlchemy (`db`).
|
||||||
|
6. Enregistre les filtres Jinja2 personnalisés (`date_fr` et `day_type_fr`).
|
||||||
|
7. Enregistre les blueprints de routes (`dashboard`, `entries`, `reports`).
|
||||||
|
8. Exécute la migration de schéma manuelle (`_migrate_db`) puis crée les tables manquantes (`db.create_all()`).
|
||||||
|
|
||||||
|
Paramètres:
|
||||||
|
config_path (str | None): Chemin optionnel vers le fichier de configuration TOML.
|
||||||
|
Par défaut, cherche `config.toml` à la racine du projet.
|
||||||
|
|
||||||
|
Retourne:
|
||||||
|
Flask: L'instance de l'application Flask configurée et prête à l'emploi.
|
||||||
|
"""
|
||||||
app = Flask(__name__, instance_relative_config=True)
|
app = Flask(__name__, instance_relative_config=True)
|
||||||
|
|
||||||
os.makedirs(app.instance_path, exist_ok=True)
|
os.makedirs(app.instance_path, exist_ok=True)
|
||||||
|
|
||||||
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{os.path.join(app.instance_path, 'worklog.db')}"
|
app.config["SQLALCHEMY_DATABASE_URI"] = (
|
||||||
|
f"sqlite:///{os.path.join(app.instance_path, 'worklog.db')}"
|
||||||
|
)
|
||||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod")
|
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod")
|
||||||
|
|
||||||
@@ -86,6 +166,7 @@ def create_app(config_path=None):
|
|||||||
from app.routes.dashboard import bp as dashboard_bp
|
from app.routes.dashboard import bp as dashboard_bp
|
||||||
from app.routes.entries import bp as entries_bp
|
from app.routes.entries import bp as entries_bp
|
||||||
from app.routes.reports import bp as reports_bp
|
from app.routes.reports import bp as reports_bp
|
||||||
|
|
||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
app.register_blueprint(entries_bp)
|
app.register_blueprint(entries_bp)
|
||||||
app.register_blueprint(reports_bp)
|
app.register_blueprint(reports_bp)
|
||||||
|
|||||||
@@ -1,34 +1,75 @@
|
|||||||
from app import db
|
|
||||||
from app.models import WorkEntry, LeaveBalance
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models import LeaveBalance, WorkEntry
|
||||||
|
|
||||||
|
|
||||||
def compute_leave_used(year: int) -> dict[str, int]:
|
def compute_leave_used(year: int) -> dict[str, int]:
|
||||||
|
"""
|
||||||
|
Calcule le nombre de jours de congés et de RTT consommés pour une année donnée.
|
||||||
|
|
||||||
|
Cette fonction interroge la base de données pour compter le nombre d'entrées
|
||||||
|
de journal (`WorkEntry`) de type 'CONGE' et 'RTT' comprises entre le 1er janvier
|
||||||
|
et le 31 décembre de l'année spécifiée.
|
||||||
|
|
||||||
|
Accès DB :
|
||||||
|
- Lecture seule sur la table `work_entries`.
|
||||||
|
|
||||||
|
Paramètres :
|
||||||
|
year (int) : L'année pour laquelle calculer les jours consommés.
|
||||||
|
|
||||||
|
Retour :
|
||||||
|
dict[str, int] : Un dictionnaire contenant :
|
||||||
|
- "conges" (int) : Le nombre de jours de congés consommés.
|
||||||
|
- "rtt" (int) : Le nombre de jours de RTT consommés.
|
||||||
|
"""
|
||||||
start = date(year, 1, 1)
|
start = date(year, 1, 1)
|
||||||
end = date(year, 12, 31)
|
end = date(year, 12, 31)
|
||||||
|
|
||||||
conges = db.session.scalar(
|
conges = (
|
||||||
|
db.session.scalar(
|
||||||
sa.select(sa.func.count()).where(
|
sa.select(sa.func.count()).where(
|
||||||
WorkEntry.date.between(start, end),
|
WorkEntry.date.between(start, end),
|
||||||
WorkEntry.day_type == "CONGE",
|
WorkEntry.day_type == "CONGE",
|
||||||
)
|
)
|
||||||
) or 0
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
rtt = db.session.scalar(
|
rtt = (
|
||||||
|
db.session.scalar(
|
||||||
sa.select(sa.func.count()).where(
|
sa.select(sa.func.count()).where(
|
||||||
WorkEntry.date.between(start, end),
|
WorkEntry.date.between(start, end),
|
||||||
WorkEntry.day_type == "RTT",
|
WorkEntry.day_type == "RTT",
|
||||||
)
|
)
|
||||||
) or 0
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
return {"conges": conges, "rtt": rtt}
|
return {"conges": conges, "rtt": rtt}
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_balance(year: int) -> LeaveBalance:
|
def get_or_create_balance(year: int) -> LeaveBalance:
|
||||||
balance = db.session.scalar(
|
"""
|
||||||
sa.select(LeaveBalance).where(LeaveBalance.year == year)
|
Récupère le solde annuel des congés et RTT pour une année donnée, ou le crée s'il n'existe pas.
|
||||||
)
|
|
||||||
|
Si aucun solde n'existe pour l'année spécifiée, un nouvel enregistrement `LeaveBalance`
|
||||||
|
est créé avec les valeurs par défaut (28 jours de congés, 19 jours de RTT) et enregistré
|
||||||
|
en base de données.
|
||||||
|
|
||||||
|
Accès DB :
|
||||||
|
- Lecture sur la table `leave_balance`.
|
||||||
|
- Écriture (insertion et commit) si l'enregistrement n'existe pas.
|
||||||
|
|
||||||
|
Paramètres :
|
||||||
|
year (int) : L'année concernée.
|
||||||
|
|
||||||
|
Retour :
|
||||||
|
LeaveBalance : L'objet représentant le solde annuel pour l'année spécifiée.
|
||||||
|
"""
|
||||||
|
balance = db.session.scalar(sa.select(LeaveBalance).where(LeaveBalance.year == year))
|
||||||
if balance is None:
|
if balance is None:
|
||||||
balance = LeaveBalance(year=year)
|
balance = LeaveBalance(year=year)
|
||||||
db.session.add(balance)
|
db.session.add(balance)
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
|
import statistics as _stats
|
||||||
|
|
||||||
|
|
||||||
def minutes_to_str(minutes: int) -> str:
|
def minutes_to_str(minutes: int) -> str:
|
||||||
|
"""
|
||||||
|
Convertit une durée en minutes en une chaîne formatée lisible (ex: "7h45" ou "-1h15").
|
||||||
|
|
||||||
|
Paramètres :
|
||||||
|
minutes (int) : Le nombre de minutes à convertir (peut être négatif).
|
||||||
|
|
||||||
|
Retour :
|
||||||
|
str : La chaîne formatée au format "[signe]HhMM".
|
||||||
|
"""
|
||||||
sign = "-" if minutes < 0 else ""
|
sign = "-" if minutes < 0 else ""
|
||||||
minutes = abs(minutes)
|
minutes = abs(minutes)
|
||||||
return f"{sign}{minutes // 60}h{minutes % 60:02d}"
|
return f"{sign}{minutes // 60}h{minutes % 60:02d}"
|
||||||
@@ -18,15 +30,79 @@ _REFERENCE_MINUTES = {
|
|||||||
|
|
||||||
|
|
||||||
def work_minutes_reference(day_type: str) -> int:
|
def work_minutes_reference(day_type: str) -> int:
|
||||||
|
"""
|
||||||
|
Retourne la durée de travail de référence en minutes pour un type de journée donné.
|
||||||
|
|
||||||
|
Les durées de référence sont :
|
||||||
|
- WORK, TT, FORMATION : 7h45 (465 minutes)
|
||||||
|
- GARDE : 10h00 (600 minutes)
|
||||||
|
- ASTREINTE, RTT, CONGE, MALADE, FERIE : 0 minute
|
||||||
|
|
||||||
|
Paramètres :
|
||||||
|
day_type (str) : Le type de journée (ex: "WORK", "TT", "CONGE").
|
||||||
|
|
||||||
|
Retour :
|
||||||
|
int : La durée de référence en minutes. Par défaut 465 minutes si le type est inconnu.
|
||||||
|
"""
|
||||||
return _REFERENCE_MINUTES.get(day_type, 465)
|
return _REFERENCE_MINUTES.get(day_type, 465)
|
||||||
|
|
||||||
|
|
||||||
def week_balance_minutes(actual_minutes: int, reference_minutes: int) -> int:
|
def week_balance_minutes(actual_minutes: int, reference_minutes: int) -> int:
|
||||||
|
"""
|
||||||
|
Calcule l'écart (solde) entre les minutes réellement travaillées et les minutes de référence.
|
||||||
|
|
||||||
|
Paramètres :
|
||||||
|
actual_minutes (int) : Le total des minutes travaillées.
|
||||||
|
reference_minutes (int) : Le total des minutes de référence.
|
||||||
|
|
||||||
|
Retour :
|
||||||
|
int : L'écart en minutes (positif si heures supplémentaires, négatif si déficit).
|
||||||
|
"""
|
||||||
return actual_minutes - reference_minutes
|
return actual_minutes - reference_minutes
|
||||||
|
|
||||||
|
|
||||||
|
def monthly_stats(entries: list) -> dict:
|
||||||
|
"""
|
||||||
|
Calcule la médiane journalière et la médiane hebdomadaire (semaines ISO) pour un groupe d'entrées.
|
||||||
|
|
||||||
|
Les absences (durée de travail de 0 minute) sont incluses dans les calculs.
|
||||||
|
Les semaines sont regroupées selon le calendrier ISO (année, numéro de semaine).
|
||||||
|
|
||||||
|
Paramètres :
|
||||||
|
entries (list) : Une liste d'objets `WorkEntry`.
|
||||||
|
|
||||||
|
Retour :
|
||||||
|
dict : Un dictionnaire contenant :
|
||||||
|
- "median_daily_min" (int) : La médiane des minutes travaillées par jour.
|
||||||
|
- "median_weekly_min" (int) : La médiane des minutes travaillées par semaine ISO.
|
||||||
|
"""
|
||||||
|
if not entries:
|
||||||
|
return {"median_daily_min": 0, "median_weekly_min": 0}
|
||||||
|
|
||||||
|
daily = [e.total_minutes() for e in entries]
|
||||||
|
median_daily = int(_stats.median(daily))
|
||||||
|
|
||||||
|
weekly: dict[tuple, int] = {}
|
||||||
|
for e in entries:
|
||||||
|
key = e.date.isocalendar()[:2] # (year, isoweek)
|
||||||
|
weekly[key] = weekly.get(key, 0) + e.total_minutes()
|
||||||
|
|
||||||
|
median_weekly = int(_stats.median(weekly.values()))
|
||||||
|
|
||||||
|
return {"median_daily_min": median_daily, "median_weekly_min": median_weekly}
|
||||||
|
|
||||||
|
|
||||||
def count_day_types(entries: list) -> dict[str, int]:
|
def count_day_types(entries: list) -> dict[str, int]:
|
||||||
"""Retourne un dict {day_type: count} pour une liste d'entrées, sans les zéros."""
|
"""
|
||||||
|
Comptabilise le nombre d'occurrences de chaque type de journée dans une liste d'entrées.
|
||||||
|
|
||||||
|
Paramètres :
|
||||||
|
entries (list) : Une liste d'objets `WorkEntry`.
|
||||||
|
|
||||||
|
Retour :
|
||||||
|
dict[str, int] : Un dictionnaire associant chaque type de journée présent à son nombre d'occurrences.
|
||||||
|
Les types de journées non représentés ne figurent pas dans le dictionnaire.
|
||||||
|
"""
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
counts[entry.day_type] = counts.get(entry.day_type, 0) + 1
|
counts[entry.day_type] = counts.get(entry.day_type, 0) + 1
|
||||||
|
|||||||
@@ -4,9 +4,22 @@ def compute_km_for_entry(
|
|||||||
motor_vehicle_id: str | None = None,
|
motor_vehicle_id: str | None = None,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
"""
|
"""
|
||||||
Retourne un dict {vehicle_id: km} pour un profil de trajet donné.
|
Calcule les distances parcourues par véhicule pour une entrée de journal donnée.
|
||||||
La clé générique 'moteur' est remplacée par motor_vehicle_id si fourni.
|
|
||||||
Retourne {} si pas de profil (TT, CONGE, etc.).
|
Cette fonction associe un profil de trajet à ses distances configurées. Si le profil
|
||||||
|
contient une clé générique 'moteur', celle-ci est remplacée par l'identifiant réel du
|
||||||
|
véhicule motorisé (`motor_vehicle_id`) s'il est fourni. Si aucun véhicule motorisé n'est
|
||||||
|
fourni, la distance associée à la clé 'moteur' est ignorée.
|
||||||
|
|
||||||
|
Paramètres :
|
||||||
|
journey_profile_id (str | None) : L'identifiant du profil de trajet (ex: "domicile_travail").
|
||||||
|
Si None, retourne un dictionnaire vide.
|
||||||
|
journeys (dict) : La configuration des trajets (généralement issue de config.toml).
|
||||||
|
motor_vehicle_id (str | None) : L'identifiant du véhicule motorisé utilisé (ex: "citadine").
|
||||||
|
|
||||||
|
Retour :
|
||||||
|
dict[str, int] : Un dictionnaire associant chaque identifiant de véhicule (ex: "velo", "citadine")
|
||||||
|
à la distance parcourue en kilomètres.
|
||||||
"""
|
"""
|
||||||
if not journey_profile_id:
|
if not journey_profile_id:
|
||||||
return {}
|
return {}
|
||||||
@@ -23,7 +36,18 @@ def compute_km_for_entry(
|
|||||||
|
|
||||||
|
|
||||||
def compute_co2_grams(km_by_vehicle: dict[str, int], vehicles: dict) -> float:
|
def compute_co2_grams(km_by_vehicle: dict[str, int], vehicles: dict) -> float:
|
||||||
"""Calcule le CO2 total en grammes pour un dict {vehicle_id: km}."""
|
"""
|
||||||
|
Calcule la quantité totale de CO2 émise en grammes pour un ensemble de distances parcourues.
|
||||||
|
|
||||||
|
Paramètres :
|
||||||
|
km_by_vehicle (dict[str, int]) : Un dictionnaire associant chaque identifiant de véhicule
|
||||||
|
à la distance parcourue en kilomètres.
|
||||||
|
vehicles (dict) : La configuration des véhicules (généralement issue de config.toml)
|
||||||
|
contenant le taux d'émission de CO2 par kilomètre (`co2_per_km`).
|
||||||
|
|
||||||
|
Retour :
|
||||||
|
float : La quantité totale de CO2 émise en grammes.
|
||||||
|
"""
|
||||||
total = 0.0
|
total = 0.0
|
||||||
for vehicle_id, km in km_by_vehicle.items():
|
for vehicle_id, km in km_by_vehicle.items():
|
||||||
vehicle = vehicles.get(vehicle_id, {})
|
vehicle = vehicles.get(vehicle_id, {})
|
||||||
@@ -32,11 +56,26 @@ def compute_co2_grams(km_by_vehicle: dict[str, int], vehicles: dict) -> float:
|
|||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
def compute_frais_reels(total_km_moteur: float, tranches: list[dict], electric: bool = False) -> float:
|
def compute_frais_reels(
|
||||||
|
total_km_moteur: float, tranches: list[dict], electric: bool = False
|
||||||
|
) -> float:
|
||||||
"""
|
"""
|
||||||
Calcule les frais réels fiscaux selon le barème kilométrique.
|
Calcule le montant des frais réels déductibles selon le barème kilométrique fiscal.
|
||||||
km_max = 0 signifie "pas de limite" (dernière tranche).
|
|
||||||
electric=True applique la majoration de 20 % pour véhicules électriques.
|
Le calcul s'effectue tranche par tranche en fonction du kilométrage annuel total parcouru
|
||||||
|
avec un véhicule motorisé. Une tranche avec `km_max = 0` signifie l'absence de limite
|
||||||
|
supérieure et est conventionnellement la dernière tranche du barème.
|
||||||
|
|
||||||
|
Une majoration de 20 % est appliquée sur le montant final si le véhicule est électrique.
|
||||||
|
|
||||||
|
Paramètres :
|
||||||
|
total_km_moteur (float) : Le kilométrage annuel total parcouru avec le véhicule motorisé.
|
||||||
|
tranches (list[dict]) : La liste des tranches du barème kilométrique pour la puissance fiscale
|
||||||
|
du véhicule (ex: taux, forfait, km_max).
|
||||||
|
electric (bool) : Indique si le véhicule est électrique (applique une majoration de 20 % si True).
|
||||||
|
|
||||||
|
Retour :
|
||||||
|
float : Le montant total calculé des frais réels en euros.
|
||||||
"""
|
"""
|
||||||
if not tranches or total_km_moteur <= 0:
|
if not tranches or total_km_moteur <= 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|||||||
@@ -1,21 +1,76 @@
|
|||||||
|
"""Module de chargement et d'accès à la configuration TOML de l'application.
|
||||||
|
|
||||||
|
Ce module sert d'interface entre l'application Flask et le fichier `config.toml`.
|
||||||
|
Toute la configuration des véhicules, des trajets et du barème kilométrique y est stockée
|
||||||
|
et chargée au démarrage dans `app.config["TOML"]`.
|
||||||
|
|
||||||
|
Contrat TOML :
|
||||||
|
1. Véhicules (`[vehicles]`) :
|
||||||
|
- Chaque véhicule possède un identifiant unique (clé).
|
||||||
|
- Attributs : `name` (nom d'affichage), `type` ("moteur" ou "velo"), `fuel` ("electric", "essence", etc.),
|
||||||
|
`co2_per_km` (émissions de CO2 en grammes par km), et optionnellement `cv` (puissance fiscale pour le barème kilométrique).
|
||||||
|
2. Trajets (`[journeys]`) :
|
||||||
|
- Profils de trajets prédéfinis (ex: "moteur_seul").
|
||||||
|
- Attributs : `name` (nom d'affichage), `distances` (dictionnaire associant un type de véhicule à une distance en km).
|
||||||
|
3. Barème kilométrique (`[bareme_kilometrique.YYYY]`) :
|
||||||
|
- Organisé par année (ex: "2026") puis par puissance fiscale (`cv_3`, `cv_4`, `cv_5`, `cv_6`, `cv_7plus`).
|
||||||
|
- Chaque catégorie contient une liste de `tranches` définissant les formules de calcul des frais réels.
|
||||||
|
- Une tranche possède : `km_max` (limite supérieure de la tranche, `km_max = 0` signifie "pas de limite supérieure"),
|
||||||
|
`taux` (coefficient multiplicateur par km) et `forfait` (montant forfaitaire à ajouter).
|
||||||
|
4. Types de jours sans trajet :
|
||||||
|
- Certains types de journées (Télétravail, Maladie, Congé, RTT, Férié) n'impliquent aucun déplacement physique.
|
||||||
|
"""
|
||||||
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
|
|
||||||
def get_vehicles():
|
def get_vehicles():
|
||||||
|
"""Récupère l'ensemble des véhicules configurés dans le fichier TOML.
|
||||||
|
|
||||||
|
Retourne:
|
||||||
|
dict: Un dictionnaire des véhicules où la clé est l'identifiant du véhicule
|
||||||
|
et la valeur est un dictionnaire contenant ses propriétés (name, type, fuel, co2_per_km, cv).
|
||||||
|
Retourne un dictionnaire vide si aucune configuration n'est chargée.
|
||||||
|
"""
|
||||||
return current_app.config.get("TOML", {}).get("vehicles", {})
|
return current_app.config.get("TOML", {}).get("vehicles", {})
|
||||||
|
|
||||||
|
|
||||||
def get_motor_vehicles():
|
def get_motor_vehicles():
|
||||||
"""Retourne uniquement les véhicules de type 'moteur'."""
|
"""Filtre et retourne uniquement les véhicules de type 'moteur'.
|
||||||
|
|
||||||
|
Cette fonction exclut les véhicules alternatifs (comme les vélos) pour ne conserver
|
||||||
|
que ceux qui possèdent une puissance fiscale (CV) et sont éligibles au barème kilométrique.
|
||||||
|
|
||||||
|
Retourne:
|
||||||
|
dict: Un dictionnaire contenant uniquement les véhicules dont le type est 'moteur'.
|
||||||
|
"""
|
||||||
return {k: v for k, v in get_vehicles().items() if v.get("type") == "moteur"}
|
return {k: v for k, v in get_vehicles().items() if v.get("type") == "moteur"}
|
||||||
|
|
||||||
|
|
||||||
def get_journeys():
|
def get_journeys():
|
||||||
|
"""Récupère l'ensemble des profils de trajets configurés dans le fichier TOML.
|
||||||
|
|
||||||
|
Retourne:
|
||||||
|
dict: Un dictionnaire des trajets où la clé est l'identifiant du trajet
|
||||||
|
et la valeur est un dictionnaire contenant ses propriétés (name, distances).
|
||||||
|
Retourne un dictionnaire vide si aucune configuration n'est chargée.
|
||||||
|
"""
|
||||||
return current_app.config.get("TOML", {}).get("journeys", {})
|
return current_app.config.get("TOML", {}).get("journeys", {})
|
||||||
|
|
||||||
|
|
||||||
def journey_has_motor(journey_profile_id: str | None) -> bool:
|
def journey_has_motor(journey_profile_id: str | None) -> bool:
|
||||||
"""Retourne True si le profil de trajet inclut un véhicule à moteur."""
|
"""Vérifie si un profil de trajet donné inclut une distance pour véhicule à moteur.
|
||||||
|
|
||||||
|
Cette validation permet de déterminer si l'utilisateur doit sélectionner un véhicule
|
||||||
|
à moteur lors de la saisie d'une journée de travail avec ce trajet.
|
||||||
|
|
||||||
|
Paramètres:
|
||||||
|
journey_profile_id (str | None): L'identifiant du profil de trajet à vérifier.
|
||||||
|
|
||||||
|
Retourne:
|
||||||
|
bool: True si le trajet existe et définit une distance pour la clé 'moteur',
|
||||||
|
False sinon ou si l'identifiant est nul.
|
||||||
|
"""
|
||||||
if not journey_profile_id:
|
if not journey_profile_id:
|
||||||
return False
|
return False
|
||||||
journeys = get_journeys()
|
journeys = get_journeys()
|
||||||
@@ -24,6 +79,24 @@ def journey_has_motor(journey_profile_id: str | None) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def get_bareme(year: int, cv: int) -> list[dict]:
|
def get_bareme(year: int, cv: int) -> list[dict]:
|
||||||
|
"""Récupère les tranches du barème kilométrique pour une année et une puissance fiscale données.
|
||||||
|
|
||||||
|
Le barème kilométrique officiel est structuré en tranches de distances annuelles.
|
||||||
|
Cette fonction sélectionne la bonne catégorie de puissance fiscale (CV) :
|
||||||
|
- cv <= 3 -> 'cv_3'
|
||||||
|
- cv == 4 -> 'cv_4'
|
||||||
|
- cv == 5 -> 'cv_5'
|
||||||
|
- cv == 6 -> 'cv_6'
|
||||||
|
- cv >= 7 -> 'cv_7plus'
|
||||||
|
|
||||||
|
Paramètres:
|
||||||
|
year (int): L'année civile concernée par le calcul.
|
||||||
|
cv (int): La puissance fiscale du véhicule en chevaux fiscaux.
|
||||||
|
|
||||||
|
Retourne:
|
||||||
|
list[dict]: Une liste de dictionnaires représentant les tranches applicables.
|
||||||
|
Chaque tranche contient 'km_max' (0 si pas de limite), 'taux' et 'forfait'.
|
||||||
|
"""
|
||||||
bareme = current_app.config.get("TOML", {}).get("bareme_kilometrique", {})
|
bareme = current_app.config.get("TOML", {}).get("bareme_kilometrique", {})
|
||||||
year_data = bareme.get(str(year), {})
|
year_data = bareme.get(str(year), {})
|
||||||
if cv <= 3:
|
if cv <= 3:
|
||||||
@@ -40,4 +113,12 @@ def get_bareme(year: int, cv: int) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def day_types_without_journey():
|
def day_types_without_journey():
|
||||||
|
"""Retourne l'ensemble des types de jours qui n'impliquent aucun trajet physique.
|
||||||
|
|
||||||
|
Ces types de jours (Télétravail, Maladie, Congé, RTT, Férié) sont exemptés de la saisie
|
||||||
|
de trajets ou de véhicules, car le travail s'effectue à distance ou l'employé est absent.
|
||||||
|
|
||||||
|
Retourne:
|
||||||
|
set[str]: Un ensemble de codes de types de jours (ex: {"TT", "MALADE", ...}).
|
||||||
|
"""
|
||||||
return {"TT", "MALADE", "CONGE", "RTT", "FERIE"}
|
return {"TT", "MALADE", "CONGE", "RTT", "FERIE"}
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
from app import db
|
from datetime import UTC, date, datetime, time
|
||||||
from datetime import date, time, datetime
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import sqlalchemy.orm as so
|
import sqlalchemy.orm as so
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
class WorkEntry(db.Model):
|
class WorkEntry(db.Model):
|
||||||
|
"""
|
||||||
|
Représente une entrée de journal de travail pour une journée unique.
|
||||||
|
|
||||||
|
Cette classe stocke les informations relatives à une journée de travail,
|
||||||
|
notamment la date, le type de journée (WORK, TT, GARDE, ASTREINTE, etc.),
|
||||||
|
les profils de trajet domicile-travail, le véhicule utilisé, un commentaire
|
||||||
|
et les plages horaires associées.
|
||||||
|
|
||||||
|
Invariants :
|
||||||
|
- La date est unique (une seule entrée par jour).
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "work_entries"
|
__tablename__ = "work_entries"
|
||||||
|
|
||||||
id: so.Mapped[int] = so.mapped_column(primary_key=True)
|
id: so.Mapped[int] = so.mapped_column(primary_key=True)
|
||||||
@@ -13,9 +27,11 @@ class WorkEntry(db.Model):
|
|||||||
motor_vehicle_id: so.Mapped[str | None] = so.mapped_column(sa.String(64), nullable=True)
|
motor_vehicle_id: so.Mapped[str | None] = so.mapped_column(sa.String(64), nullable=True)
|
||||||
day_type: so.Mapped[str] = so.mapped_column(sa.String(16), nullable=False, default="WORK")
|
day_type: so.Mapped[str] = so.mapped_column(sa.String(16), nullable=False, default="WORK")
|
||||||
comment: so.Mapped[str | None] = so.mapped_column(sa.Text, nullable=True)
|
comment: so.Mapped[str | None] = so.mapped_column(sa.Text, nullable=True)
|
||||||
created_at: so.Mapped[datetime] = so.mapped_column(sa.DateTime, default=datetime.utcnow)
|
created_at: so.Mapped[datetime] = so.mapped_column(
|
||||||
|
sa.DateTime, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
updated_at: so.Mapped[datetime] = so.mapped_column(
|
updated_at: so.Mapped[datetime] = so.mapped_column(
|
||||||
sa.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
sa.DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
|
||||||
)
|
)
|
||||||
|
|
||||||
time_slots: so.Mapped[list["TimeSlot"]] = so.relationship(
|
time_slots: so.Mapped[list["TimeSlot"]] = so.relationship(
|
||||||
@@ -23,6 +39,17 @@ class WorkEntry(db.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def total_minutes(self) -> int:
|
def total_minutes(self) -> int:
|
||||||
|
"""
|
||||||
|
Calcule la durée totale travaillée dans la journée en minutes.
|
||||||
|
|
||||||
|
Cette méthode somme la durée de toutes les plages horaires (`TimeSlot`)
|
||||||
|
associées à cette entrée. Elle gère le passage de minuit : si l'heure de fin
|
||||||
|
d'une plage est inférieure ou égale à son heure de début, la plage est
|
||||||
|
considérée comme se terminant le lendemain (ajout de 24 heures).
|
||||||
|
|
||||||
|
Retour :
|
||||||
|
int : La durée totale en minutes.
|
||||||
|
"""
|
||||||
total = 0
|
total = 0
|
||||||
for slot in self.time_slots:
|
for slot in self.time_slots:
|
||||||
start = slot.start_time.hour * 60 + slot.start_time.minute
|
start = slot.start_time.hour * 60 + slot.start_time.minute
|
||||||
@@ -33,11 +60,24 @@ class WorkEntry(db.Model):
|
|||||||
return total
|
return total
|
||||||
|
|
||||||
def total_hours_str(self) -> str:
|
def total_hours_str(self) -> str:
|
||||||
|
"""
|
||||||
|
Retourne la durée totale travaillée sous forme de chaîne formatée (ex: "7h45").
|
||||||
|
|
||||||
|
Retour :
|
||||||
|
str : La durée formatée au format "HhMM".
|
||||||
|
"""
|
||||||
minutes = self.total_minutes()
|
minutes = self.total_minutes()
|
||||||
return f"{minutes // 60}h{minutes % 60:02d}"
|
return f"{minutes // 60}h{minutes % 60:02d}"
|
||||||
|
|
||||||
|
|
||||||
class TimeSlot(db.Model):
|
class TimeSlot(db.Model):
|
||||||
|
"""
|
||||||
|
Représente une plage horaire de travail au sein d'une journée.
|
||||||
|
|
||||||
|
Chaque plage possède une heure de début et une heure de fin. Elle est rattachée
|
||||||
|
à une entrée de journal (`WorkEntry`).
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "time_slots"
|
__tablename__ = "time_slots"
|
||||||
|
|
||||||
id: so.Mapped[int] = so.mapped_column(primary_key=True)
|
id: so.Mapped[int] = so.mapped_column(primary_key=True)
|
||||||
@@ -49,6 +89,13 @@ class TimeSlot(db.Model):
|
|||||||
|
|
||||||
|
|
||||||
class LeaveBalance(db.Model):
|
class LeaveBalance(db.Model):
|
||||||
|
"""
|
||||||
|
Représente le solde annuel des congés et RTT pour une année donnée.
|
||||||
|
|
||||||
|
Stocke les quotas initiaux/totaux de congés payés et de RTT alloués pour l'année.
|
||||||
|
Par défaut, un utilisateur bénéficie de 28 jours de congés et 19 jours de RTT.
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "leave_balance"
|
__tablename__ = "leave_balance"
|
||||||
|
|
||||||
id: so.Mapped[int] = so.mapped_column(primary_key=True)
|
id: so.Mapped[int] = so.mapped_column(primary_key=True)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Package contenant les blueprints de routes de l'application.
|
||||||
|
|
||||||
|
Ce package regroupe les différents modules de routes (blueprints) de l'application :
|
||||||
|
- `dashboard` : Gère l'affichage du tableau de bord principal.
|
||||||
|
- `entries` : Gère la saisie, la modification et la suppression des entrées de temps.
|
||||||
|
- `reports` : Gère la génération des rapports d'activité annuels et mensuels.
|
||||||
|
"""
|
||||||
|
|||||||
@@ -1,18 +1,48 @@
|
|||||||
from flask import Blueprint, render_template
|
"""Blueprint des routes du tableau de bord principal.
|
||||||
|
|
||||||
|
Ce module gère l'affichage de la page d'accueil (le tableau de bord). Il calcule et rassemble
|
||||||
|
les statistiques clés de la semaine courante et du mois en cours, ainsi que le solde des congés
|
||||||
|
et RTT pour l'année civile.
|
||||||
|
"""
|
||||||
|
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.models import WorkEntry
|
|
||||||
from app.business.time_calc import minutes_to_str, work_minutes_reference
|
|
||||||
from app.business.travel_calc import compute_km_for_entry, compute_co2_grams
|
|
||||||
from app.business.leave_calc import compute_leave_used, get_or_create_balance
|
from app.business.leave_calc import compute_leave_used, get_or_create_balance
|
||||||
from app.config_loader import get_vehicles, get_journeys
|
from app.business.time_calc import minutes_to_str, work_minutes_reference
|
||||||
|
from app.business.travel_calc import compute_co2_grams, compute_km_for_entry
|
||||||
|
from app.config_loader import get_journeys, get_vehicles
|
||||||
|
from app.models import WorkEntry
|
||||||
|
|
||||||
bp = Blueprint("dashboard", __name__)
|
bp = Blueprint("dashboard", __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/")
|
@bp.route("/")
|
||||||
def index():
|
def index():
|
||||||
|
"""Affiche le tableau de bord principal de l'utilisateur.
|
||||||
|
|
||||||
|
Cette route effectue les opérations suivantes :
|
||||||
|
1. Détermine la date du jour et calcule les limites de la semaine courante (du lundi au dimanche).
|
||||||
|
2. Récupère toutes les entrées de temps (`WorkEntry`) de la semaine courante pour calculer :
|
||||||
|
- Le temps de travail effectif cumulé (`week_actual`).
|
||||||
|
- Le temps de travail de référence théorique (`week_ref`) selon le type de chaque journée.
|
||||||
|
- Le solde d'heures de la semaine (`week_balance` = effectif - référence).
|
||||||
|
3. Récupère toutes les entrées de temps du mois en cours (du 1er jour du mois jusqu'à aujourd'hui) pour calculer :
|
||||||
|
- Les distances parcourues par véhicule (`month_km`) à partir des profils de trajets associés.
|
||||||
|
- Les émissions de CO2 correspondantes (`month_co2`).
|
||||||
|
4. Récupère ou initialise le solde annuel des congés et RTT (`balance`) et calcule les jours posés/utilisés (`used`).
|
||||||
|
5. Vérifie s'il existe déjà une entrée de temps pour la journée d'aujourd'hui (`today_entry`).
|
||||||
|
6. Rend le template `dashboard.html` avec l'ensemble de ces données de contexte.
|
||||||
|
|
||||||
|
Méthode HTTP :
|
||||||
|
GET
|
||||||
|
|
||||||
|
Retourne:
|
||||||
|
str: Le rendu HTML de la page du tableau de bord (`dashboard.html`).
|
||||||
|
"""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
year = today.year
|
year = today.year
|
||||||
|
|
||||||
@@ -45,9 +75,7 @@ def index():
|
|||||||
balance = get_or_create_balance(year)
|
balance = get_or_create_balance(year)
|
||||||
used = compute_leave_used(year)
|
used = compute_leave_used(year)
|
||||||
|
|
||||||
today_entry = db.session.scalar(
|
today_entry = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.date == today))
|
||||||
sa.select(WorkEntry).where(WorkEntry.date == today)
|
|
||||||
)
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
|
|||||||
@@ -1,9 +1,34 @@
|
|||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
"""Blueprint des routes de gestion des entrées de temps (WorkEntry).
|
||||||
|
|
||||||
|
Ce module gère le cycle de vie complet des entrées journalières de travail :
|
||||||
|
- Affichage de la liste historique des entrées.
|
||||||
|
- Création d'une nouvelle entrée (formulaire et traitement POST).
|
||||||
|
- Modification d'une entrée existante (formulaire pré-rempli et traitement POST).
|
||||||
|
- Suppression d'une entrée.
|
||||||
|
|
||||||
|
Règles de validation et de cohérence des données :
|
||||||
|
1. Unicité de la date : Une seule entrée (`WorkEntry`) est autorisée par jour.
|
||||||
|
2. Types de jours sans trajet : Si le type de jour est dans `day_types_without_journey()` (TT, MALADE, CONGE, RTT, FERIE),
|
||||||
|
le profil de trajet (`journey_profile_id`) est forcé à `None`.
|
||||||
|
3. Véhicule à moteur : Si le profil de trajet sélectionné n'inclut pas de véhicule à moteur (vérifié via `journey_has_motor`),
|
||||||
|
le véhicule à moteur (`motor_vehicle_id`) est forcé à `None`.
|
||||||
|
4. Plages horaires : Les plages horaires (`TimeSlot`) existantes d'une entrée sont supprimées et recréées à chaque soumission
|
||||||
|
pour simplifier la mise à jour des plages multiples.
|
||||||
|
"""
|
||||||
|
|
||||||
from datetime import date, time
|
from datetime import date, time
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.models import WorkEntry, TimeSlot
|
from app.config_loader import (
|
||||||
from app.config_loader import get_journeys, get_motor_vehicles, day_types_without_journey, journey_has_motor
|
day_types_without_journey,
|
||||||
|
get_journeys,
|
||||||
|
get_motor_vehicles,
|
||||||
|
journey_has_motor,
|
||||||
|
)
|
||||||
|
from app.models import TimeSlot, WorkEntry
|
||||||
|
|
||||||
bp = Blueprint("entries", __name__, url_prefix="/entries")
|
bp = Blueprint("entries", __name__, url_prefix="/entries")
|
||||||
|
|
||||||
@@ -22,15 +47,55 @@ DAY_TYPES = [
|
|||||||
|
|
||||||
@bp.route("/")
|
@bp.route("/")
|
||||||
def list_entries():
|
def list_entries():
|
||||||
entries = db.session.scalars(
|
"""Affiche la liste historique de toutes les entrées de temps enregistrées.
|
||||||
sa.select(WorkEntry).order_by(WorkEntry.date.desc())
|
|
||||||
).all()
|
Cette route récupère l'ensemble des entrées (`WorkEntry`) triées par date décroissante
|
||||||
|
et les transmet au template pour affichage sous forme de tableau ou de liste.
|
||||||
|
|
||||||
|
Méthode HTTP :
|
||||||
|
GET
|
||||||
|
|
||||||
|
Retourne:
|
||||||
|
str: Le rendu HTML de la liste des entrées (`entry_list.html`).
|
||||||
|
"""
|
||||||
|
entries = db.session.scalars(sa.select(WorkEntry).order_by(WorkEntry.date.desc())).all()
|
||||||
return render_template("entry_list.html", entries=entries)
|
return render_template("entry_list.html", entries=entries)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/new", methods=["GET", "POST"])
|
@bp.route("/new", methods=["GET", "POST"])
|
||||||
@bp.route("/<int:entry_id>/edit", methods=["GET", "POST"])
|
@bp.route("/<int:entry_id>/edit", methods=["GET", "POST"])
|
||||||
def entry_form(entry_id=None):
|
def entry_form(entry_id=None):
|
||||||
|
"""Gère l'affichage du formulaire et l'enregistrement (création ou modification) d'une entrée.
|
||||||
|
|
||||||
|
Cette route est doublement mappée pour la création (`/new`) et l'édition (`/<entry_id>/edit`).
|
||||||
|
|
||||||
|
Comportement en GET :
|
||||||
|
- Si `entry_id` est fourni, récupère l'entrée correspondante en base de données. Si elle n'existe pas,
|
||||||
|
affiche un message d'erreur et redirige vers la liste des entrées.
|
||||||
|
- Prépare le contexte nécessaire au formulaire : liste des types de jours, profils de trajets,
|
||||||
|
véhicules à moteur disponibles, types de jours sans trajet, et la date du jour par défaut.
|
||||||
|
- Rend le template `entry_form.html`.
|
||||||
|
|
||||||
|
Comportement en POST :
|
||||||
|
- Extrait et valide les données du formulaire : date, type de jour, trajet, véhicule à moteur, commentaire.
|
||||||
|
- Applique les règles de cohérence (mise à `None` du trajet ou du véhicule si les conditions ne sont pas remplies).
|
||||||
|
- En création : vérifie qu'aucune entrée n'existe déjà à cette date. Si c'est le cas, affiche une erreur.
|
||||||
|
- Enregistre ou met à jour l'objet `WorkEntry` en base de données.
|
||||||
|
- Supprime toutes les plages horaires (`TimeSlot`) existantes associées à cette entrée.
|
||||||
|
- Parcourt les listes d'heures de début (`start_time`) et de fin (`end_time`) soumises, et recrée les objets
|
||||||
|
`TimeSlot` valides associés à l'entrée.
|
||||||
|
- Valide la transaction en base de données (`db.session.commit()`), affiche un message de succès
|
||||||
|
et redirige vers le tableau de bord.
|
||||||
|
|
||||||
|
Paramètres:
|
||||||
|
entry_id (int | None): L'identifiant de l'entrée à modifier, ou None pour une nouvelle entrée.
|
||||||
|
|
||||||
|
Méthodes HTTP :
|
||||||
|
GET, POST
|
||||||
|
|
||||||
|
Retourne:
|
||||||
|
str | Response: Le rendu HTML du formulaire (GET) ou une redirection HTTP (POST / erreur).
|
||||||
|
"""
|
||||||
entry = None
|
entry = None
|
||||||
if entry_id:
|
if entry_id:
|
||||||
entry = db.session.get(WorkEntry, entry_id)
|
entry = db.session.get(WorkEntry, entry_id)
|
||||||
@@ -51,9 +116,7 @@ def entry_form(entry_id=None):
|
|||||||
journey_profile_id = None
|
journey_profile_id = None
|
||||||
|
|
||||||
if entry is None:
|
if entry is None:
|
||||||
existing = db.session.scalar(
|
existing = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.date == entry_date))
|
||||||
sa.select(WorkEntry).where(WorkEntry.date == entry_date)
|
|
||||||
)
|
|
||||||
if existing:
|
if existing:
|
||||||
flash(f"Une entrée existe déjà pour le {entry_date}.", "error")
|
flash(f"Une entrée existe déjà pour le {entry_date}.", "error")
|
||||||
return redirect(url_for("entries.entry_form"))
|
return redirect(url_for("entries.entry_form"))
|
||||||
@@ -72,11 +135,13 @@ def entry_form(entry_id=None):
|
|||||||
ends = request.form.getlist("end_time")
|
ends = request.form.getlist("end_time")
|
||||||
for s, e in zip(starts, ends):
|
for s, e in zip(starts, ends):
|
||||||
if s and e:
|
if s and e:
|
||||||
db.session.add(TimeSlot(
|
db.session.add(
|
||||||
|
TimeSlot(
|
||||||
entry=entry,
|
entry=entry,
|
||||||
start_time=time.fromisoformat(s),
|
start_time=time.fromisoformat(s),
|
||||||
end_time=time.fromisoformat(e),
|
end_time=time.fromisoformat(e),
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Entrée enregistrée.", "success")
|
flash("Entrée enregistrée.", "success")
|
||||||
@@ -96,6 +161,21 @@ def entry_form(entry_id=None):
|
|||||||
|
|
||||||
@bp.route("/<int:entry_id>/delete", methods=["POST"])
|
@bp.route("/<int:entry_id>/delete", methods=["POST"])
|
||||||
def delete_entry(entry_id):
|
def delete_entry(entry_id):
|
||||||
|
"""Supprime une entrée de temps existante.
|
||||||
|
|
||||||
|
Cette route récupère l'entrée par son identifiant, la supprime de la base de données
|
||||||
|
(les plages horaires associées sont également supprimées en cascade si configuré, ou gérées par SQLAlchemy),
|
||||||
|
valide la transaction, affiche un message de succès et redirige vers la liste des entrées.
|
||||||
|
|
||||||
|
Paramètres:
|
||||||
|
entry_id (int): L'identifiant de l'entrée à supprimer.
|
||||||
|
|
||||||
|
Méthode HTTP :
|
||||||
|
POST (sécurisé contre les suppressions accidentelles via GET)
|
||||||
|
|
||||||
|
Retourne:
|
||||||
|
Response: Une redirection HTTP vers la liste des entrées (`entries.list_entries`).
|
||||||
|
"""
|
||||||
entry = db.session.get(WorkEntry, entry_id)
|
entry = db.session.get(WorkEntry, entry_id)
|
||||||
if entry:
|
if entry:
|
||||||
db.session.delete(entry)
|
db.session.delete(entry)
|
||||||
|
|||||||
@@ -1,17 +1,71 @@
|
|||||||
from flask import Blueprint, render_template, request
|
"""Blueprint des routes de génération des rapports et statistiques.
|
||||||
|
|
||||||
|
Ce module gère l'affichage des rapports annuels et mensuels. Il calcule les distances cumulées,
|
||||||
|
les émissions de CO2, les frais réels selon le barème kilométrique officiel, et compile les statistiques
|
||||||
|
de temps de travail (médianes quotidiennes et hebdomadaires) pour chaque mois de l'année sélectionnée.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from flask import Blueprint, render_template, request
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
|
from app.business.time_calc import count_day_types, minutes_to_str, monthly_stats
|
||||||
|
from app.business.travel_calc import compute_co2_grams, compute_frais_reels, compute_km_for_entry
|
||||||
|
from app.config_loader import get_bareme, get_journeys, get_vehicles
|
||||||
from app.models import WorkEntry
|
from app.models import WorkEntry
|
||||||
from app.business.travel_calc import compute_km_for_entry, compute_co2_grams, compute_frais_reels
|
|
||||||
from app.business.time_calc import count_day_types
|
|
||||||
from app.config_loader import get_vehicles, get_journeys, get_bareme
|
|
||||||
|
|
||||||
bp = Blueprint("reports", __name__, url_prefix="/reports")
|
bp = Blueprint("reports", __name__, url_prefix="/reports")
|
||||||
|
|
||||||
|
MONTHS_FR = {
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/")
|
@bp.route("/")
|
||||||
def index():
|
def index():
|
||||||
|
"""Génère et affiche le rapport d'activité annuel et mensuel.
|
||||||
|
|
||||||
|
Cette route effectue les opérations suivantes :
|
||||||
|
1. Récupère l'année cible depuis les paramètres de requête HTTP GET (`year`). Par défaut, utilise l'année en cours.
|
||||||
|
2. Récupère toutes les entrées de temps (`WorkEntry`) de cette année civile.
|
||||||
|
3. Récupère la configuration des véhicules et des trajets depuis le fichier TOML.
|
||||||
|
4. Calcule les statistiques annuelles cumulées :
|
||||||
|
- Distances parcourues par véhicule (`total_km`).
|
||||||
|
- Émissions de CO2 totales en grammes (`total_co2`), converties ensuite en kilogrammes.
|
||||||
|
- Frais réels remboursables par véhicule (`frais_reels`) en appliquant le barème kilométrique officiel
|
||||||
|
de l'année correspondante (avec une majoration de +20% pour les véhicules électriques).
|
||||||
|
- Nombre de jours par type de journée (`day_type_counts`).
|
||||||
|
5. Regroupe les entrées par mois pour calculer les statistiques mensuelles :
|
||||||
|
- Nom du mois en français.
|
||||||
|
- Nombre de jours saisis.
|
||||||
|
- Distances parcourues par véhicule et distance totale du mois.
|
||||||
|
- Durée quotidienne médiane de travail et durée hebdomadaire médiane de travail (formatées en chaînes "HHhMM").
|
||||||
|
6. Rend le template `reports.html` avec l'ensemble de ces données de contexte.
|
||||||
|
|
||||||
|
Méthode HTTP :
|
||||||
|
GET
|
||||||
|
|
||||||
|
Paramètres de requête (Query Params) :
|
||||||
|
year (int, optionnel) : L'année civile pour laquelle générer le rapport (ex: `?year=2026`).
|
||||||
|
Par défaut, l'année courante.
|
||||||
|
|
||||||
|
Retourne:
|
||||||
|
str: Le rendu HTML de la page des rapports (`reports.html`).
|
||||||
|
"""
|
||||||
year = request.args.get("year", date.today().year, type=int)
|
year = request.args.get("year", date.today().year, type=int)
|
||||||
start = date(year, 1, 1)
|
start = date(year, 1, 1)
|
||||||
end = date(year, 12, 31)
|
end = date(year, 12, 31)
|
||||||
@@ -23,6 +77,7 @@ def index():
|
|||||||
vehicles = get_vehicles()
|
vehicles = get_vehicles()
|
||||||
journeys = get_journeys()
|
journeys = get_journeys()
|
||||||
|
|
||||||
|
# --- Stats annuelles (inchangées) ---
|
||||||
total_km = {}
|
total_km = {}
|
||||||
total_co2 = 0.0
|
total_co2 = 0.0
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
@@ -42,6 +97,34 @@ def index():
|
|||||||
|
|
||||||
day_type_counts = count_day_types(entries)
|
day_type_counts = count_day_types(entries)
|
||||||
|
|
||||||
|
# --- Stats mensuelles ---
|
||||||
|
entries_by_month: dict[int, list] = defaultdict(list)
|
||||||
|
for entry in entries:
|
||||||
|
entries_by_month[entry.date.month].append(entry)
|
||||||
|
|
||||||
|
monthly_data = {}
|
||||||
|
for month in range(1, 13):
|
||||||
|
month_entries = entries_by_month.get(month, [])
|
||||||
|
|
||||||
|
month_km: dict[str, int] = {}
|
||||||
|
for entry in month_entries:
|
||||||
|
km = compute_km_for_entry(entry.journey_profile_id, journeys, entry.motor_vehicle_id)
|
||||||
|
for v, d in km.items():
|
||||||
|
month_km[v] = month_km.get(v, 0) + d
|
||||||
|
|
||||||
|
stats = monthly_stats(month_entries)
|
||||||
|
|
||||||
|
monthly_data[month] = {
|
||||||
|
"month_name": MONTHS_FR[month],
|
||||||
|
"entry_count": len(month_entries),
|
||||||
|
"km_by_vehicle": month_km,
|
||||||
|
"km_total": sum(month_km.values()),
|
||||||
|
"median_daily_str": minutes_to_str(stats["median_daily_min"]) if month_entries else "–",
|
||||||
|
"median_weekly_str": minutes_to_str(stats["median_weekly_min"])
|
||||||
|
if month_entries
|
||||||
|
else "–",
|
||||||
|
}
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"reports.html",
|
"reports.html",
|
||||||
year=year,
|
year=year,
|
||||||
@@ -50,4 +133,5 @@ def index():
|
|||||||
frais_reels=frais_reels,
|
frais_reels=frais_reels,
|
||||||
vehicles=vehicles,
|
vehicles=vehicles,
|
||||||
day_type_counts=day_type_counts,
|
day_type_counts=day_type_counts,
|
||||||
|
monthly_data=monthly_data,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -78,4 +78,72 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Détail mensuel -->
|
||||||
|
<p class="font-display text-lg font-semibold mt-8 mb-3" style="color:var(--ink);">Détail mensuel</p>
|
||||||
|
|
||||||
|
{% for month_num in range(1, 13) %}
|
||||||
|
{% set m = monthly_data[month_num] %}
|
||||||
|
<div class="card card-ink mb-4">
|
||||||
|
<div class="flex items-baseline justify-between mb-2">
|
||||||
|
<p class="card-label">{{ m.month_name }}</p>
|
||||||
|
<span class="text-xs" style="color:#9A9288;">{{ m.entry_count }} j</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if m.entry_count == 0 %}
|
||||||
|
<p class="text-sm" style="color:#9A9288;">Aucune entrée</p>
|
||||||
|
{% else %}
|
||||||
|
{# --- Barre transport --- #}
|
||||||
|
{% if m.km_total > 0 %}
|
||||||
|
<p class="font-data font-semibold mb-1" style="font-size:1.2rem; color:var(--ink);">
|
||||||
|
{{ m.km_total }}<span class="font-normal text-xs ml-1" style="color:#9A9288;">km</span>
|
||||||
|
</p>
|
||||||
|
<div class="flex rounded overflow-hidden mb-1" style="height:10px; background:#e5e2dc;">
|
||||||
|
{% for vehicle_id, km in m.km_by_vehicle.items() %}
|
||||||
|
{% set v_info = vehicles.get(vehicle_id, {}) %}
|
||||||
|
{% if v_info.get('type') == 'velo' %}
|
||||||
|
{% set color = '#4ade80' %}
|
||||||
|
{% elif v_info.get('fuel') == 'electric' %}
|
||||||
|
{% set color = '#818cf8' %}
|
||||||
|
{% else %}
|
||||||
|
{% set color = '#d4a574' %}
|
||||||
|
{% endif %}
|
||||||
|
{% set pct = (km / m.km_total * 100) | round(1) %}
|
||||||
|
<div style="width:{{ pct }}%; background-color:{{ color }};"></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-x-3 mb-3">
|
||||||
|
{% for vehicle_id, km in m.km_by_vehicle.items() %}
|
||||||
|
{% set v_info = vehicles.get(vehicle_id, {}) %}
|
||||||
|
{% if v_info.get('type') == 'velo' %}
|
||||||
|
{% set color = '#4ade80' %}
|
||||||
|
{% elif v_info.get('fuel') == 'electric' %}
|
||||||
|
{% set color = '#818cf8' %}
|
||||||
|
{% else %}
|
||||||
|
{% set color = '#d4a574' %}
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-xs" style="color:#8A8278;">
|
||||||
|
<span style="display:inline-block; width:8px; height:8px; border-radius:2px; background:{{ color }}; margin-right:3px;"></span>
|
||||||
|
{{ vehicles.get(vehicle_id, {}).get('name', vehicle_id) }} ({{ km }} km)
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-xs mb-3" style="color:#9A9288;">Aucun déplacement</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# --- Stats temporelles --- #}
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs mb-0.5" style="color:#9A9288;">Médiane / jour</p>
|
||||||
|
<p class="font-data font-semibold" style="color:var(--ink);">{{ m.median_daily_str }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs mb-0.5" style="color:#9A9288;">Médiane / semaine</p>
|
||||||
|
<p class="font-data font-semibold" style="color:var(--ink);">{{ m.median_weekly_str }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
79
config.toml
79
config.toml
@@ -115,3 +115,82 @@ forfait = 1515
|
|||||||
km_max = 0
|
km_max = 0
|
||||||
taux = 0.470
|
taux = 0.470
|
||||||
forfait = 0
|
forfait = 0
|
||||||
|
|
||||||
|
# --- Barème kilométrique voitures 2026 (revenus 2025) ---
|
||||||
|
# Source : https://www.service-public.gouv.fr/particuliers/actualites/A14686
|
||||||
|
# Majoration +20% pour véhicules électriques gérée dans travel_calc.py
|
||||||
|
|
||||||
|
[[bareme_kilometrique.2026.cv_3.tranches]]
|
||||||
|
km_max = 5000
|
||||||
|
taux = 0.529
|
||||||
|
forfait = 0
|
||||||
|
|
||||||
|
[[bareme_kilometrique.2026.cv_3.tranches]]
|
||||||
|
km_max = 20000
|
||||||
|
taux = 0.316
|
||||||
|
forfait = 1065
|
||||||
|
|
||||||
|
[[bareme_kilometrique.2026.cv_3.tranches]]
|
||||||
|
km_max = 0
|
||||||
|
taux = 0.370
|
||||||
|
forfait = 0
|
||||||
|
|
||||||
|
[[bareme_kilometrique.2026.cv_4.tranches]]
|
||||||
|
km_max = 5000
|
||||||
|
taux = 0.606
|
||||||
|
forfait = 0
|
||||||
|
|
||||||
|
[[bareme_kilometrique.2026.cv_4.tranches]]
|
||||||
|
km_max = 20000
|
||||||
|
taux = 0.340
|
||||||
|
forfait = 1330
|
||||||
|
|
||||||
|
[[bareme_kilometrique.2026.cv_4.tranches]]
|
||||||
|
km_max = 0
|
||||||
|
taux = 0.407
|
||||||
|
forfait = 0
|
||||||
|
|
||||||
|
[[bareme_kilometrique.2026.cv_5.tranches]]
|
||||||
|
km_max = 5000
|
||||||
|
taux = 0.636
|
||||||
|
forfait = 0
|
||||||
|
|
||||||
|
[[bareme_kilometrique.2026.cv_5.tranches]]
|
||||||
|
km_max = 20000
|
||||||
|
taux = 0.357
|
||||||
|
forfait = 1395
|
||||||
|
|
||||||
|
[[bareme_kilometrique.2026.cv_5.tranches]]
|
||||||
|
km_max = 0
|
||||||
|
taux = 0.427
|
||||||
|
forfait = 0
|
||||||
|
|
||||||
|
[[bareme_kilometrique.2026.cv_6.tranches]]
|
||||||
|
km_max = 5000
|
||||||
|
taux = 0.665
|
||||||
|
forfait = 0
|
||||||
|
|
||||||
|
[[bareme_kilometrique.2026.cv_6.tranches]]
|
||||||
|
km_max = 20000
|
||||||
|
taux = 0.374
|
||||||
|
forfait = 1457
|
||||||
|
|
||||||
|
[[bareme_kilometrique.2026.cv_6.tranches]]
|
||||||
|
km_max = 0
|
||||||
|
taux = 0.447
|
||||||
|
forfait = 0
|
||||||
|
|
||||||
|
[[bareme_kilometrique.2026.cv_7plus.tranches]]
|
||||||
|
km_max = 5000
|
||||||
|
taux = 0.697
|
||||||
|
forfait = 0
|
||||||
|
|
||||||
|
[[bareme_kilometrique.2026.cv_7plus.tranches]]
|
||||||
|
km_max = 20000
|
||||||
|
taux = 0.394
|
||||||
|
forfait = 1515
|
||||||
|
|
||||||
|
[[bareme_kilometrique.2026.cv_7plus.tranches]]
|
||||||
|
km_max = 0
|
||||||
|
taux = 0.470
|
||||||
|
forfait = 0
|
||||||
|
|||||||
276
docs/onboarding.md
Normal file
276
docs/onboarding.md
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
# Guide d'intégration (Onboarding) pour les développeurs
|
||||||
|
|
||||||
|
Bienvenue sur le projet **Tableau de bord pro** ! Ce guide est conçu pour t'aider à prendre en main rapidement l'application, comprendre son architecture, ses règles métier et savoir comment y apporter des modifications courantes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Objectif fonctionnel
|
||||||
|
|
||||||
|
Le **Tableau de bord pro** est une application web personnelle permettant de suivre :
|
||||||
|
- **Le temps de travail quotidien** : saisie des heures travaillées, des types de journées (travail, télétravail, garde, astreinte, formation, RTT, congé, maladie, férié) et des commentaires associés.
|
||||||
|
- **Les déplacements professionnels** : calcul automatique des distances parcourues par véhicule, estimation des émissions de CO₂ et calcul des frais réels déductibles selon le barème kilométrique fiscal officiel.
|
||||||
|
- **Le solde des congés et RTT** : suivi annuel des jours posés et du solde restant par rapport aux quotas alloués.
|
||||||
|
- **Les rapports d'activité** : bilans annuels et mensuels compilant les kilomètres, le CO₂, les frais réels et la répartition des types de journées.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Prérequis et démarrage local
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
- **Python 3.11+** installé sur ta machine.
|
||||||
|
|
||||||
|
### Démarrage local
|
||||||
|
Pour lancer l'application en mode développement, exécute les commandes suivantes dans ton terminal :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Créer l'environnement virtuel Python
|
||||||
|
python -m venv .venv
|
||||||
|
|
||||||
|
# 2. Activer l'environnement virtuel et installer les dépendances
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 3. Lancer le serveur de développement Flask
|
||||||
|
.venv/bin/python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
L'application est alors accessible localement à l'adresse suivante : [http://localhost:5000](http://localhost:5000).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture de l'application
|
||||||
|
|
||||||
|
L'application est construite avec le framework **Flask** en utilisant le *Factory Pattern* (via la fonction `create_app` dans `app/__init__.py`).
|
||||||
|
|
||||||
|
### Structure des dossiers et fichiers clés
|
||||||
|
- `run.py` : Point d'entrée pour lancer le serveur de développement.
|
||||||
|
- `config.toml` : Fichier de configuration statique (véhicules, trajets, barèmes fiscaux).
|
||||||
|
- `app/` : Code source de l'application.
|
||||||
|
- `__init__.py` : Initialisation de Flask, configuration de la base de données, enregistrement des blueprints et des filtres Jinja2.
|
||||||
|
- `config_loader.py` : Interface de lecture du fichier `config.toml`.
|
||||||
|
- `models.py` : Définition des modèles de données SQLAlchemy (SQLite).
|
||||||
|
- `business/` : Logique métier pure, **sans dépendance à Flask** (facile à tester) :
|
||||||
|
- `time_calc.py` : Calculs de durées, de soldes d'heures et statistiques de temps.
|
||||||
|
- `travel_calc.py` : Calculs de distances, d'émissions de CO₂ et de frais réels.
|
||||||
|
- `leave_calc.py` : Calculs de congés et RTT consommés.
|
||||||
|
- `routes/` : Contrôleurs Flask gérant les requêtes HTTP et le rendu des templates :
|
||||||
|
- `dashboard.py` : Page d'accueil et vue d'ensemble.
|
||||||
|
- `entries.py` : Gestion (CRUD) des entrées de temps.
|
||||||
|
- `reports.py` : Génération des rapports annuels et mensuels.
|
||||||
|
- `templates/` : Fichiers HTML utilisant Jinja2.
|
||||||
|
- `tests/` : Tests unitaires et d'intégration (pytest).
|
||||||
|
|
||||||
|
### Technologies Frontend
|
||||||
|
- **Tailwind CSS (via CDN)** : Utilisé pour le style. Les variables de design et les classes personnalisées (comme `.card`, `.btn-primary`, etc.) sont définies directement dans la balise `<style>` de `app/templates/base.html`.
|
||||||
|
- **HTMX** : Utilisé pour rendre l'interface dynamique sans rechargement complet de page.
|
||||||
|
- **JavaScript** : Uniquement du JS inline dans `app/templates/entry_form.html` pour la gestion dynamique du formulaire de saisie.
|
||||||
|
|
||||||
|
### Authentification
|
||||||
|
L'application n'intègre **aucun système d'authentification interne**. La sécurité et l'authentification sont entièrement déléguées à un serveur **HAProxy** situé en amont (upstream) en production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Flux d'une saisie de journée
|
||||||
|
|
||||||
|
Voici ce qui se passe lorsqu'un utilisateur saisit ou modifie une journée de travail :
|
||||||
|
|
||||||
|
1. **Affichage du formulaire (GET `/entries/new` ou `/entries/<id>/edit`)** :
|
||||||
|
- La route `entry_form` dans `app/routes/entries.py` récupère les véhicules à moteur et les profils de trajets depuis `config.toml` via `app/config_loader.py`.
|
||||||
|
- Elle transmet ces données au template `entry_form.html`.
|
||||||
|
|
||||||
|
2. **Soumission du formulaire (POST)** :
|
||||||
|
- L'utilisateur envoie la date, le type de journée, le trajet, le véhicule à moteur utilisé, le commentaire et les plages horaires.
|
||||||
|
- **Application des règles de cohérence** :
|
||||||
|
- Si le type de journée est un jour sans trajet (ex: Télétravail, Congé), le trajet est forcé à `None`.
|
||||||
|
- Si le trajet sélectionné n'implique pas de véhicule à moteur, le véhicule à moteur est forcé à `None`.
|
||||||
|
- **Enregistrement de l'entrée (`WorkEntry`)** :
|
||||||
|
- En création, l'application vérifie qu'aucune entrée n'existe déjà à cette date (unicité de la date).
|
||||||
|
- L'entrée est créée ou mise à jour dans la table `work_entries`.
|
||||||
|
- **Gestion des plages horaires (`TimeSlot`)** :
|
||||||
|
- Pour simplifier la mise à jour, toutes les plages horaires existantes de cette journée sont **supprimées** de la base de données.
|
||||||
|
- Les nouvelles plages horaires soumises sont lues, validées et recréées sous forme d'objets `TimeSlot` liés à la `WorkEntry`.
|
||||||
|
- **Validation** : La transaction est validée via `db.session.commit()`, et l'utilisateur est redirigé vers le tableau de bord.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Rôle de `config.toml` vs SQLite
|
||||||
|
|
||||||
|
L'application sépare strictement la configuration métier statique des données utilisateur dynamiques.
|
||||||
|
|
||||||
|
### `config.toml` (Configuration statique)
|
||||||
|
Ce fichier contient les paramètres qui ne changent pas fréquemment et qui définissent les règles de calcul :
|
||||||
|
- **Véhicules (`[vehicles.*]`)** : Nom, type (moteur ou velo), carburant (electric, diesel, essence, none), émissions de CO₂ par km, et puissance fiscale (CV).
|
||||||
|
- **Trajets (`[journeys.*]`)** : Profils de trajets prédéfinis (ex: "moteur_seul", "moteur_velo") avec les distances associées par type de moyen de transport.
|
||||||
|
- **Barème kilométrique (`[bareme_kilometrique.YYYY.*]`)** : Les tranches fiscales officielles de remboursement par année et par puissance fiscale (CV).
|
||||||
|
|
||||||
|
*Note : Ces données sont chargées en mémoire au démarrage de l'application dans `app.config["TOML"]`.*
|
||||||
|
|
||||||
|
### SQLite / `instance/worklog.db` (Données dynamiques)
|
||||||
|
La base de données stocke l'activité saisie par l'utilisateur :
|
||||||
|
- **`work_entries`** : Une ligne par jour saisi (date, type de jour, ID du trajet, ID du véhicule, commentaire, timestamps).
|
||||||
|
- **`time_slots`** : Les plages horaires travaillées associées à une journée (heure de début, heure de fin, ID de l'entrée).
|
||||||
|
- **`leave_balance`** : Les quotas annuels de congés et de RTT (année, total congés, total RTT).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Règles métier essentielles
|
||||||
|
|
||||||
|
Pour éviter les erreurs de calcul, garde bien en tête ces règles fondamentales :
|
||||||
|
|
||||||
|
- **Types de journées** : Les types autorisés sont `WORK`, `TT`, `GARDE`, `ASTREINTE`, `FORMATION`, `RTT`, `CONGE`, `MALADE`, `FERIE`.
|
||||||
|
- **Absence de trajet** : Les types `TT`, `MALADE`, `CONGE`, `RTT` et `FERIE` n'impliquent aucun trajet physique (définis dans `day_types_without_journey()`).
|
||||||
|
- **Durée de référence du travail** :
|
||||||
|
- `WORK`, `TT`, `FORMATION` : 7h45 (soit 465 minutes).
|
||||||
|
- `GARDE` : 10h00 (soit 600 minutes).
|
||||||
|
- `ASTREINTE`, `RTT`, `CONGE`, `MALADE`, `FERIE` : 0 minute.
|
||||||
|
- **Calcul du temps de travail** : La méthode `total_minutes()` de `WorkEntry` somme les durées des `TimeSlot`. Elle gère le **passage de minuit** : si l'heure de fin est inférieure ou égale à l'heure de début (ex: 22:00 à 02:00), elle ajoute automatiquement 24 heures à la plage.
|
||||||
|
- **Frais réels et véhicules électriques** :
|
||||||
|
- Le calcul des frais réels applique les tranches du barème kilométrique de `config.toml`. Une tranche avec `km_max = 0` représente la tranche supérieure sans limite.
|
||||||
|
- Les véhicules électriques (`fuel = "electric"`) bénéficient d'une **majoration automatique de 20 %** sur le montant calculé des frais réels (géré dans `app/business/travel_calc.py`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Tests et Qualité du code
|
||||||
|
|
||||||
|
### Exécuter les tests
|
||||||
|
Les tests sont écrits avec **pytest**. Les tests de logique métier (`test_time_calc.py`, `test_travel_calc.py`) n'ont aucune dépendance à Flask et s'exécutent très rapidement. Les tests de routes utilisent des fixtures définies dans `tests/conftest.py` (base de données SQLite en mémoire et configuration TOML temporaire).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Exécuter tous les tests
|
||||||
|
.venv/bin/python -m pytest
|
||||||
|
|
||||||
|
# Exécuter un fichier de test spécifique
|
||||||
|
.venv/bin/python -m pytest tests/test_time_calc.py -v
|
||||||
|
|
||||||
|
# Exécuter un test unitaire précis
|
||||||
|
.venv/bin/python -m pytest tests/test_routes.py::test_create_entry -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qualité et Formatage du code
|
||||||
|
Le projet utilise **Ruff** comme outil unique pour le linting, le tri des imports et le formatage du code. La configuration est définie dans `pyproject.toml`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Vérifier le code (linter)
|
||||||
|
.venv/bin/ruff check .
|
||||||
|
|
||||||
|
# Vérifier le formatage
|
||||||
|
.venv/bin/ruff format --check .
|
||||||
|
|
||||||
|
# Appliquer automatiquement le formatage et corriger les imports
|
||||||
|
.venv/bin/ruff format .
|
||||||
|
.venv/bin/ruff check --fix .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Guide de modifications courantes
|
||||||
|
|
||||||
|
### A. Ajouter ou modifier un véhicule
|
||||||
|
Pour ajouter un nouveau véhicule (par exemple, une nouvelle voiture de 4 CV), ouvre `config.toml` et ajoute une section sous `[vehicles]` :
|
||||||
|
```toml
|
||||||
|
[vehicles.ma_nouvelle_voiture]
|
||||||
|
name = "Ma Nouvelle Voiture"
|
||||||
|
fuel = "essence"
|
||||||
|
co2_per_km = 120
|
||||||
|
cv = 4
|
||||||
|
type = "moteur"
|
||||||
|
```
|
||||||
|
|
||||||
|
### B. Ajouter ou modifier un trajet prédéfini
|
||||||
|
Pour ajouter un trajet combinant voiture et vélo, ouvre `config.toml` et ajoute une section sous `[journeys]` :
|
||||||
|
```toml
|
||||||
|
[journeys.mon_nouveau_trajet]
|
||||||
|
name = "Mon Nouveau Trajet"
|
||||||
|
distances = { moteur = 10, velo = 5 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### C. Mettre à jour le barème kilométrique annuel
|
||||||
|
Chaque année, l'administration fiscale publie un nouveau barème. Pour l'ajouter (par exemple pour l'année 2027), ajoute les tranches correspondantes dans `config.toml` :
|
||||||
|
```toml
|
||||||
|
[[bareme_kilometrique.2027.cv_4.tranches]]
|
||||||
|
km_max = 5000
|
||||||
|
taux = 0.606
|
||||||
|
forfait = 0
|
||||||
|
|
||||||
|
[[bareme_kilometrique.2027.cv_4.tranches]]
|
||||||
|
km_max = 20000
|
||||||
|
taux = 0.340
|
||||||
|
forfait = 1330
|
||||||
|
|
||||||
|
[[bareme_kilometrique.2027.cv_4.tranches]]
|
||||||
|
km_max = 0
|
||||||
|
taux = 0.407
|
||||||
|
forfait = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### D. Ajouter un nouveau type de journée
|
||||||
|
Si tu dois ajouter un nouveau type de journée (par exemple `REPOS`) :
|
||||||
|
1. Ouvre `app/routes/entries.py` et ajoute le type à la liste `DAY_TYPES` :
|
||||||
|
```python
|
||||||
|
DAY_TYPES = [
|
||||||
|
...
|
||||||
|
("REPOS", "Repos"),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
2. Ouvre `app/__init__.py` et ajoute sa traduction française dans `_DAY_TYPE_LABELS` :
|
||||||
|
```python
|
||||||
|
_DAY_TYPE_LABELS = {
|
||||||
|
...
|
||||||
|
"REPOS": "Repos",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. Ouvre `app/business/time_calc.py` et définis sa durée de référence dans `_REFERENCE_MINUTES` (ex: 0 minute pour un repos) :
|
||||||
|
```python
|
||||||
|
_REFERENCE_MINUTES = {
|
||||||
|
...
|
||||||
|
"REPOS": 0,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. Si ce type de journée n'implique aucun trajet, ouvre `app/config_loader.py` et ajoute-le dans `day_types_without_journey()` :
|
||||||
|
```python
|
||||||
|
def day_types_without_journey():
|
||||||
|
return {"TT", "MALADE", "CONGE", "RTT", "FERIE", "REPOS"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Déploiement, limites et migrations
|
||||||
|
|
||||||
|
### Déploiement en production
|
||||||
|
L'application est déployée dans `/var/www/tableau-de-bord-pro/` et est gérée par un service **systemd** qui lance **Gunicorn**.
|
||||||
|
|
||||||
|
Pour mettre à jour la production :
|
||||||
|
```bash
|
||||||
|
# 1. Copier les fichiers (en excluant l'environnement virtuel et la base de données locale)
|
||||||
|
sudo rsync -a --exclude='.venv' --exclude='instance' . /var/www/tableau-de-bord-pro/
|
||||||
|
|
||||||
|
# 2. Se positionner dans le dossier de production
|
||||||
|
cd /var/www/tableau-de-bord-pro
|
||||||
|
|
||||||
|
# 3. Mettre à jour les dépendances si nécessaire
|
||||||
|
sudo .venv/bin/pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 4. S'assurer que les permissions sont correctes
|
||||||
|
sudo chown -R www-data:www-data /var/www/tableau-de-bord-pro
|
||||||
|
|
||||||
|
# 5. Redémarrer le service systemd
|
||||||
|
sudo systemctl restart tableau-de-bord-pro
|
||||||
|
```
|
||||||
|
|
||||||
|
### Limites et Migrations de base de données
|
||||||
|
- **Pas d'Alembic** : Le projet n'utilise pas d'outil de migration automatique comme Alembic. La base de données est initialisée avec `db.create_all()`.
|
||||||
|
- **Changement de schéma** : Si tu modifies un modèle dans `app/models.py` (par exemple en ajoutant un champ) :
|
||||||
|
- En **développement** : Tu peux simplement supprimer le fichier `instance/worklog.db` pour qu'il soit recréé au prochain démarrage (attention, cela supprime tes données de test).
|
||||||
|
- En **production** : Tu dois écrire une migration manuelle dans la fonction `_migrate_db` située dans `app/__init__.py`. Cette fonction s'exécute au démarrage de l'application, vérifie l'existence des colonnes via SQLite, et applique les instructions `ALTER TABLE` nécessaires de manière sécurisée.
|
||||||
|
|
||||||
|
### Horodatages de métadonnées et calculs de durée métier
|
||||||
|
- **Métadonnées (`created_at`, `updated_at`)** : L'application utilise `datetime.now(UTC)` pour enregistrer ces métadonnées. Bien que les valeurs soient émises en UTC, SQLite et SQLAlchemy stockent et restituent par défaut des objets `datetime` naïfs (sans `tzinfo`). Ces champs sont actuellement informatifs et non exploités par la logique métier. Si un besoin de lecture ou de manipulation de ces métadonnées avec fuseau émergeait, les pistes incluent l'utilisation de types `DateTime(timezone=True)` ou la stricte convention documentée « naïf = UTC ».
|
||||||
|
- **Calculs de durée métier et transitions DST** : Le calcul de la durée des plages horaires de travail (`TimeSlot` via `total_minutes()`) est totalement indépendant des métadonnées et repose sur des heures murales (locales). Une plage horaire traversant un changement d'heure saisonnier (passage heure d'été/hiver / DST) soulève un enjeu métier spécifique (gestion des durées d'heures locales) qui nécessiterait, le cas échéant, une représentation dédiée ou une politique métier spécifique.
|
||||||
|
- **`db.get_engine()`** : Déprécié dans Flask-SQLAlchemy 3.x. Utilise toujours `db.engine` à la place.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Liens utiles
|
||||||
|
|
||||||
|
Pour aller plus loin, n'hésite pas à consulter les documents suivants à la racine du projet :
|
||||||
|
- [README.md](../README.md) : Présentation générale, instructions d'installation détaillées et script d'import CSV en masse.
|
||||||
|
- [AGENTS.md](../AGENTS.md) : Guide de développement et consignes pour les agents d'intelligence artificielle travaillant sur ce dépôt.
|
||||||
@@ -312,7 +312,7 @@ def _migrate_db(app):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
engine = db.get_engine()
|
engine = db.engine
|
||||||
if "motor_vehicle_id" not in columns:
|
if "motor_vehicle_id" not in columns:
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
conn.execute(sa.text(
|
conn.execute(sa.text(
|
||||||
|
|||||||
@@ -384,7 +384,7 @@ git commit -m "feat: TOML config loader for vehicles, journeys, and tax scales"
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from app import db
|
from app import db
|
||||||
from datetime import date, time, datetime
|
from datetime import date, time, datetime, UTC
|
||||||
from enum import Enum as PyEnum
|
from enum import Enum as PyEnum
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import sqlalchemy.orm as so
|
import sqlalchemy.orm as so
|
||||||
@@ -410,9 +410,9 @@ class WorkEntry(db.Model):
|
|||||||
journey_profile_id: so.Mapped[str | None] = so.mapped_column(sa.String(64), nullable=True)
|
journey_profile_id: so.Mapped[str | None] = so.mapped_column(sa.String(64), nullable=True)
|
||||||
day_type: so.Mapped[str] = so.mapped_column(sa.String(16), nullable=False, default="WORK")
|
day_type: so.Mapped[str] = so.mapped_column(sa.String(16), nullable=False, default="WORK")
|
||||||
comment: so.Mapped[str | None] = so.mapped_column(sa.Text, nullable=True)
|
comment: so.Mapped[str | None] = so.mapped_column(sa.Text, nullable=True)
|
||||||
created_at: so.Mapped[datetime] = so.mapped_column(sa.DateTime, default=datetime.utcnow)
|
created_at: so.Mapped[datetime] = so.mapped_column(sa.DateTime, default=lambda: datetime.now(UTC))
|
||||||
updated_at: so.Mapped[datetime] = so.mapped_column(
|
updated_at: so.Mapped[datetime] = so.mapped_column(
|
||||||
sa.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
sa.DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
|
||||||
)
|
)
|
||||||
|
|
||||||
time_slots: so.Mapped[list["TimeSlot"]] = so.relationship(
|
time_slots: so.Mapped[list["TimeSlot"]] = so.relationship(
|
||||||
|
|||||||
66
docs/plans/2026-03-13-monthly-stats-design.md
Normal file
66
docs/plans/2026-03-13-monthly-stats-design.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Design : statistiques mensuelles dans la page Rapports
|
||||||
|
|
||||||
|
Date : 2026-03-13
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Ajouter une section "Détail mensuel" sous les cartes annuelles existantes dans `/reports`. 12 cartes (une par mois) donnant en un coup d'œil : distance parcourue par mode de transport + durée de travail typique.
|
||||||
|
|
||||||
|
## Décisions clés
|
||||||
|
|
||||||
|
- **Types de jours inclus** : tous les jours enregistrés (y compris absences = 0 min).
|
||||||
|
- **Statistique temporelle** : médiane (plus robuste que la moyenne face aux GARDE à 10h et aux absences).
|
||||||
|
- **"Par semaine"** : médiane de la durée totale hebdomadaire (somme des minutes par semaine ISO, puis médiane).
|
||||||
|
- **Layout** : 12 cartes empilées sous un titre "Détail mensuel".
|
||||||
|
- **Couleurs transport** : évocatrices (vert vélo, indigo électrique, ambre diesel).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Données calculées par mois
|
||||||
|
|
||||||
|
```python
|
||||||
|
monthly_data[month_int] = {
|
||||||
|
"km_by_vehicle": {vehicle_id: km},
|
||||||
|
"km_total": int,
|
||||||
|
"median_daily_min": int, # médiane de entry.total_minutes() sur toutes les entries du mois
|
||||||
|
"median_weekly_min": int, # médiane des sommes hebdomadaires (semaines ISO)
|
||||||
|
"entry_count": int,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Répartition du code
|
||||||
|
|
||||||
|
- **`app/business/time_calc.py`** : nouvelle fonction `monthly_stats(entries)` → calcule les médianes journalière et hebdomadaire. Pas de dépendance Flask.
|
||||||
|
- **`app/routes/reports.py`** : groupement des entries par mois + calcul des km par véhicule (déjà dans ce fichier), appel de `monthly_stats`, transmission du tout au template.
|
||||||
|
- **`app/templates/reports.html`** : section "Détail mensuel" avec les 12 cartes.
|
||||||
|
|
||||||
|
## Visuel des cartes
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ JANVIER 21 jours │
|
||||||
|
│ │
|
||||||
|
│ 312 km │
|
||||||
|
│ [████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░] │
|
||||||
|
│ ■ Vélo (132 km) ■ Électrique (108 km) │
|
||||||
|
│ ■ Diesel (72 km) │
|
||||||
|
│ │
|
||||||
|
│ Médiane/jour 7h52 Médiane/sem. 38h45 │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Barre transport** : `div` flex, sections `width: X%` en `style=` inline (contrainte Tailwind CDN).
|
||||||
|
- **Couleurs** :
|
||||||
|
- Vélo → `#4ade80` (vert vif)
|
||||||
|
- Électrique (fuel = "electric") → `#818cf8` (indigo)
|
||||||
|
- Diesel → `#d4a574` (ambre chaud)
|
||||||
|
- **Légende** : carré coloré + nom du véhicule + km, sous la barre.
|
||||||
|
- **Mois vide** (0 entrées) : carte discrète, texte "Aucune entrée", sans barre.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Nouvelle fonction `monthly_stats()` dans `tests/test_time_calc.py` :
|
||||||
|
- cas nominal (entries avec time_slots)
|
||||||
|
- absences (total_minutes = 0) incluses dans la médiane
|
||||||
|
- mois avec une seule semaine
|
||||||
|
- Route `reports` : vérifier que `monthly_data` est bien transmis au template (test existant à étendre si besoin).
|
||||||
383
docs/plans/2026-03-13-monthly-stats.md
Normal file
383
docs/plans/2026-03-13-monthly-stats.md
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
# Monthly Stats Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Ajouter une section "Détail mensuel" dans `/reports` avec 12 cartes (une par mois) affichant distance totale, barre de répartition par transport, médiane journalière et médiane hebdomadaire.
|
||||||
|
|
||||||
|
**Architecture:** Nouvelle fonction pure `monthly_stats()` dans `time_calc.py` pour les médianes. Groupement par mois + km dans `reports.py`. Rendu HTML avec barre CSS inline (contrainte Tailwind CDN).
|
||||||
|
|
||||||
|
**Tech Stack:** Flask, Jinja2, SQLAlchemy, Python `statistics` stdlib (pas de dépendance à ajouter).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Préparation : créer la branche
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout master
|
||||||
|
git checkout -b monthly-stats-reports
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1 : `monthly_stats()` dans `time_calc.py`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/business/time_calc.py`
|
||||||
|
- Test: `tests/test_time_calc.py`
|
||||||
|
|
||||||
|
**Step 1 : Écrire les tests dans `tests/test_time_calc.py`**
|
||||||
|
|
||||||
|
Ajouter en bas du fichier (après les imports existants, ajouter `TimeSlot` et `time`) :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.models import WorkEntry, TimeSlot
|
||||||
|
from app.business.time_calc import monthly_stats
|
||||||
|
from datetime import date, time as dtime
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(d: date, *slots: tuple[str, str]) -> WorkEntry:
|
||||||
|
"""Helper : crée un WorkEntry avec des TimeSlots."""
|
||||||
|
entry = WorkEntry(date=d, day_type="WORK")
|
||||||
|
entry.time_slots = [
|
||||||
|
TimeSlot(start_time=dtime(*[int(x) for x in s.split(":")]),
|
||||||
|
end_time=dtime(*[int(x) for x in e.split(":")]))
|
||||||
|
for s, e in slots
|
||||||
|
]
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _absence(d: date) -> WorkEntry:
|
||||||
|
"""Helper : crée un WorkEntry sans time_slots (0 min)."""
|
||||||
|
entry = WorkEntry(date=d, day_type="CONGE")
|
||||||
|
entry.time_slots = []
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_stats_empty():
|
||||||
|
result = monthly_stats([])
|
||||||
|
assert result == {"median_daily_min": 0, "median_weekly_min": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_stats_median_daily_odd():
|
||||||
|
# 420, 465, 510 → médiane = 465
|
||||||
|
entries = [
|
||||||
|
_entry(date(2025, 1, 6), ("9:00", "16:00")), # 420 min
|
||||||
|
_entry(date(2025, 1, 7), ("9:00", "16:45")), # 465 min
|
||||||
|
_entry(date(2025, 1, 8), ("9:00", "17:30")), # 510 min
|
||||||
|
]
|
||||||
|
result = monthly_stats(entries)
|
||||||
|
assert result["median_daily_min"] == 465
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_stats_includes_absences():
|
||||||
|
# 0, 465 → médiane de 2 valeurs = (0+465)/2 = 232 (int)
|
||||||
|
entries = [
|
||||||
|
_absence(date(2025, 1, 6)),
|
||||||
|
_entry(date(2025, 1, 7), ("9:00", "16:45")),
|
||||||
|
]
|
||||||
|
result = monthly_stats(entries)
|
||||||
|
assert result["median_daily_min"] == 232
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_stats_median_weekly():
|
||||||
|
# Semaine 1 : 465+465 = 930 min
|
||||||
|
# Semaine 2 : 420 min
|
||||||
|
# médiane([930, 420]) = (420+930)/2 = 675
|
||||||
|
entries = [
|
||||||
|
_entry(date(2025, 1, 6), ("9:00", "16:45")), # sem 2
|
||||||
|
_entry(date(2025, 1, 7), ("9:00", "16:45")), # sem 2
|
||||||
|
_entry(date(2025, 1, 13), ("9:00", "16:00")), # sem 3
|
||||||
|
]
|
||||||
|
result = monthly_stats(entries)
|
||||||
|
assert result["median_weekly_min"] == 675
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2 : Vérifier que les tests échouent**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest tests/test_time_calc.py::test_monthly_stats_empty tests/test_time_calc.py::test_monthly_stats_median_daily_odd -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultat attendu : `ImportError` ou `FAILED` — `monthly_stats` n'existe pas encore.
|
||||||
|
|
||||||
|
**Step 3 : Implémenter `monthly_stats()` dans `app/business/time_calc.py`**
|
||||||
|
|
||||||
|
Ajouter en bas du fichier :
|
||||||
|
|
||||||
|
```python
|
||||||
|
import statistics as _stats
|
||||||
|
|
||||||
|
|
||||||
|
def monthly_stats(entries: list) -> dict:
|
||||||
|
"""
|
||||||
|
Calcule médiane journalière et médiane hebdomadaire (semaines ISO)
|
||||||
|
pour un groupe d'entrées. Les absences (total_minutes=0) sont incluses.
|
||||||
|
"""
|
||||||
|
if not entries:
|
||||||
|
return {"median_daily_min": 0, "median_weekly_min": 0}
|
||||||
|
|
||||||
|
daily = [e.total_minutes() for e in entries]
|
||||||
|
median_daily = int(_stats.median(daily))
|
||||||
|
|
||||||
|
weekly: dict[tuple, int] = {}
|
||||||
|
for e in entries:
|
||||||
|
key = e.date.isocalendar()[:2] # (year, isoweek)
|
||||||
|
weekly[key] = weekly.get(key, 0) + e.total_minutes()
|
||||||
|
|
||||||
|
median_weekly = int(_stats.median(weekly.values()))
|
||||||
|
|
||||||
|
return {"median_daily_min": median_daily, "median_weekly_min": median_weekly}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4 : Vérifier que tous les tests passent**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest tests/test_time_calc.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultat attendu : tous PASSED.
|
||||||
|
|
||||||
|
**Step 5 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -c commit.gpgsign=false add app/business/time_calc.py tests/test_time_calc.py
|
||||||
|
git -c commit.gpgsign=false commit -m "feat: monthly_stats() — médiane journalière et hebdomadaire"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2 : Calcul mensuel dans `reports.py`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/routes/reports.py`
|
||||||
|
|
||||||
|
**Step 1 : Mettre à jour `reports.py`**
|
||||||
|
|
||||||
|
Remplacer le contenu complet par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import Blueprint, render_template, request
|
||||||
|
from datetime import date
|
||||||
|
from collections import defaultdict
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from app import db
|
||||||
|
from app.models import WorkEntry
|
||||||
|
from app.business.travel_calc import compute_km_for_entry, compute_co2_grams, compute_frais_reels
|
||||||
|
from app.business.time_calc import count_day_types, monthly_stats, minutes_to_str
|
||||||
|
from app.config_loader import get_vehicles, get_journeys, get_bareme
|
||||||
|
|
||||||
|
bp = Blueprint("reports", __name__, url_prefix="/reports")
|
||||||
|
|
||||||
|
MONTHS_FR = {
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/")
|
||||||
|
def index():
|
||||||
|
year = request.args.get("year", date.today().year, type=int)
|
||||||
|
start = date(year, 1, 1)
|
||||||
|
end = date(year, 12, 31)
|
||||||
|
|
||||||
|
entries = db.session.scalars(
|
||||||
|
sa.select(WorkEntry).where(WorkEntry.date.between(start, end))
|
||||||
|
).all()
|
||||||
|
|
||||||
|
vehicles = get_vehicles()
|
||||||
|
journeys = get_journeys()
|
||||||
|
|
||||||
|
# --- Stats annuelles (inchangées) ---
|
||||||
|
total_km = {}
|
||||||
|
total_co2 = 0.0
|
||||||
|
for entry in entries:
|
||||||
|
km = compute_km_for_entry(entry.journey_profile_id, journeys, entry.motor_vehicle_id)
|
||||||
|
for v, d in km.items():
|
||||||
|
total_km[v] = total_km.get(v, 0) + d
|
||||||
|
total_co2 += compute_co2_grams(km, vehicles)
|
||||||
|
|
||||||
|
frais_reels = {}
|
||||||
|
for vehicle_id, km in total_km.items():
|
||||||
|
vehicle = vehicles.get(vehicle_id, {})
|
||||||
|
cv = vehicle.get("cv")
|
||||||
|
if cv:
|
||||||
|
tranches = get_bareme(year, cv)
|
||||||
|
electric = vehicle.get("fuel") == "electric"
|
||||||
|
frais_reels[vehicle_id] = round(compute_frais_reels(km, tranches, electric=electric), 2)
|
||||||
|
|
||||||
|
day_type_counts = count_day_types(entries)
|
||||||
|
|
||||||
|
# --- Stats mensuelles ---
|
||||||
|
entries_by_month: dict[int, list] = defaultdict(list)
|
||||||
|
for entry in entries:
|
||||||
|
entries_by_month[entry.date.month].append(entry)
|
||||||
|
|
||||||
|
monthly_data = {}
|
||||||
|
for month in range(1, 13):
|
||||||
|
month_entries = entries_by_month.get(month, [])
|
||||||
|
|
||||||
|
month_km: dict[str, int] = {}
|
||||||
|
for entry in month_entries:
|
||||||
|
km = compute_km_for_entry(entry.journey_profile_id, journeys, entry.motor_vehicle_id)
|
||||||
|
for v, d in km.items():
|
||||||
|
month_km[v] = month_km.get(v, 0) + d
|
||||||
|
|
||||||
|
stats = monthly_stats(month_entries)
|
||||||
|
|
||||||
|
monthly_data[month] = {
|
||||||
|
"month_name": MONTHS_FR[month],
|
||||||
|
"entry_count": len(month_entries),
|
||||||
|
"km_by_vehicle": month_km,
|
||||||
|
"km_total": sum(month_km.values()),
|
||||||
|
"median_daily_str": minutes_to_str(stats["median_daily_min"]) if month_entries else "–",
|
||||||
|
"median_weekly_str": minutes_to_str(stats["median_weekly_min"]) if month_entries else "–",
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"reports.html",
|
||||||
|
year=year,
|
||||||
|
total_km=total_km,
|
||||||
|
total_co2_kg=round(total_co2 / 1000, 2),
|
||||||
|
frais_reels=frais_reels,
|
||||||
|
vehicles=vehicles,
|
||||||
|
day_type_counts=day_type_counts,
|
||||||
|
monthly_data=monthly_data,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2 : Vérifier que les tests existants passent toujours**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest tests/test_routes.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultat attendu : tous PASSED (aucune régression).
|
||||||
|
|
||||||
|
**Step 3 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -c commit.gpgsign=false add app/routes/reports.py
|
||||||
|
git -c commit.gpgsign=false commit -m "feat: calcul stats mensuelles dans reports (km + médianes)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3 : Section mensuelle dans `reports.html`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/templates/reports.html`
|
||||||
|
|
||||||
|
**Step 1 : Ajouter la section "Détail mensuel" dans `reports.html`**
|
||||||
|
|
||||||
|
Ajouter le bloc suivant avant `{% endblock %}` (après la carte "Répartition") :
|
||||||
|
|
||||||
|
```jinja2
|
||||||
|
<!-- Détail mensuel -->
|
||||||
|
<p class="font-display text-lg font-semibold mt-8 mb-3" style="color:var(--ink);">Détail mensuel</p>
|
||||||
|
|
||||||
|
{% for month_num in range(1, 13) %}
|
||||||
|
{% set m = monthly_data[month_num] %}
|
||||||
|
<div class="card card-ink mb-4">
|
||||||
|
<div class="flex items-baseline justify-between mb-2">
|
||||||
|
<p class="card-label">{{ m.month_name }}</p>
|
||||||
|
<span class="text-xs" style="color:#9A9288;">{{ m.entry_count }} j</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if m.entry_count == 0 %}
|
||||||
|
<p class="text-sm" style="color:#9A9288;">Aucune entrée</p>
|
||||||
|
{% else %}
|
||||||
|
{# --- Barre transport --- #}
|
||||||
|
{% if m.km_total > 0 %}
|
||||||
|
<p class="font-data font-semibold mb-1" style="font-size:1.2rem; color:var(--ink);">
|
||||||
|
{{ m.km_total }}<span class="font-normal text-xs ml-1" style="color:#9A9288;">km</span>
|
||||||
|
</p>
|
||||||
|
<div class="flex rounded overflow-hidden mb-1" style="height:10px; background:#e5e2dc;">
|
||||||
|
{% for vehicle_id, km in m.km_by_vehicle.items() %}
|
||||||
|
{% set v_info = vehicles.get(vehicle_id, {}) %}
|
||||||
|
{% if v_info.get('type') == 'velo' %}
|
||||||
|
{% set color = '#4ade80' %}
|
||||||
|
{% elif v_info.get('fuel') == 'electric' %}
|
||||||
|
{% set color = '#818cf8' %}
|
||||||
|
{% else %}
|
||||||
|
{% set color = '#d4a574' %}
|
||||||
|
{% endif %}
|
||||||
|
{% set pct = (km / m.km_total * 100) | round(1) %}
|
||||||
|
<div style="width:{{ pct }}%; background-color:{{ color }};"></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-x-3 mb-3">
|
||||||
|
{% for vehicle_id, km in m.km_by_vehicle.items() %}
|
||||||
|
{% set v_info = vehicles.get(vehicle_id, {}) %}
|
||||||
|
{% if v_info.get('type') == 'velo' %}
|
||||||
|
{% set color = '#4ade80' %}
|
||||||
|
{% elif v_info.get('fuel') == 'electric' %}
|
||||||
|
{% set color = '#818cf8' %}
|
||||||
|
{% else %}
|
||||||
|
{% set color = '#d4a574' %}
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-xs" style="color:#8A8278;">
|
||||||
|
<span style="display:inline-block; width:8px; height:8px; border-radius:2px; background:{{ color }}; margin-right:3px;"></span>
|
||||||
|
{{ vehicles.get(vehicle_id, {}).get('name', vehicle_id) }} ({{ km }} km)
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-xs mb-3" style="color:#9A9288;">Aucun déplacement</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# --- Stats temporelles --- #}
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs mb-0.5" style="color:#9A9288;">Médiane / jour</p>
|
||||||
|
<p class="font-data font-semibold" style="color:var(--ink);">{{ m.median_daily_str }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs mb-0.5" style="color:#9A9288;">Médiane / semaine</p>
|
||||||
|
<p class="font-data font-semibold" style="color:var(--ink);">{{ m.median_weekly_str }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2 : Vérifier manuellement dans le navigateur**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Naviguer vers `http://localhost:5000/reports/` et vérifier :
|
||||||
|
- Section "Détail mensuel" présente
|
||||||
|
- 12 cartes affichées (mois sans données : "Aucune entrée")
|
||||||
|
- Barre colorée pour les mois avec des km
|
||||||
|
- Médianes affichées au format "Xh00"
|
||||||
|
|
||||||
|
**Step 3 : Ajouter un test de route pour la section mensuelle**
|
||||||
|
|
||||||
|
Dans `tests/test_routes.py`, ajouter :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_reports_monthly_section(client):
|
||||||
|
response = client.get("/reports/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Détail mensuel" in response.text
|
||||||
|
assert "Janvier" in response.text
|
||||||
|
assert "Décembre" in response.text
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4 : Vérifier que tous les tests passent**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultat attendu : tous PASSED.
|
||||||
|
|
||||||
|
**Step 5 : Commit final**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -c commit.gpgsign=false add app/templates/reports.html tests/test_routes.py
|
||||||
|
git -c commit.gpgsign=false commit -m "feat: section détail mensuel dans la page rapports"
|
||||||
|
```
|
||||||
16
pyproject.toml
Normal file
16
pyproject.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[tool.ruff]
|
||||||
|
target-version = "py311"
|
||||||
|
line-length = 100
|
||||||
|
include = ["*.py"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E4", "E7", "E9", "F", "I"]
|
||||||
|
|
||||||
|
# Cet import différé est intentionnel : il prépare sys.path pour le script
|
||||||
|
# lancé directement (imports des modules de l'app après insertion du parent).
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"scripts/import_csv.py" = ["E402"]
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
@@ -3,3 +3,4 @@ flask-sqlalchemy>=3.1
|
|||||||
pytest>=8.0
|
pytest>=8.0
|
||||||
pytest-flask>=1.3
|
pytest-flask>=1.3
|
||||||
gunicorn>=22.0
|
gunicorn>=22.0
|
||||||
|
ruff>=0.9,<1.0
|
||||||
|
|||||||
166
scripts/import_csv.py
Executable file
166
scripts/import_csv.py
Executable file
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script d'import CSV vers la base de données SQLite.
|
||||||
|
|
||||||
|
Format CSV attendu :
|
||||||
|
date,day_type,journey_profile_id,motor_vehicle_id,start_time,end_time,comment
|
||||||
|
|
||||||
|
Exemple :
|
||||||
|
2025-06-02,WORK,moteur_seul,familiale,09:00;14:00,17:45;12:00,Travail normal
|
||||||
|
2025-06-03,TT,,,09:00,17:45,Télétravail
|
||||||
|
|
||||||
|
Pour plusieurs plages horaires, séparer les heures par des points-virgules (;).
|
||||||
|
|
||||||
|
Types de jour valides : WORK, TT, GARDE, ASTREINTE, FORMATION, RTT, CONGE, MALADE, FERIE
|
||||||
|
|
||||||
|
En cas de conflit sur une date, les données existantes sont conservées et un avertissement est affiché.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import sys
|
||||||
|
from datetime import date, time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# Ajouter le dossier parent au path pour importer les modules
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from app import create_app, db
|
||||||
|
from app.config_loader import day_types_without_journey, journey_has_motor
|
||||||
|
from app.models import TimeSlot, WorkEntry
|
||||||
|
|
||||||
|
DAY_TYPES = {"WORK", "TT", "GARDE", "ASTREINTE", "FORMATION", "RTT", "CONGE", "MALADE", "FERIE"}
|
||||||
|
|
||||||
|
|
||||||
|
def main(csv_path: str, config_path: str | None = None):
|
||||||
|
"""Importe les données depuis un fichier CSV vers la base de données."""
|
||||||
|
|
||||||
|
# Créer l'application Flask avec la config
|
||||||
|
app = create_app(config_path=config_path)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
conflicts = []
|
||||||
|
imported_count = 0
|
||||||
|
|
||||||
|
# Lire le fichier CSV
|
||||||
|
with open(csv_path, "r", encoding="utf-8") as f:
|
||||||
|
csv_reader = csv.DictReader(f)
|
||||||
|
|
||||||
|
for row_num, row in enumerate(csv_reader, start=2):
|
||||||
|
try:
|
||||||
|
# Parser la date
|
||||||
|
entry_date = date.fromisoformat(row.get("date", "").strip())
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
conflicts.append(f"Ligne {row_num}: date invalide ou manquante")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Valider le type de jour
|
||||||
|
day_type = row.get("day_type", "WORK").strip().upper()
|
||||||
|
if day_type not in DAY_TYPES:
|
||||||
|
conflicts.append(f"Ligne {row_num}: type de jour invalide '{day_type}'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Récupérer les autres champs
|
||||||
|
journey_profile_id = row.get("journey_profile_id", "").strip() or None
|
||||||
|
motor_vehicle_id = row.get("motor_vehicle_id", "").strip() or None
|
||||||
|
comment = row.get("comment", "").strip() or None
|
||||||
|
|
||||||
|
# Si le type de jour n'a pas de trajet, forcer journey_profile_id à None
|
||||||
|
if day_type in day_types_without_journey():
|
||||||
|
journey_profile_id = None
|
||||||
|
|
||||||
|
# Si le profil de trajet n'a pas de moteur, forcer motor_vehicle_id à None
|
||||||
|
if journey_profile_id and not journey_has_motor(journey_profile_id):
|
||||||
|
motor_vehicle_id = None
|
||||||
|
|
||||||
|
# Vérifier si une entrée existe déjà pour cette date
|
||||||
|
existing = db.session.scalar(
|
||||||
|
sa.select(WorkEntry).where(WorkEntry.date == entry_date)
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Vérifier si les données sont différentes
|
||||||
|
is_different = (
|
||||||
|
existing.day_type != day_type
|
||||||
|
or existing.journey_profile_id != journey_profile_id
|
||||||
|
or existing.motor_vehicle_id != motor_vehicle_id
|
||||||
|
or existing.comment != comment
|
||||||
|
)
|
||||||
|
if is_different:
|
||||||
|
conflicts.append(
|
||||||
|
f"Ligne {row_num}: conflit sur la date {entry_date}. "
|
||||||
|
f"Données existantes conservées."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Créer la nouvelle entrée
|
||||||
|
entry = WorkEntry(
|
||||||
|
date=entry_date,
|
||||||
|
day_type=day_type,
|
||||||
|
journey_profile_id=journey_profile_id,
|
||||||
|
motor_vehicle_id=motor_vehicle_id,
|
||||||
|
comment=comment,
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
|
||||||
|
# Ajouter les plages horaires
|
||||||
|
start_times = row.get("start_time", "").split(";")
|
||||||
|
end_times = row.get("end_time", "").split(";")
|
||||||
|
|
||||||
|
for s, e in zip(start_times, end_times):
|
||||||
|
s = s.strip()
|
||||||
|
e = e.strip()
|
||||||
|
if s and e:
|
||||||
|
try:
|
||||||
|
db.session.add(
|
||||||
|
TimeSlot(
|
||||||
|
entry=entry,
|
||||||
|
start_time=time.fromisoformat(s),
|
||||||
|
end_time=time.fromisoformat(e),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
conflicts.append(
|
||||||
|
f"Ligne {row_num}: format d'heure invalide '{s}' ou '{e}'"
|
||||||
|
)
|
||||||
|
db.session.rollback()
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
imported_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
db.session.rollback()
|
||||||
|
break
|
||||||
|
|
||||||
|
# Valider et commiter
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
except sa.exc.SQLAlchemyError as e:
|
||||||
|
db.session.rollback()
|
||||||
|
print(f"Erreur lors du commit: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Afficher les résultats
|
||||||
|
print(f"Import terminé: {imported_count} entrée(s) importée(s)")
|
||||||
|
|
||||||
|
if conflicts:
|
||||||
|
print(f"\n⚠️ {len(conflicts)} avertissement(s):")
|
||||||
|
for conflict in conflicts:
|
||||||
|
print(f" - {conflict}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Importer un fichier CSV dans la base de données")
|
||||||
|
parser.add_argument("csv_file", help="Chemin vers le fichier CSV à importer")
|
||||||
|
parser.add_argument(
|
||||||
|
"--config", default=None, help="Chemin vers le fichier config.toml (optionnel)"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
sys.exit(main(args.csv_file, args.config))
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from app import create_app, db as _db
|
|
||||||
|
from app import create_app
|
||||||
|
from app import db as _db
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app(tmp_path):
|
def app(tmp_path):
|
||||||
config_path = tmp_path / "config.toml"
|
config_path = tmp_path / "config.toml"
|
||||||
config_path.write_text("""
|
config_path.write_text(
|
||||||
|
"""
|
||||||
[vehicles.citadine]
|
[vehicles.citadine]
|
||||||
name = "Citadine électrique"
|
name = "Citadine électrique"
|
||||||
fuel = "electric"
|
fuel = "electric"
|
||||||
@@ -59,7 +62,9 @@ forfait = 699
|
|||||||
km_max = 0
|
km_max = 0
|
||||||
taux = 0.364
|
taux = 0.364
|
||||||
forfait = 0
|
forfait = 0
|
||||||
""", encoding="utf-8")
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
application = create_app(config_path=str(config_path))
|
application = create_app(config_path=str(config_path))
|
||||||
application.config["TESTING"] = True
|
application.config["TESTING"] = True
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
def test_get_vehicles_returns_configured_vehicles(app):
|
def test_get_vehicles_returns_configured_vehicles(app):
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from app.config_loader import get_vehicles
|
from app.config_loader import get_vehicles
|
||||||
|
|
||||||
vehicles = get_vehicles()
|
vehicles = get_vehicles()
|
||||||
assert "familiale" in vehicles
|
assert "familiale" in vehicles
|
||||||
assert vehicles["familiale"]["co2_per_km"] == 142
|
assert vehicles["familiale"]["co2_per_km"] == 142
|
||||||
@@ -9,6 +10,7 @@ def test_get_vehicles_returns_configured_vehicles(app):
|
|||||||
def test_get_motor_vehicles_excludes_velo(app):
|
def test_get_motor_vehicles_excludes_velo(app):
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from app.config_loader import get_motor_vehicles
|
from app.config_loader import get_motor_vehicles
|
||||||
|
|
||||||
motor = get_motor_vehicles()
|
motor = get_motor_vehicles()
|
||||||
assert "familiale" in motor
|
assert "familiale" in motor
|
||||||
assert "citadine" in motor
|
assert "citadine" in motor
|
||||||
@@ -19,6 +21,7 @@ def test_get_motor_vehicles_excludes_velo(app):
|
|||||||
def test_get_journeys_returns_profiles(app):
|
def test_get_journeys_returns_profiles(app):
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from app.config_loader import get_journeys
|
from app.config_loader import get_journeys
|
||||||
|
|
||||||
journeys = get_journeys()
|
journeys = get_journeys()
|
||||||
assert "moteur_seul" in journeys
|
assert "moteur_seul" in journeys
|
||||||
assert journeys["moteur_seul"]["distances"]["moteur"] == 25
|
assert journeys["moteur_seul"]["distances"]["moteur"] == 25
|
||||||
@@ -27,6 +30,7 @@ def test_get_journeys_returns_profiles(app):
|
|||||||
def test_journey_has_motor_true(app):
|
def test_journey_has_motor_true(app):
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from app.config_loader import journey_has_motor
|
from app.config_loader import journey_has_motor
|
||||||
|
|
||||||
assert journey_has_motor("moteur_seul") is True
|
assert journey_has_motor("moteur_seul") is True
|
||||||
assert journey_has_motor("moteur_velo") is True
|
assert journey_has_motor("moteur_velo") is True
|
||||||
|
|
||||||
@@ -34,6 +38,7 @@ def test_journey_has_motor_true(app):
|
|||||||
def test_journey_has_motor_false(app):
|
def test_journey_has_motor_false(app):
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from app.config_loader import journey_has_motor
|
from app.config_loader import journey_has_motor
|
||||||
|
|
||||||
assert journey_has_motor("velo_seul") is False
|
assert journey_has_motor("velo_seul") is False
|
||||||
assert journey_has_motor(None) is False
|
assert journey_has_motor(None) is False
|
||||||
|
|
||||||
@@ -41,6 +46,7 @@ def test_journey_has_motor_false(app):
|
|||||||
def test_get_bareme_returns_tranches(app):
|
def test_get_bareme_returns_tranches(app):
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from app.config_loader import get_bareme
|
from app.config_loader import get_bareme
|
||||||
|
|
||||||
tranches = get_bareme(2025, 5)
|
tranches = get_bareme(2025, 5)
|
||||||
assert len(tranches) == 3
|
assert len(tranches) == 3
|
||||||
assert tranches[0]["taux"] == 0.548
|
assert tranches[0]["taux"] == 0.548
|
||||||
@@ -49,6 +55,7 @@ def test_get_bareme_returns_tranches(app):
|
|||||||
def test_day_types_without_journey(app):
|
def test_day_types_without_journey(app):
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from app.config_loader import day_types_without_journey
|
from app.config_loader import day_types_without_journey
|
||||||
|
|
||||||
types = day_types_without_journey()
|
types = day_types_without_journey()
|
||||||
assert "TT" in types
|
assert "TT" in types
|
||||||
assert "WORK" not in types
|
assert "WORK" not in types
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from app.business.leave_calc import compute_leave_used, get_or_create_balance
|
|
||||||
from app.models import WorkEntry, LeaveBalance
|
|
||||||
from app import db
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
import sqlalchemy as sa
|
|
||||||
|
from app import db
|
||||||
|
from app.business.leave_calc import compute_leave_used, get_or_create_balance
|
||||||
|
from app.models import LeaveBalance, WorkEntry
|
||||||
|
|
||||||
|
|
||||||
def test_compute_leave_used_conges(app):
|
def test_compute_leave_used_conges(app):
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from app.models import WorkEntry, TimeSlot
|
from datetime import date
|
||||||
from app import db
|
|
||||||
from datetime import date, time
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models import WorkEntry
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_empty(client):
|
def test_dashboard_empty(client):
|
||||||
response = client.get("/")
|
response = client.get("/")
|
||||||
@@ -17,7 +19,9 @@ def test_entry_form_get(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_create_entry(client, app):
|
def test_create_entry(client, app):
|
||||||
response = client.post("/entries/new", data={
|
response = client.post(
|
||||||
|
"/entries/new",
|
||||||
|
data={
|
||||||
"date": "2025-06-02",
|
"date": "2025-06-02",
|
||||||
"day_type": "WORK",
|
"day_type": "WORK",
|
||||||
"journey_profile_id": "moteur_seul",
|
"journey_profile_id": "moteur_seul",
|
||||||
@@ -25,13 +29,13 @@ def test_create_entry(client, app):
|
|||||||
"start_time": ["09:00"],
|
"start_time": ["09:00"],
|
||||||
"end_time": ["17:45"],
|
"end_time": ["17:45"],
|
||||||
"comment": "",
|
"comment": "",
|
||||||
}, follow_redirects=True)
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
entry = db.session.scalar(
|
entry = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.date == date(2025, 6, 2)))
|
||||||
sa.select(WorkEntry).where(WorkEntry.date == date(2025, 6, 2))
|
|
||||||
)
|
|
||||||
assert entry is not None
|
assert entry is not None
|
||||||
assert entry.day_type == "WORK"
|
assert entry.day_type == "WORK"
|
||||||
assert len(entry.time_slots) == 1
|
assert len(entry.time_slots) == 1
|
||||||
@@ -66,15 +70,15 @@ def test_delete_entry(client, app):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
deleted = db.session.scalar(
|
deleted = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.id == entry_id))
|
||||||
sa.select(WorkEntry).where(WorkEntry.id == entry_id)
|
|
||||||
)
|
|
||||||
assert deleted is None
|
assert deleted is None
|
||||||
|
|
||||||
|
|
||||||
def test_create_entry_velo_no_motor_vehicle(client, app):
|
def test_create_entry_velo_no_motor_vehicle(client, app):
|
||||||
"""Un trajet vélo seul ne doit pas enregistrer de motor_vehicle_id."""
|
"""Un trajet vélo seul ne doit pas enregistrer de motor_vehicle_id."""
|
||||||
response = client.post("/entries/new", data={
|
response = client.post(
|
||||||
|
"/entries/new",
|
||||||
|
data={
|
||||||
"date": "2025-06-10",
|
"date": "2025-06-10",
|
||||||
"day_type": "WORK",
|
"day_type": "WORK",
|
||||||
"journey_profile_id": "velo_seul",
|
"journey_profile_id": "velo_seul",
|
||||||
@@ -82,12 +86,20 @@ def test_create_entry_velo_no_motor_vehicle(client, app):
|
|||||||
"start_time": ["08:30"],
|
"start_time": ["08:30"],
|
||||||
"end_time": ["17:00"],
|
"end_time": ["17:00"],
|
||||||
"comment": "",
|
"comment": "",
|
||||||
}, follow_redirects=True)
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
entry = db.session.scalar(
|
entry = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.date == date(2025, 6, 10)))
|
||||||
sa.select(WorkEntry).where(WorkEntry.date == date(2025, 6, 10))
|
|
||||||
)
|
|
||||||
assert entry is not None
|
assert entry is not None
|
||||||
assert entry.motor_vehicle_id is None
|
assert entry.motor_vehicle_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reports_monthly_section(client):
|
||||||
|
response = client.get("/reports/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Détail mensuel" in response.text
|
||||||
|
assert "Janvier" in response.text
|
||||||
|
assert "Décembre" in response.text
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
from datetime import date
|
||||||
|
from datetime import time as dtime
|
||||||
|
|
||||||
from app.business.time_calc import (
|
from app.business.time_calc import (
|
||||||
|
count_day_types,
|
||||||
minutes_to_str,
|
minutes_to_str,
|
||||||
work_minutes_reference,
|
monthly_stats,
|
||||||
week_balance_minutes,
|
week_balance_minutes,
|
||||||
|
work_minutes_reference,
|
||||||
)
|
)
|
||||||
|
from app.models import TimeSlot, WorkEntry
|
||||||
|
|
||||||
|
|
||||||
def test_minutes_to_str_basic():
|
def test_minutes_to_str_basic():
|
||||||
@@ -42,11 +48,6 @@ def test_week_balance_negative():
|
|||||||
assert week_balance_minutes(2200, 2325) == -125
|
assert week_balance_minutes(2200, 2325) == -125
|
||||||
|
|
||||||
|
|
||||||
from app.business.time_calc import count_day_types
|
|
||||||
from app.models import WorkEntry
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
|
|
||||||
def test_count_day_types_basic():
|
def test_count_day_types_basic():
|
||||||
entries = [
|
entries = [
|
||||||
WorkEntry(date=date(2025, 1, 2), day_type="WORK"),
|
WorkEntry(date=date(2025, 1, 2), day_type="WORK"),
|
||||||
@@ -62,6 +63,65 @@ def test_count_day_types_empty():
|
|||||||
assert count_day_types([]) == {}
|
assert count_day_types([]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(d: date, *slots: tuple[str, str]) -> WorkEntry:
|
||||||
|
"""Helper : crée un WorkEntry avec des TimeSlots."""
|
||||||
|
entry = WorkEntry(date=d, day_type="WORK")
|
||||||
|
entry.time_slots = [
|
||||||
|
TimeSlot(
|
||||||
|
start_time=dtime(*[int(x) for x in s.split(":")]),
|
||||||
|
end_time=dtime(*[int(x) for x in e.split(":")]),
|
||||||
|
)
|
||||||
|
for s, e in slots
|
||||||
|
]
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _absence(d: date) -> WorkEntry:
|
||||||
|
"""Helper : crée un WorkEntry sans time_slots (0 min)."""
|
||||||
|
entry = WorkEntry(date=d, day_type="CONGE")
|
||||||
|
entry.time_slots = []
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_stats_empty():
|
||||||
|
result = monthly_stats([])
|
||||||
|
assert result == {"median_daily_min": 0, "median_weekly_min": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_stats_median_daily_odd():
|
||||||
|
# 420, 465, 510 → médiane = 465
|
||||||
|
entries = [
|
||||||
|
_entry(date(2025, 1, 6), ("9:00", "16:00")), # 420 min
|
||||||
|
_entry(date(2025, 1, 7), ("9:00", "16:45")), # 465 min
|
||||||
|
_entry(date(2025, 1, 8), ("9:00", "17:30")), # 510 min
|
||||||
|
]
|
||||||
|
result = monthly_stats(entries)
|
||||||
|
assert result["median_daily_min"] == 465
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_stats_includes_absences():
|
||||||
|
# 0, 465 → médiane de 2 valeurs = (0+465)/2 = 232 (int)
|
||||||
|
entries = [
|
||||||
|
_absence(date(2025, 1, 6)),
|
||||||
|
_entry(date(2025, 1, 7), ("9:00", "16:45")),
|
||||||
|
]
|
||||||
|
result = monthly_stats(entries)
|
||||||
|
assert result["median_daily_min"] == 232
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_stats_median_weekly():
|
||||||
|
# Semaine 1 : 465+465 = 930 min
|
||||||
|
# Semaine 2 : 420 min
|
||||||
|
# médiane([930, 420]) = (420+930)/2 = 675
|
||||||
|
entries = [
|
||||||
|
_entry(date(2025, 1, 6), ("9:00", "16:45")), # sem 2
|
||||||
|
_entry(date(2025, 1, 7), ("9:00", "16:45")), # sem 2
|
||||||
|
_entry(date(2025, 1, 13), ("9:00", "16:00")), # sem 3
|
||||||
|
]
|
||||||
|
result = monthly_stats(entries)
|
||||||
|
assert result["median_weekly_min"] == 675
|
||||||
|
|
||||||
|
|
||||||
def test_count_day_types_single_type():
|
def test_count_day_types_single_type():
|
||||||
entries = [
|
entries = [
|
||||||
WorkEntry(date=date(2025, 2, 1), day_type="RTT"),
|
WorkEntry(date=date(2025, 2, 1), day_type="RTT"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from app.business.travel_calc import (
|
from app.business.travel_calc import (
|
||||||
compute_km_for_entry,
|
|
||||||
compute_co2_grams,
|
compute_co2_grams,
|
||||||
compute_frais_reels,
|
compute_frais_reels,
|
||||||
|
compute_km_for_entry,
|
||||||
)
|
)
|
||||||
|
|
||||||
VEHICLES = {
|
VEHICLES = {
|
||||||
|
|||||||
Reference in New Issue
Block a user