feat: calculateur de frais de déplacement - backend et modèles Flutter

- Cloud Function travel.js : autocomplete Google Places + calcul itinéraires
  via Google Routes API avec péages Ulys /legs (precision=6) + /rate
- Modèles : VehicleModel, DepotModel, RouteResultModel + FuelPrices
- Services : VehicleService, TravelService (Firestore CRUD + API calls)
- Gestion des données : 3 nouveaux onglets (Dépôts, Véhicules, Prix carburants)
- Autocomplétion adresse dans le formulaire événement
- Dialog calcul frais : config + carte flutter_map OSM + sélection itinéraire
- Injection option FRAIS_KM dans l'événement à la sélection
- flutter_map 7.0.2 + latlong2 0.9.1 ajoutés
- npm: csv-parser + @mapbox/polyline installés dans functions
This commit is contained in:
ElPoyo
2026-06-04 14:28:22 +02:00
parent bc93f3fa9a
commit e14b333a67
22 changed files with 3940 additions and 24 deletions
+314
View File
@@ -0,0 +1,314 @@
'use strict';
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const csv = require('csv-parser');
const polylineLib = require('@mapbox/polyline');
const auth = require('../utils/auth');
const logger = require('firebase-functions/logger');
// ─────────────────────────────────────────────
// Chargement du CSV des gares de péage (cache mémoire)
// ─────────────────────────────────────────────
let _tollStations = null;
function loadTollStations() {
return new Promise((resolve, reject) => {
if (_tollStations) return resolve(_tollStations);
const csvPath = path.join(__dirname, '../travel/gares_peage_export.csv');
if (!fs.existsSync(csvPath)) {
logger.warn('[Travel] CSV not found at ' + csvPath);
_tollStations = [];
return resolve(_tollStations);
}
const results = [];
fs.createReadStream(csvPath)
.pipe(csv())
.on('data', (row) => {
if (row.id_gare && row.lat && row.lon) {
results.push({
id: row.id_gare,
operatorId: row.id_gare.substring(0, 2),
tollId: row.id_gare.substring(2, 5),
name: row.nom || '',
lat: parseFloat(row.lat),
lon: parseFloat(row.lon),
});
}
})
.on('end', () => { _tollStations = results; resolve(results); })
.on('error', reject);
});
}
// ─────────────────────────────────────────────
// Ulys — Détection des péages sur un tracé
// POST https://api-ulys.azure-api.net/placemark/v2/legs?precision=6&includeLayersIds=GaresPeage
// Body = la polyline encodée (string brute, pas de JSON wrapper)
// ─────────────────────────────────────────────
async function getUlysTollLegs(encodedPolyline) {
try {
const url = 'https://api-ulys.azure-api.net/placemark/v2/legs?precision=6&includeLayersIds=GaresPeage';
const body = JSON.stringify(encodedPolyline);
const res = await axios.post(url, body, {
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body).toString(),
'Host': 'api-ulys.azure-api.net',
},
timeout: 10000,
});
return res.data;
} catch (e) {
logger.warn('[Travel] Ulys /legs failed:', e.message);
return null;
}
}
// ─────────────────────────────────────────────
// Ulys — Tarif pour un segment (entrée → sortie)
// POST https://api-ulys.azure-api.net/tollstation/v1/rate
// ─────────────────────────────────────────────
async function getUlysRate(vehicleCategory, passages) {
try {
const payload = {
vehicleCategory: String(vehicleCategory),
paymentOption: 2,
tollPassages: passages.map((p) => ({
toll: { operatorId: p.operatorId, tollId: p.tollId },
})),
};
const body = JSON.stringify(payload);
const res = await axios.post(
'https://api-ulys.azure-api.net/tollstation/v1/rate',
payload,
{
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body).toString(),
'Host': 'api-ulys.azure-api.net',
},
timeout: 8000,
},
);
const data = res.data;
if (Array.isArray(data) && data.length > 0) {
// Système fermé : 1 réponse avec entranceToll + exitToll
if (data.length === 1 && data[0].price > 0) return data[0].price;
// Système ouvert (barrières individuelles) : sommer les prix
const total = data.reduce((sum, d) => sum + (d.price || 0), 0);
return total > 0 ? total : null;
}
return null;
} catch (e) {
return null;
}
}
// ─────────────────────────────────────────────
// Calcul du total de péage via Ulys /legs puis /rate
// ─────────────────────────────────────────────
async function calculateTollCost(encodedPolyline, vehicleCategory) {
try {
// 1. Demander à Ulys les gares sur le tracé
const legsData = await getUlysTollLegs(encodedPolyline);
if (legsData && Array.isArray(legsData.features) && legsData.features.length > 0) {
// Extraire les gares dans l'ordre du tracé
const tollGates = [];
for (const feature of legsData.features) {
const props = feature.properties || {};
const id = props.id_gare || props.id || props.gareId;
if (id && id.length >= 5) {
tollGates.push({
id,
operatorId: id.substring(0, 2),
tollId: id.substring(2, 5),
name: props.nom || props.name || id,
});
}
}
if (tollGates.length === 0) return 0;
// Greedy: trouver les segments fermés + barrières ouvertes
return await _computeTollFromGates(tollGates, vehicleCategory);
}
// Fallback : pas de résultat Ulys /legs, retourner 0
logger.info('[Travel] Ulys /legs returned no toll gates for this route');
return 0;
} catch (e) {
logger.error('[Travel] calculateTollCost error:', e);
return 0;
}
}
async function _computeTollFromGates(gates, vehicleCategory) {
let total = 0;
let i = 0;
while (i < gates.length) {
let found = false;
// Essayer le segment fermé le plus long possible (greedy backward)
for (let j = gates.length - 1; j > i; j--) {
const price = await getUlysRate(vehicleCategory, [gates[i], gates[j]]);
if (price !== null && price > 0) {
total += price;
i = j;
found = true;
break;
}
}
if (!found) {
// Barrière ouverte : tarif unitaire
const price = await getUlysRate(vehicleCategory, [gates[i]]);
if (price !== null && price > 0 && price < 20) {
total += price;
}
i++;
}
}
return total;
}
// ─────────────────────────────────────────────
// EXPORT: Google Maps Autocomplete (proxy CORS)
// ─────────────────────────────────────────────
exports.googleMapsAutocomplete = async (req, res) => {
if (req.method === 'OPTIONS') {
return res.status(204).send('');
}
try {
await auth.authenticateUser(req);
const body = req.body.data || req.body;
const query = body.query || req.query.query;
if (!query) return res.status(400).json({ error: 'query is required' });
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) return res.status(500).json({ error: 'GOOGLE_MAPS_API_KEY not configured' });
const url = new URL('https://maps.googleapis.com/maps/api/place/autocomplete/json');
url.searchParams.set('input', query);
url.searchParams.set('key', apiKey);
url.searchParams.set('language', 'fr');
url.searchParams.set('components', 'country:fr');
url.searchParams.set('types', 'address');
const gRes = await axios.get(url.toString(), { timeout: 5000 });
return res.status(200).json(gRes.data);
} catch (e) {
logger.error('[Travel] googleMapsAutocomplete error:', e.message);
return res.status(500).json({ error: e.message });
}
};
// ─────────────────────────────────────────────
// EXPORT: Google Maps Compute Route (2 itinéraires + péages Ulys)
// ─────────────────────────────────────────────
exports.googleMapsComputeRoute = async (req, res) => {
if (req.method === 'OPTIONS') {
return res.status(204).send('');
}
try {
await auth.authenticateUser(req);
const body = req.body.data || req.body;
const { origin, destination, vehicleTollCategory = 2 } = body;
if (!origin || !destination) {
return res.status(400).json({ error: 'origin and destination are required' });
}
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) return res.status(500).json({ error: 'GOOGLE_MAPS_API_KEY not configured' });
const routesUrl = 'https://routes.googleapis.com/directions/v2:computeRoutes';
const fieldMask = [
'routes.distanceMeters',
'routes.duration',
'routes.polyline.encodedPolyline',
'routes.travelAdvisory.tollInfo',
].join(',');
const commonPayload = {
travelMode: 'DRIVE',
routingPreference: 'TRAFFIC_AWARE',
origin: { address: origin },
destination: { address: destination },
};
const [resToll, resNoToll] = await Promise.all([
axios.post(routesUrl, { ...commonPayload, routeModifiers: { avoidTolls: false } }, {
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': fieldMask,
},
timeout: 15000,
}),
axios.post(routesUrl, { ...commonPayload, routeModifiers: { avoidTolls: true } }, {
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': fieldMask,
},
timeout: 15000,
}),
]);
const routes = [];
// --- Route avec péage ---
if (resToll.data.routes && resToll.data.routes.length > 0) {
const r = resToll.data.routes[0];
const poly = r.polyline?.encodedPolyline || '';
let tollCost = 0;
if (poly) {
tollCost = await calculateTollCost(poly, vehicleTollCategory);
}
routes.push({
routeType: 'TOLL',
distanceMeters: r.distanceMeters || 0,
durationSeconds: _parseDuration(r.duration),
encodedPolyline: poly,
tollCost,
});
}
// --- Route sans péage ---
if (resNoToll.data.routes && resNoToll.data.routes.length > 0) {
const r = resNoToll.data.routes[0];
const poly = r.polyline?.encodedPolyline || '';
// N'ajouter que si différente de la route avec péage
const isDifferent = routes.length === 0 ||
r.distanceMeters !== routes[0].distanceMeters ||
Math.abs(_parseDuration(r.duration) - routes[0].durationSeconds) > 60;
if (isDifferent) {
routes.push({
routeType: 'TOLL_FREE',
distanceMeters: r.distanceMeters || 0,
durationSeconds: _parseDuration(r.duration),
encodedPolyline: poly,
tollCost: 0,
});
}
}
return res.status(200).json({ routes });
} catch (e) {
logger.error('[Travel] googleMapsComputeRoute error:', e.message, e.response?.data);
return res.status(500).json({ error: e.message });
}
};
function _parseDuration(durationStr) {
if (!durationStr) return 0;
if (typeof durationStr === 'number') return durationStr;
// Format: "1234s"
const match = String(durationStr).match(/^(\d+)s?$/);
return match ? parseInt(match[1]) : 0;
}