diff --git a/docs/plans/2026-03-11-multi-vehicule.md b/docs/plans/2026-03-11-multi-vehicule.md new file mode 100644 index 0000000..360cf83 --- /dev/null +++ b/docs/plans/2026-03-11-multi-vehicule.md @@ -0,0 +1,762 @@ +# Sélection de véhicule à moteur — Plan d'implémentation + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Permettre de choisir quel véhicule à moteur a été utilisé pour chaque trajet (citadine électrique, familiale thermique, moto), avec détail par véhicule dans les rapports. + +**Architecture:** Les profils de trajet utilisent désormais la clé générique `moteur` (au lieu de `voiture`). Un nouveau champ `motor_vehicle_id` sur `WorkEntry` stocke le véhicule spécifique choisi. `compute_km_for_entry` substitue `moteur` → ID du véhicule réel pour que le reste du pipeline (CO2, frais réels, rapports) fonctionne sans autre changement. + +**Tech Stack:** Flask, SQLAlchemy, SQLite (migration ALTER TABLE), TOML, Jinja2/HTMX, pytest + +--- + +## Contexte important + +- `config.toml` : `[bareme_kilometrique]` reste identique (inchangé) +- Pas d'Alembic : migration manuelle via `ALTER TABLE ADD COLUMN` au démarrage +- Les tests utilisent une fixture `app` dans `tests/conftest.py` avec un TOML inline — à mettre à jour +- Lancer les tests : `.venv/bin/python -m pytest -v` + +--- + +## Task 1 : Mise à jour config.toml + conftest + +**Files:** +- Modify: `config.toml` +- Modify: `tests/conftest.py` + +**Step 1 : Mettre à jour `config.toml`** + +Remplacer la section vehicles et journeys existante par : + +```toml +[vehicles.citadine] +name = "Citadine électrique" +fuel = "electric" +co2_per_km = 0 +cv = 3 +type = "moteur" + +[vehicles.familiale] +name = "Familiale thermique" +fuel = "diesel" +co2_per_km = 142 +cv = 5 +type = "moteur" + +[vehicles.moto] +name = "Moto" +fuel = "essence" +co2_per_km = 90 +cv = 3 +type = "moteur" + +[vehicles.velo] +name = "Vélo" +fuel = "none" +co2_per_km = 0 +type = "velo" + +# Profils de trajet : "moteur" = véhicule à moteur générique, remplacé au runtime par le véhicule sélectionné +[journeys.moteur_seul] +name = "Véhicule à moteur seul" +distances = { moteur = 25 } + +[journeys.moteur_velo] +name = "Véhicule à moteur + Vélo" +distances = { moteur = 14, velo = 8 } + +[journeys.velo_seul] +name = "Vélo seul" +distances = { velo = 24 } +``` + +Conserver le bloc `[bareme_kilometrique.2025.*]` existant tel quel. + +**Step 2 : Mettre à jour la fixture TOML dans `tests/conftest.py`** + +Remplacer le contenu TOML inline de la fixture `app` par : + +```python + config_path.write_text(""" +[vehicles.citadine] +name = "Citadine électrique" +fuel = "electric" +co2_per_km = 0 +cv = 3 +type = "moteur" + +[vehicles.familiale] +name = "Familiale thermique" +fuel = "diesel" +co2_per_km = 142 +cv = 5 +type = "moteur" + +[vehicles.moto] +name = "Moto" +fuel = "essence" +co2_per_km = 90 +cv = 3 +type = "moteur" + +[vehicles.velo] +name = "Vélo" +fuel = "none" +co2_per_km = 0 +type = "velo" + +[journeys.moteur_seul] +name = "Véhicule à moteur seul" +distances = { moteur = 25 } + +[journeys.moteur_velo] +name = "Véhicule à moteur + Vélo" +distances = { moteur = 14, velo = 8 } + +[journeys.velo_seul] +name = "Vélo seul" +distances = { velo = 24 } + +[[bareme_kilometrique.2025.cv_5.tranches]] +km_max = 3000 +taux = 0.548 +forfait = 0 + +[[bareme_kilometrique.2025.cv_5.tranches]] +km_max = 6000 +taux = 0.316 +forfait = 699 + +[[bareme_kilometrique.2025.cv_5.tranches]] +km_max = 0 +taux = 0.364 +forfait = 0 +""", encoding="utf-8") +``` + +**Step 3 : Vérifier que les tests existants passent encore** + +```bash +.venv/bin/python -m pytest tests/test_config_loader.py -v +``` + +Expected : certains tests vont échouer car les clés `voiture`/`voiture_seule` n'existent plus — c'est normal, on les corrigera à la tâche suivante. + +**Step 4 : Commit** + +```bash +git add config.toml tests/conftest.py +git commit -m "refactor: rename vehicle keys to generic moteur/type in config" +``` + +--- + +## Task 2 : Mise à jour config_loader.py + ses tests + +**Files:** +- Modify: `app/config_loader.py` +- Modify: `tests/test_config_loader.py` + +**Step 1 : Mettre à jour `app/config_loader.py`** + +Ajouter la fonction `get_motor_vehicles()` et `journey_has_motor()`. Remplacer le contenu complet : + +```python +from flask import current_app + + +def get_vehicles(): + return current_app.config.get("TOML", {}).get("vehicles", {}) + + +def get_motor_vehicles(): + """Retourne uniquement les véhicules de type 'moteur'.""" + return {k: v for k, v in get_vehicles().items() if v.get("type") == "moteur"} + + +def get_journeys(): + 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.""" + if not journey_profile_id: + return False + journeys = get_journeys() + profile = journeys.get(journey_profile_id, {}) + return "moteur" in profile.get("distances", {}) + + +def get_bareme(year: int, cv: int) -> list[dict]: + bareme = current_app.config.get("TOML", {}).get("bareme_kilometrique", {}) + year_data = bareme.get(str(year), {}) + if cv <= 5: + key = "cv_5" + elif cv <= 7: + key = "cv_6_7" + elif cv <= 9: + key = "cv_8_9" + else: + key = "cv_10_11" + return year_data.get(key, {}).get("tranches", []) + + +def day_types_without_journey(): + return {"TT", "MALADE", "CONGE", "RTT", "FERIE"} +``` + +**Step 2 : Mettre à jour `tests/test_config_loader.py`** + +```python +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 + + +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 + assert "moto" in motor + assert "velo" not in motor + + +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 + + +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 + + +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 + + +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 + + +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 +``` + +**Step 3 : Lancer les tests** + +```bash +.venv/bin/python -m pytest tests/test_config_loader.py -v +``` +Expected : 7 PASSED + +**Step 4 : Commit** + +```bash +git add app/config_loader.py tests/test_config_loader.py +git commit -m "feat: add get_motor_vehicles() and journey_has_motor() to config_loader" +``` + +--- + +## Task 3 : Modèle + migration DB + +**Files:** +- Modify: `app/models.py` +- Modify: `app/__init__.py` + +**Step 1 : Ajouter `motor_vehicle_id` dans `app/models.py`** + +Dans la classe `WorkEntry`, ajouter le champ après `journey_profile_id` : + +```python +motor_vehicle_id: so.Mapped[str | None] = so.mapped_column(sa.String(64), nullable=True) +``` + +**Step 2 : Ajouter la migration au démarrage dans `app/__init__.py`** + +Ajouter la fonction `_migrate_db` et l'appeler dans `create_app` juste avant `db.create_all()` : + +```python +def _migrate_db(app): + """Applique les migrations de schéma manquantes (pas d'Alembic).""" + 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 + conn = sqlite3.connect(db_path) + cursor = conn.execute("PRAGMA table_info(work_entries)") + columns = {row[1] for row in cursor.fetchall()} + conn.close() + + with app.app_context(): + engine = db.get_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.commit() +``` + +Dans `create_app`, remplacer : +```python + with app.app_context(): + db.create_all() +``` +par : +```python + with app.app_context(): + _migrate_db(app) + db.create_all() +``` + +Ajouter l'import manquant en tête de `app/__init__.py` : +```python +import sqlalchemy as sa +``` + +**Step 3 : Vérifier** + +```bash +.venv/bin/python -c "from app import create_app; app = create_app(); print('Migration OK')" +``` +Expected : `Migration OK` + +**Step 4 : Commit** + +```bash +git add app/models.py app/__init__.py +git commit -m "feat: add motor_vehicle_id to WorkEntry with auto-migration" +``` + +--- + +## Task 4 : Mise à jour travel_calc.py + ses tests + +**Files:** +- Modify: `app/business/travel_calc.py` +- Modify: `tests/test_travel_calc.py` + +**Step 1 : Mettre à jour `app/business/travel_calc.py`** + +Remplacer `compute_km_for_entry` pour substituer la clé générique `moteur` par l'ID du véhicule réel : + +```python +def compute_km_for_entry( + journey_profile_id: str | None, + journeys: dict, + 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.). + """ + if not journey_profile_id: + return {} + profile = journeys.get(journey_profile_id, {}) + distances = dict(profile.get("distances", {})) + + if "moteur" in distances: + motor_km = distances.pop("moteur") + if motor_vehicle_id: + distances[motor_vehicle_id] = motor_km + # Si pas de motor_vehicle_id, on ignore la distance moteur (cas dégradé) + + return distances + + +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}.""" + total = 0.0 + for vehicle_id, km in km_by_vehicle.items(): + vehicle = vehicles.get(vehicle_id, {}) + co2 = vehicle.get("co2_per_km", 0) + total += km * co2 + return total + + +def compute_frais_reels(total_km_moteur: float, tranches: list[dict]) -> float: + """ + Calcule les frais réels fiscaux selon le barème kilométrique. + km_max = 0 signifie "pas de limite" (dernière tranche). + """ + if not tranches or total_km_moteur <= 0: + return 0.0 + for tranche in tranches: + km_max = tranche["km_max"] + if km_max == 0 or total_km_moteur <= km_max: + return total_km_moteur * tranche["taux"] + tranche.get("forfait", 0) + last = tranches[-1] + return total_km_moteur * last["taux"] + last.get("forfait", 0) +``` + +**Step 2 : Mettre à jour `tests/test_travel_calc.py`** + +```python +from app.business.travel_calc import ( + compute_km_for_entry, + compute_co2_grams, + compute_frais_reels, +) + +VEHICLES = { + "familiale": {"co2_per_km": 142, "cv": 5, "type": "moteur"}, + "citadine": {"co2_per_km": 0, "cv": 3, "type": "moteur"}, + "velo": {"co2_per_km": 0, "type": "velo"}, +} + +JOURNEYS = { + "moteur_seul": {"distances": {"moteur": 25}}, + "moteur_velo": {"distances": {"moteur": 14, "velo": 8}}, + "velo_seul": {"distances": {"velo": 24}}, +} + +TRANCHES_CV5 = [ + {"km_max": 3000, "taux": 0.548, "forfait": 0}, + {"km_max": 6000, "taux": 0.316, "forfait": 699}, + {"km_max": 0, "taux": 0.364, "forfait": 0}, +] + + +def test_compute_km_moteur_seul_avec_vehicule(): + km = compute_km_for_entry("moteur_seul", JOURNEYS, "familiale") + assert km == {"familiale": 25} + + +def test_compute_km_moteur_velo_avec_vehicule(): + km = compute_km_for_entry("moteur_velo", JOURNEYS, "citadine") + assert km == {"citadine": 14, "velo": 8} + + +def test_compute_km_velo_seul(): + km = compute_km_for_entry("velo_seul", JOURNEYS) + assert km == {"velo": 24} + + +def test_compute_km_sans_vehicule_moteur_ignore(): + # Profil moteur mais sans motor_vehicle_id → distance moteur ignorée + km = compute_km_for_entry("moteur_seul", JOURNEYS, None) + assert km == {} + + +def test_compute_km_no_journey(): + assert compute_km_for_entry(None, JOURNEYS) == {} + + +def test_compute_co2_familiale(): + assert compute_co2_grams({"familiale": 25}, VEHICLES) == 25 * 142 + + +def test_compute_co2_citadine_electrique(): + assert compute_co2_grams({"citadine": 25}, VEHICLES) == 0 + + +def test_compute_co2_velo(): + assert compute_co2_grams({"velo": 24}, VEHICLES) == 0 + + +def test_frais_reels_tranche1(): + result = compute_frais_reels(2000, TRANCHES_CV5) + assert abs(result - 1096.0) < 0.01 + + +def test_frais_reels_tranche2(): + result = compute_frais_reels(4000, TRANCHES_CV5) + assert abs(result - 1963.0) < 0.01 + + +def test_frais_reels_tranche3(): + result = compute_frais_reels(7000, TRANCHES_CV5) + assert abs(result - 2548.0) < 0.01 +``` + +**Step 3 : Lancer les tests** + +```bash +.venv/bin/python -m pytest tests/test_travel_calc.py -v +``` +Expected : 11 PASSED + +**Step 4 : Commit** + +```bash +git add app/business/travel_calc.py tests/test_travel_calc.py +git commit -m "feat: compute_km_for_entry resolves generic moteur key to specific vehicle" +``` + +--- + +## Task 5 : Routes + formulaire de saisie + +**Files:** +- Modify: `app/routes/dashboard.py` +- Modify: `app/routes/entries.py` +- Modify: `app/routes/reports.py` +- Modify: `app/templates/entry_form.html` + +**Step 1 : Mettre à jour les appels à `compute_km_for_entry` dans `app/routes/dashboard.py`** + +Changer la ligne : +```python +km = compute_km_for_entry(entry.journey_profile_id, journeys) +``` +en : +```python +km = compute_km_for_entry(entry.journey_profile_id, journeys, entry.motor_vehicle_id) +``` + +**Step 2 : Mettre à jour `app/routes/entries.py`** + +a) Ajouter l'import : +```python +from app.config_loader import get_journeys, get_motor_vehicles, day_types_without_journey, journey_has_motor +``` + +b) Dans la branche `POST` de `entry_form`, ajouter la récupération de `motor_vehicle_id` après `journey_profile_id` : +```python +motor_vehicle_id = request.form.get("motor_vehicle_id") or None + +# Si le profil n'a pas de composante moteur, on ignore +if not journey_has_motor(journey_profile_id): + motor_vehicle_id = None +``` + +c) Sauvegarder dans le modèle : +```python +entry.motor_vehicle_id = motor_vehicle_id +``` + +d) Passer `motor_vehicles` au template dans la branche `GET` : +```python +motor_vehicles = get_motor_vehicles() +return render_template( + "entry_form.html", + entry=entry, + day_types=DAY_TYPES, + journeys=journeys, + motor_vehicles=motor_vehicles, + day_types_without_journey=day_types_without_journey(), + today=date.today().isoformat(), +) +``` + +**Step 3 : Mettre à jour `app/routes/reports.py`** + +Changer la ligne : +```python +km = compute_km_for_entry(entry.journey_profile_id, journeys) +``` +en : +```python +km = compute_km_for_entry(entry.journey_profile_id, journeys, entry.motor_vehicle_id) +``` + +**Step 4 : Mettre à jour `app/templates/entry_form.html`** + +Après le bloc `