Calcul de trajet auto après scrape + recalcul local intelligent

- trajet.py : fonction couts() pure (calcul des coûts sans appel réseau)
- app.py : mémorisation distance/durée/péage + adresses dans le CSV ;
  auto-calcul best-effort après ajout (scrape simple et en lot) ;
  api_distance recalcule en local si adresses inchangées, complet sinon
- index.html : affichage auto du trajet en cache à l'ouverture du prospect
This commit is contained in:
jerem
2026-06-13 17:43:24 +02:00
parent 0952c0bfb5
commit 73d1653225
3 changed files with 113 additions and 18 deletions

93
app.py
View File

@@ -26,6 +26,8 @@ COLONNES = [
"Nom du prospect", "Statut", "Département", "Ville", "Code Postal", "Adresse",
"Date d'ajout", "Date de contact", "Nom de contact",
"Téléphone", "Email", "Infos du lieu", "Type", "Lien Facebook", "Notes",
"Trajet distance km", "Trajet durée min", "Trajet km péage",
"Trajet adresse départ", "Trajet adresse arrivée",
]
STATUT_DEFAUT = "À contacter"
@@ -49,6 +51,8 @@ CONFIG_DEFAUT = {
app = Flask(__name__, static_folder="static")
verrou_scrape = threading.Lock()
verrou_csv = threading.Lock()
# Sérialise les calculs de trajet (un appel Nominatim/OSRM à la fois, ~1 req/s).
verrou_trajet = threading.Lock()
def lire_prospects():
@@ -121,6 +125,56 @@ def ecrire_config(config):
os.replace(tmp, CONFIG_PATH)
def _adresse_arrivee(p):
"""Reconstruit l'adresse d'arrivée d'un prospect (Adresse, Code Postal, Ville)."""
return ", ".join(
x.strip() for x in (p.get("Adresse"), p.get("Code Postal"), p.get("Ville")) if x and x.strip())
def _tarifs(config):
"""(conso L/100km, prix €/L, péage €/km) depuis la config, avec replis."""
return (
float(config.get("conso_l_100km") or 0) or CONFIG_DEFAUT["conso_l_100km"],
float(config.get("prix_carburant") or 0) or CONFIG_DEFAUT["prix_carburant"],
float(config.get("cout_peage_km") or 0), # 0 = pas de péage compté
)
def _ecrire_colonnes_trajet(ligne, depart, arrivee, res):
"""Mémorise dans une ligne les données géo + adresses utilisées pour le calcul."""
ligne["Trajet distance km"] = str(res["distance_km"])
ligne["Trajet durée min"] = str(res["duree_min"])
ligne["Trajet km péage"] = str(res["km_peage"])
ligne["Trajet adresse départ"] = depart
ligne["Trajet adresse arrivée"] = arrivee
def _auto_trajet(ligne, config):
"""Calcule et mémorise le trajet d'un prospect fraîchement ajouté (best-effort).
Conçu pour tourner dans un thread daemon : un échec réseau ne doit jamais
affecter le scrape. `verrou_trajet` sérialise les appels Nominatim/OSRM.
"""
try:
depart = (config.get("adresse_depart") or "").strip()
arrivee = _adresse_arrivee(ligne)
lien = (ligne.get("Lien Facebook") or "").strip()
if not depart or not arrivee:
return
with verrou_trajet:
res = trajet.calculer(depart, arrivee, *_tarifs(config))
with verrou_csv:
lignes = lire_prospects()
cible = next(
(l for l in lignes if (l.get("Lien Facebook") or "").strip() == lien), None
) if lien else None
if cible is not None:
_ecrire_colonnes_trajet(cible, depart, arrivee, res)
ecrire_prospects(lignes)
except Exception:
pass # best-effort : le trajet sera calculé au clic si besoin
@app.get("/")
def accueil():
return app.send_static_file("index.html")
@@ -186,6 +240,7 @@ def api_ajouter():
lignes = lire_prospects()
lignes.append(ligne)
ecrire_prospects(lignes)
threading.Thread(target=_auto_trajet, args=(ligne, lire_config()), daemon=True).start()
return jsonify({"ok": True, "index": len(lignes) - 1})
@@ -274,9 +329,11 @@ def _ajouter_resultat_lot(resultat):
journaliser(url, "doublon", champs["Nom du prospect"])
return {"url": url, "statut": "doublon", "nom": champs["Nom du prospect"]}
champs["Statut"] = STATUT_DEFAUT
lignes.append({col: champs.get(col, "") for col in COLONNES})
ligne = {col: champs.get(col, "") for col in COLONNES}
lignes.append(ligne)
ecrire_prospects(lignes)
journaliser(url, "ajoute", champs["Nom du prospect"])
threading.Thread(target=_auto_trajet, args=(ligne, lire_config()), daemon=True).start()
return {"url": url, "statut": "ajoute", "nom": champs["Nom du prospect"]}
@@ -377,24 +434,42 @@ def api_distance(idx):
"error": "pas_de_depart",
"message": "Renseignez d'abord votre adresse de départ dans les Paramètres.",
}), 400
arrivee = ", ".join(x.strip() for x in (p.get("Adresse"), p.get("Code Postal"), p.get("Ville")) if x and x.strip())
arrivee = _adresse_arrivee(p)
if not arrivee:
return jsonify({
"error": "pas_d_adresse",
"message": "Ce prospect n'a pas d'adresse ou de ville exploitable.",
}), 400
conso, prix, peage = _tarifs(config)
# Recalcul local : les données géo en cache restent valides tant que les deux
# adresses n'ont pas changé. On réapplique simplement les prix courants.
if (p.get("Trajet distance km")
and p.get("Trajet adresse départ") == depart
and p.get("Trajet adresse arrivée") == arrivee):
try:
resultat = trajet.calculer(
depart, arrivee,
float(config.get("conso_l_100km") or 0) or CONFIG_DEFAUT["conso_l_100km"],
float(config.get("prix_carburant") or 0) or CONFIG_DEFAUT["prix_carburant"],
float(config.get("cout_peage_km") or 0), # 0 = pas de péage compté
)
return jsonify(resultat)
return jsonify({**trajet.couts(
float(p["Trajet distance km"]),
float(p.get("Trajet durée min") or 0),
float(p.get("Trajet km péage") or 0),
conso, prix, peage,
), "source": "local"})
except (TypeError, ValueError):
pass # données en cache illisibles : on bascule sur un recalcul complet
# Recalcul complet : appels Nominatim/OSRM, puis mémorisation des données géo.
try:
resultat = trajet.calculer(depart, arrivee, conso, prix, peage)
except trajet.ErreurTrajet as e:
return jsonify({"error": "trajet", "message": str(e)}), 502
except Exception as e:
return jsonify({"error": "erreur", "message": f"Échec du calcul : {e}"}), 500
with verrou_csv:
lignes = lire_prospects()
if 0 <= idx < len(lignes):
_ecrire_colonnes_trajet(lignes[idx], depart, arrivee, resultat)
ecrire_prospects(lignes)
return jsonify({**resultat, "source": "api"})
if __name__ == "__main__":

View File

@@ -908,13 +908,18 @@ function construireDetail(corps, p) {
trajetBtn.textContent = "🚗 Calculer le trajet";
const resu = document.createElement("div");
resu.className = "trajet-resu";
trajetBtn.addEventListener("click", async () => {
// `silencieux` : affichage auto à l'ouverture (données déjà en cache) — pas de spinner.
async function calculerTrajet(silencieux) {
trajetBtn.disabled = true;
resu.innerHTML = '<span class="spinner sombre"></span> Calcul en cours…';
if (!silencieux) resu.innerHTML = '<span class="spinner sombre"></span> Calcul en cours…';
try {
const rep = await fetch(`/api/distance/${p.index}`);
const d = await rep.json();
if (!rep.ok) { resu.innerHTML = `<span style="color:#b03a2e">${echap(d.message || "Échec du calcul.")}</span>`; return; }
if (!rep.ok) {
// En mode silencieux, on n'affiche pas d'erreur : on laisse simplement le bouton.
if (!silencieux) resu.innerHTML = `<span style="color:#b03a2e">${echap(d.message || "Échec du calcul.")}</span>`;
return;
}
const detailAller = d.peage_aller > 0
? ` <span class="sous">(carburant ${d.carburant_aller} € + péage ${d.peage_aller} €)</span>` : "";
const detailAR = d.peage_aller_retour > 0
@@ -925,13 +930,17 @@ function construireDetail(corps, p) {
`Aller : <strong>${d.cout_aller} €</strong>${detailAller}<br>` +
`Aller-retour : <strong>${d.cout_aller_retour} €</strong>${detailAR} ` +
`<span class="sous">(${d.distance_aller_retour_km} km)</span>`;
trajetBtn.textContent = "🔄 Recalculer le trajet";
} catch {
resu.innerHTML = '<span style="color:#b03a2e">Le serveur ne répond pas.</span>';
if (!silencieux) resu.innerHTML = '<span style="color:#b03a2e">Le serveur ne répond pas.</span>';
} finally {
trajetBtn.disabled = false;
}
});
}
trajetBtn.addEventListener("click", () => calculerTrajet(false));
secTrajet.append(trajetBtn, resu);
// Trajet déjà calculé (après le scrape) : on l'affiche tout de suite (recalcul local instantané).
if ((p["Trajet distance km"] || "").trim()) calculerTrajet(true);
corps.append(secInfos, secContact, secTrajet);
}

View File

@@ -75,14 +75,15 @@ def itineraire(depart, arrivee):
return route["distance"] / 1000.0, route["duration"] / 60.0, metres_autoroute / 1000.0
def calculer(adresse_depart, adresse_arrivee, conso_l_100km, prix_carburant, cout_peage_km=0.0):
"""Estime distance, durée, coût carburant et péage (aller simple et aller-retour).
def couts(distance_km, duree_min, km_peage, conso_l_100km, prix_carburant, cout_peage_km=0.0):
"""Données géographiques + tarifs -> dict complet (coûts aller simple et aller-retour).
Calcul purement local (aucun appel réseau) : permet de réappliquer de nouveaux prix
sur une distance déjà connue sans re-géocoder ni re-router.
Le péage est estimé : km d'autoroute du trajet × `cout_peage_km` (tarif moyen
paramétrable, en €/km). Mettre 0 pour ne pas compter de péage.
"""
distance_km, duree_min, km_peage = itineraire(
geocoder(adresse_depart), geocoder(adresse_arrivee))
carburant = distance_km / 100.0 * conso_l_100km * prix_carburant
peage = km_peage * cout_peage_km
cout_aller = carburant + peage
@@ -100,6 +101,16 @@ def calculer(adresse_depart, adresse_arrivee, conso_l_100km, prix_carburant, cou
}
def calculer(adresse_depart, adresse_arrivee, conso_l_100km, prix_carburant, cout_peage_km=0.0):
"""Estime distance, durée, coût carburant et péage (aller simple et aller-retour).
Fait les appels réseau (Nominatim + OSRM) puis délègue le calcul des coûts à `couts`.
"""
distance_km, duree_min, km_peage = itineraire(
geocoder(adresse_depart), geocoder(adresse_arrivee))
return couts(distance_km, duree_min, km_peage, conso_l_100km, prix_carburant, cout_peage_km)
if __name__ == "__main__":
import sys
if len(sys.argv) != 3: