diff --git a/services/targo-hub/lib/legacy-dispatch-sync.js b/services/targo-hub/lib/legacy-dispatch-sync.js index 2f4501b..b92ab82 100644 --- a/services/targo-hub/lib/legacy-dispatch-sync.js +++ b/services/targo-hub/lib/legacy-dispatch-sync.js @@ -553,8 +553,11 @@ async function buildJob (t, { dryRun = false } = {}) { // CAMPING : l'adresse de service est un terrain de camping (signal = sujet/ville/adresse de service). Le résolveur préfère // alors le LOT précis (placemarks.nom) puis le centroïde camping ; les coords delivery de camping (souvent erronées) en dernier. const camp = campingFor(await getCampings(), [t.subject, t.dv_city, t.dv_addr]) - // ALGORITHME DÉVELOPPÉ (le plus autoritaire) : placemarks_id frais / delivery / fibre rue-désambiguïsée / campings par nom de lot. - { const dev = await resolveDevCoords(pool(), { pmid: t.dv_pmid, dlat: t.dv_lat, dlon: t.dv_lon, address: svcAddr || billAddr, camp }); if (dev) { payload.latitude = dev.lat; payload.longitude = dev.lon; coordSrc = dev.src } } + // AUTORITÉ #0 — point d'installation EXACT (table `fibre` liée au service de la delivery). Le drop réel du tech → + // géofence + distances de dispatch les plus précis possibles. Prime sur delivery 0/0, centroïde camping et RQA. + if (t.delivery_id) { const fx = await fibreByDelivery(pool(), t.delivery_id); if (fx) { payload.latitude = fx.lat; payload.longitude = fx.lon; coordSrc = fx.src } } + // ALGORITHME DÉVELOPPÉ : placemarks_id frais / delivery / fibre rue-désambiguïsée / campings par nom de lot. + if (!coordSrc) { const dev = await resolveDevCoords(pool(), { pmid: t.dv_pmid, dlat: t.dv_lat, dlon: t.dv_lon, address: svcAddr || billAddr, camp }); if (dev) { payload.latitude = dev.lat; payload.longitude = dev.lon; coordSrc = dev.src } } if (sl) { payload.service_location = sl.name if (!coordSrc) { const sc = coord(sl.latitude, sl.longitude); if (sc) { payload.latitude = sc.lat; payload.longitude = sc.lon; coordSrc = 'service_location' } } // repli si rien d'autre @@ -1794,17 +1797,26 @@ function _haversineM (la1, lo1, la2, lo2) { const R = 6371000, r = Math.PI / 180 async function geolocateJobs ({ dryRun = true, limit = 3000, refreshCamp = false, refreshFibre = false } = {}) { const p = pool(); if (!p) return { ok: false, error: 'legacy DB indispo' } let jobs = [] - try { jobs = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'assigned', 'in_progress', 'On Hold']]], fields: ['name', 'address', 'latitude', 'longitude'], limit: 8000 }) } catch (e) { return { ok: false, error: 'Dispatch Job: ' + e.message } } + try { jobs = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'assigned', 'in_progress', 'On Hold']]], fields: ['name', 'address', 'latitude', 'longitude', 'legacy_ticket_id'], limit: 8000 }) } catch (e) { return { ok: false, error: 'Dispatch Job: ' + e.message } } const campings = await getCampings() const hasC = (v) => v != null && v !== '' && Math.abs(+v) > 1e-4 // refreshFibre : RE-vérifie AUSSI les jobs DÉJÀ coordonnés (pour corriger les coords erronées, ex. rue neuve placée à Valleyfield) — n'écrase que si la fibre CONFIRME la rue. - const targets = jobs.filter(j => { const addr = String(j.address || '').trim(); if (!addr) return false; if (!hasC(j.latitude)) return true; return refreshFibre || (refreshCamp && !!campingFor(campings, [addr])) }).slice(0, limit) + // Sans coords : on cible (adresse OU ticket legacy → fibre-par-service possible). Déjà coordonné : re-vérif SEULEMENT si + // demandé (refreshFibre = tick horaire, ou refreshCamp) — l'autorité #0 fibre-par-service (fx) est alors tentée et corrige + // les pins imprécis liés à une fibre. On évite de re-requêter la fibre de CHAQUE job coordonné à chaque passage. + const targets = jobs.filter(j => { const addr = String(j.address || '').trim(); if (!hasC(j.latitude)) return !!(addr || j.legacy_ticket_id); return refreshFibre || (refreshCamp && !!campingFor(campings, [addr])) }).slice(0, limit) if (!targets.length) return { ok: true, dryRun, refreshCamp, refreshFibre, targets: 0 } + // Map legacy_ticket_id → delivery_id (1 requête) pour l'autorité #0 fibre-par-service (drop d'installation exact). + const tids = [...new Set(targets.map(j => parseInt(j.legacy_ticket_id, 10)).filter(Boolean))] + const dlvByTicket = new Map() + if (tids.length) { try { const [tr] = await p.query('SELECT id, delivery_id FROM ticket WHERE id IN (?)', [tids]); for (const r of (tr || [])) dlvByTicket.set(String(r.id), r.delivery_id) } catch (e) {} } const recs = [] for (const j of targets) { const a = _parseAddr(j.address); const camp = campingFor(campings, [j.address]) || null const fm = (a.zip && a.civic) ? await fibreMatch(p, a.zip, a.civic, a.street) : null - recs.push({ name: j.name, first: String(j.address).split(',')[0], camp, pmid: fm && fm.pmid ? fm.pmid : null, flat: fm ? fm.flat : null, flon: fm ? fm.flon : null, confirmed: !!(fm && _streetConfirmed(fm.rue, a.street)), curLat: hasC(j.latitude) ? +j.latitude : null, curLon: hasC(j.longitude) ? +j.longitude : null }) + const did = dlvByTicket.get(String(j.legacy_ticket_id)) + const fx = did ? await fibreByDelivery(p, did) : null // AUTORITÉ #0 : drop exact par service + recs.push({ name: j.name, first: String(j.address || '').split(',')[0], camp, fx, pmid: fm && fm.pmid ? fm.pmid : null, flat: fm ? fm.flat : null, flon: fm ? fm.flon : null, confirmed: !!(fm && _streetConfirmed(fm.rue, a.street)), curLat: hasC(j.latitude) ? +j.latitude : null, curLon: hasC(j.longitude) ? +j.longitude : null }) } const fresh = await placemarksLookup([...new Set(recs.map(r => r.pmid).filter(Boolean))]) const allNoms = new Set(); recs.forEach(r => { r.variants = lotVariants(r.first); r.variants.forEach(n => allNoms.add(n)) }) @@ -1814,7 +1826,8 @@ async function geolocateJobs ({ dryRun = true, limit = 3000, refreshCamp = false const pm = r.pmid != null ? fresh[String(r.pmid)] : null const nm = (r.variants || []).map(x => byNom[x]).find(x => x && _inQC(x.lat, x.lon)) || null let lat, lon, src - if (r.camp) { // camping : lot (nom) > placemarks_id frais > centroïde camping > fibre + if (r.fx) { lat = r.fx.lat; lon = r.fx.lon; src = r.fx.src } // AUTORITÉ #0 : drop d'installation EXACT (fibre par service) — prime sur tout + else if (r.camp) { // camping : lot (nom) > placemarks_id frais > centroïde camping > fibre if (nm) { lat = nm.lat; lon = nm.lon; src = 'placemarks_nom' } else if (pm && _inQC(pm.lat, pm.lon)) { lat = pm.lat; lon = pm.lon; src = 'placemarks' } else if (_inQC(r.camp.latitude, r.camp.longitude)) { lat = +r.camp.latitude; lon = +r.camp.longitude; src = 'camping' } @@ -1829,10 +1842,12 @@ async function geolocateJobs ({ dryRun = true, limit = 3000, refreshCamp = false lat = +(+lat).toFixed(6); lon = +(+lon).toFixed(6) let moved = null if (r.curLat != null) { - // Déjà coordonné : on n'ÉCRASE que si haute confiance (camping refresh comme avant ; sinon fibre RUE CONFIRMÉE) ET déplacement > 100 m. - const confident = r.camp ? true : (r.confirmed && (src === 'placemarks' || src === 'fibre_addr')) + // Déjà coordonné : on n'ÉCRASE que si haute confiance. fibre-par-service (fx) = drop EXACT → toujours confiant, seuil serré + // (30 m) car même une petite correction compte pour un géofence de 150-250 m. Sinon : camping refresh, ou fibre RUE CONFIRMÉE, seuil 100 m. + const confident = r.fx ? true : (r.camp ? true : (r.confirmed && (src === 'placemarks' || src === 'fibre_addr'))) if (!confident) { unchanged++; continue } - const d = _haversineM(r.curLat, r.curLon, lat, lon); if (d < 100) { unchanged++; continue } moved = Math.round(d) + const minMove = r.fx ? 30 : 100 + const d = _haversineM(r.curLat, r.curLon, lat, lon); if (d < minMove) { unchanged++; continue } moved = Math.round(d) } updates.push({ name: r.name, lat, lon, src, moved_m: moved, addr: r.first }); tally[src] = (tally[src] || 0) + 1 } @@ -1845,6 +1860,25 @@ async function geolocateJobs ({ dryRun = true, limit = 3000, refreshCamp = false // (si l'adresse est un terrain de camping) → ordre SPÉCIFIQUE : le LOT (placemarks.nom) est la source la plus précise ET // fiable pour un camping ; les coords delivery de camping sont souvent erronées (autre lot/résidence du compte) → en DERNIER. // Camping : nom(lot) > placemarks_id frais > centroïde camping > fibre > delivery. Sinon : placemarks_id > delivery > fibre > nom. +// AUTORITÉ #0 des coords : le point d'installation EXACT via la table `fibre` liée au SERVICE de la delivery +// (ticket.delivery_id → service.delivery_id → fibre.*_service_id). C'est le DROP physique réel où le tech intervient +// → la coord la plus précise possible (précède delivery 0/0, centroïde camping, RQA). Préfère le placemark d'infra si lié. +async function fibreByDelivery (p, deliveryId) { + if (!p || !deliveryId) return null + try { + const [rows] = await p.query( + `SELECT f.latitude flat, f.longitude flon, f.placemarks_id pmid, f.rue + FROM service s + JOIN fibre f ON (f.service_id = s.id OR f.internet_service_id = s.id OR f.manage_service_id = s.id OR f.telephone_service_id = s.id OR f.tele_service_id = s.id) + WHERE s.delivery_id = ? AND f.latitude IS NOT NULL AND f.latitude <> 0 + ORDER BY f.id DESC LIMIT 1`, [deliveryId]) + const r = rows && rows[0]; if (!r) return null + if (r.pmid) { const f = await placemarksLookup([r.pmid]); const c = f[String(r.pmid)]; if (c && _inQC(c.lat, c.lon)) return { lat: c.lat, lon: c.lon, src: 'fibre_service_placemarks' } } + if (_inQC(r.flat, r.flon)) return { lat: +r.flat, lon: +r.flon, src: 'fibre_service' } + } catch (e) { /* fibre/service indispo → sources suivantes */ } + return null +} + async function resolveDevCoords (p, { pmid, dlat, dlon, address, camp } = {}) { const nomHit = async () => { const noms = lotVariants(String(address || '').split(',')[0]); if (!noms.length) return null; const bn = await placemarksByNom(noms); const h = noms.map(n => bn[n]).find(x => x && _inQC(x.lat, x.lon)); return h ? { lat: h.lat, lon: h.lon, src: 'placemarks_nom' } : null } const pmHit = async () => { if (!pmid) return null; const f = await placemarksLookup([pmid]); const c = f[String(pmid)]; return (c && _inQC(c.lat, c.lon)) ? { lat: c.lat, lon: c.lon, src: 'placemarks' } : null } @@ -2722,4 +2756,4 @@ async function provisionIdentities ({ dryRun = true, scope = 'ledger' } = {}) { return res } -module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, postTicketLegacy, ticketThread, ticketAssignState, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, backfillServiceLocations, backfillIssueCustomers, backfillSourceIssue, backfillDependsOn, startSync, stopSync, fetchTargoTickets, staleTickets, staleNudge, publishPreview, publishJob, techHistoryBackfill, techHistory, provisionIdentities, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests +module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, postTicketLegacy, ticketThread, ticketAssignState, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, backfillServiceLocations, backfillIssueCustomers, backfillSourceIssue, backfillDependsOn, geolocateJobs, fibreByDelivery, startSync, stopSync, fetchTargoTickets, staleTickets, staleNudge, publishPreview, publishJob, techHistoryBackfill, techHistory, provisionIdentities, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index d64e32a..c4d2f6b 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -136,9 +136,13 @@ async function osrmMatrix (jobs, vehicles) { }) const j = JSON.parse(raw) if (j.code !== 'Ok' || !Array.isArray(j.durations)) throw new Error('osrm code ' + (j.code || '?')) - // Matrice NxN complète en MINUTES : temps de route OSRM pour les paires géolocalisées, 0 (neutre) pour tout nœud sans coords. - const M = Array.from({ length: N }, () => new Array(N).fill(0)) - for (let a = 0; a < withIdx.length; a++) for (let b = 0; b < withIdx.length; b++) { const s = j.durations[a][b]; M[withIdx[a]][withIdx[b]] = (s == null ? 0 : Math.round(s / 60 * 10) / 10) } + // Matrice NxN en MINUTES. HARDENING : un nœud SANS coords n'est plus à 0 min de tout (= « adjacent gratuit », ce qui faisait + // insérer les jobs sans coords n'importe où dans une tournée) mais à NO_COORD_MIN (« loin/inconnu », > toute vraie étape ~90 min) + // → le solveur ne les chaîne plus gratuitement au milieu des vrais arrêts. Diagonale = 0. + const NO_COORD_MIN = 180 + const M = Array.from({ length: N }, () => new Array(N).fill(NO_COORD_MIN)) + for (let i = 0; i < N; i++) M[i][i] = 0 + for (let a = 0; a < withIdx.length; a++) for (let b = 0; b < withIdx.length; b++) { const s = j.durations[a][b]; M[withIdx[a]][withIdx[b]] = (s == null ? NO_COORD_MIN : Math.round(s / 60 * 10) / 10) } return M } // GET brut vers OSRM (helper des proxys /roster/osrm-*) — remplace les appels Mapbox payants du SPA.