'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 polylineCoords = polylineLib.decode(encodedPolyline); const ulysUrl = 'https://api-ulys.azure-api.net/placemark/v2/legs?precision=5&includeLayersIds=GaresPeage'; let finalPolyline = encodedPolyline; // OPTION 1 : Mapbox Route Recreation if (process.env.MAPBOX_API_KEY && polylineCoords.length > 2) { logger.info('[Travel] MAPBOX_API_KEY is present. Recreating route with Mapbox for Ulys precision...'); try { // Envoyer uniquement le point de départ et le point d'arrivée // Mapbox s'occupe de recréer l'itinéraire complet de la meilleure façon const waypoints = [polylineCoords[0], polylineCoords[polylineCoords.length - 1]]; // Mapbox expects longitude,latitude const coordinatesString = waypoints.map(p => `${p[1]},${p[0]}`).join(';'); const mapboxUrl = `https://api.mapbox.com/directions/v5/mapbox/driving/${coordinatesString}?geometries=polyline&overview=full&access_token=${process.env.MAPBOX_API_KEY}`; const mapboxRes = await axios.get(mapboxUrl); if (mapboxRes.data && mapboxRes.data.routes && mapboxRes.data.routes.length > 0) { finalPolyline = mapboxRes.data.routes[0].geometry; logger.info('[Travel] Mapbox route recreation successful.'); } } catch (mapboxErr) { logger.error('[Travel] Mapbox API error:', mapboxErr.response ? mapboxErr.response.data : mapboxErr.message); // Fallback to Google Maps polyline if Mapbox fails } } // Appeler Ulys /legs const res = await axios.post( ulysUrl, JSON.stringify(finalPolyline), { headers: { 'Content-Type': 'application/json', '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 now = new Date().toISOString(); const payload = { vehicleCategory: String(vehicleCategory), paymentOption: 2, tollPassages: passages.map((p) => ({ toll: { operatorId: p.operatorId, tollId: p.tollId }, passageDate: now, })), }; 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) { if (passages.length === 2) { // We expect a single closed system response if (data.length === 1 && data[0].entranceToll && data[0].exitToll && data[0].price > 0) { return data[0].price; } return null; } else if (passages.length === 1) { if (data.length === 1 && data[0].price > 0) { return data[0].price; } return 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); const features = Array.isArray(legsData) ? legsData : (legsData && legsData.features ? legsData.features : []); if (features && features.length > 0) { // Extraire les gares dans l'ordre du tracé const tollGates = []; for (const feature of features) { const props = feature.properties || feature.Placemark || feature.placemark || {}; // La réponse Ulys peut utiliser différents noms de champs // On cherche l'identifiant de la gare dans tous les champs connus let id = props.id_gare || props.idGare || props.id || props.gareId || props.gare_id || props.tollStationId; if (!id && props.Code) { id = props.Code.split('_')[0]; } if (!id) continue; const idStr = String(id); if (idStr.length < 5) continue; tollGates.push({ id: idStr, operatorId: idStr.substring(0, 2), tollId: idStr.substring(2, 5), name: props.nom || props.name || props.label || idStr, }); } logger.info(`[Travel] Ulys /legs found ${tollGates.length} toll gates`); 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.API_MAPS; if (!apiKey) return res.status(500).json({ error: 'API_MAPS not configured in .env' }); 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.API_MAPS; if (!apiKey) return res.status(500).json({ error: 'API_MAPS not configured in .env' }); 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 = []; console.log("resToll.data.routes length:", resToll.data.routes ? resToll.data.routes.length : 0); if (!resToll.data.routes) console.log("resToll.data:", JSON.stringify(resToll.data, null, 2)); // --- 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; } exports.getUlysTollLegs = getUlysTollLegs;