fix(legacy-sync): adresse du job = ville RÉELLE du ticket (sujet), plus l'ancienne SL de facturation
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 <noreply@anthropic.com>
This commit is contained in:
parent
608a525038
commit
00b13e06fd
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user