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", "Nom du prospect", "Statut", "Département", "Ville", "Code Postal", "Adresse",
"Date d'ajout", "Date de contact", "Nom de contact", "Date d'ajout", "Date de contact", "Nom de contact",
"Téléphone", "Email", "Infos du lieu", "Type", "Lien Facebook", "Notes", "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" STATUT_DEFAUT = "À contacter"
@@ -49,6 +51,8 @@ CONFIG_DEFAUT = {
app = Flask(__name__, static_folder="static") app = Flask(__name__, static_folder="static")
verrou_scrape = threading.Lock() verrou_scrape = threading.Lock()
verrou_csv = 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(): def lire_prospects():
@@ -121,6 +125,56 @@ def ecrire_config(config):
os.replace(tmp, CONFIG_PATH) 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("/") @app.get("/")
def accueil(): def accueil():
return app.send_static_file("index.html") return app.send_static_file("index.html")
@@ -186,6 +240,7 @@ def api_ajouter():
lignes = lire_prospects() lignes = lire_prospects()
lignes.append(ligne) lignes.append(ligne)
ecrire_prospects(lignes) ecrire_prospects(lignes)
threading.Thread(target=_auto_trajet, args=(ligne, lire_config()), daemon=True).start()
return jsonify({"ok": True, "index": len(lignes) - 1}) return jsonify({"ok": True, "index": len(lignes) - 1})
@@ -274,9 +329,11 @@ def _ajouter_resultat_lot(resultat):
journaliser(url, "doublon", champs["Nom du prospect"]) journaliser(url, "doublon", champs["Nom du prospect"])
return {"url": url, "statut": "doublon", "nom": champs["Nom du prospect"]} return {"url": url, "statut": "doublon", "nom": champs["Nom du prospect"]}
champs["Statut"] = STATUT_DEFAUT 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) ecrire_prospects(lignes)
journaliser(url, "ajoute", champs["Nom du prospect"]) 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"]} return {"url": url, "statut": "ajoute", "nom": champs["Nom du prospect"]}
@@ -377,24 +434,42 @@ def api_distance(idx):
"error": "pas_de_depart", "error": "pas_de_depart",
"message": "Renseignez d'abord votre adresse de départ dans les Paramètres.", "message": "Renseignez d'abord votre adresse de départ dans les Paramètres.",
}), 400 }), 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: if not arrivee:
return jsonify({ return jsonify({
"error": "pas_d_adresse", "error": "pas_d_adresse",
"message": "Ce prospect n'a pas d'adresse ou de ville exploitable.", "message": "Ce prospect n'a pas d'adresse ou de ville exploitable.",
}), 400 }), 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:
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: try:
resultat = trajet.calculer( resultat = trajet.calculer(depart, arrivee, conso, prix, peage)
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)
except trajet.ErreurTrajet as e: except trajet.ErreurTrajet as e:
return jsonify({"error": "trajet", "message": str(e)}), 502 return jsonify({"error": "trajet", "message": str(e)}), 502
except Exception as e: except Exception as e:
return jsonify({"error": "erreur", "message": f"Échec du calcul : {e}"}), 500 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__": if __name__ == "__main__":

View File

@@ -908,13 +908,18 @@ function construireDetail(corps, p) {
trajetBtn.textContent = "🚗 Calculer le trajet"; trajetBtn.textContent = "🚗 Calculer le trajet";
const resu = document.createElement("div"); const resu = document.createElement("div");
resu.className = "trajet-resu"; 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; 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 { try {
const rep = await fetch(`/api/distance/${p.index}`); const rep = await fetch(`/api/distance/${p.index}`);
const d = await rep.json(); 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 const detailAller = d.peage_aller > 0
? ` <span class="sous">(carburant ${d.carburant_aller} € + péage ${d.peage_aller} €)</span>` : ""; ? ` <span class="sous">(carburant ${d.carburant_aller} € + péage ${d.peage_aller} €)</span>` : "";
const detailAR = d.peage_aller_retour > 0 const detailAR = d.peage_aller_retour > 0
@@ -925,13 +930,17 @@ function construireDetail(corps, p) {
`Aller : <strong>${d.cout_aller} €</strong>${detailAller}<br>` + `Aller : <strong>${d.cout_aller} €</strong>${detailAller}<br>` +
`Aller-retour : <strong>${d.cout_aller_retour} €</strong>${detailAR} ` + `Aller-retour : <strong>${d.cout_aller_retour} €</strong>${detailAR} ` +
`<span class="sous">(${d.distance_aller_retour_km} km)</span>`; `<span class="sous">(${d.distance_aller_retour_km} km)</span>`;
trajetBtn.textContent = "🔄 Recalculer le trajet";
} catch { } 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 { } finally {
trajetBtn.disabled = false; trajetBtn.disabled = false;
} }
}); }
trajetBtn.addEventListener("click", () => calculerTrajet(false));
secTrajet.append(trajetBtn, resu); 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); 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 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): def couts(distance_km, duree_min, km_peage, 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). """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 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. 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 carburant = distance_km / 100.0 * conso_l_100km * prix_carburant
peage = km_peage * cout_peage_km peage = km_peage * cout_peage_km
cout_aller = carburant + peage 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__": if __name__ == "__main__":
import sys import sys
if len(sys.argv) != 3: if len(sys.argv) != 3: