Files
tableau-de-bord/docs/plans/2026-03-11-multi-vehicule.md

22 KiB
Raw Blame History

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 :

[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 :

    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

.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

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 :

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

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

.venv/bin/python -m pytest tests/test_config_loader.py -v

Expected : 7 PASSED

Step 4 : Commit

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 :

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

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 :

    with app.app_context():
        db.create_all()

par :

    with app.app_context():
        _migrate_db(app)
        db.create_all()

Ajouter l'import manquant en tête de app/__init__.py :

import sqlalchemy as sa

Step 3 : Vérifier

.venv/bin/python -c "from app import create_app; app = create_app(); print('Migration OK')"

Expected : Migration OK

Step 4 : Commit

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 :

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

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

.venv/bin/python -m pytest tests/test_travel_calc.py -v

Expected : 11 PASSED

Step 4 : Commit

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 :

km = compute_km_for_entry(entry.journey_profile_id, journeys)

en :

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 :

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 :

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 :

entry.motor_vehicle_id = motor_vehicle_id

d) Passer motor_vehicles au template dans la branche GET :

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 :

km = compute_km_for_entry(entry.journey_profile_id, journeys)

en :

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 <div id="journey-section"> existant, ajouter un bloc pour la sélection du véhicule à moteur.

Remplacer la div journey-section entière par :

  <div id="journey-section"
       class="{% if entry and entry.day_type in day_types_without_journey %}hidden{% endif %}">
    <label class="block text-sm font-medium text-gray-700 mb-1">Trajet domicile-travail</label>
    <select name="journey_profile_id" id="journey_profile_id"
            onchange="updateMotorVehicleVisibility(this.value)"
            class="w-full border rounded-lg px-3 py-2 text-sm">
      <option value="">— Pas de déplacement —</option>
      {% for jid, jdata in journeys.items() %}
      <option value="{{ jid }}"
              data-has-motor="{{ 'true' if 'moteur' in jdata.distances else 'false' }}"
              {% if entry and entry.journey_profile_id == jid %}selected{% endif %}>
        {{ jdata.name }}
        ({% for v, d in jdata.distances.items() %}{{ d }} km {{ v }}{% if not loop.last %} + {% endif %}{% endfor %})
      </option>
      {% endfor %}
    </select>
  </div>

  <div id="motor-vehicle-section" class="{% if not entry or not entry.motor_vehicle_id %}hidden{% endif %}">
    <label class="block text-sm font-medium text-gray-700 mb-1">Véhicule à moteur utilisé</label>
    <div class="grid grid-cols-2 gap-2">
      {% for vid, vdata in motor_vehicles.items() %}
      <label class="cursor-pointer">
        <input type="radio" name="motor_vehicle_id" value="{{ vid }}"
               {% if entry and entry.motor_vehicle_id == vid %}checked{% endif %}
               class="sr-only peer">
        <div class="text-center text-sm py-2 px-1 rounded-lg border-2 border-gray-200
                    peer-checked:border-orange-500 peer-checked:bg-orange-50 peer-checked:text-orange-700
                    hover:border-gray-300 transition">
          {{ vdata.name }}
        </div>
      </label>
      {% endfor %}
    </div>
  </div>

Step 5 : Mettre à jour le bloc <script> dans entry_form.html

Remplacer la fonction updateJourneyVisibility et ajouter updateMotorVehicleVisibility :

const NO_JOURNEY_TYPES = {{ day_types_without_journey | list | tojson }};

function updateJourneyVisibility(dayType) {
  const journeySection = document.getElementById('journey-section');
  const hidden = NO_JOURNEY_TYPES.includes(dayType);
  journeySection.classList.toggle('hidden', hidden);
  if (hidden) {
    updateMotorVehicleVisibility('');
  } else {
    const select = document.getElementById('journey_profile_id');
    if (select) updateMotorVehicleVisibility(select.value);
  }
}

function updateMotorVehicleVisibility(journeyId) {
  const section = document.getElementById('motor-vehicle-section');
  const select = document.getElementById('journey_profile_id');
  const selectedOption = select ? select.querySelector(`option[value="${journeyId}"]`) : null;
  const hasMotor = selectedOption && selectedOption.dataset.hasMotor === 'true';
  section.classList.toggle('hidden', !hasMotor);
}

function addTimeSlot() {
  const container = document.getElementById('time-slots');
  const row = document.createElement('div');
  row.className = 'flex gap-2 items-center time-slot-row';
  row.innerHTML = `
    <input type="time" name="start_time" class="flex-1 border rounded-lg px-3 py-2 text-sm">
    <span class="text-gray-400">→</span>
    <input type="time" name="end_time" class="flex-1 border rounded-lg px-3 py-2 text-sm">
    <button type="button" onclick="this.closest('.time-slot-row').remove()"
            class="text-red-400 hover:text-red-600 text-xl leading-none">×</button>
  `;
  container.appendChild(row);
}

Step 6 : Vérifier que Flask démarre

.venv/bin/python -c "from app import create_app; app = create_app(); print('OK')"

Expected : OK

Step 7 : Commit

git add app/routes/ app/templates/entry_form.html
git commit -m "feat: motor vehicle selector in entry form and routes"

Task 6 : Mise à jour tests d'intégration

Files:

  • Modify: tests/test_routes.py

Step 1 : Mettre à jour tests/test_routes.py

Mettre à jour test_create_entry pour inclure motor_vehicle_id et ajouter un test vérifiant le champ :

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)
    assert response.status_code == 200

    with app.app_context():
        import sqlalchemy as sa
        from app import db
        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 entry.motor_vehicle_id == "familiale"
        assert len(entry.time_slots) == 1


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)
    assert response.status_code == 200

    with app.app_context():
        import sqlalchemy as sa
        from app import db
        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

Step 2 : Lancer tous les tests

.venv/bin/python -m pytest -v

Expected : tous PASSED

Step 3 : Commit

git add tests/test_routes.py
git commit -m "test: update integration tests for motor vehicle selection"

Résumé des commits

# Message
1 refactor: rename vehicle keys to generic moteur/type in config
2 feat: add get_motor_vehicles() and journey_has_motor() to config_loader
3 feat: add motor_vehicle_id to WorkEntry with auto-migration
4 feat: compute_km_for_entry resolves generic moteur key to specific vehicle
5 feat: motor vehicle selector in entry form and routes
6 test: update integration tests for motor vehicle selection