Pont legacy : coords GPS fiables (delivery→SL→RQA→Mapbox) + routage routier réel (Mapbox Matrix)

Pont (legacy-dispatch-sync.js) :
- Import des coordonnées par job via cascade : table legacy `delivery` (point de service exact,
  JOIN ticket.delivery_id) > Service Location ERPNext > géocodage RQA > géocodage Mapbox.
  Validation bornes Québec (coord()). Couverture 153/172 (89%).
- Géocodage RQA corrigé : retrait du générique de voie (Rue/Rang/Chemin absent de
  odonyme_recompose_normal) + code postal non accolé au terme (sinon ilike ne matche jamais).
- Repli Mapbox geocoding pour rues trop récentes pour le RQA (MAPBOX_TOKEN).
- Backfill + UPGRADE : coords delivery écrasent des coords SL moins précises (jamais l'inverse).
- Comptabilité honnête : vérifie r.ok sur create/update (erp ne throw pas) → errors + error_samples.
- Verrou de sérialisation sync() : tick + runs manuels ne se chevauchent plus (frappe_pg).
- Subject tronqué à 140 (champ Data) → corrige CharacterLengthExceededError sur jobs sans SL.
- Observabilité : coord_src tally + error_samples dans le résumé.

Ops Planification (éditeur de journée) :
- travelBetween() consulte une matrice Mapbox Matrix chargée à l'ouverture (loadDayRoute) →
  temps de trajet ROUTIERS RÉELS ; réordonnancement instantané sans nouvelle requête.
  Repli haversine si Mapbox indispo. Indicateur 🚗 réel vs 📏 vol d'oiseau.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-06 14:43:34 -04:00
parent 0298f414ed
commit 2c3d7e9814
3 changed files with 201 additions and 21 deletions

View File

@ -684,7 +684,7 @@
<template v-for="(j, i) in dayEditor.list" :key="j.name">
<!-- temps de transport estimé depuis le job précédent (l'espace entre 2 blocs) -->
<div v-if="i > 0" class="de-travel">
<template v-if="travelBetween(dayEditor.list[i - 1], j)">🚗 ~{{ travelBetween(dayEditor.list[i - 1], j).min }} min · {{ travelBetween(dayEditor.list[i - 1], j).km }} km</template>
<template v-if="dayLeg(i)">{{ dayLeg(i).real ? '🚗' : '📏' }} {{ dayLeg(i).real ? '' : '~' }}{{ dayLeg(i).min }} min<template v-if="dayLeg(i).km != null"> · {{ dayLeg(i).km }} km</template><q-tooltip class="bg-grey-9" style="font-size:11px">{{ dayLeg(i).real ? 'Temps routier réel (routes Mapbox)' : 'Estimation à vol doiseau (coords approximatives ou Mapbox indisponible)' }}</q-tooltip></template>
<template v-else>🚗 transport ? (adresse/coords manquantes)</template>
</div>
<div class="de-row" :class="{ 'de-drag': dayEditor.dragIdx === i }"
@ -744,6 +744,7 @@ import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } fr
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API Mapbox Matrix), déjà utilisé par le Dispatch
import { legacyDeptColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé avec le board Dispatch)
import TechSelect from 'src/components/shared/TechSelect.vue'
import SkillSelect from 'src/components/shared/SkillSelect.vue'
@ -1025,12 +1026,36 @@ function gotoDispatch (t, dateIso) {
}
// Éditeur de JOURNÉE (fenêtre contextuelle ciblée clic sur le progressbar)
// Garde le contexte de la grille derrière. Timeline + réordonnancement DRAG-DROP + retrait d'un job.
const dayEditor = reactive({ open: false, tech: null, day: null, list: [], saving: false, dragIdx: null })
const dayEditor = reactive({ open: false, tech: null, day: null, list: [], saving: false, dragIdx: null, travelMap: {}, routeReady: false })
function openDayEditor (t, d) {
dayEditor.tech = t; dayEditor.day = d
// RDV confirmé (ou heure légacy précise) = heure FIXE verrouillé ; sinon flexible (replanifiable par la tournée).
dayEditor.list = cellJobs(t.id, d.iso).map(j => ({ ...j, locked: j.booking_status === 'Confirmé', showDetail: false }))
dayEditor.dragIdx = null; dayEditor.open = true
dayEditor.dragIdx = null; dayEditor.travelMap = {}; dayEditor.routeReady = false; dayEditor.open = true
loadDayRoute() // charge la matrice de temps routiers RÉELS (Mapbox) packedDay les utilise dès l'arrivée (réactif)
}
// Matrice des temps de trajet ROUTIERS RÉELS entre tous les jobs du jour (Mapbox Matrix, 1 requête).
// Indépendante de l'ordre le réordonnancement réutilise la matrice SANS nouvelle requête (recalcul instantané).
// Repli silencieux sur l'haversine si Mapbox indispo ou coords manquantes.
async function loadDayRoute () {
const key = (dayEditor.tech && dayEditor.tech.id) + '|' + (dayEditor.day && dayEditor.day.iso)
const pts = dayEditor.list.filter(j => j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon)).slice(0, 25) // Matrix = 25 coords max
if (pts.length < 2 || !MAPBOX_TOKEN) { dayEditor.travelMap = {}; dayEditor.routeReady = false; return }
const coords = pts.map(j => `${(+j.lon).toFixed(6)},${(+j.lat).toFixed(6)}`).join(';')
const url = `https://api.mapbox.com/directions-matrix/v1/mapbox/driving/${coords}?annotations=duration,distance&access_token=${MAPBOX_TOKEN}`
try {
const r = await fetch(url); if (!r.ok) throw new Error('matrix ' + r.status)
const d = await r.json(); const dur = d.durations || [], dist = d.distances || []
if (key !== ((dayEditor.tech && dayEditor.tech.id) + '|' + (dayEditor.day && dayEditor.day.iso))) return // l'éditeur a changé de cible entre-temps
const map = {}
for (let i = 0; i < pts.length; i++) for (let k = 0; k < pts.length; k++) {
if (i === k) continue
const sec = dur[i] && dur[i][k]; const m = dist[i] && dist[i][k]
if (sec == null) continue
map[pts[i].name + '>' + pts[k].name] = { min: Math.max(2, Math.round(sec / 60)), km: m != null ? Math.round(m / 100) / 10 : null, real: true }
}
dayEditor.travelMap = map; dayEditor.routeReady = true
} catch (e) { dayEditor.travelMap = {}; dayEditor.routeReady = false } // repli haversine
}
const dayOcc = () => (dayEditor.tech && dayEditor.day) ? cellOcc(dayEditor.tech.id, dayEditor.day.iso) : null
const dayBands = () => (dayEditor.tech && dayEditor.day) ? cellBands(dayEditor.tech.id, dayEditor.day.iso) : []
@ -1046,7 +1071,14 @@ function jobMinutes (j) { return Math.round((Number(j.dur) || 0) * 60) }
function setJobMinutes (j, min) { const m = Math.max(5, Math.round((Number(min) || 0) / 5) * 5); j.dur = Math.round(m / 60 * 100) / 100 }
// Temps de transport estimé entre 2 jobs (haversine via coords Service Location) provisoire, en attendant la géoloc live (Capacitor)
function haversineKm (la1, lo1, la2, lo2) { if ([la1, lo1, la2, lo2].some(v => v == null)) return null; const R = 6371; const r = x => x * Math.PI / 180; const dLa = r(la2 - la1); const dLo = r(lo2 - lo1); const s = Math.sin(dLa / 2) ** 2 + Math.cos(r(la1)) * Math.cos(r(la2)) * Math.sin(dLo / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(s)) }
function travelBetween (a, b) { const km = haversineKm(a && a.lat, a && a.lon, b && b.lat, b && b.lon); if (km == null) return null; return { km: Math.round(km * 10) / 10, min: Math.max(5, Math.round(km / 40 * 60) + 5) } } // 40 km/h + 5 min tampon
function travelBetween (a, b) {
if (!a || !b) return null
const hit = dayEditor.travelMap && dayEditor.travelMap[a.name + '>' + b.name]
if (hit) return hit // temps routier RÉEL (Mapbox Matrix)
const km = haversineKm(a.lat, a.lon, b.lat, b.lon); if (km == null) return null
return { km: Math.round(km * 10) / 10, min: Math.max(5, Math.round(km / 40 * 60) + 5), real: false } // repli : 40 km/h + 5 min tampon (vol d'oiseau)
}
function dayLeg (i) { return i > 0 ? travelBetween(dayEditor.list[i - 1], dayEditor.list[i]) : null } // trajet vers le job i depuis le précédent
const fmtHM = (h) => { if (h == null) return '—'; const m = Math.round(h * 60); const hh = Math.floor(m / 60), mm = m % 60; return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') } // heure décimale HH:MM (padded, pour start_time)
function dayShiftStartH () { const t = dayEditor.tech, d = dayEditor.day; if (!t || !d) return 8; const w = winOf(t.id, d.iso, false); return w ? w.s : 8 }
// PLANIFICATEUR DE TOURNÉE : recalcule les heures depuis l'ordre de la liste + durées + transport.

View File

@ -66,8 +66,42 @@ Posé dans `/opt/targo-hub/.env`. ⚠️ Après modif de `.env`, **recréer** le
70 tickets importés (0 erreur, 9 clients non matchés = comptes post-migration + 2 tickets internes
« FORMATION EN HAUTEUR »). Récurrence active (15 min).
## Coordonnées GPS & routage routier réel (2026-06-06 f)
Le pont importe des **coordonnées fiables** par job (pour le routage routier réel dans l'éditeur de
tournée). Cascade de sources, de la plus précise à la plus large :
1. **`delivery` legacy** (point de service exact, via `ticket.delivery_id → delivery.latitude/longitude`)
— JOIN ajouté à `fetchTargoTickets`. Source de référence ; on préfère aussi l'**adresse de service**
(`delivery.address1/city/zip`) à l'adresse de facturation du compte.
2. **Service Location ERPNext** (coords du client matché) — repli.
3. **Géocodage RQA** (`address-search`/`address-validate`, Répertoire des adresses du Québec) — autoritaire
en rural. ⚠️ Le générique de voie (« Rue »/« Rang »/« Chemin ») est **retiré** du terme (absent de
`odonyme_recompose_normal` → sinon l'ilike ne matche jamais) et le **code postal n'est PAS accolé** au
terme (ses tokens seraient exigés dans le nom de rue).
4. **Géocodage Mapbox** (`MAPBOX_TOKEN`, clé publique) — couvre les rues trop récentes pour le RQA.
Contraint au Québec (`country=ca` + proximity + bornes `coord()`).
Validation `coord()` : bornes Québec (lat 44→63, lon 80→57) → rejette 0/0 et placeholders. Backfill
**+ UPGRADE** : sur un job existant, on remplit les coords absentes ET on **écrase** des coords Service
Location moins précises par les coords `delivery` (point exact) — jamais l'inverse. Caches géocodage au
niveau module (1 appel par adresse / vie du hub ; échecs mémorisés). Couverture mise en service :
**153/172 jobs (89 %)** — `coord_src` : delivery 26 · SL 38 · RQA 17 · Mapbox 29 · aucune 15.
**Routage routier réel (Ops → Planification → éditeur de journée)** : `loadDayRoute()` appelle l'**API
Mapbox Matrix** une fois à l'ouverture (toutes les durées routières d'un coup) → `travelBetween()` retourne
le temps RÉEL ; le réordonnancement réutilise la matrice **sans nouvelle requête**. Repli haversine
(40 km/h) si Mapbox indispo. Indicateur 🚗 (réel) vs 📏 (vol d'oiseau).
## Robustesse (2026-06-06 f)
- **Comptabilité honnête** : `erp.create/update` ne *throw* pas (renvoient `{ok:false,error}`) → le pont
vérifie `r.ok` (sinon `errors++` + `error_samples` dans le résumé). Avant : creates échoués comptés réussis.
- **Verrou de sérialisation** sur `sync()` : tick récurrent + runs manuels ne se chevauchent JAMAIS
(frappe_pg sans concurrence → sinon « socket hang up » + écritures perdues dans un rollback).
- **Subject tronqué à 140** (champ `Data` Frappe) : les jobs sans Service Location ajoutaient l'adresse au
sujet → `CharacterLengthExceededError`. Détail complet conservé dans `legacy_detail`/coords.
- Env : `MAPBOX_TOKEN` ajouté à `/opt/targo-hub/.env` (clé publique `pk.`) → recréer le conteneur.
## TODO / idées
- Fermer/annuler le Dispatch Job quand le ticket legacy passe `closed` (v1 ne gère que open).
- Matcher les clients non matchés (créer le Customer / enrichir `legacy_account_id`) → réduit les 15 « aucune coord ».
- Géoloc live du tech (Capacitor `transistorsoft/capacitor-background-geolocation`) → 1er point de la tournée.
- Filtrer les départements non-terrain (ToDo, Support Informatique, Conception…) si bruit.
- Matcher les 9 clients manquants (créer le Customer ou enrichir `legacy_account_id`).
- Écrire en retour le tech assigné / la date vers le legacy (bidirectionnel) — non fait (lecture seule legacy).

View File

@ -23,7 +23,9 @@
*/
const erp = require('./erp')
const cfg = require('./config')
const { log, json } = require('./helpers')
const { log, json, httpRequest } = require('./helpers')
const { searchAddresses } = require('./address-search') // géocodeur RQA (Répertoire des adresses du Québec)
const { scoreMatch } = require('./address-validate') // scoring de correspondance adresse↔RQA
let mysql
try { mysql = require('mysql2/promise') } catch { /* dépendance optionnelle */ }
@ -51,6 +53,73 @@ function startTime (dueTime) {
}
const norm = (s) => (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').trim()
// Coords legacy = chaînes ("-73.5599440" / "45.2528570"). On parse + valide les bornes Québec
// (lat 44→63, lon -80→-57) pour rejeter 0/0, placeholders et valeurs aberrantes → routage fiable.
function coord (lat, lon) {
const la = parseFloat(lat), lo = parseFloat(lon)
if (!isFinite(la) || !isFinite(lo)) return null
if (la < 44 || la > 63 || lo < -80 || lo > -57) return null
return { lat: la, lon: lo }
}
// Géocodage de repli via RQA (Répertoire des adresses du Québec) — source autoritaire, fiable en
// rural (vs Mapbox qui peut dévier de plusieurs km). Cache au niveau MODULE (persiste entre les ticks)
// → chaque adresse n'est géocodée qu'une fois par cycle de vie du hub ; les échecs sont mémorisés
// (valeur null) pour ne PAS marteler RQA à chaque cycle. N'accepte qu'une correspondance fiable (≥0.7).
// Générique de voie (Rue/Rang/Chemin…) ABSENT de `odonyme_recompose_normal` côté RQA → on le retire
// du terme de recherche, sinon l'ilike mot-à-mot ne matche jamais. (Le code postal NE doit PAS être
// accolé au terme : ses tokens seraient exigés dans le nom de rue.)
const ROAD_GENERIC_RE = /\b(rue|chemin|ch|rang|route|rte|avenue|av|ave|boul(?:evard)?|bd|mont[ée]e|c[ôo]te|place|pl|impasse|all[ée]e|terrasse|croissant|carr[ée]|cours|quai|ruelle|voie)\b\.?/gi
const cleanRoadTerm = (s) => String(s || '').replace(ROAD_GENERIC_RE, ' ').replace(/\s+/g, ' ').trim()
const _geoCache = new Map()
async function geocodeRQA (addressLine, postalCode, city) {
const key = norm([addressLine, postalCode, city].filter(Boolean).join('|'))
if (!key || !addressLine) return null
if (_geoCache.has(key)) return _geoCache.get(key)
let res = null
try {
const term = cleanRoadTerm(addressLine) || addressLine // civique + rue sans le générique (PC NON accolé)
const rows = await searchAddresses(term, 15)
if (rows && rows.length) {
const best = rows
.map(r => ({ r, score: scoreMatch({ address_line: addressLine, postal_code: postalCode, city }, r) }))
.sort((a, b) => b.score - a.score)[0]
if (best && best.score >= 0.7) {
const c = coord(best.r.latitude, best.r.longitude)
if (c) res = c
}
}
} catch (e) { log('geocodeRQA error:', e.message) } // RQA indispo → pas de coords (échec mémorisé)
_geoCache.set(key, res)
return res
}
// Repli Mapbox (token public déjà utilisé par le Dispatch) pour les rues TROP RÉCENTES pour le RQA
// (nouveaux développements absents du répertoire). Moins précis en rural que le RQA mais « une coord
// vaut mieux que zéro » pour le routage. Contraint au Québec (country=ca + proximity Montérégie +
// bornes coord()). Cache module. Désactivé si MAPBOX_TOKEN absent de l'env.
const MAPBOX_TOKEN = process.env.MAPBOX_TOKEN || ''
const _mbCache = new Map()
async function geocodeMapbox (addressLine, city, postalCode) {
if (!MAPBOX_TOKEN || !addressLine) return null
const key = norm([addressLine, city, postalCode].filter(Boolean).join('|'))
if (_mbCache.has(key)) return _mbCache.get(key)
let res = null
try {
const q = [addressLine, city, 'Québec'].filter(Boolean).join(', ')
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(q)}.json` +
`?country=ca&proximity=-73.5,45.2&limit=1&types=address&language=fr&access_token=${MAPBOX_TOKEN}`
const r = await httpRequest(url, '', { timeout: 12000 })
const f = r && r.data && Array.isArray(r.data.features) && r.data.features[0]
if (f && Array.isArray(f.center) && (f.relevance == null || f.relevance >= 0.6)) {
const c = coord(f.center[1], f.center[0]) // Mapbox = [lon, lat]
if (c) res = c
}
} catch (e) { log('geocodeMapbox error:', e.message) }
_mbCache.set(key, res)
return res
}
let _pool
function pool () {
if (!mysql) return null
@ -83,9 +152,10 @@ function stripHtml (html, max = 1500) {
async function fetchTargoTickets () {
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
const [rows] = await p.query(
`SELECT t.id, t.subject, t.dept_id, dd.name AS dept, t.due_date, t.due_time, t.priority, t.bon_id, t.account_id,
`SELECT t.id, t.subject, t.dept_id, dd.name AS dept, t.due_date, t.due_time, t.priority, t.bon_id, t.account_id, t.delivery_id,
t.date_create, t.last_update,
a.first_name, a.last_name, a.company, a.address1, a.address2, a.city, a.state, a.zip,
dv.latitude AS dv_lat, dv.longitude AS dv_lon, dv.address1 AS dv_addr, dv.city AS dv_city, dv.zip AS dv_zip,
(SELECT mm.msg FROM ticket_msg mm
WHERE mm.ticket_id = t.id AND mm.msg LIKE '%connect_ministra%'
ORDER BY mm.id DESC LIMIT 1) AS activation_msg,
@ -94,6 +164,7 @@ async function fetchTargoTickets () {
FROM ticket t
LEFT JOIN ticket_dept dd ON dd.id = t.dept_id
LEFT JOIN account a ON a.id = t.account_id
LEFT JOIN delivery dv ON dv.id = t.delivery_id
WHERE t.status = 'open' AND t.assign_to = ?
ORDER BY t.due_date DESC`,
[TARGO_TECH_STAFF_ID],
@ -133,10 +204,16 @@ async function buildJob (t) {
const sl = cust ? await resolveServiceLocation(cust.name, t.city) : null
const jt = jobType(t.dept_id)
const cname = cust ? cust.customer_name : ([t.first_name, t.last_name].filter(Boolean).join(' ') || t.company || '')
const addr = [t.address1, t.address2, t.city, t.state, t.zip].filter(Boolean).join(', ')
// Coords : la table legacy `delivery` (point de service réel, via ticket.delivery_id) est la
// source la plus fiable (lat/long par adresse). On préfère donc l'adresse de service à l'adresse
// de facturation du compte, et les coords delivery aux coords Service Location ERPNext (placeholders).
const dc = coord(t.dv_lat, t.dv_lon)
const svcAddr = [t.dv_addr, t.dv_city, t.dv_zip].filter(Boolean).join(', ')
const billAddr = [t.address1, t.address2, t.city, t.state, t.zip].filter(Boolean).join(', ')
const addr = svcAddr || billAddr
let subject = (t.subject || '').trim() || ([t.dept, cname].filter(Boolean).join(' — '))
if (!sl && addr) subject = (subject + ' · ' + addr) // pas de Service Location → on garde l'adresse visible dans le sujet
subject = subject.slice(0, 250)
subject = subject.slice(0, 140) // Subject = champ Data Frappe (max 140 car.) ; le détail complet est dans legacy_detail/coords
const payload = {
ticket_id: 'LEG-' + t.id,
@ -157,29 +234,53 @@ async function buildJob (t) {
const sd = tzDate(t.due_date); if (sd) payload.scheduled_date = sd
const st = startTime(t.due_time); if (st) payload.start_time = st
if (cust) payload.customer = cust.name
let coordSrc = null
if (dc) { payload.latitude = dc.lat; payload.longitude = dc.lon; coordSrc = 'delivery' } // source fiable : point de service legacy
if (sl) {
payload.service_location = sl.name
if (sl.latitude != null && sl.latitude !== '') payload.latitude = sl.latitude
if (sl.longitude != null && sl.longitude !== '') payload.longitude = sl.longitude
if (!coordSrc) { const sc = coord(sl.latitude, sl.longitude); if (sc) { payload.latitude = sc.lat; payload.longitude = sc.lon; coordSrc = 'service_location' } } // repli si pas de delivery
}
return { legacy_id: String(t.id), payload, matched: { customer: !!cust, service_location: !!sl, customer_name: cname }, dept: t.dept, addr }
if (!coordSrc && addr) { // replis géocodage sur l'adresse de service (sinon facturation) : RQA (autoritaire) puis Mapbox (couverture)
const useSvc = !!svcAddr
const line = useSvc ? t.dv_addr : t.address1
const zip = useSvc ? t.dv_zip : t.zip
const ci = useSvc ? t.dv_city : t.city
const g = await geocodeRQA(line, zip, ci)
if (g) { payload.latitude = g.lat; payload.longitude = g.lon; coordSrc = 'rqa_geocode' }
else { const mb = await geocodeMapbox(line, ci, zip); if (mb) { payload.latitude = mb.lat; payload.longitude = mb.lon; coordSrc = 'mapbox_geocode' } }
}
return { legacy_id: String(t.id), payload, matched: { customer: !!cust, service_location: !!sl, customer_name: cname, coords: !!coordSrc, coord_src: coordSrc, delivery_id: t.delivery_id || null }, dept: t.dept, addr }
}
async function findExisting (legacyId) {
const r = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'status', 'assigned_tech', 'scheduled_date', 'legacy_dept', 'legacy_activation_url', 'legacy_detail'], limit: 1 })
const r = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'status', 'assigned_tech', 'scheduled_date', 'legacy_dept', 'legacy_activation_url', 'legacy_detail', 'latitude', 'longitude', 'service_location'], limit: 1 })
return (r && r[0]) || null
}
// VERROU de sérialisation : frappe_pg ne supporte pas la concurrence. Le tick récurrent ET les runs
// manuels (preview/run) passent tous par `sync()` → on les met en FILE pour qu'ils ne se chevauchent
// JAMAIS (sinon « socket hang up » + écritures perdues dans un rollback). Chaque appel attend le précédent.
let _syncLock = Promise.resolve()
function sync (opts = {}) {
const run = _syncLock.then(() => syncImpl(opts), () => syncImpl(opts))
_syncLock = run.then(() => {}, () => {}) // le suivant attend, quel que soit le résultat
return run
}
// Cœur : parcourt les tickets, crée/maj les Dispatch Jobs. SÉQUENTIEL (frappe_pg ne supporte pas la concurrence).
async function sync ({ dryRun = false } = {}) {
async function syncImpl ({ dryRun = false } = {}) {
resetCaches()
const tickets = await fetchTargoTickets()
let created = 0, updated = 0, skipped = 0, errors = 0, unmatched = 0
let created = 0, updated = 0, skipped = 0, errors = 0, unmatched = 0, coordsFilled = 0, noCoords = 0
const coordTally = {} // observabilité : répartition des sources de coords (delivery/service_location/rqa_geocode/none)
const errSamples = [] // observabilité : échantillon des erreurs create/update (« ne rien échapper »)
const details = []
for (const t of tickets) {
try {
const b = await buildJob(t)
if (!b.matched.customer) unmatched++
coordTally[b.matched.coord_src || 'none'] = (coordTally[b.matched.coord_src || 'none'] || 0) + 1
if (!b.matched.coords) noCoords++ // ni delivery ni Service Location ni RQA → routage indisponible (à diagnostiquer)
const ex = await findExisting(b.legacy_id)
if (ex) {
// Déjà importé. Backfill du département (métadonnée couleur, sans risque) + maj date SEULEMENT
@ -188,14 +289,27 @@ async function sync ({ dryRun = false } = {}) {
if (!ex.legacy_dept && b.payload.legacy_dept) patch.legacy_dept = b.payload.legacy_dept
if (!ex.legacy_activation_url && b.payload.legacy_activation_url) patch.legacy_activation_url = b.payload.legacy_activation_url // backfill lien activation (sans risque)
if (b.payload.legacy_detail && ex.legacy_detail !== b.payload.legacy_detail) patch.legacy_detail = b.payload.legacy_detail // (re)backfill description + dates (idempotent : ne réécrit que si différent)
// Coords (localisation, sans risque pour l'ordonnancement) : on remplit si absentes/0 côté ERPNext,
// ET on UPGRADE vers les coords `delivery` (point de service exact) si elles diffèrent des coords
// existantes (souvent issues du Service Location, moins précises). delivery écrase ; SL/RQA non.
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
const exHas = hasCoord(ex.latitude) && hasCoord(ex.longitude)
const isDeliveryUpgrade = b.matched.coord_src === 'delivery' && exHas &&
(Math.abs(parseFloat(ex.latitude) - b.payload.latitude) > 1e-5 || Math.abs(parseFloat(ex.longitude) - b.payload.longitude) > 1e-5)
if (b.payload.latitude != null && (!exHas || isDeliveryUpgrade)) { patch.latitude = b.payload.latitude; patch.longitude = b.payload.longitude; coordsFilled++ }
if (!ex.service_location && b.payload.service_location) patch.service_location = b.payload.service_location // backfill lien Service Location
if (ex.status === 'open' && !ex.assigned_tech && b.payload.scheduled_date && b.payload.scheduled_date !== ex.scheduled_date) patch.scheduled_date = b.payload.scheduled_date
if (!dryRun && Object.keys(patch).length) { await erp.update('Dispatch Job', ex.name, patch); updated++; details.push({ legacy_id: b.legacy_id, action: 'update', job: ex.name, patch }) }
else skipped++
if (!dryRun && Object.keys(patch).length) {
const r = await erp.update('Dispatch Job', ex.name, patch)
if (r && r.ok) { updated++; details.push({ legacy_id: b.legacy_id, action: 'update', job: ex.name, patch }) }
else { errors++; const msg = (r && r.error) || 'update failed'; errSamples.push({ legacy_id: b.legacy_id, action: 'update', error: String(msg).slice(0, 200) }); details.push({ legacy_id: b.legacy_id, action: 'update-failed', job: ex.name, error: msg }) }
} else skipped++
} else if (dryRun) {
created++; details.push({ legacy_id: b.legacy_id, action: 'would-create', subject: b.payload.subject, job_type: b.payload.job_type, dept: b.dept, scheduled_date: b.payload.scheduled_date || null, start_time: b.payload.start_time || null, customer: b.matched.customer_name, customer_matched: b.matched.customer, sl_matched: b.matched.service_location, addr: b.addr })
created++; details.push({ legacy_id: b.legacy_id, action: 'would-create', subject: b.payload.subject, job_type: b.payload.job_type, dept: b.dept, scheduled_date: b.payload.scheduled_date || null, start_time: b.payload.start_time || null, customer: b.matched.customer_name, customer_matched: b.matched.customer, sl_matched: b.matched.service_location, coords: b.matched.coords, coord_src: b.matched.coord_src, delivery_id: b.matched.delivery_id, addr: b.addr })
} else {
const r = await erp.create('Dispatch Job', b.payload)
created++; details.push({ legacy_id: b.legacy_id, action: 'created', job: (r && r.name) || null, subject: b.payload.subject, customer_matched: b.matched.customer })
if (r && r.ok) { created++; details.push({ legacy_id: b.legacy_id, action: 'created', job: r.name, subject: b.payload.subject, customer_matched: b.matched.customer }) }
else { errors++; const msg = (r && r.error) || 'create failed'; errSamples.push({ legacy_id: b.legacy_id, action: 'create', error: String(msg).slice(0, 200) }); details.push({ legacy_id: b.legacy_id, action: 'create-failed', error: msg }) }
}
} catch (e) {
errors++; details.push({ legacy_id: String(t.id), error: String((e && e.message) || e) })
@ -203,7 +317,7 @@ async function sync ({ dryRun = false } = {}) {
}
let closedResolved = 0
if (!dryRun) { try { const cr = await closeResolved(); closedResolved = cr.closed } catch (e) { log('closeResolved error:', e.message) } } // retire les DJ dont le ticket legacy est fermé
const summary = { ok: true, dryRun, tech_staff_id: TARGO_TECH_STAFF_ID, tickets: tickets.length, created, updated, skipped, errors, unmatched_customer: unmatched, closed: closedResolved }
const summary = { ok: true, dryRun, tech_staff_id: TARGO_TECH_STAFF_ID, tickets: tickets.length, created, updated, skipped, errors, unmatched_customer: unmatched, coords_filled: coordsFilled, no_coords: noCoords, coord_src: coordTally, error_samples: errSamples.slice(0, 6), closed: closedResolved }
if (!dryRun) { _lastRun = { at: new Date().toISOString(), ...summary }; log(`legacy-dispatch-sync: ${JSON.stringify(summary)}`) } // heartbeat
return { ...summary, details }
}