'use strict' /** * legacy-dispatch-sync.js — PONT legacy (osTicket/MariaDB) → Dispatch Job (ERPNext). * * Tire RÉGULIÈREMENT les tickets ouverts assignés au compte « Tech Targo » * (staff id 3301 dans la DB legacy `gestionclient`) et crée/maj un Dispatch Job * dans ERPNext pour les répartir sur la grille Planification / le tableau Dispatch. * * Pourquoi 3301 : dans le legacy, le travail terrain à dispatcher est assigné au * compte générique « Tech Targo » (default_staff des dépts Installation/Réparation/ * Fibre). C'est exactement « les tickets assignés à tech targo ». * * IDEMPOTENT : chaque ticket legacy porte un `legacy_ticket_id` sur le Dispatch Job. * On cherche avant de créer → jamais de doublon. On NE clobbe PAS le travail du * répartiteur : un job déjà assigné/déplacé n'est plus touché (maj de date seulement * tant qu'il est encore `open` + non assigné). * * Routes : GET /dispatch/legacy-sync/preview (dry-run, 0 écriture) · POST /dispatch/legacy-sync/run * Récurrence : startSync() (setInterval, cf. server.js), désactivable via LEGACY_DISPATCH_SYNC=off. * * Pré-requis : champ Custom Field `legacy_ticket_id` sur Dispatch Job * (dispatch-app/frappe-setup/setup_dispatch_custom_fields.py). */ const erp = require('./erp') const cfg = require('./config') const { log, json } = require('./helpers') let mysql try { mysql = require('mysql2/promise') } catch { /* dépendance optionnelle */ } const TARGO_TECH_STAFF_ID = Number(process.env.LEGACY_TARGO_STAFF_ID) || 3301 // compte « Tech Targo » (pool de dispatch) // dept_id legacy → job_type Dispatch Job (valeurs valides : Installation/Réparation/Retrait/Dépannage/Autre) const DEPT_JOBTYPE = { 27: 'Installation', 12: 'Installation', 7: 'Installation', // Installation Fibre / Installation / Monteur 26: 'Réparation', 10: 'Réparation', 33: 'Réparation', // Réparation Fibre / Réparation / Fusionneur 15: 'Retrait', // Désinstallation } const DUR = { Installation: 2, 'Réparation': 1.5, Retrait: 1, 'Dépannage': 1, Autre: 1 } // durée par défaut (le legacy n'en a pas) const jobType = (deptId) => DEPT_JOBTYPE[deptId] || 'Autre' const prio = (p) => { p = Number(p) || 0; return p >= 3 ? 'high' : p === 2 ? 'medium' : 'low' } // due_date legacy = epoch à minuit LOCAL → date America/Toronto (évite le décalage UTC) const tzDate = (unix) => (unix ? new Date(Number(unix) * 1000).toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) : null) function startTime (dueTime) { if (!dueTime) return null const m = String(dueTime).match(/^(\d{1,2}):(\d{2})/) if (m) return m[1].padStart(2, '0') + ':' + m[2] + ':00' const t = String(dueTime).toLowerCase() if (t === 'am') return '08:00:00' if (t === 'pm') return '13:00:00' return null // 'day' / inconnu → pas d'heure précise } const norm = (s) => (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').trim() let _pool function pool () { if (!mysql) return null if (!_pool) { _pool = mysql.createPool({ host: cfg.LEGACY_DB_HOST, user: cfg.LEGACY_DB_USER, password: cfg.LEGACY_DB_PASS, database: cfg.LEGACY_DB_NAME, connectionLimit: 2, waitForConnections: true, connectTimeout: 8000, }) } return _pool } async function fetchTargoTickets () { const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub') const [rows] = await p.query( `SELECT t.id, t.subject, t.dept_id, dd.name AS dept, t.due_date, t.due_time, t.priority, t.bon_id, t.account_id, a.first_name, a.last_name, a.company, a.address1, a.address2, a.city, a.state, a.zip FROM ticket t LEFT JOIN ticket_dept dd ON dd.id = t.dept_id LEFT JOIN account a ON a.id = t.account_id WHERE t.status = 'open' AND t.assign_to = ? ORDER BY t.due_date DESC`, [TARGO_TECH_STAFF_ID], ) return rows || [] } // caches par run (vidés à chaque cycle) pour éviter les requêtes répétées let _custCache = new Map() let _slCache = new Map() function resetCaches () { _custCache = new Map(); _slCache = new Map() } async function resolveCustomer (accountId) { if (!accountId) return null const k = String(accountId) if (_custCache.has(k)) return _custCache.get(k) const r = await erp.list('Customer', { filters: [['legacy_account_id', '=', k]], fields: ['name', 'customer_name'], limit: 1 }) const c = (r && r[0]) || null _custCache.set(k, c) return c } async function resolveServiceLocation (custName, city) { if (!custName) return null let list = _slCache.get(custName) if (!list) { list = (await erp.list('Service Location', { filters: [['customer', '=', custName]], fields: ['name', 'address_line', 'city', 'latitude', 'longitude'], limit: 10 })) || [] _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 return list[0] } // Construit le payload Dispatch Job à partir d'un ticket legacy (+ infos de matching). async function buildJob (t) { const cust = await resolveCustomer(t.account_id) const sl = cust ? await resolveServiceLocation(cust.name, t.city) : null const jt = jobType(t.dept_id) const cname = cust ? cust.customer_name : ([t.first_name, t.last_name].filter(Boolean).join(' ') || t.company || '') const addr = [t.address1, t.address2, t.city, t.state, t.zip].filter(Boolean).join(', ') let subject = (t.subject || '').trim() || ([t.dept, cname].filter(Boolean).join(' — ')) if (!sl && addr) subject = (subject + ' · ' + addr) // pas de Service Location → on garde l'adresse visible dans le sujet subject = subject.slice(0, 250) const payload = { ticket_id: 'LEG-' + t.id, subject, job_type: jt, duration_h: DUR[jt] || 1, priority: prio(t.priority), status: 'open', order_source: 'Manual', legacy_ticket_id: String(t.id), legacy_dept: t.dept || '', // département legacy granulaire → coloriage « comme legacy » (Installation Fibre / Réparation Fibre / Télé / Téléphonie…) } const sd = tzDate(t.due_date); if (sd) payload.scheduled_date = sd const st = startTime(t.due_time); if (st) payload.start_time = st if (cust) payload.customer = cust.name if (sl) { payload.service_location = sl.name if (sl.latitude != null && sl.latitude !== '') payload.latitude = sl.latitude if (sl.longitude != null && sl.longitude !== '') payload.longitude = sl.longitude } return { legacy_id: String(t.id), payload, matched: { customer: !!cust, service_location: !!sl, customer_name: cname }, dept: t.dept, addr } } async function findExisting (legacyId) { const r = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'status', 'assigned_tech', 'scheduled_date', 'legacy_dept'], limit: 1 }) return (r && r[0]) || null } // Cœur : parcourt les tickets, crée/maj les Dispatch Jobs. SÉQUENTIEL (frappe_pg ne supporte pas la concurrence). async function sync ({ dryRun = false } = {}) { resetCaches() const tickets = await fetchTargoTickets() let created = 0, updated = 0, skipped = 0, errors = 0, unmatched = 0 const details = [] for (const t of tickets) { try { const b = await buildJob(t) if (!b.matched.customer) unmatched++ const ex = await findExisting(b.legacy_id) if (ex) { // Déjà importé. Backfill du département (métadonnée couleur, sans risque) + maj date SEULEMENT // s'il est encore au pool (open + non assigné) → on ne clobbe jamais le travail du répartiteur. const patch = {} if (!ex.legacy_dept && b.payload.legacy_dept) patch.legacy_dept = b.payload.legacy_dept if (ex.status === 'open' && !ex.assigned_tech && b.payload.scheduled_date && b.payload.scheduled_date !== ex.scheduled_date) patch.scheduled_date = b.payload.scheduled_date if (!dryRun && Object.keys(patch).length) { await erp.update('Dispatch Job', ex.name, patch); updated++; details.push({ legacy_id: b.legacy_id, action: 'update', job: ex.name, patch }) } else skipped++ } else if (dryRun) { created++; details.push({ legacy_id: b.legacy_id, action: 'would-create', subject: b.payload.subject, job_type: b.payload.job_type, dept: b.dept, scheduled_date: b.payload.scheduled_date || null, start_time: b.payload.start_time || null, customer: b.matched.customer_name, customer_matched: b.matched.customer, sl_matched: b.matched.service_location, addr: b.addr }) } else { const r = await erp.create('Dispatch Job', b.payload) created++; details.push({ legacy_id: b.legacy_id, action: 'created', job: (r && r.name) || null, subject: b.payload.subject, customer_matched: b.matched.customer }) } } catch (e) { errors++; details.push({ legacy_id: String(t.id), error: String((e && e.message) || e) }) } } const summary = { ok: true, dryRun, tech_staff_id: TARGO_TECH_STAFF_ID, tickets: tickets.length, created, updated, skipped, errors, unmatched_customer: unmatched } if (!dryRun) log(`legacy-dispatch-sync: ${JSON.stringify(summary)}`) return { ...summary, details } } // ── Récurrence (setInterval) ── let _timer = null function startSync () { // OPT-IN : la récurrence ne démarre QUE si LEGACY_DISPATCH_SYNC ∈ {on,1,true}. // (Évite toute écriture automatique surprise au boot ; preview/run manuels restent dispo via les routes.) if (!/^(on|1|true)$/i.test(String(process.env.LEGACY_DISPATCH_SYNC || ''))) { log('legacy-dispatch-sync: récurrence désactivée (poser LEGACY_DISPATCH_SYNC=on pour activer)'); return } if (!mysql) { log('legacy-dispatch-sync: mysql2 absent → pont inactif'); return } const minutes = Number(process.env.LEGACY_DISPATCH_SYNC_MIN) || 15 const tick = () => sync({ dryRun: false }).catch(e => log('legacy-dispatch-sync tick error:', e.message)) // 1er passage différé (laisse le boot se stabiliser), puis toutes les `minutes`. setTimeout(tick, 90 * 1000) _timer = setInterval(tick, minutes * 60 * 1000) log(`legacy-dispatch-sync: pont actif (toutes les ${minutes} min, staff ${TARGO_TECH_STAFF_ID})`) } function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } } async function handle (req, res, method, path) { try { if (path === '/dispatch/legacy-sync/preview' && method === 'GET') return json(res, 200, await sync({ dryRun: true })) if (path === '/dispatch/legacy-sync/run' && method === 'POST') return json(res, 200, await sync({ dryRun: false })) return json(res, 404, { error: 'route inconnue' }) } catch (e) { return json(res, 500, { error: String((e && e.message) || e) }) } } module.exports = { handle, sync, startSync, stopSync, fetchTargoTickets }