From cd25fa250481c4148dc71951271165a22f701895 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Sat, 18 Jul 2026 18:02:37 -0400 Subject: [PATCH] =?UTF-8?q?fix(dispatch):=20double-assign=20F=20+=20m?= =?UTF-8?q?=C3=A9dias=20terrain=20dans=20le=20ticket=20+=20deep=20links=20?= =?UTF-8?q?natifs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - capacityByDay compte les jobs assignés SANS heure (untimed_used_h au prorata AM/PM/Soir) - /roster/assign-job : garde 409 si le ticket F est déjà à un autre vrai tech (force pour écraser) ; SPA = dialog confirm - mirrorAssign : start_time depuis F due_time (am/pm) + fix due_date (cur.→r.) + backfill fill-only dans watchLegacy (97% des tickets F = 'day' → le spread capacité est le fix porteur) - pushAssignments : garde anti-clobber (conflits F skippés, force=1) + compteur conflicts - ticket-collab : courriels de notification → deep link OPS /#/tickets?open= (fini le desk) ; TicketsPage lit ?open= ; Dashboard ouvre le ticket précis - DetailModal + SupplierInvoices : lien desk gaté can('manage_settings') - NOUVEAU JobMediaModule (photos tech géo-vérifiées + journal terrain) monté dans IssueDetail onsite + volet Planif ; hub /roster/job-media + /field/photo-file signé ; JobThread poll silencieux 45s - legacy-sync emailList : strip caractères invisibles (soft hyphen U+00AD…) — fixait un 417 InvalidEmailAddress en boucle (compte 15988) Déployé prod 2026-07-18 (hub restart + SPA /opt/ops-app), sondes live OK. Co-Authored-By: Claude Fable 5 --- apps/ops/src/api/roster.js | 33 ++++- .../modules/JobMediaModule.vue | 117 ++++++++++++++++++ .../detail-sections/modules/JobThread.vue | 17 ++- apps/ops/src/pages/DashboardPage.vue | 5 +- apps/ops/src/pages/SupplierInvoicesPage.vue | 5 +- apps/ops/src/pages/TicketsPage.vue | 13 +- .../targo-hub/lib/legacy-dispatch-sync.js | 69 ++++++++--- services/targo-hub/lib/legacy-sync.js | 5 +- services/targo-hub/lib/roster.js | 61 ++++++++- services/targo-hub/lib/ticket-collab.js | 8 +- 10 files changed, 302 insertions(+), 31 deletions(-) create mode 100644 apps/ops/src/components/shared/detail-sections/modules/JobMediaModule.vue diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index 82a5f74..f1f37d9 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -12,7 +12,12 @@ async function _fetch (path, init = {}, ms = 45000) { const timer = setTimeout(() => ctl.abort(), ms) try { const r = await fetch(HUB + path, { ...init, signal: ctl.signal }) - if (!r.ok) throw new Error('Roster API ' + r.status) + if (!r.ok) { + // Le corps d'erreur porte parfois un contexte actionnable (ex. 409 conflict de /roster/assign-job) → l'attacher. + let body = null; try { body = await r.json() } catch (e2) { /* corps non-JSON */ } + const err = new Error((body && body.error) || ('Roster API ' + r.status)); err.status = r.status; if (body) err.body = body + throw err + } return await r.json() } catch (e) { if (e && e.name === 'AbortError') throw new Error('Roster API timeout (' + Math.round(ms / 1000) + 's) — serveur trop lent ou injoignable') @@ -146,13 +151,37 @@ export const closeLegacyTicket = (ticket) => jpost('/dispatch/legacy-sync/close- // Fermeture EN LOT (1 appel pour N tickets) — efficace export const batchCloseLegacy = (ids) => jpost('/dispatch/legacy-sync/batch-close?tickets=' + encodeURIComponent(ids.join(',')), {}) // Assigner un job à un tech (date = case déposée) -export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job, tech, date }) +// Assignation avec GARDE-FOU F : si le ticket legacy est déjà assigné dans F à un autre vrai tech (fenêtre aveugle +// du miroir ~3 min), le hub renvoie 409 conflict → on demande confirmation AVANT d'écraser (jamais silencieux). +export const assignJob = async (job, tech, date, { force = false } = {}) => { + try { + return await jpost('/roster/assign-job', { job, tech, date, force }) + } catch (e) { + const b = e && e.body + if (b && b.conflict) { + const { Dialog } = await import('quasar') + return new Promise((resolve, reject) => { + Dialog.create({ + title: 'Déjà assigné dans F', + message: `Le ticket ${b.ticket || ''} est déjà assigné dans F à ${b.f_staff_name || ('staff #' + b.f_staff_id)}${b.f_status ? ' (ticket ' + b.f_status + ')' : ''}. Réassigner quand même ?`, + cancel: { label: 'Annuler', flat: true, color: 'grey-8' }, + ok: { label: 'Réassigner quand même', color: 'negative', unelevated: true }, + persistent: true, + }).onOk(() => { assignJob(job, tech, date, { force: true }).then(resolve, reject) }) + .onCancel(() => reject(new Error('Assignation annulée — déjà assigné dans F à ' + (b.f_staff_name || ('staff #' + b.f_staff_id))))) + }) + } + throw e + } +} // Fil complet d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only export const legacyTicketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id)) // Réordonner / re-prioriser les jobs d'un tech×jour : updates = [{ job, route_order, priority? }] export const reorderJobs = (updates) => jpost('/roster/reorder-jobs', { updates }) // Retirer un job d'un tech (retour au pool non assigné) — ERPNext SEULEMENT (redistributions auto) export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job }) +// Médias terrain d'un job : photos du tech (géo-vérifiées) + journal completion_notes (arrivées, scans, notes). +export const jobMedia = (job) => jget('/roster/job-media?job=' + encodeURIComponent(job)) // Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis sur la carte export const setJobLocation = (job, lat, lon, address) => jpost('/roster/job/set-location', { job, lat, lon, address }) // Actions rapides du pool (mobile) : patch d'un Dispatch Job (priority/status/required_skill/scheduled_date/notes — liste blanche côté hub). diff --git a/apps/ops/src/components/shared/detail-sections/modules/JobMediaModule.vue b/apps/ops/src/components/shared/detail-sections/modules/JobMediaModule.vue new file mode 100644 index 0000000..78fce3b --- /dev/null +++ b/apps/ops/src/components/shared/detail-sections/modules/JobMediaModule.vue @@ -0,0 +1,117 @@ + + + + + + diff --git a/apps/ops/src/components/shared/detail-sections/modules/JobThread.vue b/apps/ops/src/components/shared/detail-sections/modules/JobThread.vue index 1b9cffc..d451e1a 100644 --- a/apps/ops/src/components/shared/detail-sections/modules/JobThread.vue +++ b/apps/ops/src/components/shared/detail-sections/modules/JobThread.vue @@ -23,7 +23,7 @@ diff --git a/apps/ops/src/pages/DashboardPage.vue b/apps/ops/src/pages/DashboardPage.vue index 579695b..79c7bd8 100644 --- a/apps/ops/src/pages/DashboardPage.vue +++ b/apps/ops/src/pages/DashboardPage.vue @@ -1,7 +1,8 @@ diff --git a/services/targo-hub/lib/legacy-dispatch-sync.js b/services/targo-hub/lib/legacy-dispatch-sync.js index 8d4c300..2f4501b 100644 --- a/services/targo-hub/lib/legacy-dispatch-sync.js +++ b/services/targo-hub/lib/legacy-dispatch-sync.js @@ -1017,7 +1017,7 @@ function pushAssignments (opts = {}) { _syncLock = run.then(() => {}, () => {}) return run } -async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '', notify = true } = {}) { +async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '', notify = true, force = false } = {}) { const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub') let actorStaff = 0 // staff legacy du répartiteur Ops (email Authentik → staff) = auteur du log/closed_by côté legacy if (actorEmail) { try { const [ar] = await p.query('SELECT id FROM staff WHERE status=1 AND lower(email)=? LIMIT 1', [String(actorEmail).toLowerCase()]); if (ar && ar[0]) actorStaff = ar[0].id } catch (e) {} } @@ -1049,7 +1049,7 @@ async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '', notify = } return { staff: null, via: null } } - let pushed = 0, skipped = 0, mismatch = 0, locked = 0, errors = 0, notified = 0; const errSamples = []; const notifyErrors = [] + let pushed = 0, skipped = 0, mismatch = 0, locked = 0, errors = 0, notified = 0, conflicts = 0; const errSamples = []; const notifyErrors = [] const samples = [] for (const j of jobs) { const techRow = techByName[j.assigned_tech] @@ -1059,6 +1059,15 @@ async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '', notify = const staffId = String(st.id) if (!tkrow || tkrow.status === 'closed') { skipped++; continue } // ticket fermé/introuvable → on ne réassigne pas if (String(tkrow.assign_to) === staffId) { skipped++; continue } // déjà assigné au bon tech → idempotent + // GARDE ANTI-CLOBBER : F tient DÉJÀ un AUTRE vrai tech (≠ pool 3301). Le push en lot n'a AUCUN arbitrage de + // récence → écraser risquerait d'effacer une assignation F plus récente (et d'aviser le mauvais tech). + // → ignoré par défaut, visible dans l'aperçu ; force=1 pour écraser en connaissance de cause. + const fAt = parseInt(tkrow.assign_to, 10) || 0 + if (!force && fAt > 0 && fAt !== TARGO_TECH_STAFF_ID) { + conflicts++ + if (samples.length < 25) samples.push({ job: j.name, ticket: j.legacy_ticket_id, de_staff: tkrow.assign_to, vers_staff: staffId + ' (' + (st.first_name || '') + ' ' + (st.last_name || '') + ')', action: '⚠ CONFLIT : F a déjà un vrai tech — ignoré (force=1 pour écraser)' }) + continue + } if (samples.length < 25) samples.push({ job: j.name, ticket: j.legacy_ticket_id, de_staff: tkrow.assign_to, vers_staff: staffId + ' (' + (st.first_name || '') + ' ' + (st.last_name || '') + ')', via, sujet: (j.subject || '').slice(0, 34) }) if (!dryRun) { const w = await legacyWrite({ action: 'reassign', ticket_id: j.legacy_ticket_id, staff_id: staffId, actor_staff_id: actorStaff || '', notify: notify ? 1 : '', address: j.address || '' }).catch(e => ({ data: { ok: false, error: e.message } })) @@ -1068,7 +1077,7 @@ async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '', notify = else { errors++; if (errSamples.length < 6) errSamples.push({ ticket: j.legacy_ticket_id, error: d.error || ('http ' + (w && w.status)) }) } } else pushed++ } - return { ok: true, dryRun, candidates: jobs.length, pushed, skipped, mismatch, locked, errors, notified, notify_errors: notifyErrors, error_samples: errSamples, samples } + return { ok: true, dryRun, candidates: jobs.length, pushed, skipped, mismatch, conflicts, locked, errors, notified, notify_errors: notifyErrors, error_samples: errSamples, samples } } // Auto-fermeture : un Dispatch Job issu du pont dont le ticket legacy est passé `closed` → on le marque « Completed » @@ -1089,6 +1098,21 @@ async function closeResolved () { return { checked: djs.length, closed, details } } +// État d'assignation LIVE d'un ticket F — garde-fou « assign-time » du roster (fenêtre aveugle du miroir ~3 min). +// Lecture seule, 1 requête. assigned = pris par un VRAI tech (≠ pool 3301) et pas fermé. +async function ticketAssignState (legacyId) { + const p = pool(); if (!p) return null + const id = parseInt(String(legacyId || '').replace(/[^0-9]/g, ''), 10); if (!id) return null + const [rows] = await p.query('SELECT t.id, t.status, t.assign_to, s.first_name, s.last_name FROM ticket t LEFT JOIN staff s ON s.id = t.assign_to WHERE t.id = ? LIMIT 1', [id]) + const r = rows && rows[0]; if (!r) return null + const at = parseInt(r.assign_to, 10) || 0 + return { + ticket: id, status: r.status, staff_id: at, + staff_name: [r.first_name, r.last_name].filter(Boolean).join(' '), + assigned: r.status !== 'closed' && at > 0 && at !== TARGO_TECH_STAFF_ID, + } +} + // Fil COMPLET d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only. async function ticketThread (legacyId) { const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub') @@ -1169,24 +1193,29 @@ async function loadStaffToTech () { // MIROIR d'assignation : quand F assigne/réassigne un ticket à un vrai tech, on REFLÈTE le tech sur le Dispatch Job // (assigned_tech + status assigned + scheduled_date d'après le due_date) au lieu de l'annuler → la TRACE est conservée // (le job reste visible SOUS LE BON TECH dans le board et l'historique). Renvoie false si le tech n'est pas mappé. -async function mirrorAssign (job, staffId, dueTs, staffToTech) { +async function mirrorAssign (job, staffId, dueTs, staffToTech, dueTime) { const tech = staffToTech[staffId]; if (!tech) return false const patch = { assigned_tech: tech, status: 'assigned' } const ts = Number(dueTs) || 0 if (ts > 0) { try { patch.scheduled_date = new Date(ts * 1000).toISOString().slice(0, 10) } catch (e) {} } + // Heure MIROIR depuis F due_time (am→08:00, pm→13:00, HH:MM tel quel) : sans start_time le job n'occupe NI la + // capacité NI les créneaux → « on peut encore choisir le même tech » (double-booking). 'day'/inconnu → pas d'heure. + const stt = startTime(dueTime) + if (stt) patch.start_time = stt try { await erp.update('Dispatch Job', job.name, patch); return true } catch (e) { return false } } async function watchLegacy () { const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible' } - const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'On Hold', 'In Progress']]], fields: ['name', 'legacy_ticket_id', 'status', 'assigned_tech', 'subject'], limit: 5000 }) + const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'On Hold', 'In Progress']]], fields: ['name', 'legacy_ticket_id', 'status', 'assigned_tech', 'start_time', 'subject'], limit: 5000 }) const jobByTk = new Map(); const ids = [] for (const j of (jobs || [])) { const id = String(j.legacy_ticket_id); jobByTk.set(id, j); const n = parseInt(id, 10); if (n) ids.push(n) } if (!ids.length) { _lastWatch = { at: new Date().toISOString(), tracked: 0, changes: 0 }; return { ok: true, tracked: 0, changes: [] } } - const [rows] = await p.query('SELECT id, status, assign_to, last_update, due_date FROM ticket WHERE id IN (?)', [ids]) + const [rows] = await p.query('SELECT id, status, assign_to, last_update, due_date, due_time FROM ticket WHERE id IN (?)', [ids]) const staffToTech = await loadStaffToTech() // pour REFLÉTER l'assignation F sur le job (conserver la trace) const seeded = _watchSnap.size > 0 // 1er passage (snapshot vide) = amorçage silencieux des DELTAS const changes = []; let closedNow = 0; let pulled = 0; let mirrored = 0 + let timed = 0; let fillBudget = 80 // remplissage start_time par passage, borné (écritures séquentielles frappe_pg) for (const r of (rows || [])) { const id = String(r.id) const prev = _watchSnap.get(id) @@ -1200,7 +1229,7 @@ async function watchLegacy () { if (!j.assigned_tech && (j.status === 'open' || j.status === 'On Hold') && cur.status !== 'closed' && at > 0 && at !== TARGO_TECH_STAFF_ID) { // F a assigné ce job (du pool) à un vrai tech. On REFLÈTE le tech sur le job (trace conservée, visible sous le bon // tech) plutôt que de l'annuler. Repli sur Cancelled seulement si le tech F n'a pas de fiche Dispatch Technician. - if (await mirrorAssign(j, at, cur.due_date, staffToTech)) { + if (await mirrorAssign(j, at, r.due_date, staffToTech, r.due_time)) { mirrored++; audit.record({ job: j.name, ticket: id, event: 'mirror-assigned', to: staffToTech[at], actor: 'F', source: 'F-watch' }) changes.push({ ticket: id, job: j.name, kind: 'mirror-assigned', to_staff: cur.assign_to, tech: staffToTech[at], subject: j.subject || '' }) } else { @@ -1208,6 +1237,13 @@ async function watchLegacy () { } continue // reflété (ou sorti du pool) → pas d'autre delta à émettre } + // (1b) REMPLISSAGE start_time (fill-only, idempotent) : job assigné SANS heure alors que F a un due_time + // exploitable (am/pm/HH:MM). Rattrape le stock miroré AVANT ce fix — sans heure, le job n'occupe ni la + // capacité ni les créneaux (double-booking). Ne touche JAMAIS une heure déjà posée par le répartiteur. + if (fillBudget > 0 && j.assigned_tech && !j.start_time && cur.status !== 'closed') { + const stt = startTime(r.due_time) + if (stt) { try { await erp.update('Dispatch Job', j.name, { start_time: stt }); j.start_time = stt; timed++; fillBudget-- } catch (e) {} } + } // (2) DELTAS diffusés — seulement une fois le snapshot amorcé if (!seeded || !prev) continue if (prev.status !== cur.status) { @@ -1219,7 +1255,7 @@ async function watchLegacy () { } else if (prev.assign_to !== cur.assign_to) { const nat = parseInt(cur.assign_to, 10) || 0 // Réassignation F (tech → autre tech) → refléter le nouveau tech sur le job pour que la trace suive la réalité F. - if (nat > 0 && nat !== TARGO_TECH_STAFF_ID && await mirrorAssign(j, nat, cur.due_date, staffToTech)) mirrored++ + if (nat > 0 && nat !== TARGO_TECH_STAFF_ID && await mirrorAssign(j, nat, r.due_date, staffToTech, r.due_time)) mirrored++ audit.record({ job: j.name, ticket: id, event: 'reassigned', from: j.assigned_tech || '', to: staffToTech[nat] || (nat === TARGO_TECH_STAFF_ID ? '(pool)' : ''), actor: 'F', source: 'F-watch' }) changes.push({ ticket: id, job: j.name, kind: 'reassigned', to_staff: cur.assign_to, pool: cur.assign_to === String(TARGO_TECH_STAFF_ID), tech: staffToTech[nat] || j.assigned_tech || '', subject: j.subject || '' }) } else if (prev.last_update !== cur.last_update) { @@ -1227,8 +1263,8 @@ async function watchLegacy () { } } if (changes.length) sse.broadcast('dispatch', 'legacy-update', { at: new Date().toISOString(), changes }) - _lastWatch = { at: new Date().toISOString(), tracked: ids.length, seeded, closed: closedNow, pulled, mirrored, changes: changes.length } - return { ok: true, tracked: ids.length, seeded, closed: closedNow, pulled, mirrored, changes } + _lastWatch = { at: new Date().toISOString(), tracked: ids.length, seeded, closed: closedNow, pulled, mirrored, timed, changes: changes.length } + return { ok: true, tracked: ids.length, seeded, closed: closedNow, pulled, mirrored, timed, changes } } // ── RÉCONCILIATION DES TECHNICIENS (union des 3 systèmes) — RAPPORT + apply manuel ────────────────────────── @@ -1947,10 +1983,13 @@ async function handle (req, res, method, path) { if (path === '/dispatch/legacy-sync/fill-coords' && method === 'POST') return json(res, 200, await fillMissingCoords({ dryRun: false })) // applique if (path === '/dispatch/legacy-sync/fix-geocoding' && method === 'GET') return json(res, 200, await fixGeocoding({ dryRun: true })) // aperçu correction coords manquantes/hors-zone if (path === '/dispatch/legacy-sync/fix-geocoding' && method === 'POST') return json(res, 200, await fixGeocoding({ dryRun: false })) // applique - if (path === '/dispatch/legacy-sync/push-assignments' && method === 'GET') return json(res, 200, await pushAssignments({ dryRun: true })) // APERÇU write-back tech → legacy (0 écriture) - if (path === '/dispatch/legacy-sync/push-assignments' && method === 'POST') { // ÉCRIT ticket.assign_to + log attribué à l'acteur Authentik ; notify=0 coupe l'avis courriel - const notify = new URL(req.url, 'http://localhost').searchParams.get('notify') !== '0' - return json(res, 200, await pushAssignments({ dryRun: false, actorEmail: req.headers['x-authentik-email'] || '', notify })) + if (path === '/dispatch/legacy-sync/push-assignments' && method === 'GET') { // APERÇU write-back tech → legacy (0 écriture) ; force=1 simule l'écrasement des conflits + const force = new URL(req.url, 'http://localhost').searchParams.get('force') === '1' + return json(res, 200, await pushAssignments({ dryRun: true, force })) + } + if (path === '/dispatch/legacy-sync/push-assignments' && method === 'POST') { // ÉCRIT ticket.assign_to + log attribué à l'acteur Authentik ; notify=0 coupe l'avis courriel ; force=1 écrase les conflits F + const sp = new URL(req.url, 'http://localhost').searchParams + return json(res, 200, await pushAssignments({ dryRun: false, actorEmail: req.headers['x-authentik-email'] || '', notify: sp.get('notify') !== '0', force: sp.get('force') === '1' })) } if (path === '/dispatch/legacy-sync/close-ticket' && method === 'POST') { // ferme un ticket legacy + marque le Dispatch Job Completed const id = new URL(req.url, 'http://localhost').searchParams.get('ticket'); if (!id) return json(res, 400, { ok: false, error: 'ticket requis' }) @@ -2683,4 +2722,4 @@ async function provisionIdentities ({ dryRun = true, scope = 'ledger' } = {}) { return res } -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, staleTickets, staleNudge, publishPreview, publishJob, techHistoryBackfill, techHistory, provisionIdentities, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests +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, 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 diff --git a/services/targo-hub/lib/legacy-sync.js b/services/targo-hub/lib/legacy-sync.js index 8ca28d8..77c8b74 100644 --- a/services/targo-hub/lib/legacy-sync.js +++ b/services/targo-hub/lib/legacy-sync.js @@ -138,7 +138,10 @@ function digits (v) { return String(v == null ? '' : v).replace(/\D/g, '') } // séparés par ; ou ,. ERPNext.email_id n'accepte qu'UN email → on garde le 1er valide comme principal // et la LISTE COMPLÈTE va dans email_billing (CC facturation, emails secondaires importés). function emailList (v) { - return clean(v).toLowerCase().split(/[;,]/).map(s => s.trim()).filter(s => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s)) + // F contient parfois des caractères INVISIBLES dans les courriels (soft hyphen U+00AD, zero-width — collages + // Outlook/Word). Ils passent notre regex (pas des \s) mais ERPNext les rejette (417 InvalidEmailAddress en + // boucle à chaque sync, ex. reynet69[U+00AD]@yahoo.com). On les strippe AVANT de valider. + return clean(v).toLowerCase().replace(/[\u00ad\u200b-\u200d\u2060\ufeff]/g, '').split(/[;,]/).map(s => s.trim()).filter(s => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s)) } function firstEmail (v) { return emailList(v)[0] || '' } diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index cc19c8e..5e170c5 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -1043,6 +1043,17 @@ async function handleFieldTech (req, res, method, path, url) { await retryWrite(() => erp.update('Dispatch Job', name, { completion_notes: (((j && j.completion_notes) || '') + note).slice(-4000) })) return json(res, 200, { ok: true, file: fn, distM, onsite, distLabel: distM != null ? fmtDistM(distM) : null }) } + if (path === '/field/photo-file' && method === 'GET') { // sert UNE photo terrain — URL signée (fieldSign du job), anti-traversal strict + const name = fieldVerify(token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' }) + const f = String(url.searchParams.get('f') || '') + const prefix = 'f_' + String(name).replace(/[^a-zA-Z0-9_-]/g, '') + '_' + if (!/^f_[a-zA-Z0-9_-]+_\d+\.(jpg|png)$/.test(f) || !f.startsWith(prefix)) return json(res, 400, { ok: false, error: 'fichier invalide' }) + try { + const buf = require('fs').readFileSync('/app/uploads/field/' + f) + res.writeHead(200, { 'Content-Type': f.endsWith('.png') ? 'image/png' : 'image/jpeg', 'Cache-Control': 'private, max-age=86400', 'Content-Length': buf.length }) + return res.end(buf) + } catch (e) { return json(res, 404, { ok: false, error: 'photo introuvable' }) } + } if (path === '/field/device' && method === 'POST') { // appareil scané (série/MAC) → checkpoint présence + note (GenieACS à brancher) const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' }) const serial = String(b.serial || '').slice(0, 60), mac = String(b.mac || '').slice(0, 40), kind = String(b.kind || 'appareil').slice(0, 30) @@ -1271,9 +1282,17 @@ async function capacityByDay (start, days, skills = null) { const sh = j.start_time ? timeToH(j.start_time) : null if (sh != null && dur > 0) DAY_SEGMENTS.forEach((seg, i) => { o.segments[i].used += segOverlap(sh, sh + dur, seg.from, seg.to) }) else if (!j.assigned_tech) o.unplaced_h += dur // dû mais pas encore placé sur l'horaire + // Job ASSIGNÉ sans heure (miroir F due_time 'day', legacy) : il occupe QUAND MÊME le tech ce jour-là. + // Avant : ni used ni unplaced → la journée paraissait libre et on suggérait le même tech (double-booking). + else if (dur > 0) o.untimed_used_h = (o.untimed_used_h || 0) + dur } for (const d of dates) { const o = out[d] + // Répartir la charge assignée SANS heure au prorata de la capacité de chaque segment (on ne sait pas QUAND, + // mais on sait que ces heures ne sont plus libres). + const unt = o.untimed_used_h || 0 + if (unt > 0) { const capTot = o.segments.reduce((x, s) => x + s.cap, 0); if (capTot > 0) o.segments.forEach(s => { s.used += unt * (s.cap / capTot) }) } + o.untimed_used_h = r1(unt) o.segments.forEach(s => { s.cap = r1(s.cap); s.used = r1(s.used); s.free = r1(s.cap - s.used) }) o.cap_h = r1(o.segments.reduce((x, s) => x + s.cap, 0)) o.used_h = r1(o.segments.reduce((x, s) => x + s.used, 0)) @@ -1614,8 +1633,20 @@ async function handle (req, res, method, path, url) { // les heures occupées mais n'affiche AUCUN bloc sur la timeline → la barre d'occupation semble figée. if (path === '/roster/assign-job' && method === 'POST') { const b = await parseBody(req); if (!b.job || !b.tech) return json(res, 400, { error: 'job + tech requis' }) - let dur = 1 - try { const jb = await erp.get('Dispatch Job', b.job, { fields: ['duration_h'] }); dur = Number(jb && jb.duration_h) || 1 } catch (e) {} + let dur = 1; let legacyId = '' + try { const jb = await erp.get('Dispatch Job', b.job, { fields: ['duration_h', 'legacy_ticket_id'] }); dur = Number(jb && jb.duration_h) || 1; legacyId = (jb && jb.legacy_ticket_id) || '' } catch (e) {} + // GARDE-FOU F (fenêtre aveugle du miroir ~3 min) : si le ticket legacy est DÉJÀ assigné dans F à un AUTRE vrai + // tech, on refuse (409 conflict) sauf force=true — le SPA affiche « Déjà assigné dans F à X — réassigner ? ». + // Best-effort : F injoignable ⇒ on n'empêche PAS l'assignation. + if (legacyId && !b.force) { + try { + const st = await require('./legacy-dispatch-sync').ticketAssignState(legacyId) + const sidTarget = (String(b.tech).match(/(\d{2,})$/) || [])[1] || '' + if (st && st.assigned && String(st.staff_id) !== sidTarget) { + return json(res, 409, { ok: false, conflict: true, ticket: legacyId, f_staff_id: st.staff_id, f_staff_name: st.staff_name || '', f_status: st.status || '', error: 'Déjà assigné dans F à ' + (st.staff_name || ('staff #' + st.staff_id)) }) + } + } catch (e) {} + } const patch = { assigned_tech: b.tech, status: 'assigned', duration_h: dur } // duration_h garanti → occupation comptée if (b.date) patch.scheduled_date = b.date let placed = null @@ -1637,6 +1668,32 @@ async function handle (req, res, method, path, url) { const m = setJobSkills(b.name, b.skills); invalidatePool() // le pool porte required_skills/required_skill → rafraîchir return json(res, 200, { ok: true, name: b.name, skills: m[String(b.name)] || [] }) } + // MÉDIAS TERRAIN d'un job : photos prises par le tech (app terrain, /field/photo) + journal completion_notes + // (arrivées géofence, scans d'appareil, notes). Sert le module « Photos & activité terrain » du détail ticket/job. + // Les passent par /field/photo-file (URL signée fieldSign — même modèle de jeton que l'app terrain). + if (path === '/roster/job-media' && method === 'GET') { + const name = qs.get('job') || '' + if (!name) return json(res, 400, { ok: false, error: 'job requis' }) + let notes = '' + try { const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['completion_notes'], limit: 1 }); notes = (r && r[0] && r[0].completion_notes) || '' } catch (e) {} + const fs = require('fs'); const dir = '/app/uploads/field' + const prefix = 'f_' + String(name).replace(/[^a-zA-Z0-9_-]/g, '') + '_' + let photos = [] + try { + photos = fs.readdirSync(dir).filter(f => f.startsWith(prefix) && /\.(jpg|png)$/.test(f)) + .map(f => { const ts = Number((f.match(/_(\d{10,})\./) || [])[1]) || 0; return { file: f, at: ts ? new Date(ts).toISOString() : null, ts } }) + .sort((a, b) => b.ts - a.ts).slice(0, 40) + } catch (e) {} + const base = (cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca').replace(/\/$/, '') + const tok = fieldSign(name) + for (const ph of photos) { + ph.url = base + '/field/photo-file?t=' + encodeURIComponent(tok) + '&f=' + encodeURIComponent(ph.file) + // Contexte de la photo depuis le journal (sur place / distance / note) — la ligne contient le nom de fichier. + const line = (notes.split('\n') || []).find(l => l.includes(ph.file)) + if (line) { ph.onsite = line.includes('✓ sur place'); ph.offsite = line.includes('⚠ à '); const m = line.match(/📍 (✓ sur place \(≈[^)]+\)|⚠ à [^·—]+)/); ph.geo = m ? m[1].trim() : ''; const n = line.match(/—\s*(.+)$/); ph.note = n ? n[1].trim() : '' } + } + return json(res, 200, { ok: true, job: name, photos, notes }) + } // Répondre au fil du billet DEPUIS LE VOLET JOB (dispatcher authentifié) — même mécanique que l'app terrain, mais l'acteur = l'agent connecté. // Note INTERNE par défaut (jamais au client) ; publique (courriel client) SEULEMENT si public=true. // #5 — Bloc RÉSERVÉ : réserve le temps d'un tech pour une tâche/projet = Dispatch Job job_type='Réservation', priorité MOYENNE, diff --git a/services/targo-hub/lib/ticket-collab.js b/services/targo-hub/lib/ticket-collab.js index 710fd16..591c4fa 100644 --- a/services/targo-hub/lib/ticket-collab.js +++ b/services/targo-hub/lib/ticket-collab.js @@ -316,7 +316,11 @@ async function addComment (doctype, name, { text, mentions = [], agent } = {}) { if (ment.length) { const gmail = require('./gmail') const subj = (doctype === 'Issue' ? 'Ticket ' : doctype + ' ') + name - const link = ERP_URL() + '/app/' + doctype.toLowerCase().replace(/ /g, '-') + '/' + encodeURIComponent(name) + // Deep link OPS (détail natif) — plus JAMAIS le desk ERPNext pour les tickets. Autres doctypes : desk en repli + // (rare ; à migrer quand un deep link générique existera). + const link = doctype === 'Issue' + ? OPS_URL() + '/#/tickets?open=' + encodeURIComponent(name) + : ERP_URL() + '/app/' + doctype.toLowerCase().replace(/ /g, '-') + '/' + encodeURIComponent(name) const body = `${shortName(agent) || 'Un collègue'} t'a mentionné sur ${subj} :\n\n${content}\n\nOuvrir : ${link}\n(OPS — collaboration, ceci ne change pas l'assignation du ticket.)` for (const to of ment) { try { await gmail.sendMessage({ to, subject: `[OPS] mention — ${subj}`, body }) } catch (e) { log('collab notify ' + to + ': ' + e.message) } } } @@ -344,7 +348,7 @@ async function createTicket ({ title, category, priority, description, customer, if (queue) { const members = (readJsonFile('/app/data/queue_members.json', {})[queue] || []).filter(e => /.+@.+\..+/.test(e)) if (members.length) { - const link = ERP_URL() + '/app/issue/' + encodeURIComponent(r.name) + const link = OPS_URL() + '/#/tickets?open=' + encodeURIComponent(r.name) // détail natif OPS (fini le desk ERPNext) const body = `Nouveau ticket pour l'équipe « ${queue} » :\n\n${data.subject}\n${customer_name ? 'Client : ' + customer_name + '\n' : ''}${description ? '\n' + description + '\n' : ''}\nOuvrir : ${link}\n— TARGO OPS` try { await require('./outbox').enqueue({ to: members.join(','), subject: `[Ticket · ${queue}] ${data.subject}`.slice(0, 90), body }, { kind: 'ticket-notif', label: queue }) } catch (e) { log('createTicket notify ' + queue + ': ' + e.message) } }