20 Commits

Author SHA1 Message Date
c5bcbf51bc docs: utiliser db.engine au lieu de db.get_engine() déprécié
Co-authored-by: OpenAI/GPT-5.6-terra <vibecoder@antoineve.me>
2026-08-13 15:28:43 +02:00
1a679ea1c7 refactor: migrer datetime.utcnow() vers datetime.now(UTC)
Co-authored-by: OpenAI/GPT-5.6-terra <vibecoder@antoineve.me>
2026-08-13 15:23:09 +02:00
b6fa09a709 doc: improve contributor guidance
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: Google/Gemini-3.5-Flash <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 12:25:08 +02:00
e9977f5a1b doc: clarify Python version requirement
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 12:02:15 +02:00
fd128dd09b chore: resolve final review findings
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Pro <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 11:48:38 +02:00
21fd877d7a doc: add developer onboarding guide
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: Google/Gemini-3.5-Flash <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 11:38:04 +02:00
bd5bc496eb doc: document configuration and application routes
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: Google/Gemini-3.5-Flash <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 11:05:10 +02:00
2d85fffd8d doc: document models and business rules
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: Google/Gemini-3.5-Flash <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Pro <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 10:58:49 +02:00
c7a0a77d1f chore: fix python style violations
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: OpenAI/GPT-5.6-Luna <vibecoder@antoineve.me>
Co-authored-by: MiniMax/MiniMax-M3 <vibecoder@antoineve.me>
Co-authored-by: DeepSeek/DeepSeek-v4-Flash <vibecoder@antoineve.me>
2026-08-13 10:14:21 +02:00
82beb6241f chore: configure python quality tooling
Co-authored-by: OpenAI/GPT-5.6-Terra <vibecoder@antoineve.me>
Co-authored-by: OpenAI/GPT-5.6-Luna <vibecoder@antoineve.me>
2026-08-13 10:08:13 +02:00
bfa48ee8a8 Add bulk CSV import script for work entries
- Add scripts/import_csv.py for direct SQLite database import
- Handle date conflicts with warnings (existing data preserved)
- Support multiple time slots per entry (semicolon-separated)
- Validate day_type, journey_profile_id, and motor_vehicle_id
- Update README.md with usage instructions
2026-05-13 18:45:27 +02:00
0956b22986 Add 2026 kilometer rate scale (unchanged from 2025) 2026-05-13 18:21:13 +02:00
8fe13615d0 CLAUDE.md -> AGENTS.md 2026-05-13 18:12:54 +02:00
7e2f158c09 Merge pull request 'feat: statistiques mensuelles dans la page rapports' (#2) from monthly-stats-reports into master
Reviewed-on: #2
2026-03-13 14:42:37 +01:00
cc664b3555 feat: section détail mensuel dans la page rapports 2026-03-13 14:36:32 +01:00
59d64885d4 feat: calcul stats mensuelles dans reports (km + médianes) 2026-03-13 14:35:18 +01:00
6d5e58273f feat: monthly_stats() — médiane journalière et hebdomadaire 2026-03-13 14:34:49 +01:00
d768156e0d docs: plan d'implémentation statistiques mensuelles 2026-03-13 14:16:31 +01:00
e781c06593 docs: design statistiques mensuelles dans la page rapports 2026-03-13 14:16:27 +01:00
d97565f1a1 Merge pull request 'feat: increase RTT from 18 to 19' (#1) from increase-rtt-to-19 into master
Reviewed-on: #1
2026-03-13 13:50:12 +01:00
29 changed files with 1891 additions and 135 deletions

2
.gitignore vendored
View File

@@ -5,3 +5,5 @@ __pycache__/
instance/
.env
*.db
.ruff_cache/
.pytest_cache/

View File

@@ -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
@@ -25,14 +27,28 @@ python -m venv .venv
sudo systemctl edit --full tableau-de-bord-pro # configurer SECRET_KEY
sudo systemctl restart tableau-de-bord-pro
# Git commit (GPG signing désactivé — pinentry inaccessible dans cet env)
git -c commit.gpgsign=false commit -m "..."
# Qualité du code (Ruff)
.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
- `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
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.
- **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.).
- **`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).

View File

@@ -74,6 +74,40 @@ Toute la configuration métier se trouve dans `config.toml` :
.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
[MIT](LICENSE.md) — Copyright (c) 2026 Antoine Van-Elstraete

View File

@@ -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_sqlalchemy import SQLAlchemy
import tomllib
import os
import sqlalchemy as sa
db = SQLAlchemy()
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
db_path = os.path.join(app.instance_path, "worklog.db")
if not os.path.exists(db_path):
return # Nouvelle DB, create_all() s'en charge
@@ -25,48 +51,102 @@ def _migrate_db(app):
engine = db.engine
if "motor_vehicle_id" not in columns:
with engine.connect() as conn:
conn.execute(sa.text(
"ALTER TABLE work_entries ADD COLUMN motor_vehicle_id VARCHAR(64)"
))
conn.execute(
sa.text("ALTER TABLE work_entries ADD COLUMN motor_vehicle_id VARCHAR(64)")
)
conn.commit()
_JOURS_FR = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
_MOIS_FR = ["", "janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre"]
_MOIS_FR = [
"",
"janvier",
"février",
"mars",
"avril",
"mai",
"juin",
"juillet",
"août",
"septembre",
"octobre",
"novembre",
"décembre",
]
_DAY_TYPE_LABELS = {
"WORK": "Travail",
"TT": "Télétravail",
"GARDE": "Garde",
"ASTREINTE": "Astreinte",
"FORMATION": "Formation",
"RTT": "RTT",
"CONGE": "Congé",
"MALADE": "Maladie",
"FERIE": "Férié",
"WORK": "Travail",
"TT": "Télétravail",
"GARDE": "Garde",
"ASTREINTE": "Astreinte",
"FORMATION": "Formation",
"RTT": "RTT",
"CONGE": "Congé",
"MALADE": "Maladie",
"FERIE": "Férié",
}
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)
def _date_fr(d):
"""Formate une date en français : 'mercredi 11 mars 2026'."""
from datetime import date as date_type
"""Filtre Jinja2 pour formater une date en français lisible.
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()]
mois = _MOIS_FR[d.month]
return f"{jour} {d.day} {mois} {d.year}"
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)
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["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.entries import bp as entries_bp
from app.routes.reports import bp as reports_bp
app.register_blueprint(dashboard_bp)
app.register_blueprint(entries_bp)
app.register_blueprint(reports_bp)

View File

@@ -1,34 +1,75 @@
from app import db
from app.models import WorkEntry, LeaveBalance
import sqlalchemy as sa
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]:
"""
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)
end = date(year, 12, 31)
conges = db.session.scalar(
sa.select(sa.func.count()).where(
WorkEntry.date.between(start, end),
WorkEntry.day_type == "CONGE",
conges = (
db.session.scalar(
sa.select(sa.func.count()).where(
WorkEntry.date.between(start, end),
WorkEntry.day_type == "CONGE",
)
)
) or 0
or 0
)
rtt = db.session.scalar(
sa.select(sa.func.count()).where(
WorkEntry.date.between(start, end),
WorkEntry.day_type == "RTT",
rtt = (
db.session.scalar(
sa.select(sa.func.count()).where(
WorkEntry.date.between(start, end),
WorkEntry.day_type == "RTT",
)
)
) or 0
or 0
)
return {"conges": conges, "rtt": rtt}
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:
balance = LeaveBalance(year=year)
db.session.add(balance)

View File

@@ -1,4 +1,16 @@
import statistics as _stats
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 ""
minutes = abs(minutes)
return f"{sign}{minutes // 60}h{minutes % 60:02d}"
@@ -18,15 +30,79 @@ _REFERENCE_MINUTES = {
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)
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
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]:
"""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] = {}
for entry in entries:
counts[entry.day_type] = counts.get(entry.day_type, 0) + 1

View File

@@ -4,9 +4,22 @@ def compute_km_for_entry(
motor_vehicle_id: str | None = None,
) -> dict[str, int]:
"""
Retourne un dict {vehicle_id: km} pour un profil de trajet donné.
La clé générique 'moteur' est remplacée par motor_vehicle_id si fourni.
Retourne {} si pas de profil (TT, CONGE, etc.).
Calcule les distances parcourues par véhicule pour une entrée de journal donnée.
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:
return {}
@@ -23,7 +36,18 @@ def compute_km_for_entry(
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
for vehicle_id, km in km_by_vehicle.items():
vehicle = vehicles.get(vehicle_id, {})
@@ -32,11 +56,26 @@ def compute_co2_grams(km_by_vehicle: dict[str, int], vehicles: dict) -> float:
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.
km_max = 0 signifie "pas de limite" (dernière tranche).
electric=True applique la majoration de 20 % pour véhicules électriques.
Calcule le montant des frais réels déductibles selon le barème kilométrique fiscal.
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:
return 0.0

View File

@@ -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
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", {})
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"}
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", {})
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:
return False
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]:
"""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", {})
year_data = bareme.get(str(year), {})
if cv <= 3:
@@ -40,4 +113,12 @@ def get_bareme(year: int, cv: int) -> list[dict]:
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"}

View File

@@ -1,10 +1,24 @@
from app import db
from datetime import date, time, datetime
from datetime import UTC, date, datetime, time
import sqlalchemy as sa
import sqlalchemy.orm as so
from app import db
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"
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)
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)
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(
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(
@@ -23,6 +39,17 @@ class WorkEntry(db.Model):
)
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
for slot in self.time_slots:
start = slot.start_time.hour * 60 + slot.start_time.minute
@@ -33,11 +60,24 @@ class WorkEntry(db.Model):
return total
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()
return f"{minutes // 60}h{minutes % 60:02d}"
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"
id: so.Mapped[int] = so.mapped_column(primary_key=True)
@@ -49,6 +89,13 @@ class TimeSlot(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"
id: so.Mapped[int] = so.mapped_column(primary_key=True)

View File

@@ -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.
"""

View File

@@ -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
import sqlalchemy as sa
from flask import Blueprint, render_template
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.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.route("/")
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()
year = today.year
@@ -45,9 +75,7 @@ def index():
balance = get_or_create_balance(year)
used = compute_leave_used(year)
today_entry = db.session.scalar(
sa.select(WorkEntry).where(WorkEntry.date == today)
)
today_entry = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.date == today))
return render_template(
"dashboard.html",

View File

@@ -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
import sqlalchemy as sa
from flask import Blueprint, flash, redirect, render_template, request, url_for
from app import db
from app.models import WorkEntry, TimeSlot
from app.config_loader import get_journeys, get_motor_vehicles, day_types_without_journey, journey_has_motor
from app.config_loader import (
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")
@@ -22,15 +47,55 @@ DAY_TYPES = [
@bp.route("/")
def list_entries():
entries = db.session.scalars(
sa.select(WorkEntry).order_by(WorkEntry.date.desc())
).all()
"""Affiche la liste historique de toutes les entrées de temps enregistrées.
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)
@bp.route("/new", methods=["GET", "POST"])
@bp.route("/<int:entry_id>/edit", methods=["GET", "POST"])
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
if entry_id:
entry = db.session.get(WorkEntry, entry_id)
@@ -51,9 +116,7 @@ def entry_form(entry_id=None):
journey_profile_id = None
if entry is None:
existing = db.session.scalar(
sa.select(WorkEntry).where(WorkEntry.date == entry_date)
)
existing = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.date == entry_date))
if existing:
flash(f"Une entrée existe déjà pour le {entry_date}.", "error")
return redirect(url_for("entries.entry_form"))
@@ -72,11 +135,13 @@ def entry_form(entry_id=None):
ends = request.form.getlist("end_time")
for s, e in zip(starts, ends):
if s and e:
db.session.add(TimeSlot(
entry=entry,
start_time=time.fromisoformat(s),
end_time=time.fromisoformat(e),
))
db.session.add(
TimeSlot(
entry=entry,
start_time=time.fromisoformat(s),
end_time=time.fromisoformat(e),
)
)
db.session.commit()
flash("Entrée enregistrée.", "success")
@@ -96,6 +161,21 @@ def entry_form(entry_id=None):
@bp.route("/<int:entry_id>/delete", methods=["POST"])
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)
if entry:
db.session.delete(entry)

View File

@@ -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
import sqlalchemy as sa
from flask import Blueprint, render_template, request
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.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")
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():
"""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)
start = date(year, 1, 1)
end = date(year, 12, 31)
@@ -23,6 +77,7 @@ def index():
vehicles = get_vehicles()
journeys = get_journeys()
# --- Stats annuelles (inchangées) ---
total_km = {}
total_co2 = 0.0
for entry in entries:
@@ -42,6 +97,34 @@ def index():
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,
@@ -50,4 +133,5 @@ def index():
frais_reels=frais_reels,
vehicles=vehicles,
day_type_counts=day_type_counts,
monthly_data=monthly_data,
)

View File

@@ -78,4 +78,72 @@
{% endif %}
</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 %}

View File

@@ -115,3 +115,82 @@ forfait = 1515
km_max = 0
taux = 0.470
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
View 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.

View File

@@ -312,7 +312,7 @@ def _migrate_db(app):
conn.close()
with app.app_context():
engine = db.get_engine()
engine = db.engine
if "motor_vehicle_id" not in columns:
with engine.connect() as conn:
conn.execute(sa.text(

View File

@@ -384,7 +384,7 @@ git commit -m "feat: TOML config loader for vehicles, journeys, and tax scales"
```python
from app import db
from datetime import date, time, datetime
from datetime import date, time, datetime, UTC
from enum import Enum as PyEnum
import sqlalchemy as sa
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)
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)
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(
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(

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

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

View File

@@ -3,3 +3,4 @@ flask-sqlalchemy>=3.1
pytest>=8.0
pytest-flask>=1.3
gunicorn>=22.0
ruff>=0.9,<1.0

166
scripts/import_csv.py Executable file
View 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))

View File

@@ -1,11 +1,14 @@
import pytest
from app import create_app, db as _db
from app import create_app
from app import db as _db
@pytest.fixture
def app(tmp_path):
config_path = tmp_path / "config.toml"
config_path.write_text("""
config_path.write_text(
"""
[vehicles.citadine]
name = "Citadine électrique"
fuel = "electric"
@@ -59,7 +62,9 @@ forfait = 699
km_max = 0
taux = 0.364
forfait = 0
""", encoding="utf-8")
""",
encoding="utf-8",
)
application = create_app(config_path=str(config_path))
application.config["TESTING"] = True

View File

@@ -1,6 +1,7 @@
def test_get_vehicles_returns_configured_vehicles(app):
with app.app_context():
from app.config_loader import get_vehicles
vehicles = get_vehicles()
assert "familiale" in vehicles
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):
with app.app_context():
from app.config_loader import get_motor_vehicles
motor = get_motor_vehicles()
assert "familiale" 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):
with app.app_context():
from app.config_loader import get_journeys
journeys = get_journeys()
assert "moteur_seul" in journeys
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):
with app.app_context():
from app.config_loader import journey_has_motor
assert journey_has_motor("moteur_seul") 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):
with app.app_context():
from app.config_loader import journey_has_motor
assert journey_has_motor("velo_seul") 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):
with app.app_context():
from app.config_loader import get_bareme
tranches = get_bareme(2025, 5)
assert len(tranches) == 3
assert tranches[0]["taux"] == 0.548
@@ -49,6 +55,7 @@ def test_get_bareme_returns_tranches(app):
def test_day_types_without_journey(app):
with app.app_context():
from app.config_loader import day_types_without_journey
types = day_types_without_journey()
assert "TT" in types
assert "WORK" not in types

View File

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

View File

@@ -1,8 +1,10 @@
from app.models import WorkEntry, TimeSlot
from app import db
from datetime import date, time
from datetime import date
import sqlalchemy as sa
from app import db
from app.models import WorkEntry
def test_dashboard_empty(client):
response = client.get("/")
@@ -17,21 +19,23 @@ def test_entry_form_get(client):
def test_create_entry(client, app):
response = client.post("/entries/new", data={
"date": "2025-06-02",
"day_type": "WORK",
"journey_profile_id": "moteur_seul",
"motor_vehicle_id": "familiale",
"start_time": ["09:00"],
"end_time": ["17:45"],
"comment": "",
}, follow_redirects=True)
response = client.post(
"/entries/new",
data={
"date": "2025-06-02",
"day_type": "WORK",
"journey_profile_id": "moteur_seul",
"motor_vehicle_id": "familiale",
"start_time": ["09:00"],
"end_time": ["17:45"],
"comment": "",
},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
entry = db.session.scalar(
sa.select(WorkEntry).where(WorkEntry.date == date(2025, 6, 2))
)
entry = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.date == date(2025, 6, 2)))
assert entry is not None
assert entry.day_type == "WORK"
assert len(entry.time_slots) == 1
@@ -66,28 +70,36 @@ def test_delete_entry(client, app):
assert response.status_code == 200
with app.app_context():
deleted = db.session.scalar(
sa.select(WorkEntry).where(WorkEntry.id == entry_id)
)
deleted = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.id == entry_id))
assert deleted is None
def test_create_entry_velo_no_motor_vehicle(client, app):
"""Un trajet vélo seul ne doit pas enregistrer de motor_vehicle_id."""
response = client.post("/entries/new", data={
"date": "2025-06-10",
"day_type": "WORK",
"journey_profile_id": "velo_seul",
"motor_vehicle_id": "",
"start_time": ["08:30"],
"end_time": ["17:00"],
"comment": "",
}, follow_redirects=True)
response = client.post(
"/entries/new",
data={
"date": "2025-06-10",
"day_type": "WORK",
"journey_profile_id": "velo_seul",
"motor_vehicle_id": "",
"start_time": ["08:30"],
"end_time": ["17:00"],
"comment": "",
},
follow_redirects=True,
)
assert response.status_code == 200
with app.app_context():
entry = db.session.scalar(
sa.select(WorkEntry).where(WorkEntry.date == date(2025, 6, 10))
)
entry = db.session.scalar(sa.select(WorkEntry).where(WorkEntry.date == date(2025, 6, 10)))
assert entry is not 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

View File

@@ -1,8 +1,14 @@
from datetime import date
from datetime import time as dtime
from app.business.time_calc import (
count_day_types,
minutes_to_str,
work_minutes_reference,
monthly_stats,
week_balance_minutes,
work_minutes_reference,
)
from app.models import TimeSlot, WorkEntry
def test_minutes_to_str_basic():
@@ -42,11 +48,6 @@ def test_week_balance_negative():
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():
entries = [
WorkEntry(date=date(2025, 1, 2), day_type="WORK"),
@@ -62,6 +63,65 @@ def test_count_day_types_empty():
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():
entries = [
WorkEntry(date=date(2025, 2, 1), day_type="RTT"),

View File

@@ -1,7 +1,7 @@
from app.business.travel_calc import (
compute_km_for_entry,
compute_co2_grams,
compute_frais_reels,
compute_km_for_entry,
)
VEHICLES = {
@@ -17,15 +17,15 @@ JOURNEYS = {
}
TRANCHES_CV3 = [
{"km_max": 5000, "taux": 0.529, "forfait": 0},
{"km_max": 5000, "taux": 0.529, "forfait": 0},
{"km_max": 20000, "taux": 0.316, "forfait": 1065},
{"km_max": 0, "taux": 0.370, "forfait": 0},
{"km_max": 0, "taux": 0.370, "forfait": 0},
]
TRANCHES_CV5 = [
{"km_max": 5000, "taux": 0.636, "forfait": 0},
{"km_max": 5000, "taux": 0.636, "forfait": 0},
{"km_max": 20000, "taux": 0.357, "forfait": 1395},
{"km_max": 0, "taux": 0.427, "forfait": 0},
{"km_max": 0, "taux": 0.427, "forfait": 0},
]