diff --git a/app.py b/app.py index b2fed17..3c515c8 100644 --- a/app.py +++ b/app.py @@ -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: + 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, - 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) + 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__": diff --git a/static/index.html b/static/index.html index d3d5bb8..8a6eca8 100644 --- a/static/index.html +++ b/static/index.html @@ -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 = ' Calcul en cours…'; + if (!silencieux) resu.innerHTML = ' Calcul en cours…'; try { const rep = await fetch(`/api/distance/${p.index}`); const d = await rep.json(); - if (!rep.ok) { resu.innerHTML = `${echap(d.message || "Échec du calcul.")}`; return; } + if (!rep.ok) { + // En mode silencieux, on n'affiche pas d'erreur : on laisse simplement le bouton. + if (!silencieux) resu.innerHTML = `${echap(d.message || "Échec du calcul.")}`; + return; + } const detailAller = d.peage_aller > 0 ? ` (carburant ${d.carburant_aller} € + péage ${d.peage_aller} €)` : ""; const detailAR = d.peage_aller_retour > 0 @@ -925,13 +930,17 @@ function construireDetail(corps, p) { `Aller : ${d.cout_aller} €${detailAller}
` + `Aller-retour : ${d.cout_aller_retour} €${detailAR} ` + `(${d.distance_aller_retour_km} km)`; + trajetBtn.textContent = "🔄 Recalculer le trajet"; } catch { - resu.innerHTML = 'Le serveur ne répond pas.'; + if (!silencieux) resu.innerHTML = 'Le serveur ne répond pas.'; } 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); } diff --git a/trajet.py b/trajet.py index 0c7301a..fc4fd39 100644 --- a/trajet.py +++ b/trajet.py @@ -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: