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

763 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<div id="journey-section">` existant, ajouter un bloc pour la sélection du véhicule à moteur.
Remplacer la div `journey-section` entière par :
```html
<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` :
```javascript
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**
```bash
.venv/bin/python -c "from app import create_app; app = create_app(); print('OK')"
```
Expected : `OK`
**Step 7 : Commit**
```bash
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 :
```python
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**
```bash
.venv/bin/python -m pytest -v
```
Expected : tous PASSED
**Step 3 : Commit**
```bash
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 |