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:
parent
ef7b51399d
commit
cd25fa2504
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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'" />
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] || '' }
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user