From 956e96b8bc0e47e39be2d0ece9c2496b2442dcae Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 16 Jul 2026 10:49:17 -0400 Subject: [PATCH] chore(hub): sync repo with deployed hub state (uncommitted prod work) Le hub deploye (/opt/targo-hub) avait derive du depot (deploiements scp directs non commites). Rapproche la branche de la realite deployee AVANT d empiler le nouveau travail: - legacy-dispatch-sync.js: liens natifs source_issue/depends_on/parent_incident + backfills (deployes, jamais commites) - ops_reassign.php: action post (note/reponse au fil, deployee) Aucun secret (creds via ops_secret.php). Fichiers = copie exacte du deploye. Co-Authored-By: Claude Opus 4.8 --- services/legacy-bridge/ops_reassign.php | 17 ++ .../targo-hub/lib/legacy-dispatch-sync.js | 243 ++++++++++++++---- 2 files changed, 213 insertions(+), 47 deletions(-) diff --git a/services/legacy-bridge/ops_reassign.php b/services/legacy-bridge/ops_reassign.php index 78edabd..6fbc1da 100644 --- a/services/legacy-bridge/ops_reassign.php +++ b/services/legacy-bridge/ops_reassign.php @@ -62,6 +62,23 @@ if ($action === 'batch_close') { out(['ok' => true, 'action' => 'batch_close', 'closed' => $closed, 'skipped' => $skipped, 'locked' => $lockedN, 'results' => $results]); } +// ─── POST : ajouter une note/réponse au fil d'un ticket depuis Ops ─── +// public=0 (DÉFAUT) = note INTERNE (jamais envoyée au client) · public=1 = message public (visible dans le ticket). +// Même mécanisme que ops_log (INSERT ticket_msg) → apparaît dans le fil lu par le hub. (int)-cast + real_escape = sûr. +if ($action === 'post') { + $ticket = (int)($_POST['ticket_id'] ?? 0); + $msg = trim((string)($_POST['msg'] ?? '')); + $public = ((($_POST['public'] ?? '0')) === '1') ? 1 : 0; + if ($ticket <= 0 || $msg === '') out(['ok' => false, 'error' => 'ticket_id + msg requis'], 400); + $r = $db->query("SELECT id FROM ticket WHERE id = $ticket LIMIT 1"); + if (!$r || !$r->fetch_assoc()) out(['ok' => false, 'error' => 'ticket introuvable'], 404); + $m = $db->real_escape_string(mb_substr($msg, 0, 6000)); + $db->query("INSERT INTO ticket_msg (ticket_id, staff_id, msg, date_orig, public) VALUES ($ticket, $logStaff, '$m', $now, $public)"); + $newId = $db->insert_id; + $db->query("UPDATE ticket SET last_update=$now WHERE id=$ticket"); + out(['ok' => true, 'action' => 'post', 'public' => $public, 'msg_id' => $newId]); +} + // ─── LECTURE / MONITORING (sync F→OPS frais — le miroir local peut être en retard) ─── // Toutes les valeurs numériques sont (int)-castées → interpolation sûre (pas d'injection). // action=ping → fraîcheur + compteurs (max date_last, max ids, heure serveur) diff --git a/services/targo-hub/lib/legacy-dispatch-sync.js b/services/targo-hub/lib/legacy-dispatch-sync.js index 5fd8927..9d550b5 100644 --- a/services/targo-hub/lib/legacy-dispatch-sync.js +++ b/services/targo-hub/lib/legacy-dispatch-sync.js @@ -384,8 +384,8 @@ async function fetchTargoTickets () { // caches par run (vidés à chaque cycle) pour éviter les requêtes répétées let _custCache = new Map() let _slCache = new Map() -let _createStats = { customers: 0, service_locations: 0, issues_linked: 0, issues_created: 0 } // observabilité : comptes/SL créés + Issue (re)liées au client pendant le run (récits dans le résumé) -function resetCaches () { _custCache = new Map(); _slCache = new Map(); _createStats = { customers: 0, service_locations: 0, issues_linked: 0, issues_created: 0 } } +let _createStats = { customers: 0, service_locations: 0, issues_linked: 0, issues_created: 0, source_issue_linked: 0, parent_incident_linked: 0, depends_on_linked: 0 } // observabilité : comptes/SL créés + Issue (re)liées au client pendant le run (récits dans le résumé) +function resetCaches () { _custCache = new Map(); _slCache = new Map(); _createStats = { customers: 0, service_locations: 0, issues_linked: 0, issues_created: 0, source_issue_linked: 0, parent_incident_linked: 0, depends_on_linked: 0 } } // Auto-création des comptes manquants pendant l'import (par défaut ON — désactivable via LEGACY_DISPATCH_CREATE_CUSTOMERS=off). // Un ticket legacy dont le compte F n'existe pas encore côté ERPNext (ex. « Robert Usereau » dispatché mais introuvable dans OPS) // crée le Customer (chemin canonique legacy-sync) + une Service Location depuis l'adresse de service → la fiche devient trouvable et liable. @@ -594,44 +594,73 @@ async function buildJob (t, { dryRun = false } = {}) { } async function findExisting (legacyId) { - const r = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'status', 'assigned_tech', 'scheduled_date', 'subject', 'legacy_dept', 'legacy_activation_url', 'legacy_detail', 'latitude', 'longitude', 'service_location', 'address'], limit: 1 }) + const r = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'status', 'assigned_tech', 'scheduled_date', 'subject', 'legacy_dept', 'legacy_activation_url', 'legacy_detail', 'latitude', 'longitude', 'service_location', 'address', 'source_issue', 'depends_on'], limit: 1 }) return (r && r[0]) || null } +// RELATIONS DURABLES-NATIVES (survivent à une coupure de F) : lie le Dispatch Job à SON Issue via le champ natif +// `source_issue` (au lieu de ne partager que `legacy_ticket_id`, une clé morte sans F). Fill-only + idempotent. +async function linkJobSourceIssue (jobName, curSourceIssue, issueName, dryRun) { + if (!jobName || !issueName || curSourceIssue) return false + if (dryRun) { _createStats.source_issue_linked++; return true } + const r = await erp.update('Dispatch Job', jobName, { source_issue: issueName }) + if (r && r.ok) { _createStats.source_issue_linked++; return true } + return false +} +// DÉPENDANCE NATIVE Job→Job depuis `ticket.waiting_for` (le ticket attend un AUTRE ticket) → champ natif `depends_on`. +// LIEN SEUL — PAS de passage auto en On Hold (la visibilité au pool reste gérée par le dispatch existant). Fill-only + idempotent. +async function linkJobDependsOn (jobName, curDependsOn, waitsOnJobName, dryRun) { + if (!jobName || !waitsOnJobName || curDependsOn || jobName === waitsOnJobName) return false + if (dryRun) { _createStats.depends_on_linked++; return true } + const r = await erp.update('Dispatch Job', jobName, { depends_on: waitsOnJobName }) + if (r && r.ok) { _createStats.depends_on_linked++; return true } + return false +} + // Assure que l'Issue ERPNext (matchée par legacy_ticket_id) porte le `customer` → le ticket legacy apparaît dans la fiche // client (useClientData.loadTickets filtre les Issue par `customer`). Met à jour l'Issue si son customer est vide/différent ; // si l'Issue manque (ticket plus récent que la dernière passe `migrate_tickets.py`), la CRÉE (minimale). Idempotent // (re-run = 0 écriture sur les déjà-liées ; migrate_tickets.py skippe le legacy_ticket_id déjà présent → pas de doublon). // Best-effort : une erreur ici n'empêche PAS le Dispatch Job (on log et on continue — cf. createServiceLocation). async function ensureIssueCustomer (t, custName, { dryRun = false } = {}) { - if (!LINK_ISSUES || !custName || !t || !t.id || dryRun) return null + if (!LINK_ISSUES || !custName || !t || !t.id) return null const legacyId = Number(t.id) if (!Number.isFinite(legacyId)) return null try { - const rows = await erp.list('Issue', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'customer'], limit: 1 }) + const rows = await erp.list('Issue', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'customer', 'parent_incident'], limit: 1 }) const iss = rows && rows[0] + let issName = iss ? iss.name : '' + let curParentIncident = iss ? iss.parent_incident : '' if (iss) { - if (iss.customer === custName) return { action: 'ok', issue: iss.name } - const r = await erp.update('Issue', iss.name, { customer: custName }) // (re)lie — n'écrit que si vide/différent - if (r && r.ok) { _createStats.issues_linked++; return { action: 'linked', issue: iss.name } } - log('ensureIssueCustomer: update échec Issue ' + iss.name + ' (ticket#' + legacyId + ') — ' + ((r && r.error) || 'update')) - return null + if (iss.customer !== custName) { // (re)lie — n'écrit que si vide/différent + if (!dryRun) { const r = await erp.update('Issue', iss.name, { customer: custName }); if (!(r && r.ok)) log('ensureIssueCustomer: update échec Issue ' + iss.name + ' (ticket#' + legacyId + ')') } + _createStats.issues_linked++ + } + } else { + // Issue absente → create minimale. issue_type/priority OMIS (liens seedés → risque LinkValidationError). + const tkStatus = String(t.tk_status || 'open').toLowerCase() + const doc = { + subject: (decodeEntities(t.subject || '').trim() || ('Ticket #' + legacyId)).slice(0, 255), + status: tkStatus === 'closed' ? 'Closed' : tkStatus === 'pending' ? 'On Hold' : 'Open', + customer: custName, company: ISSUE_COMPANY, legacy_ticket_id: legacyId, + } + const od = tzDate(t.date_create); if (od) doc.opening_date = od + if (dryRun) { _createStats.issues_created++ } else { + const r = await erp.create('Issue', doc) + if (r && r.ok && r.name) { issName = r.name; _createStats.issues_created++ } else { log('ensureIssueCustomer: create échec ticket#' + legacyId + ' — ' + ((r && r.error) || 'create')); return null } + } } - // Issue absente → create minimale. issue_type/priority OMIS volontairement (liens vers doctypes seedés → risque - // LinkValidationError) : la fiche n'en a pas besoin pour lister le ticket. Le `subject`/`status`/`opening_date` - // suffisent à un rendu correct (loadTickets trie par is_important desc, opening_date desc). - const tkStatus = String(t.tk_status || 'open').toLowerCase() - const doc = { - subject: (decodeEntities(t.subject || '').trim() || ('Ticket #' + legacyId)).slice(0, 255), - status: tkStatus === 'closed' ? 'Closed' : tkStatus === 'pending' ? 'On Hold' : 'Open', - customer: custName, - company: ISSUE_COMPANY, - legacy_ticket_id: legacyId, + // HIÉRARCHIE NATIVE (durable, survit à F) : ticket.parent → Issue.parent_incident (fill-only) + parent.is_incident. + // Maintenu LIVE ici (avant : recalculé seulement par la migration batch). Résout l'Issue du parent par legacy_ticket_id. + if (issName && Number(t.parent) > 0 && !curParentIncident) { + const pr = await erp.list('Issue', { filters: [['legacy_ticket_id', '=', Number(t.parent)]], fields: ['name', 'is_incident'], limit: 1 }) + const parent = pr && pr[0] + if (parent && parent.name) { + if (!dryRun) { await erp.update('Issue', issName, { parent_incident: parent.name }); if (!parent.is_incident) await erp.update('Issue', parent.name, { is_incident: 1 }) } + _createStats.parent_incident_linked++ + } } - const od = tzDate(t.date_create); if (od) doc.opening_date = od - const r = await erp.create('Issue', doc) - if (r && r.ok && r.name) { _createStats.issues_created++; return { action: 'created', issue: r.name } } - log('ensureIssueCustomer: create échec ticket#' + legacyId + ' — ' + ((r && r.error) || 'create')) + return { action: iss ? 'ok' : 'created', issue: issName } } catch (e) { log('ensureIssueCustomer: exception ticket#' + legacyId + ' — ' + (e && e.message)) } return null } @@ -699,6 +728,7 @@ async function ingestAssignedImpl ({ dryRun = false, loDays = -7, hiDays = 30, i const ex = await findExisting(String(t.id)) if (ex && ex.assigned_tech && hasCo(ex.latitude) && hasCo(ex.longitude)) { skipped++; if (dryRun) details.push({ legacy_id: String(t.id), action: 'exists-complete' }); continue } const b = await buildJob(t, { dryRun }) + let jobName = ex ? ex.name : ''; const jobSrcIssue = ex ? (ex.source_issue || '') : ''; const jobDependsOn = ex ? (ex.depends_on || '') : '' // relations natives (source_issue + depends_on) const isClosed = String(t.tk_status) === 'closed' b.payload.assigned_tech = tech; b.payload.status = isClosed ? 'Completed' : 'assigned' // ticket F fermé → job déjà fait if (ex) { @@ -715,13 +745,15 @@ async function ingestAssignedImpl ({ dryRun = false, loDays = -7, hiDays = 30, i created++; details.push({ legacy_id: b.legacy_id, action: 'would-create', tech, subject: b.payload.subject, dept: t.dept, scheduled_date: b.payload.scheduled_date || null, skill: deptSkill(t.dept || subj), customer: b.matched.customer_name, customer_matched: b.matched.customer, coords: b.matched.coords, coord_src: b.matched.coord_src }) } else { const r = await erp.create('Dispatch Job', b.payload) - if (r && r.ok) created++; else { errors++; errSamples.push({ legacy_id: b.legacy_id, error: (r && r.error) || 'create' }) } + if (r && r.ok) { created++; jobName = r.name } else { errors++; errSamples.push({ legacy_id: b.legacy_id, error: (r && r.error) || 'create' }) } } - // Relie l'Issue sous-jacente au client (les DJ « complets » court-circuités plus haut sont couverts par backfillIssueCustomers). - if (b.payload.customer) await ensureIssueCustomer(t, b.payload.customer, { dryRun }) + // Relie l'Issue au client (+ hiérarchie parent_incident) ; puis lie le DJ à SON Issue (source_issue = relation NATIVE durable). + if (b.payload.customer) { const ir = await ensureIssueCustomer(t, b.payload.customer, { dryRun }); if (ir && ir.issue) await linkJobSourceIssue(jobName, jobSrcIssue, ir.issue, dryRun) } + // Dépendance native : ticket.waiting_for → depends_on (Job du ticket attendu). Lien seul. + if (Number(t.waiting_for) > 0 && jobName) { const wj = await findExisting(String(t.waiting_for)); if (wj && wj.name) await linkJobDependsOn(jobName, jobDependsOn, wj.name, dryRun) } } catch (e) { errors++; errSamples.push({ legacy_id: String(t.id), error: String((e && e.message) || e) }) } } - return { ok: true, dryRun, window_days: [loDays, hiDays], candidates: (rows || []).length, created, updated, skipped, errors, created_customers: _createStats.customers, created_service_locations: _createStats.service_locations, issues_linked: _createStats.issues_linked, issues_created: _createStats.issues_created, error_samples: errSamples.slice(0, 6), sample: details.slice(0, 25) } + return { ok: true, dryRun, window_days: [loDays, hiDays], candidates: (rows || []).length, created, updated, skipped, errors, created_customers: _createStats.customers, created_service_locations: _createStats.service_locations, issues_linked: _createStats.issues_linked, issues_created: _createStats.issues_created, source_issue_linked: _createStats.source_issue_linked, parent_incident_linked: _createStats.parent_incident_linked, depends_on_linked: _createStats.depends_on_linked, error_samples: errSamples.slice(0, 6), sample: details.slice(0, 25) } } function ingestAssigned (opts = {}) { const run = _syncLock.then(() => ingestAssignedImpl(opts), () => ingestAssignedImpl(opts)); _syncLock = run.then(() => {}, () => {}); return run } // même verrou séquentiel que sync (frappe_pg) @@ -740,6 +772,7 @@ async function syncImpl ({ dryRun = false } = {}) { 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) + let jobName = ex ? ex.name : ''; const jobSrcIssue = ex ? (ex.source_issue || '') : ''; const jobDependsOn = ex ? (ex.depends_on || '') : '' // relations natives (source_issue + depends_on) if (ex) { // Déjà importé. Backfill du département (métadonnée couleur, sans risque) + maj date SEULEMENT // s'il est encore au pool (open + non assigné) → on ne clobbe jamais le travail du répartiteur. @@ -772,18 +805,20 @@ async function syncImpl ({ dryRun = false } = {}) { 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, parent: b.matched.parent, addr: b.addr }) } else { const r = await erp.create('Dispatch Job', b.payload) - 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 }) } + if (r && r.ok) { created++; jobName = r.name; 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 }) } } - // Relie l'Issue sous-jacente au client (indépendant du DJ : couvre aussi le cas « DJ déjà là mais Issue.customer vide »). - if (b.payload.customer) await ensureIssueCustomer(t, b.payload.customer, { dryRun }) + // Relie l'Issue sous-jacente au client (+ hiérarchie parent_incident) ; puis lie le DJ à SON Issue (source_issue = relation NATIVE durable). + if (b.payload.customer) { const ir = await ensureIssueCustomer(t, b.payload.customer, { dryRun }); if (ir && ir.issue) await linkJobSourceIssue(jobName, jobSrcIssue, ir.issue, dryRun) } + // Dépendance native : ticket.waiting_for → depends_on (Job du ticket attendu). Lien seul. + if (Number(t.waiting_for) > 0 && jobName) { const wj = await findExisting(String(t.waiting_for)); if (wj && wj.name) await linkJobDependsOn(jobName, jobDependsOn, wj.name, dryRun) } } catch (e) { errors++; details.push({ legacy_id: String(t.id), error: String((e && e.message) || e) }) } } 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, created_customers: _createStats.customers, created_service_locations: _createStats.service_locations, issues_linked: _createStats.issues_linked, issues_created: _createStats.issues_created, coords_filled: coordsFilled, no_coords: noCoords, coord_src: coordTally, error_samples: errSamples.slice(0, 6), closed: closedResolved } + const summary = { ok: true, dryRun, tech_staff_id: TARGO_TECH_STAFF_ID, tickets: tickets.length, created, updated, skipped, errors, unmatched_customer: unmatched, created_customers: _createStats.customers, created_service_locations: _createStats.service_locations, issues_linked: _createStats.issues_linked, issues_created: _createStats.issues_created, source_issue_linked: _createStats.source_issue_linked, parent_incident_linked: _createStats.parent_incident_linked, depends_on_linked: _createStats.depends_on_linked, 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 } } @@ -1294,13 +1329,22 @@ function startSync () { // ingestAssigned re-scanne ~1000 tickets → on ne le lance qu'1 tick sur N (défaut 4 ≈ horaire à 15 min), pas à chaque tick. // (Le court-circuit « déjà importé » rend les passages en régime permanent rapides, mais on borne quand même la cadence.) const assignedEvery = Math.max(1, Number(process.env.LEGACY_DISPATCH_ASSIGNED_EVERY) || 4) + // AUTO-CORRECTION des coords PÉRIMÉES : les coords erronées (posées par un ancien géocodage, avant que la fibre indexe + // l'adresse) NE se corrigeaient jamais toutes seules — la sync ne remplit que les coords MANQUANTES (0,0/NULL). Ici on + // relance geolocateJobs({refreshFibre}) qui RE-vérifie les jobs DÉJÀ coordonnés et n'écrase QUE si la fibre CONFIRME la + // rue ET que le déplacement > 100 m (cf. geolocateJobs) → les vieux pins décalés se soignent seuls. 1 tick sur N (~horaire). + // Désactivable via LEGACY_DISPATCH_REFRESH_FIBRE_AUTO=off. + const refreshFibreAuto = !/^(off|0|false|no)$/i.test(String(process.env.LEGACY_DISPATCH_REFRESH_FIBRE_AUTO || '')) + const refreshFibreEvery = Math.max(1, Number(process.env.LEGACY_DISPATCH_REFRESH_FIBRE_EVERY) || 4) let _tickN = 0 const tick = () => { if (_syncInFlight) { log('legacy-dispatch-sync: passage précédent encore en cours → tick sauté'); return } _syncInFlight = true - const runAssigned = assignedAuto && (_tickN % assignedEvery === 0); _tickN++ + const runAssigned = assignedAuto && (_tickN % assignedEvery === 0) + const runRefresh = refreshFibreAuto && (_tickN % refreshFibreEvery === 0); _tickN++ sync({ dryRun: false }) .then(() => runAssigned ? ingestAssigned({ dryRun: false, allDates: true }) : null) + .then(() => runRefresh ? geolocateJobs({ dryRun: false, refreshFibre: true }).then(r => { if (r && r.written) log(`legacy-dispatch-sync: auto-refresh fibre → ${r.written} coord(s) corrigée(s)`) }) : null) .catch(e => log('legacy-dispatch-sync tick error:', e.message)) .finally(() => { _syncInFlight = false }) } @@ -1601,15 +1645,21 @@ async function geolocateFromInfra ({ dryRun = true, limit = 1000, jobs = true } else { none++; continue } updates.push({ name: s.name, did: +s.legacy_delivery_id, lat: +(+lat).toFixed(6), lon: +(+lon).toFixed(6), src }) } - let writtenSL = 0, writtenJobs = 0, blockedNoCustomer = 0, failedSL = 0 + let writtenSL = 0, writtenSLsql = 0, writtenJobs = 0, blockedNoCustomer = 0, failedSL = 0 if (!dryRun) { // ⚠️ erp.update NE LÈVE PAS : il renvoie {ok:false,error} → toujours vérifier .ok. Une SL sans `customer` → MandatoryError au PUT. for (const u of updates) { const r = await erp.update('Service Location', u.name, { latitude: u.lat, longitude: u.lon }).catch(e => ({ ok: false, error: e.message })) if (r && r.ok) writtenSL++ - else if (/customer/i.test(String((r && r.error) || ''))) blockedNoCustomer++ - else { failedSL++; log('geoloc SL ' + u.name + ': ' + ((r && r.error) || 'échec')) } + else if (/customer/i.test(String((r && r.error) || ''))) { + // SL SANS `customer` (fibre DISPONIBLE, pas encore de client) = cas NORMAL, PAS orphelin. Le PUT ERPNext re-valide + // tout le doc → MandatoryError. On écrit alors les coords en DIRECT via le pool PG (même DB ERPNext que address-db) : + // les coords n'ont AUCUNE dénormalisation dépendante, donc bypass validation = sûr (idem backfill batch 2026-07-14). + try { const rr = await addrdb.pool().query('UPDATE "tabService Location" SET latitude=$1, longitude=$2, modified=now() WHERE name=$3', [u.lat, u.lon, u.name]); if (rr && rr.rowCount) writtenSLsql++; else blockedNoCustomer++ } catch (e) { blockedNoCustomer++; log('geoloc SL SQL ' + u.name + ': ' + e.message) } + } else { failedSL++; log('geoloc SL ' + u.name + ': ' + ((r && r.error) || 'échec')) } } + // Propager les coords écrites (REST ou SQL) vers gf_latitude/gf_longitude des Address liées encore à 0 (source géo canonique du collapse). + try { await addrdb.pool().query('UPDATE "tabAddress" a SET gf_latitude=sl.latitude, gf_longitude=sl.longitude, modified=now() FROM "tabService Location" sl WHERE sl.linked_address=a.name AND sl.latitude IS NOT NULL AND abs(sl.latitude)>0.01 AND (a.gf_latitude IS NULL OR abs(coalesce(a.gf_latitude,0))<0.01) AND sl.name = ANY($1)', [updates.map(u => u.name)]) } catch (e) { log('geoloc gf_ propagate: ' + e.message) } if (jobs && updates.length) { const coordByLoc = {}; for (const u of updates) coordByLoc[u.name] = u const locNames = updates.map(u => u.name) @@ -1620,7 +1670,7 @@ async function geolocateFromInfra ({ dryRun = true, limit = 1000, jobs = true } } } } - return { ok: true, dryRun, missing: missing.length, resolvable: updates.length, via_placemarks: viaPm, via_delivery: viaDeliv, via_fibre_addr: viaFibre, via_nom: viaNom, unresolved: none, written_service_locations: writtenSL, blocked_no_customer: blockedNoCustomer, failed: failedSL, written_jobs: writtenJobs, sample: updates.slice(0, 8) } + return { ok: true, dryRun, missing: missing.length, resolvable: updates.length, via_placemarks: viaPm, via_delivery: viaDeliv, via_fibre_addr: viaFibre, via_nom: viaNom, unresolved: none, written_service_locations: writtenSL, written_sql_no_customer: writtenSLsql, blocked_no_customer: blockedNoCustomer, failed: failedSL, written_jobs: writtenJobs, sample: updates.slice(0, 8) } } // ── Géoloc par ADRESSE (réutilisable, tolérant aux variantes + COMPARAISON DE RUE pour désambiguïser) ── @@ -1635,20 +1685,48 @@ function _parseAddr (address) { // Civique AVEC le suffixe d'unité « -4 » (format F « bâtiment-unité », ex. « 102-4 ») : la fibre indexe le terrain « 102-4 », // donc « terrain LIKE '102-4%' » cible le BON immeuble ; « 102 » seul ratissait toutes les rues avec un civique 102. const cm = first.match(/^\s*(\d+(?:-\d+)?)\s*[A-Za-z]?/); const civic = cm ? cm[1] : '' - const street = first.replace(/^\s*\d+(?:-\d+)?\s*[A-Za-z]?\s*/, '').trim() + // Le suffixe d'unité (« 3A ») doit être COLLÉ au numéro puis suivi d'un espace ; sinon le `\s*[A-Za-z]?` + // mangeait le « R » de « Rue » (ex. « 4-3 Rue Des Cèdres » → street « ue Des Cèdres »). + const street = first.replace(/^\s*\d+(?:-\d+)?[A-Za-z]?\s+/, '').trim() return { zip, civic, street } } +// Un civique « A-B » est AMBIGU : bâtiment-unité (F indexe le terrain « A-B », ex. « 102-4 Rue Elsie ») OU unité-civique +// (le VRAI civique est le 2ᵉ nombre, ex. « 4-3 Rue Des Cèdres » = app. 4 au civique 3, F indexe « 3 »). On essaie les deux +// ordres, du plus précis au plus large, et la RUE tranche (cf. fibreMatch). Un civique simple garde le comportement historique. +function _civicCandidates (civic) { + const c = String(civic || '').trim(); if (!c) return [] + const m = c.match(/^(\d+)-(\d+)$/); if (!m) return [{ pat: c + '%', kind: 'like' }] + const [, a, b] = m + return [ + { pat: a + '-' + b + '%', kind: 'like' }, // A-B : bâtiment-unité (comportement Elsie, historique) + { pat: b + '-' + a + '%', kind: 'like' }, // B-A : ordre inverse stocké + { pat: b, kind: 'exact' }, // B : unité-civique → civique = 2ᵉ nombre + { pat: a, kind: 'exact' } // A : repli (civique = 1ᵉ nombre, unité au 2ᵉ) + ] +} // Match fibre par zip+civique, DÉSAMBIGUÏSÉ par la rue (compare quand plusieurs civiques identiques). → ligne fibre ou null. +// Tolère l'ordre AMBIGU du civique « A-B » (bâtiment-unité vs unité-civique) via _civicCandidates : le candidat as-parsed +// garde le comportement historique ; chaque ordre ALTERNATIF n'est accepté que si la RUE concorde (jamais deviné). async function fibreMatch (p, zip, civic, street) { - const z = String(zip || '').replace(/\s/g, '').toUpperCase(); const cv = String(civic || '').trim() - if (!z || !cv) return null - let rows = [] - try { [rows] = await p.query("SELECT placemarks_id pmid, latitude flat, longitude flon, rue FROM fibre WHERE REPLACE(UPPER(zip),' ','')=? AND terrain LIKE ? LIMIT 12", [z, cv + '%']) } catch (e) { return null } - if (!rows.length) return null - if (rows.length === 1) return rows[0] + const z = String(zip || '').replace(/\s/g, '').toUpperCase() + const cands = _civicCandidates(civic) + if (!z || !cands.length) return null const ns = _normStreet(street) - if (ns) { const m = rows.find(r => { const rr = _normStreet(r.rue); return rr && (rr === ns || (rr.length >= 4 && (rr.includes(ns) || ns.includes(rr)))) }); if (m) return m } - return null // plusieurs civiques + aucune rue concordante → on NE devine pas (évite le mauvais « 95 Rue Principale » vs « Rang du Cinq ») + const streetHit = (rows) => ns ? rows.find(r => { const rr = _normStreet(r.rue); return rr && (rr === ns || (rr.length >= 4 && (rr.includes(ns) || ns.includes(rr)))) }) : null + for (let i = 0; i < cands.length; i++) { + const cand = cands[i] + const sql = "SELECT placemarks_id pmid, latitude flat, longitude flon, rue FROM fibre WHERE REPLACE(UPPER(zip),' ','')=? AND " + + (cand.kind === 'exact' ? 'terrain=?' : 'terrain LIKE ?') + ' LIMIT 12' + let rows = [] + try { [rows] = await p.query(sql, [z, cand.pat]) } catch (e) { return null } + if (!rows.length) continue + // Ordre ALTERNATIF (i>0) : n'accepter que si la RUE concorde — un civique inversé/partiel ne doit JAMAIS être deviné + // sans confirmation de rue. Le candidat as-parsed (i===0) garde le comportement historique (row unique accepté). + if (rows.length === 1) { if (i === 0 || !ns) return rows[0]; if (_streetConfirmed(rows[0].rue, street)) return rows[0]; continue } + const m = streetHit(rows); if (m) return m + // plusieurs civiques + aucune rue concordante → on NE devine pas (évite le mauvais « 95 Rue Principale » vs « Rang du Cinq ») + } + return null } function _haversineM (la1, lo1, la2, lo2) { const R = 6371000, r = Math.PI / 180; const dLa = (la2 - la1) * r, dLo = (lo2 - lo1) * r; const a = Math.sin(dLa / 2) ** 2 + Math.cos(la1 * r) * Math.cos(la2 * r) * Math.sin(dLo / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(a)) } @@ -2147,4 +2225,75 @@ async function backfillIssueCustomersImpl ({ dryRun = true, limit = 0, throttleM return { ok: true, dryRun, jobs: totalJobs, already_linked: alreadyLinked, linked, created_issues: createdIssues, missing_issue: missingIssue, errors, sample: samples, error_samples: errSamples } } -module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, postTicketLegacy, ticketThread, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, backfillServiceLocations, backfillIssueCustomers, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests +// Backfill du lien NATIF durable Dispatch Job → Issue (`source_issue`) pour TOUS les jobs legacy (y compris ceux +// court-circuités par le sync). Résout l'Issue par legacy_ticket_id. Idempotent (ne touche que source_issue vide). +// C'est ce qui fait SURVIVRE la relation ticket↔job à une coupure de F (legacy_ticket_id devient une clé morte). +function backfillSourceIssue (opts = {}) { const run = _syncLock.then(() => backfillSourceIssueImpl(opts), () => backfillSourceIssueImpl(opts)); _syncLock = run.then(() => {}, () => {}); return run } +async function backfillSourceIssueImpl ({ dryRun = true, limit = 0, throttleMs = 40 } = {}) { + const sleep = (ms) => new Promise(r => setTimeout(r, ms)) + const jobs = [] + for (let start = 0; ; start += 500) { + const r = await erp.listRaw('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['source_issue', 'is', 'not set']], fields: ['name', 'legacy_ticket_id'], limit: 500, start }) + if (!r.ok) return { ok: false, error: 'Dispatch Job list: ' + r.error, at_start: start } + jobs.push(...r.rows) + if (r.rows.length < 500) break + if (limit > 0 && jobs.length >= limit) break + } + const rows = limit > 0 ? jobs.slice(0, limit) : jobs + const ids = [...new Set(rows.map(j => Number(j.legacy_ticket_id)).filter(Number.isFinite))] + const issByTk = new Map() + for (let i = 0; i < ids.length; i += 200) { + const chunk = ids.slice(i, i + 200) + const iss = await erp.list('Issue', { filters: [['legacy_ticket_id', 'in', chunk]], fields: ['name', 'legacy_ticket_id'], limit: chunk.length + 10 }) + for (const r of (iss || [])) issByTk.set(Number(r.legacy_ticket_id), r) + } + let linked = 0, missingIssue = 0, errors = 0; const errSamples = [] + for (const j of rows) { + const iss = issByTk.get(Number(j.legacy_ticket_id)) + if (!iss) { missingIssue++; continue } + if (dryRun) { linked++; continue } + try { const r = await erp.update('Dispatch Job', j.name, { source_issue: iss.name }); if (r && r.ok) linked++; else { errors++; if (errSamples.length < 20) errSamples.push({ job: j.name, error: (r && r.error) || 'update' }) } } + catch (e) { errors++; if (errSamples.length < 20) errSamples.push({ job: j.name, error: String((e && e.message) || e) }) } + if (throttleMs) await sleep(throttleMs) + } + return { ok: true, dryRun, jobs: rows.length, linked, missing_issue: missingIssue, errors, error_samples: errSamples } +} + +// Backfill de la DÉPENDANCE NATIVE Job→Job (`depends_on`) depuis legacy `ticket.waiting_for` (le ticket attend un +// AUTRE ticket). LIEN SEUL (pas d'On Hold auto — visibilité pool inchangée). Idempotent (ne touche que depends_on vide). +function backfillDependsOn (opts = {}) { const run = _syncLock.then(() => backfillDependsOnImpl(opts), () => backfillDependsOnImpl(opts)); _syncLock = run.then(() => {}, () => {}); return run } +async function backfillDependsOnImpl ({ dryRun = true, throttleMs = 40 } = {}) { + const sleep = (ms) => new Promise(r => setTimeout(r, ms)) + const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible sur le hub' } + const [wrows] = await p.query('SELECT id, waiting_for FROM ticket WHERE waiting_for > 0') + const waitMap = new Map(); for (const r of (wrows || [])) waitMap.set(Number(r.id), Number(r.waiting_for)) + if (!waitMap.size) return { ok: true, dryRun, legacy_pairs: 0, candidates: 0, linked: 0, missing_job: 0, errors: 0 } + // Jobs candidats : depends_on vide + legacy_ticket_id présent, dont le ticket a un waiting_for. + const cand = [] + for (let start = 0; ; start += 500) { + const r = await erp.listRaw('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['depends_on', 'is', 'not set']], fields: ['name', 'legacy_ticket_id'], limit: 500, start }) + if (!r.ok) return { ok: false, error: 'Dispatch Job list: ' + r.error, at_start: start } + for (const j of r.rows) if (waitMap.has(Number(j.legacy_ticket_id))) cand.push(j) + if (r.rows.length < 500) break + } + // Résout le Job du ticket ATTENDU (par legacy_ticket_id). + const waitingIds = [...new Set(cand.map(j => waitMap.get(Number(j.legacy_ticket_id))).filter(Number.isFinite))] + const jobByTk = new Map() + for (let i = 0; i < waitingIds.length; i += 200) { + const chunk = waitingIds.slice(i, i + 200) + const js = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', 'in', chunk.map(String)]], fields: ['name', 'legacy_ticket_id'], limit: chunk.length + 10 }) + for (const r of (js || [])) jobByTk.set(Number(r.legacy_ticket_id), r.name) + } + let linked = 0, missingJob = 0, errors = 0; const errSamples = [] + for (const j of cand) { + const wjob = jobByTk.get(waitMap.get(Number(j.legacy_ticket_id))) + if (!wjob || wjob === j.name) { missingJob++; continue } + if (dryRun) { linked++; continue } + try { const r = await erp.update('Dispatch Job', j.name, { depends_on: wjob }); if (r && r.ok) linked++; else { errors++; if (errSamples.length < 20) errSamples.push({ job: j.name, error: (r && r.error) || 'update' }) } } + catch (e) { errors++; if (errSamples.length < 20) errSamples.push({ job: j.name, error: String((e && e.message) || e) }) } + if (throttleMs) await sleep(throttleMs) + } + return { ok: true, dryRun, legacy_pairs: waitMap.size, candidates: cand.length, linked, missing_job: missingJob, errors, error_samples: errSamples } +} + +module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, postTicketLegacy, ticketThread, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, backfillServiceLocations, backfillIssueCustomers, backfillSourceIssue, backfillDependsOn, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests