fix(dispatch): double-assign F + médias terrain dans le ticket + deep links natifs

- 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 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-18 18:02:37 -04:00
parent ef7b51399d
commit cd25fa2504
10 changed files with 302 additions and 31 deletions

View File

@ -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).

View File

@ -0,0 +1,117 @@
<!--
JobMediaModule photos prises par le TECH sur place (app terrain /field/photo, géo-vérifiées vs l'adresse du job)
+ journal d'activité terrain (completion_notes : arrivées géofence, scans d'appareil, notes). Module RÉUTILISABLE
(détail ticket « Intervention sur site » + volet job Planification). Autonome : charge via roster.jobMedia(name),
se rafraîchit seul pendant que le panneau est ouvert (les photos arrivent PENDANT la visite).
-->
<template>
<div v-if="props.name">
<div class="text-subtitle2 q-mt-md q-mb-xs row items-center">
Photos &amp; activité terrain
<span v-if="photos.length" class="text-grey-5 q-ml-sm" style="font-size:10px">· {{ photos.length }} photo(s)</span>
<q-space />
<q-btn flat dense round size="sm" icon="refresh" :loading="loading" @click="reload(false)"><q-tooltip>Rafraîchir</q-tooltip></q-btn>
</div>
<!-- Photos : vignettes cliquables ; badge « sur place » (distance GPS adresse du job) = preuve d'adresse ;
la légende (note du tech) sert à identifier l'appareil photographié. -->
<div v-if="photos.length" class="jm-grid">
<div v-for="p in photos" :key="p.file" class="jm-cell" @click="openPhoto(p)">
<img :src="p.url" loading="lazy" :alt="p.note || 'photo terrain'" />
<div class="jm-meta">
<span v-if="p.onsite" class="jm-badge jm-ok"> sur place</span>
<span v-else-if="p.offsite" class="jm-badge jm-warn"> hors adresse</span>
<span v-if="p.at" class="jm-ts">{{ fmtDT(p.at) }}</span>
</div>
<div v-if="p.note" class="jm-note">{{ p.note }}</div>
</div>
</div>
<!-- Journal terrain (plus récent en haut) : arrivé/reparti, scans SN/MAC, photos, hors-zone -->
<div v-if="journal.length" class="q-mt-xs">
<a class="jm-toggle" @click.prevent="showJournal = !showJournal">
{{ showJournal ? 'Masquer' : 'Voir' }} le journal terrain ({{ journal.length }})
</a>
<div v-if="showJournal" class="jm-journal">
<div v-for="(l, i) in journal" :key="i" class="jm-line">{{ l }}</div>
</div>
</div>
<div v-if="!loading && !photos.length && !journal.length" class="text-grey-5" style="font-size:11px">
Aucune photo ni activité terrain pour l'instant.
</div>
<!-- Plein écran -->
<q-dialog v-model="viewer">
<q-card style="max-width:94vw">
<q-card-section class="q-pa-xs row items-center">
<div class="text-caption q-ml-sm">
<b v-if="current && current.onsite" class="text-positive"> sur place</b>
<b v-else-if="current && current.offsite" class="text-warning"> hors adresse</b>
<span v-if="current && current.geo" class="text-grey-7 q-ml-xs">{{ current.geo }}</span>
<span v-if="current && current.at" class="text-grey-6 q-ml-xs">· {{ fmtDT(current.at) }}</span>
<span v-if="current && current.note" class="q-ml-xs"> {{ current.note }}</span>
</div>
<q-space /><q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<img v-if="current" :src="current.url" style="max-width:94vw;max-height:82vh;display:block" :alt="current.note || 'photo terrain'" />
</q-card>
</q-dialog>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import * as roster from 'src/api/roster'
import { formatDateTime as fmtDT } from 'src/composables/useFormatters'
const props = defineProps({
name: { type: String, default: '' }, // docname du Dispatch Job
})
const loading = ref(false)
const photos = ref([])
const journal = ref([])
const showJournal = ref(false)
const viewer = ref(false)
const current = ref(null)
function openPhoto (p) { current.value = p; viewer.value = true }
async function reload (silent = true) {
if (!props.name) return
if (!silent) loading.value = true
try {
const r = await roster.jobMedia(props.name)
photos.value = (r && r.photos) || []
// Journal = lignes non-vides de completion_notes, PLUS RÉCENT EN HAUT (comme le fil du billet).
journal.value = String((r && r.notes) || '').split('\n').map(s => s.replace(/^•\s*/, '').trim()).filter(Boolean).reverse().slice(0, 60)
} catch (e) { /* best-effort : le module ne casse jamais le panneau */ } finally { loading.value = false }
}
let pollTimer = null
onMounted(() => {
reload(false)
// Les photos/checkpoints arrivent pendant la visite petit poll tant que le panneau est ouvert (silencieux).
pollTimer = setInterval(() => { if (document.visibilityState === 'visible') reload(true) }, 60000)
})
onBeforeUnmount(() => { if (pollTimer) clearInterval(pollTimer) })
watch(() => props.name, () => { photos.value = []; journal.value = []; reload(false) })
defineExpose({ reload })
</script>
<style scoped>
.jm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; }
.jm-cell { cursor: pointer; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; background: #fafafa; }
.jm-cell img { width: 100%; height: 84px; object-fit: cover; display: block; }
.jm-meta { display: flex; align-items: center; gap: 4px; padding: 2px 5px 0; flex-wrap: wrap; }
.jm-badge { font-size: 9px; font-weight: 700; border-radius: 4px; padding: 0 4px; }
.jm-ok { color: #1b5e20; background: #e6f4ec; }
.jm-warn { color: #8a5a00; background: #fbf0dc; }
.jm-ts { font-size: 9px; color: #90a4ae; }
.jm-note { font-size: 10px; color: #455a64; padding: 0 5px 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.jm-toggle { font-size: 11px; color: #5e7a76; cursor: pointer; text-decoration: underline dotted; }
.jm-journal { margin-top: 4px; max-height: 180px; overflow-y: auto; background: #f6f8fb; border-radius: 6px; padding: 6px 8px; }
.jm-line { font-size: 11px; color: #37474f; white-space: pre-wrap; border-top: 1px dashed #e0e6ef; padding: 2px 0; }
.jm-line:first-child { border-top: none; }
</style>

View File

@ -23,7 +23,7 @@
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import * as roster from 'src/api/roster'
import { formatDateTime as fmtDT } from 'src/composables/useFormatters'
@ -41,13 +41,20 @@ const threadStatus = computed(() => (activeThread.value && activeThread.value.st
// Fil PLUS RÉCENT EN HAUT (le dernier commentaire porte souvent l'info la plus importante).
const messages = computed(() => { const m = (activeThread.value && activeThread.value.messages) || []; return m.slice().reverse() })
async function reload () {
async function reload (silent = false) {
if (props.thread || !props.lid) return
loading.value = true; loadErr.value = false
try { loaded.value = await roster.ticketThread(props.lid) } catch (e) { loaded.value = { error: true, messages: [] }; loadErr.value = true } finally { loading.value = false }
if (!silent || !loaded.value) { loading.value = true } // silencieux = pas de spinner qui clignote sur le poll
loadErr.value = false
try { loaded.value = await roster.ticketThread(props.lid) } catch (e) { if (!silent) { loaded.value = { error: true, messages: [] }; loadErr.value = true } } finally { loading.value = false }
}
watch(() => props.lid, () => { loaded.value = null; reload() })
onMounted(reload)
// Les réponses des techs (app terrain) arrivent PENDANT que le panneau est ouvert le fil se met à jour tout seul.
let pollTimer = null
onMounted(() => {
reload()
pollTimer = setInterval(() => { if (document.visibilityState === 'visible' && !loading.value) reload(true) }, 45000)
})
onBeforeUnmount(() => { if (pollTimer) clearInterval(pollTimer) })
defineExpose({ reload })
</script>

View File

@ -1,7 +1,8 @@
<template>
<q-page padding>
<!-- Real-time outage alerts -->
<OutageAlertsPanel class="q-mb-md" @open-ticket="id => $router.push('/tickets')" />
<!-- Ouvre le TICKET précis (détail natif via deep link ?open=), pas seulement la liste. -->
<OutageAlertsPanel class="q-mb-md" @open-ticket="id => $router.push(id ? { path: '/tickets', query: { open: id } } : '/tickets')" />
<!-- KPI cards (cliquables page liée · sous-ligne = tendance/contexte) -->
<div class="row q-col-gutter-md q-mb-lg">
@ -148,7 +149,7 @@
<div class="ops-card">
<div class="text-subtitle1 text-weight-bold q-mb-sm">Tickets ouverts</div>
<q-list separator>
<q-item v-for="t in openTickets" :key="t.name" clickable @click="$router.push('/tickets')">
<q-item v-for="t in openTickets" :key="t.name" clickable @click="$router.push({ path: '/tickets', query: { open: t.name } })">
<q-item-section avatar style="min-width:32px">
<q-icon name="confirmation_number" size="20px"
:color="t.priority === 'Urgent' ? 'red' : t.priority === 'High' ? 'orange-8' : 'grey-5'" />

View File

@ -68,7 +68,8 @@
<!-- Lien PI déjà créé -->
<q-banner v-if="selected.purchase_invoice" class="bg-green-1 text-green-9 q-mb-sm" dense rounded>
<q-icon name="check_circle" /> Brouillon créé : <b>{{ selected.purchase_invoice }}</b>
<template #action><q-btn flat dense color="green-9" icon="open_in_new" label="Ouvrir dans ERPNext" no-caps :href="erpBase + '/app/purchase-invoice/' + encodeURIComponent(selected.purchase_invoice)" target="_blank" /></template>
<!-- Desk ERPNext = escape hatch ADMIN (le brouillon PI n'a pas de vue native ; la finance le revoit au grand livre). -->
<template #action><q-btn v-if="can('manage_settings')" flat dense color="green-9" icon="open_in_new" label="Ouvrir dans ERPNext" no-caps :href="erpBase + '/app/purchase-invoice/' + encodeURIComponent(selected.purchase_invoice)" target="_blank" /></template>
</q-banner>
<div class="row items-center q-gutter-sm">
@ -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 })

View File

@ -170,7 +170,8 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { listDocs, countDocs } from 'src/api/erp'
import DataTable from 'src/components/shared/DataTable.vue'
import { formatDate, staffColor, staffInitials } from 'src/composables/useFormatters'
@ -366,11 +367,21 @@ function onIntervenedFollow ({ name, doctype, following }) {
myFollows.value = following ? [...new Set([...myFollows.value, name])] : myFollows.value.filter(n => n !== name)
}
// DEEP LINK « ouvrir ce ticket » : /#/tickets?open=ISS- (utilisé par les courriels de notification du hub
// fini le desk ERPNext). Ouvre le détail NATIF (DetailModal IssueDetail) par-dessus la liste.
const route = useRoute()
function openFromQuery () {
const name = route.query && route.query.open
if (name) loadModalTicket(String(name))
}
watch(() => route.query && route.query.open, () => openFromQuery())
onMounted(async () => {
await Promise.all([loadIssueTypes(), loadMyDepartments(), loadMyFollows()])
ownerFilter.value = myMode.value === 'personnel' ? 'mine' : 'depts' // scope par défaut selon le mode de la personne (department/tags « Mes départements »)
pagination.value.rowsPerPage = ownerFilter.value === 'all' ? 25 : 100
await loadTickets()
openFromQuery()
})
</script>

View File

@ -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

View File

@ -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] || '' }

View File

@ -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 <img> 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,

View File

@ -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) }
}