376 lines
14 KiB
JavaScript
376 lines
14 KiB
JavaScript
'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;
|