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:
parent
16da087e18
commit
48faeba72b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user