fix(dispatch): coords job EXACTES (fibre par service) + hardening distances auto-dispatch

Précision coords (le levier amont unique : alimente géofence + matrice OSRM + solveur, tous lus à chaud) :
- fibreByDelivery(deliveryId) : AUTORITÉ #0 = le DROP d'installation exact via ticket.delivery_id → service → fibre.*_service_id
  (placemark d'infra si lié, sinon coord fibre). Câblé dans buildJob (avant resolveDevCoords) ET geolocateJobs (refreshFibre,
  seuil de réécriture 30 m car drop exact). Le tick horaire l'applique tout seul désormais.
- BACKFILL exécuté : 15 jobs actifs corrigés (14 fibre_service_placemarks) — dont LEG-247012 (7,5 km !) et LEG-254147 (14 km) qui
  étaient grossièrement mal géocodés → snappés au drop exact. Idempotent (re-run 0).

Hardening auto-dispatch (audit) :
- osrmMatrix : un nœud SANS coords n'est plus à 0 min de tout (« adjacent gratuit » → insertion n'importe où) mais à 180 min
  (« loin/inconnu ») → le solveur ne chaîne plus gratuitement les jobs sans coords au milieu des vrais arrêts.

Restent des jobs sans source coord (ex. lots camping Lac des Pins sans ligne fibre) = data gap F, pas résolvable ici.
Suivi recommandé (non fait, flag) : valider bornes QC sur les chemins de LECTURE (pas que l'ingestion) ; retirer les défauts
tech silencieux (Montréal/Ste-Clotilde) ; sortir les jobs sans coords du solveur vers le pool « à situer » ; invalider le cache
matrice SPA quand les coords changent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-19 08:36:08 -04:00
parent 16da087e18
commit 48faeba72b
2 changed files with 51 additions and 13 deletions

View File

@ -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 // 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. // 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]) 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. // AUTORITÉ #0 — point d'installation EXACT (table `fibre` liée au service de la delivery). Le drop réel du tech →
{ 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 } } // 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) { if (sl) {
payload.service_location = sl.name 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 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 } = {}) { async function geolocateJobs ({ dryRun = true, limit = 3000, refreshCamp = false, refreshFibre = false } = {}) {
const p = pool(); if (!p) return { ok: false, error: 'legacy DB indispo' } const p = pool(); if (!p) return { ok: false, error: 'legacy DB indispo' }
let jobs = [] 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 campings = await getCampings()
const hasC = (v) => v != null && v !== '' && Math.abs(+v) > 1e-4 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. // 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 } 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 = [] const recs = []
for (const j of targets) { for (const j of targets) {
const a = _parseAddr(j.address); const camp = campingFor(campings, [j.address]) || null 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 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 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)) }) 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 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 const nm = (r.variants || []).map(x => byNom[x]).find(x => x && _inQC(x.lat, x.lon)) || null
let lat, lon, src 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' } 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 (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' } 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) lat = +(+lat).toFixed(6); lon = +(+lon).toFixed(6)
let moved = null let moved = null
if (r.curLat != 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. // Déjà coordonné : on n'ÉCRASE que si haute confiance. fibre-par-service (fx) = drop EXACT → toujours confiant, seuil serré
const confident = r.camp ? true : (r.confirmed && (src === 'placemarks' || src === 'fibre_addr')) // (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 } 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 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 // (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. // 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. // 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 } = {}) { 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 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 } 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 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

View File

@ -136,9 +136,13 @@ async function osrmMatrix (jobs, vehicles) {
}) })
const j = JSON.parse(raw) const j = JSON.parse(raw)
if (j.code !== 'Ok' || !Array.isArray(j.durations)) throw new Error('osrm code ' + (j.code || '?')) 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. // Matrice NxN en MINUTES. HARDENING : un nœud SANS coords n'est plus à 0 min de tout (= « adjacent gratuit », ce qui faisait
const M = Array.from({ length: N }, () => new Array(N).fill(0)) // insérer les jobs sans coords n'importe où dans une tournée) mais à NO_COORD_MIN (« loin/inconnu », > toute vraie étape ~90 min)
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) } // → 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 return M
} }
// GET brut vers OSRM (helper des proxys /roster/osrm-*) — remplace les appels Mapbox payants du SPA. // GET brut vers OSRM (helper des proxys /roster/osrm-*) — remplace les appels Mapbox payants du SPA.