# 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 `
` existant, ajouter un bloc pour la sélection du véhicule à moteur. Remplacer la div `journey-section` entière par : ```html
{% for vid, vdata in motor_vehicles.items() %} {% endfor %}
``` **Step 5 : Mettre à jour le bloc `