diff --git a/apps/ops/src/modules/dispatch/components/RightPanel.vue b/apps/ops/src/modules/dispatch/components/RightPanel.vue index 04f19ba..b291be8 100644 --- a/apps/ops/src/modules/dispatch/components/RightPanel.vue +++ b/apps/ops/src/modules/dispatch/components/RightPanel.vue @@ -90,6 +90,10 @@ const onDeleteTag = inject('onDeleteTag') +
+ Détails du ticket +
{{ panel.data.job.legacyDetail }}
+
Note{{ panel.data.job.note }}
Tags diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 8c33a37..ac5ff14 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -544,12 +544,12 @@
Aucun job à assigner 🎉
Groupe ({{ grp.jobs.length }}) — tout sélectionner (terrain)
-
+
{{ jobIsOnsite(j) ? 'Sur site (terrain)' : 'À distance / netadmin — pas pour un tech terrain' }} {{ j.step_order }} - {{ j.subject || j.service_type || j.name }} + {{ j.subject || j.service_type || j.name }}{{ j.legacy_detail }} En attente de {{ j.depends_on || 'la tâche précédente' }}
@@ -675,6 +675,7 @@ import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } fr import { onBeforeRouteLeave, useRouter } from 'vue-router' import { useQuasar } from 'quasar' import * as roster from 'src/api/roster' +import { legacyDeptColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé avec le board Dispatch) import TechSelect from 'src/components/shared/TechSelect.vue' import SkillSelect from 'src/components/shared/SkillSelect.vue' import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags partagé (Dispatch) : condensé, création à la volée, couleurs @@ -965,6 +966,9 @@ function hashColor (label) { let h = 0; for (const c of String(label)) h = (h * const customTags = ref([]) // [{label,color}] créés à la volée (localStorage) function saveCustomTags () { localStorage.setItem('roster-skill-tags-v1', JSON.stringify(customTags.value)) } function getTagColor (label) { const ct = customTags.value.find(x => x.label === label); return (ct && ct.color) || hashColor(label) } +// Couleur d'une carte job du panneau « à assigner » : type legacy → type ERPNext → compétence (cohérent avec le board Dispatch) +const JOBTYPE_COLOR = { Installation: '#46992f', 'Réparation': '#f1c84b', Retrait: '#c0392b', 'Dépannage': '#f59e0b', Autre: '#90a4ae' } +function panelJobColor (j) { return legacyDeptColor(j.legacy_dept) || JOBTYPE_COLOR[j.job_type] || (j.required_skill ? getTagColor(j.required_skill) : '#90a4ae') } const tagCatalog = computed(() => { const m = new Map() for (const ct of customTags.value) m.set(ct.label, { name: ct.label, label: ct.label, color: ct.color || hashColor(ct.label), category: 'Custom' }) diff --git a/apps/ops/src/stores/dispatch.js b/apps/ops/src/stores/dispatch.js index 8e358e6..1484efa 100644 --- a/apps/ops/src/stores/dispatch.js +++ b/apps/ops/src/stores/dispatch.js @@ -59,6 +59,7 @@ export const useDispatchStore = defineStore('dispatch', () => { legacyDept: j.legacy_dept || null, // département osTicket legacy → coloriage par type legacyTicketId: j.legacy_ticket_id || null, // n° ticket legacy (affiché dans le panneau détail) legacyActivationUrl: j.legacy_activation_url || null, // lien connect_ministra (activation STB TV) + legacyDetail: j.legacy_detail || null, // description/contenu du ticket legacy (1er message du fil) parentJob: j.parent_job || null, stepOrder: j.step_order || 0, onOpenWebhook: j.on_open_webhook || null, diff --git a/services/targo-hub/lib/legacy-dispatch-sync.js b/services/targo-hub/lib/legacy-dispatch-sync.js index aafa21f..7e721e0 100644 --- a/services/targo-hub/lib/legacy-dispatch-sync.js +++ b/services/targo-hub/lib/legacy-dispatch-sync.js @@ -67,6 +67,18 @@ function pool () { // On le ré-extrait tel quel (zéro reconstruction). Sous-requête = le ticket_msg le plus récent qui le contient. const ACTIVATION_RE = /https?:\/\/[^\s"'<>]*connect_ministra\.php[^\s"'<>]*/i function extractActivationUrl (msg) { if (!msg) return ''; const m = String(msg).match(ACTIVATION_RE); return m ? m[0] : '' } +// Détail du ticket = 1er message du fil legacy (HTML osTicket) → texte lisible, tronqué, pour l'afficher dans Ops. +function stripHtml (html, max = 1500) { + if (!html) return '' + let s = String(html) + .replace(/<\s*br\s*\/?\s*>/gi, '\n').replace(/<\/\s*(p|div|li|tr)\s*>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>') + .replace(/�*39;|'|'/gi, "'").replace(/"/gi, '"') + .replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim() + if (s.length > max) s = s.slice(0, max) + '…' + return s +} async function fetchTargoTickets () { const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub') @@ -75,7 +87,9 @@ async function fetchTargoTickets () { a.first_name, a.last_name, a.company, a.address1, a.address2, a.city, a.state, a.zip, (SELECT mm.msg FROM ticket_msg mm WHERE mm.ticket_id = t.id AND mm.msg LIKE '%connect_ministra%' - ORDER BY mm.id DESC LIMIT 1) AS activation_msg + ORDER BY mm.id DESC LIMIT 1) AS activation_msg, + (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_dept dd ON dd.id = t.dept_id LEFT JOIN account a ON a.id = t.account_id @@ -135,6 +149,7 @@ async function buildJob (t) { legacy_dept: t.dept || '', // département legacy granulaire → coloriage « comme legacy » (Installation Fibre / Réparation Fibre / Télé / Téléphonie…) } const actUrl = extractActivationUrl(t.activation_msg); if (actUrl) payload.legacy_activation_url = actUrl // lien connect_ministra (déjà dans le fil) + const detail = stripHtml(t.first_msg); if (detail) payload.legacy_detail = detail // description/contenu du ticket legacy → visible dans Ops 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 @@ -147,7 +162,7 @@ async function buildJob (t) { } 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', 'legacy_activation_url'], limit: 1 }) + const r = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'status', 'assigned_tech', 'scheduled_date', 'legacy_dept', 'legacy_activation_url', 'legacy_detail'], limit: 1 }) return (r && r[0]) || null } @@ -168,6 +183,7 @@ async function sync ({ dryRun = false } = {}) { const patch = {} if (!ex.legacy_dept && b.payload.legacy_dept) patch.legacy_dept = b.payload.legacy_dept if (!ex.legacy_activation_url && b.payload.legacy_activation_url) patch.legacy_activation_url = b.payload.legacy_activation_url // backfill lien activation (sans risque) + if (!ex.legacy_detail && b.payload.legacy_detail) patch.legacy_detail = b.payload.legacy_detail // backfill description du ticket 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++ @@ -182,12 +198,28 @@ async function sync ({ dryRun = false } = {}) { } } 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)}`) + if (!dryRun) { _lastRun = { at: new Date().toISOString(), ...summary }; log(`legacy-dispatch-sync: ${JSON.stringify(summary)}`) } // heartbeat return { ...summary, details } } +// Réconciliation : prouve qu'AUCUN ticket n'est échappé. Compare legacy(assign_to=3301, open) ↔ Dispatch Jobs (legacy_ticket_id). +async function reconcile () { + const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub') + const [rows] = await p.query('SELECT id FROM ticket WHERE status = ? AND assign_to = ?', ['open', TARGO_TECH_STAFF_ID]) + const legacyIds = new Set((rows || []).map(r => String(r.id))) + // Dispatch Jobs issus du pont (legacy_ticket_id renseigné) + const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', '']], fields: ['name', 'legacy_ticket_id', 'status', 'assigned_tech'], limit: 5000 }) + const erpIds = new Set((djs || []).map(j => String(j.legacy_ticket_id))) + const missing = [...legacyIds].filter(id => !erpIds.has(id)) // legacy ouvert mais PAS dans ERPNext = échappé → à corriger + // orphelins = DJ encore "open"/non assigné dont le ticket legacy n'est plus ouvert(3301) (fermé/réassigné côté legacy) + const stillOpen = (djs || []).filter(j => j.status === 'open' && !j.assigned_tech) + const orphan = stillOpen.filter(j => !legacyIds.has(String(j.legacy_ticket_id))).map(j => ({ job: j.name, legacy_ticket_id: j.legacy_ticket_id })) + return { ok: true, legacy_open_3301: legacyIds.size, erpnext_bridged: erpIds.size, missing_count: missing.length, missing, orphan_count: orphan.length, orphan, last_sync: _lastRun } +} + // ── Récurrence (setInterval) ── let _timer = null +let _lastRun = null // heartbeat : dernier passage réussi (pour /status + Uptime-Kuma) 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.) @@ -206,6 +238,12 @@ 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 })) + if (path === '/dispatch/legacy-sync/reconcile' && method === 'GET') return json(res, 200, await reconcile()) + if (path === '/dispatch/legacy-sync/status' && method === 'GET') { // heartbeat pour Uptime-Kuma (keyword "stale":false) + const ageMin = _lastRun ? Math.round((Date.now() - Date.parse(_lastRun.at)) / 60000) : null + const max = (Number(process.env.LEGACY_DISPATCH_SYNC_MIN) || 15) * 3 // toléré = 3 ticks + return json(res, 200, { ok: true, enabled: /^(on|1|true)$/i.test(String(process.env.LEGACY_DISPATCH_SYNC || '')), last_sync: _lastRun, age_min: ageMin, stale: ageMin == null || ageMin > max }) + } return json(res, 404, { error: 'route inconnue' }) } catch (e) { return json(res, 500, { error: String((e && e.message) || e) }) diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 1aaf15e..bc84ec7 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -734,7 +734,7 @@ async function handle (req, res, method, path, url) { } // Jobs À ASSIGNER (non assignés) avec leur groupe/dépendances (parent_job, depends_on, step_order, chaîne On Hold). if (path === '/roster/unassigned-jobs' && method === 'GET') { - const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'subject', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech'], orderBy: 'modified desc', limit: 400 }) + const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech'], orderBy: 'modified desc', limit: 400 }) const jobs = (rows || []).filter(j => !j.assigned_tech) // non assignés for (const j of jobs) j.required_skill = skillForJob(j) await attachLocations(jobs)