From 00b13e06fd5c603b3c879740756f4b87ec4ca36d Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 24 Jul 2026 12:27:34 -0400 Subject: [PATCH] =?UTF-8?q?fix(legacy-sync):=20adresse=20du=20job=20=3D=20?= =?UTF-8?q?ville=20R=C3=89ELLE=20du=20ticket=20(sujet),=20plus=20l'ancienn?= =?UTF-8?q?e=20SL=20de=20facturation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug #255465 : job routé sur Sainte-Clotilde alors que le service est à Saint-Cyprien. Cause : la Service Location était appariée par la ville de FACTURATION (t.city) → matchait une vieille SL, et l'« override SL » propageait cette mauvaise adresse par-dessus l'adresse correcte du billet. Correctif : la ville de service = celle du SUJET (source de vérité), sinon delivery, sinon facturation. - resolveServiceLocation apparie par sujet>delivery>facturation, matching st/ste tolérant (cityMatch). - l'override par la SL ne s'applique QUE si la SL est dans la ville réelle ET que l'adresse du billet ne l'est PAS (delivery périmée / déménagement, ex. LEG-254850) → sinon on GARDE l'adresse du billet. Le repli coords SL n'est utilisé que si la SL est dans la ville du ticket. + previewTicket(id) : dry-run ciblé (0 écriture) pour vérifier l'adresse/coords résolues d'un ticket. Co-Authored-By: Claude Opus 4.8 --- .../targo-hub/lib/legacy-dispatch-sync.js | 76 +++++++++++++++---- 1 file changed, 62 insertions(+), 14 deletions(-) diff --git a/services/targo-hub/lib/legacy-dispatch-sync.js b/services/targo-hub/lib/legacy-dispatch-sync.js index 0625e86..af27523 100644 --- a/services/targo-hub/lib/legacy-dispatch-sync.js +++ b/services/targo-hub/lib/legacy-dispatch-sync.js @@ -417,7 +417,7 @@ async function resolveServiceLocation (custName, city) { _slCache.set(custName, list) } if (!list.length) return null - if (city) { const hit = list.find(l => norm(l.city) === norm(city)); if (hit) return hit } // préfère la ville qui matche + if (city) { const hit = list.find(l => cityMatch(l.city, city)); if (hit) return hit } // préfère la SL de la ville visée (st/ste tolérant) return list[0] } // Résout le Customer par legacy_account_id ; s'il MANQUE et qu'on n'est pas en dry-run, le CRÉE via le chemin canonique @@ -498,9 +498,30 @@ async function townCenterFromSubject (subject) { return out ? { ...out } : null } +// Clé de ville robuste : minuscule, sans accents, « st/ste » → « saint/sainte », séparateurs normalisés (piège récurrent). +function cityKey (s) { const x = String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, ' ').trim(); return x.replace(/\bste\b/g, 'sainte').replace(/\bst\b/g, 'saint') } +// Même endroit ? égalité de clé, ou l'une contenue dans l'autre (« Saint-Cyprien » ⊂ « Saint-Cyprien-de-Napierville »). +function cityMatch (a, b) { const ka = cityKey(a); const kb = cityKey(b); if (!ka || !kb) return false; if (ka === kb) return true; return ka.length >= 6 && kb.length >= 6 && (ka.includes(kb) || kb.includes(ka)) } +// Ville RÉELLE nommée dans le SUJET du ticket = source de vérité de l'adresse de service (le billet est créé POUR cette ville). +// Même parsing que townCenterFromSubject, mais retourne le NOM canonique développé « Saint-… » (pour comparer aux villes SL/delivery). +async function cityFromSubject (subject) { + try { + await muni.load() + const segs = String(subject || '').split(/\s*[-|:·•/]\s*/).map(s => s.trim()).filter(s => s && s.length >= 3 && !/^\d/.test(s)) + let r = null + for (const s of segs) { const m = muni.resolveSync(s); if (m.matched && !m.ambiguous && m.score >= 1) { r = m; break } } + if (!r) for (const s of segs) { const m = muni.resolveSync(s); if (m.matched && !m.ambiguous) { r = m; break } } + if (r) return String(r.canonical || '').split(/\s+/).map(w => w === 'ST' ? 'Saint' : w === 'STE' ? 'Sainte' : w).join('-') + } catch (e) { /* muni indispo → pas de ville sujet, on retombe sur delivery/facturation */ } + return '' +} + async function buildJob (t, { dryRun = false } = {}) { const cust = await resolveOrCreateCustomer(t.account_id, dryRun) // crée le compte s'il manque (sauf dry-run) - let sl = cust ? await resolveServiceLocation(cust.name, t.city) : null + const subjCity = await cityFromSubject(t.subject) // ville RÉELLE du ticket (sujet) → apparie la BONNE Service Location + // SL appariée par la ville de SERVICE (sujet > delivery > facturation) — PAS la facturation seule (bug #255465 : + // facturation Sainte-Clotilde matchait une vieille SL alors que le service est à Saint-Cyprien). + let sl = cust ? await resolveServiceLocation(cust.name, subjCity || t.dv_city || 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 || '') // Coords : la table legacy `delivery` (point de service réel, via ticket.delivery_id) est la @@ -562,20 +583,20 @@ async function buildJob (t, { dryRun = false } = {}) { if (sl) { payload.service_location = sl.name const sc = coord(sl.latitude, sl.longitude) - // ADRESSE DE SERVICE DU BILLET : l'adresse/coords viennent de la delivery legacy, qui peut être PÉRIMÉE (client - // DÉMÉNAGÉ) ou viser une AUTRE propriété du client. La Service Location est appariée par la VILLE du ticket (= - // l'adresse pour laquelle le billet a été créé). On écrase par la SL LIÉE UNIQUEMENT si la delivery correspond à - // une AUTRE Service Location DU MÊME CLIENT (cas déménagement/multi-adresses, bug LEG-254850 : delivery = ancienne - // adresse Saint-Louis, SL = nouvelle Franklin). Un site EXTERNE (ex. cabane à sucre) ne matche AUCUNE SL → on - // GARDE l'adresse du billet. _slCache est déjà peuplé par resolveServiceLocation (ligne « let sl = … » ci-dessus). - const delivCity = norm(svcAddr ? t.dv_city : t.city) - const slList = (cust && _slCache.get(cust.name)) || [] - const ownOtherAddr = sl.city && delivCity && norm(sl.city) !== delivCity && slList.some(l => l.name !== sl.name && norm(l.city) === delivCity) - if (sc && ownOtherAddr) { + // Ville RÉELLE du ticket = celle du SUJET (fiable), sinon delivery, sinon facturation. L'adresse/pin du BILLET (addr) + // sont gardés PAR DÉFAUT. On n'écrase par la SL QUE si l'adresse du billet ne correspond PAS à la ville réelle + // (delivery périmée / déménagement, ex. LEG-254850 : delivery Saint-Louis périmée, service réel Franklin) ET que la SL, + // elle, correspond à la ville réelle. Ainsi une VIEILLE SL (ex. adresse de facturation Sainte-Clotilde) dans une AUTRE + // ville que le service (bug #255465 : service à Saint-Cyprien) n'écrase JAMAIS l'adresse correcte du billet. + const realCity = subjCity || (svcAddr ? t.dv_city : t.city) + const addrCity = svcAddr ? t.dv_city : t.city + const slMatchesReal = cityMatch(sl.city, realCity) + const addrMatchesReal = cityMatch(addrCity, realCity) + if (sc && slMatchesReal && !addrMatchesReal) { // delivery périmée + SL dans la bonne ville → la SL fait autorité payload.latitude = sc.lat; payload.longitude = sc.lon; coordSrc = 'service_location_override' const slAddr = [sl.address_line, sl.city, sl.postal_code].filter(Boolean).join(', ') if (slAddr) payload.address = slAddr.slice(0, 140) - } else if (!coordSrc && sc) { payload.latitude = sc.lat; payload.longitude = sc.lon; coordSrc = 'service_location' } // repli si rien d'autre + } else if (!coordSrc && sc && slMatchesReal) { payload.latitude = sc.lat; payload.longitude = sc.lon; coordSrc = 'service_location' } // repli coords : SEULEMENT si la SL est dans la ville du ticket } if (!coordSrc && addr) { // replis géocodage sur l'adresse de service (sinon facturation) : RQA → OSM (validé) → CENTRE DU CODE POSTAL (borné) const useSvc = !!svcAddr @@ -2772,4 +2793,31 @@ 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, 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 +// Dry-run CIBLÉ d'UN ticket legacy (0 écriture) → adresse/coords résolues + ville sujet/delivery/facturation. +// Outil de débogage adressage/routage : `require('./lib/legacy-dispatch-sync').previewTicket(255465)`. +async function previewTicket (id) { + const p = pool(); if (!p) return { error: 'legacy DB indisponible' } + 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, + COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) AS account_id, + COALESCE(NULLIF(t.delivery_id, 0), NULLIF(pt.delivery_id, 0)) AS delivery_id, + t.parent, t.date_create, t.last_update, + a.first_name, a.last_name, a.company, a.email, a.cell, a.tel_home, a.address1, a.address2, a.city, a.state, a.zip, + dv.latitude AS dv_lat, dv.longitude AS dv_lon, dv.placemarks_id AS dv_pmid, dv.address1 AS dv_addr, dv.city AS dv_city, dv.zip AS dv_zip, + (SELECT mm3.msg FROM ticket_msg mm3 WHERE mm3.ticket_id = t.id ORDER BY mm3.id ASC LIMIT 1) AS first_msg + FROM ticket t + LEFT JOIN ticket pt ON pt.id = t.parent + LEFT JOIN ticket_dept dd ON dd.id = t.dept_id + LEFT JOIN account a ON a.id = COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) + LEFT JOIN delivery dv ON dv.id = COALESCE(NULLIF(t.delivery_id, 0), NULLIF(pt.delivery_id, 0), + (SELECT d2.id FROM delivery d2 WHERE COALESCE(NULLIF(t.account_id,0),NULLIF(pt.account_id,0))>0 AND d2.account_id=COALESCE(NULLIF(t.account_id,0),NULLIF(pt.account_id,0)) AND d2.latitude IS NOT NULL AND d2.latitude<>0 AND ABS(d2.latitude)>1 ORDER BY d2.id DESC LIMIT 1), + (SELECT d3.id FROM delivery d3 WHERE COALESCE(NULLIF(t.account_id,0),NULLIF(pt.account_id,0))>0 AND d3.account_id=COALESCE(NULLIF(t.account_id,0),NULLIF(pt.account_id,0)) ORDER BY d3.id DESC LIMIT 1)) + WHERE t.id = ? LIMIT 1`, [id]) + if (!rows || !rows[0]) return { error: 'ticket ' + id + ' introuvable' } + const t = rows[0] + const subjCity = await cityFromSubject(t.subject) + const b = await buildJob(t, { dryRun: true }) + return { legacy_id: b.legacy_id, subject: b.payload.subject, subject_city: subjCity, dv_city: t.dv_city, bill_city: t.city, addr_ticket: b.addr, job_address: b.payload.address, latitude: b.payload.latitude, longitude: b.payload.longitude, coord_src: b.matched.coord_src, service_location: b.payload.service_location } +} + +module.exports = { handle, sync, previewTicket, 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