@@ -87,6 +88,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
+import { usePermissions } from 'src/composables/usePermissions'
import DynamicFilter from 'src/components/shared/DynamicFilter.vue'
const $q = useQuasar()
@@ -100,6 +102,7 @@ const filterState = ref({ search: '', chips: { statut: 'open' } })
const chipGroups = [{ key: 'statut', label: 'Statut', icon: 'label', options: [{ value: 'open', label: 'À traiter' }, { value: 'Created', label: 'Créées' }, { value: 'Ignored', label: 'Ignorées' }] }]
const invoiceTo = ref('factures@targointernet.com')
const erpBase = ref('https://erp.gigafibre.ca')
+const { can } = usePermissions() // lien desk PI = admin seulement
const imgError = ref(false)
watch(selected, () => { imgError.value = false })
diff --git a/apps/ops/src/pages/TicketsPage.vue b/apps/ops/src/pages/TicketsPage.vue
index 7a46c61..6c8b7c9 100644
--- a/apps/ops/src/pages/TicketsPage.vue
+++ b/apps/ops/src/pages/TicketsPage.vue
@@ -170,7 +170,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) }
}