From 70bf25ea847f922f636585737cda967956d666fa Mon Sep 17 00:00:00 2001 From: louispaulb Date: Sat, 6 Jun 2026 09:33:53 -0400 Subject: [PATCH] =?UTF-8?q?feat(dispatch):=20pont=20legacy(osTicket)?= =?UTF-8?q?=E2=86=92Dispatch=20Job=20pour=20les=20tickets=20=C2=AB=20Tech?= =?UTF-8?q?=20Targo=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tire régulièrement les tickets ouverts assignés au compte « Tech Targo » (staff 3301) de la DB legacy MariaDB et crée/maj un Dispatch Job ERPNext (pool à répartir). - lib/legacy-dispatch-sync.js : fetch (status=open AND assign_to=3301) + mapping customer (legacy_account_id) / Service Location (coords) / job_type (dept) / scheduled_date (epoch→America/Toronto) / start_time (am|pm|HH:MM) / priority - Idempotent via Custom Field Dispatch Job.legacy_ticket_id (lookup avant create) ; ne clobbe pas le travail du répartiteur (maj date seulement si encore open+non assigné) - SÉQUENTIEL (frappe_pg) ; endpoints GET preview (dry-run) + POST run - Récurrence opt-in : startSync() au boot, LEGACY_DISPATCH_SYNC=on + _MIN=15 - server.js : route /dispatch/legacy-sync + startSync() - doc docs/features/legacy-dispatch-bridge.md + index Mise en service : 70 tickets importés (0 erreur), récurrence 15 min active. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/features/README.md | 1 + docs/features/legacy-dispatch-bridge.md | 65 ++++++ .../targo-hub/lib/legacy-dispatch-sync.js | 202 ++++++++++++++++++ services/targo-hub/server.js | 4 + 4 files changed, 272 insertions(+) create mode 100644 docs/features/legacy-dispatch-bridge.md create mode 100644 services/targo-hub/lib/legacy-dispatch-sync.js diff --git a/docs/features/README.md b/docs/features/README.md index c5a283b..17f6c05 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -8,6 +8,7 @@ modes. Open the one that matches the feature you're changing. |---|---| | [dispatch.md](dispatch.md) | Ops dispatch board: drag-and-drop scheduling, tech assignment with skill tags, travel-time optimization, magic-link SMS issuance, live SSE updates | | [roster.md](roster.md) | Planification (Roster AI): grille hebdo ressources × jours, garde live, solveur OR-Tools, scoring priorité (maîtrise⊕vitesse⊕coût), panneau « jobs à assigner » (drag-drop + aperçu occupation), timeline ressource, dialogues d'impact, booking roster-aware | +| [legacy-dispatch-bridge.md](legacy-dispatch-bridge.md) | Pont legacy→dispatch: tire régulièrement les tickets osTicket « Tech Targo » (staff 3301) de la MariaDB legacy → Dispatch Job ERPNext (idempotent via `legacy_ticket_id`), mapping client/Service Location/type/date, endpoints preview/run, scheduler opt-in | | [tech-mobile.md](tech-mobile.md) | Field tech app (three surfaces: SSR `/t/{jwt}`, transitional `apps/field/`, unified `/ops/#/j/*`). Native camera → Gemini scanner, equipment install/remove, JWT auth, offline queue | | [customer-portal.md](customer-portal.md) | Passwordless customer self-service at `portal.gigafibre.ca`: magic-link email (24h JWT), invoice + ticket view, Stripe-linked payment flows | | [billing-payments.md](billing-payments.md) | Stripe integration (Checkout, Billing Portal, webhook), subscription lifecycle, invoice generation, payment reconciliation, PPA (Plan de paiement automatique), Klarna BNPL | diff --git a/docs/features/legacy-dispatch-bridge.md b/docs/features/legacy-dispatch-bridge.md new file mode 100644 index 0000000..80dc812 --- /dev/null +++ b/docs/features/legacy-dispatch-bridge.md @@ -0,0 +1,65 @@ +# Pont legacy → Dispatch (osTicket → Dispatch Job) — Handoff dev + +Tire **régulièrement** les tickets ouverts assignés au compte « Tech Targo » dans la +DB legacy (osTicket/MariaDB `gestionclient`) et crée/maj un **Dispatch Job** ERPNext +pour les répartir (grille Planification / tableau Dispatch). + +## Pourquoi « Tech Targo » = staff id 3301 +Dans le legacy, le travail terrain à dispatcher est assigné au compte générique +**« Tech Targo »** (`staff.id = 3301`, username `tech`) — c'est le `default_staff` des +départements techniciens (Installation/Réparation/Fibre). Filtre du pont : +`ticket.status='open' AND ticket.assign_to=3301`. (~70 tickets au démarrage.) +Override possible via `LEGACY_TARGO_STAFF_ID`. + +## Surfaces +| Quoi | Où | +|---|---| +| Module | `services/targo-hub/lib/legacy-dispatch-sync.js` | +| Routage + scheduler | `services/targo-hub/server.js` (`/dispatch/legacy-sync`, `startSync()` au boot) | +| Champ idempotence | Custom Field `Dispatch Job.legacy_ticket_id` (`dispatch-app/frappe-setup/setup_dispatch_custom_fields.py`) | +| Conso côté UI | Pool « à assigner » du tableau Dispatch + panneau « Jobs à assigner » de la Planification | + +## Mapping ticket legacy → Dispatch Job +| Dispatch Job | Source legacy | Notes | +|---|---|---| +| `legacy_ticket_id` | `ticket.id` | **clé d'idempotence** (lookup avant create) | +| `ticket_id` | `'LEG-' + ticket.id` | nom lisible du DJ | +| `subject` | `ticket.subject` | + adresse ajoutée si pas de Service Location | +| `customer` | `Customer` où `legacy_account_id = ticket.account_id` | 61/70 matchés ; sinon laissé vide | +| `service_location` + `latitude`/`longitude` | `Service Location` du customer (ville qui matche) | → pin carte | +| `job_type` | `ticket.dept_id` → {Installation, Réparation, Retrait, Autre} | valeurs valides du Select | +| `scheduled_date` | `ticket.due_date` (epoch) | converti **America/Toronto** (anti-décalage UTC) | +| `start_time` | `ticket.due_time` | `HH:MM` tel quel · `am`→08:00 · `pm`→13:00 · `day`→aucune | +| `priority` | `ticket.priority` (1/2/≥3) | → low / medium / high | +| `duration_h` | défaut par type | Install 2h · Répar 1.5h · autre 1h (le legacy n'en a pas) | +| `status` | — | toujours `open` (pool ; PAS auto-assigné à un tech précis) | + +## Comportement +- **Idempotent** : 1 ticket legacy = 1 Dispatch Job (clé `legacy_ticket_id`). Re-run ⇒ 0 doublon. +- **Ne clobbe PAS le répartiteur** : un DJ déjà assigné/déplacé n'est plus touché ; maj de `scheduled_date` + seulement tant qu'il est encore `open` + non assigné. +- **SÉQUENTIEL** (frappe_pg ne supporte pas la concurrence) — pas de `Promise.all`. + +## Endpoints +- `GET /dispatch/legacy-sync/preview` — **dry-run, 0 écriture** : ce qui serait créé + matching client/SL + non-matchés. +- `POST /dispatch/legacy-sync/run` — exécute la synchro (création/maj). Retourne `{tickets, created, updated, skipped, errors, unmatched_customer}`. + +## Récurrence +`startSync()` (server.js, au boot) — **opt-in** via env : +``` +LEGACY_DISPATCH_SYNC=on # active la récurrence (sinon preview/run manuels seulement) +LEGACY_DISPATCH_SYNC_MIN=15 # période en minutes (défaut 15) +``` +Posé dans `/opt/targo-hub/.env`. ⚠️ Après modif de `.env`, **recréer** le conteneur +(`cd /opt/targo-hub && docker compose up -d`) — `docker restart` ne relit pas l'env_file. +1er passage différé de 90 s après le boot, puis toutes les `MIN` minutes. + +## État (mise en service 2026-06-06) +70 tickets importés (0 erreur, 9 clients non matchés = comptes post-migration + 2 tickets internes +« FORMATION EN HAUTEUR »). Récurrence active (15 min). + +## TODO / idées +- Fermer/annuler le Dispatch Job quand le ticket legacy passe `closed` (v1 ne gère que open). +- Filtrer les départements non-terrain (ToDo, Support Informatique, Conception…) si bruit. +- Matcher les 9 clients manquants (créer le Customer ou enrichir `legacy_account_id`). +- Écrire en retour le tech assigné / la date vers le legacy (bidirectionnel) — non fait (lecture seule legacy). diff --git a/services/targo-hub/lib/legacy-dispatch-sync.js b/services/targo-hub/lib/legacy-dispatch-sync.js new file mode 100644 index 0000000..52d0194 --- /dev/null +++ b/services/targo-hub/lib/legacy-dispatch-sync.js @@ -0,0 +1,202 @@ +'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), + } + 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'], 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é : on ne touche QUE s'il est encore au pool (open + non assigné) et que la date a changé. + if (!dryRun && ex.status === 'open' && !ex.assigned_tech && b.payload.scheduled_date && b.payload.scheduled_date !== ex.scheduled_date) { + await erp.update('Dispatch Job', ex.name, { scheduled_date: b.payload.scheduled_date }) + updated++; details.push({ legacy_id: b.legacy_id, action: 'reschedule', job: ex.name, date: b.payload.scheduled_date }) + } 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 } diff --git a/services/targo-hub/server.js b/services/targo-hub/server.js index 1d42332..3fbd67b 100644 --- a/services/targo-hub/server.js +++ b/services/targo-hub/server.js @@ -116,6 +116,7 @@ const server = http.createServer(async (req, res) => { // iCal feed: /dispatch/calendar/TECH-001.ics?token=xxx (token auth, no SSO) const icalMatch = path.match(/^\/dispatch\/calendar\/(.+)\.ics$/) if (icalMatch && method === 'GET') return ical.handleCalendar(req, res, icalMatch[1], url.searchParams) + if (path.startsWith('/dispatch/legacy-sync')) return require('./lib/legacy-dispatch-sync').handle(req, res, method, path) if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path) if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').handle(req, res, method, path) // Legacy-MariaDB analytical reports — must be checked BEFORE the ERPNext @@ -224,4 +225,7 @@ server.listen(cfg.PORT, '0.0.0.0', () => { // Start PPA (pre-authorized payment) cron scheduler try { require('./lib/payments').startPPACron() } catch (e) { log('PPA cron failed to start:', e.message) } + // Pont legacy (osTicket) → Dispatch Job : tire les tickets « Tech Targo » à dispatcher + try { require('./lib/legacy-dispatch-sync').startSync() } + catch (e) { log('legacy-dispatch-sync failed to start:', e.message) } })