docs: add implementation plan for multi-vehicle selection

This commit is contained in:
2026-03-11 17:01:35 +01:00
parent b6c142dc5b
commit ab27d7a345

View File

@@ -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 `<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 |