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 <noreply@anthropic.com>
This commit is contained in:
parent
e0e1da1c01
commit
956e96b8bc
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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++
|
||||
}
|
||||
// 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).
|
||||
} 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,
|
||||
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) { _createStats.issues_created++; return { action: 'created', issue: r.name } }
|
||||
log('ensureIssueCustomer: create échec ticket#' + legacyId + ' — ' + ((r && r.error) || 'create'))
|
||||
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 }
|
||||
}
|
||||
}
|
||||
// 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++
|
||||
}
|
||||
}
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user