763 lines
22 KiB
Markdown
763 lines
22 KiB
Markdown
# 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 |
|